大厂常问的HashMap线程安全问题,看这一篇就够了!

HashMap源码分析

笔记首页

序号内容链接地址
1HashMap的继承体系,HashMap的内部类,成员变量https://blog.csdn.net/weixin_44141495/article/details/108327490
2HashMap的常见方法的实现流程https://blog.csdn.net/weixin_44141495/article/details/108329558
3HashMap的一些特定算法,常量的分析https://blog.csdn.net/weixin_44141495/article/details/108305494
4HashMap的线程安全问题(1.7和1.8)https://blog.csdn.net/weixin_44141495/article/details/108250160
5HashMap的线程安全问题解决方案https://blog.csdn.net/weixin_44141495/article/details/108420327
6Map的四种遍历方式,以及删除操作https://blog.csdn.net/weixin_44141495/article/details/108329525
7HashMap1.7和1.8的区别https://blog.csdn.net/weixin_44141495/article/details/108402128

HashMap源码分析系列 – HashMap的继承体系,内部类,成员变量

Jdk1.7HashMap线程安全问题 (全过程分析)

我们都知道Jdk1.7的HashMap存在安全问题,在多线程环境下,扩容的时候可能会形成环状链表导致死循环的问题,别问我们为什么知道,面试题啊!

这篇帖子我来讲一下Jdk1.7HashMap在扩容时的线程安全问题

首先我们看一下HashMap在扩容的流程

代码流程

  1. 扩容相关常量
  • DEFAULT_LOAD_FACTOR:默认负载因子,这个参数是判断扩容时的重要参数,当Map中的元素的数量达到最大容量乘上负载因子时,就会进行扩容。如果在构造方法中没有指定,那么默认就是0.75。这个0.75是个非常合理的值,如果负载因子等于1,那么只有元素数量达到最大容量的时候才会进行扩容,导致每一个桶的链表长度都过长,运行效率变低。如果负载因子等于0.5,那么Map每存储一半的元素就扩容,浪费内存空间

  • size:容量达到阈值时(table数组长度乘加载因子就是阈值),发送扩容。

  • table:存储Entry也就是我们存储的key,value的对象数组,扩容时会生成一个新的数组,长度为此数组的一倍,然后逐一将这个table的元素移至新的数组,然后将新的数组覆盖原数组来实现扩容。

    /**
     * 默认负载因子
     */
    static final float DEFAULT_LOAD_FACTOR = 0.75f;

    /**
     * Entry数组
     */
    transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;
    /**
     * 容量
     */
    transient int size;
  1. 扩容的条件

什么时候执行扩容方法?我们设想,什么时候我们会需要去判断Map的容量是否太多?当然是添加的时候,当我们新增元素的时候,需要去判断是否能够存下这个元素,如果存的下就存,存不下就扩容再存。

  1. 扩容的流程

我们拿put方法举例

public V put(K key, V value) {}

首先我们有一些条件判断,包括是否需要初始化,是否是空值,当然我们今天的重点是扩容,当不需要初始化,不是空值,我们走addEmpty 方法。

/**
 * 添加
 */
public V put(K key, V value) {
    //判断是否是空表
    if (table == EMPTY_TABLE) {
        //初始化
        inflateTable(threshold);
    }
    //判断是否是空值
    if (key == null)
        return putForNullKey(value);
    int hash = hash(key);
    int i = indexFor(hash, table.length);
    for (Entry<K,V> e = table[i]; e != null; e = e.next) {
        Object k;
        if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
            V oldValue = e.value;
            e.value = value;
            e.recordAccess(this);
            return oldValue;
        }
    }
    modCount++;
    //封装所需参数,准备添加
    addEntry(hash, key, value, i);
    return null;
}

此方法时判断是否需要扩容,如果不扩容,我们执行创建Entry方法。这里我们看到,扩容方法传入了一个参数,也就是扩容之后的新长度,是默认Entry数组长度的两倍,也就是容量翻倍,我们走进这个方法

/**
 * 添加条目
 *
 */
void addEntry(int hash, K key, V value, int bucketIndex) {
    //判断是否需要扩容
    if ((size >= threshold) && (null != table[bucketIndex])) {
        //扩容
        resize(2 * table.length);
        //重新计算hash值
        hash = (null != key) ? hash(key) : 0;
        //计算所要插入的桶的索引值
        bucketIndex = indexFor(hash, table.length);
    }
    //执行新增Entry方法
    createEntry(hash, key, value, bucketIndex);
}

这里会进行一些条件判断,如果这个HashMap的容量已经非常大了,新的长度会大于我们预设的最大容量,这时直接return;来终止这个方法。如果程序不走这步,我们看到HashMap新建了一个数组,长度是newCapacity也就是之前我们传入的2 * table.length,然后执行transffer方法和initHashSeedAsNeeded方法。initHashSeedAsNeeded方法主要是判断一下是否需要初始化散列终止,其实就是怕你HashMap的值太过集中不够散列。我们不关注这个方法的实现细节,我们走进transfer方法。

/**
 * 调整
 *
 * @param newCapacity 新容量
 */
void resize(int newCapacity) {
    Entry[] oldTable = table;
    int oldCapacity = oldTable.length;
    //达到最大值,无法扩容
    if (oldCapacity == MAXIMUM_CAPACITY) {
        threshold = Integer.MAX_VALUE;
        return;
    }
    Entry[] newTable = new Entry[newCapacity];
    //将数据转移到新的Entry[]数组中
    transfer(newTable, initHashSeedAsNeeded(newCapacity));//初始化散列种子
    //覆盖原数组
    table = newTable;
    threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}

前面的方法时判断是否要扩容和创建新长度的数组,我们知道这个数组是空的,我们需要把原来数组的值逐一复制到这个新的数组中,这里是比较经典的链表操作,相信刷过力扣的都能实现这个链表操作。首先是遍历table数组,如果遍历到Entry不为空,我们进入while循环,每次操作结束都将进入循环的e用e.next覆盖,直至链表到达尾部,即e!=null 但是 e.next==null。

/**
     * 转移
     *
     * @param newTable 新表格
     * @param rehash   重新处理
     */
    void transfer(Entry[] newTable, boolean rehash) {
        int newCapacity = newTable.length;
        for (Entry<K,V> e : table) {
            while(null != e) {
                Entry<K,V> next = e.next;
                //这两个方法时决定插入位置,和链表操作无关,我们重点看链表操作的过程
                if (rehash) {
                    e.hash = null == e.key ? 0 : hash(e.key);
                }
                int i = indexFor(e.hash, newCapacity);
                e.next = newTable[i];
                newTable[i] = e;
                e = next;
            }
        }
    }

链表插入过程

Jdk7的底层数据结构是数组加链表,一条链表又叫桶。我们以一个简单的长度为2的数组扩容到长度为4的数组为例。

image-20200826220836394

  1. 标记下一个节点
Entry<K,V> next = e.next;

此时我们循环里面的e代表图中的10,也就是头节点,next代表头节点的next,也就是图中的9

image-20200826221309667

  1. 改变next指向
e.next = newTable[i];

我们看到10和9之间的指向消失了,但是9,8不会被垃圾回收,因为我们的next指向了9,由于是第一次插入,newTable[i]实际上等于NULL,没有图中的指向关系,这里我们为了方便理解。

image-20200826221701170

  1. 覆盖原值
newTable[i] = e;

我们看到有两个10,为什么呢?应为你的10是存在原数组中的,原数组有对这个存放10的Entry的引用,此时我们又用这个10覆盖了新数组的newTable[i]值,多了一次引用,所以原来的10也存在。

image-20200826221929578

  1. 准备下一次的插入
e = next;

之前我们有对9的引用,所以9不会被垃圾清除,我们用9覆盖e,准备下一次的插入操作。

image-20200826222240735

5.下一次插入

这次我直接快速演示过程了,不小心把10在新数组的那几帧剪掉了。

6.小总结

这就是我们所说的头插法,当然今天的问题主要是头插法在多线程环境如何导致死循环的。

大家学习多线程的时候应该都有做过卖票系统的线程安全的案例吧,就是说同时两个线程进入一个方法,导致出卖票超出最大票数。

7.线程安全问题出现的时机

当我们有多个线程操作一个Map的时候可能出现同时进行扩若的情况,我们看看具体情况。

这是扩容的准备,transfer方法是搬家,我们看到只有transfer方法执行完毕才会修改threshold的值,而threshold阈值是判断是否进行扩容的重要变量,也就是由之前提到的容量*负载因子计算的到的,所以很有可能同时有多个线程进入了resize方法,这是出现安全隐患的先决条件。

/**
 * 调整
 *
 * @param newCapacity 新容量
 */
void resize(int newCapacity) {
    Entry[] oldTable = table;
    int oldCapacity = oldTable.length;
    
    if (oldCapacity == MAXIMUM_CAPACITY) {
        threshold = Integer.MAX_VALUE;
        return;
    }
    //新数组
    Entry[] newTable = new Entry[newCapacity];
    //搬家
    transfer(newTable, initHashSeedAsNeeded(newCapacity));//初始化散列种子
    
    table = newTable;
    threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}

我们假设一号线程在执行完Entry<K,V> next = e.next;这行代码之后被挂起,失去执行权。我们的二号线程进入transfer这个方法,执行完全部流程之后,一号线程才被唤醒,继续执行。

/**
     * 转移
     *
     * @param newTable 新表格
     * @param rehash   重新处理
     */
    void transfer(Entry[] newTable, boolean rehash) {
        int newCapacity = newTable.length;
        for (Entry<K,V> e : table) {
            while(null != e) {
                //假设一号线程执行完这一行代码就挂了!
                Entry<K,V> next = e.next;
                if (rehash) {
                    e.hash = null == e.key ? 0 : hash(e.key);
                }
                int i = indexFor(e.hash, newCapacity);
                e.next = newTable[i];
                newTable[i] = e;
                e = next;
            }
        }
    }

链表死循环形成过程

我们假设new table1是一号线程创建的新数组,new table2是二号线程创建的新数组,一号线程的e我标为e1,next标为next2。

image-20200826224420674

我们执行二号线程的全部流程。

我们看到一号线程的e1和next1始终指向10和9。

这是二号线程执行完毕了,轮到一号线程了

我们看到我们原来标记的Entry都跑到了新的数组,安全问题已经出现了!我们按照流程走一遍。

image-20200826225921877

Entry<K,V> next = e.next;这行代码我们一号线程挂起前就执行了,我们执行其他操作:(太晚了,我搬运了!)

通过设置断点让Thread1和Thread2同时Debug到transfer方法的首行,注意此时两个线程已经成功添加数据,放开Thread1的断点至transfer方法的Entry next=e.next;这一行,然后放开Thread2的断点,让Thread2进行resize

img

之后Thread1被被调度回来继续执行后面代码

img

img

img

至此HashMap出现了环形链表…

问题找到了,我们验证一下

void transfer(Entry[] newTable, boolean rehash) {
        int newCapacity = newTable.length;
        for (Entry<K,V> e : table) {
            while(null != e) {
                Entry<K,V> next = e.next;
                if (rehash) {
                    e.hash = null == e.key ? 0 : hash(e.key);
                }
                int i = indexFor(e.hash, newCapacity);
                e.next = newTable[i];
                //判断是否出现死循环
                if (e.next!=null && e.next.next!=null && e.next.next==e){ 
                    System.out.println("bug!");}
                newTable[i] = e;
                e = next;
            }
        }
    }

测试方法

我们线程多一点,put次数多一点,让扩容出错的概率高一点,我们跑一下代码

public static void testJdk7HashMap(){
        final Jdk7HashMap<Integer, Integer> map = new Jdk7HashMap<>();
        Runnable runnable=new Runnable() {
            @Override
            public void run() {
                for (int i = 0; i < 11111111; i++) {
                    map.put(i,i);
                }
            }
        };
        new Thread(runnable).start();
        new Thread(runnable).start();
        new Thread(runnable).start();
        new Thread(runnable).start();
        new Thread(runnable).start();
        new Thread(runnable).start();
        new Thread(runnable).start();
    }

之前录屏,IDEA都挂了!

image-20200826232243956

JDK1.8中的线程不安全

那么Jdk1.8就安全了?

根据上面JDK1.7出现的问题,在JDK1.8中已经得到了很好的解决,如果你去阅读1.8的源码会发现找不到transfer函数,因为JDK1.8直接在resize函数中完成了数据迁移。另外说一句,JDK1.8在进行元素插入时使用的是尾插法。

为什么说JDK1.8会出现数据覆盖的情况喃,我们来看一下下面这段JDK1.8中的put操作代码:

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
        Node<K,V>[] tab; Node<K,V> p; int n, i;
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;
        if ((p = tab[i = (n - 1) & hash]) == null) // 如果没有hash碰撞则直接插入元素
            tab[i] = newNode(hash, key, value, null);
        else {
            Node<K,V> e; K k;
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;
            else if (p instanceof TreeNode)
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            else {
                for (int binCount = 0; ; ++binCount) {
                    if ((e = p.next) == null) {
                        p.next = newNode(hash, key, value, null);
                        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                            treeifyBin(tab, hash);
                        break;
                    }
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    p = e;
                }
            }
            if (e != null) { // existing mapping for key
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                afterNodeAccess(e);
                return oldValue;
            }
        }
        ++modCount;
        if (++size > threshold)
            resize();
        afterNodeInsertion(evict);
        return null;
    }
123456789101112131415161718192021222324252627282930313233343536373839404142

其中第六行代码是判断是否出现hash碰撞,假设两个线程A、B都在进行put操作,并且hash函数计算出的插入下标是相同的,当线程A执行完第六行代码后由于时间片耗尽导致被挂起,而线程B得到时间片后在该下标处插入了元素,完成了正常的插入,然后线程A获得时间片,由于之前已经进行了hash碰撞的判断,所有此时不会再进行判断,而是直接进行插入,这就导致了线程B插入的数据被线程A覆盖了,从而线程不安全。

除此之前,还有就是代码的第38行处有个++size,我们这样想,还是线程A、B,这两个线程同时进行put操作时,假设当前HashMap的zise大小为10,当线程A执行到第38行代码时,从主内存中获得size的值为10后准备进行+1操作,但是由于时间片耗尽只好让出CPU,线程B快乐的拿到CPU还是从主内存中拿到size的值10进行+1操作,完成了put操作并将size=11写回主内存,然后线程A再次拿到CPU并继续执行(此时size的值仍为10),当执行完put操作后,还是将size=11写回内存,此时,线程A、B都执行了一次put操作,但是size的值只增加了1,所有说还是由于数据覆盖又导致了线程不安全。

总结

  • Jdk8虽然采用尾插法告别了这个问题,但是Jdk8的HashMap也线程安全问题,在单线程的情况下,这些集合容器都能发挥其应有的功效,所以我们多线程情况下要有相应的解决策略才行!
  • 14
    点赞
  • 17
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值