ConcurrentHashMap原理

在jdk8中,concurrentHashMap是我们认为的线程安全的hashmap,这篇博客主要记录concurrentHashMap的原理
在jdk8中,hashMap的结构是:数组 + 链表 + 红黑树,concurrentHashMap也一样,也是这个结构,只是concurrentHashMap多了线程安全

源码入口

我们以put的过程为例,来介绍concurrentHashMap线程安全的原因
和hashMap一样,在put的时候,其实内部调用的是putVal()方法
在这里插入图片描述

下面是put的核心源码,我们一一拆解

final V putVal(K key, V value, boolean onlyIfAbsent) {
        if (key == null || value == null) throw new NullPointerException();
        int hash = spread(key.hashCode());
        int binCount = 0;

        for (Node<K,V>[] tab = table;;) {
            Node<K,V> f; int n, i, fh;
            //1.在put元素的时候,首先判断当前tab是否为空,为空,就初始化
            if (tab == null || (n = tab.length) == 0)
                tab = initTable();
            //2.如果tab不为空,就根据key计算出一个下标值,判断数组中这个位置是否为null,为null,就new一个新的node节点,通过cas设置到该数组对应的元素中
            else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
                if (casTabAt(tab, i, null,
                             new Node<K,V>(hash, key, value, null)))
                    break;                   // no lock when adding to empty bin
            }
            /**
             * 3.这里的f就是根据put的key计算得到的元素下标位置,如果hash值为-1,表示当前有其他线程正在进行扩容
             * 如果有其他线程在扩容,那helpTransfer的意思是帮助另外一个线程去扩容
             *
             * 这里要帮助扩容是这样的:
             *  如果A线程正在对数组进行扩容,会把A线程自己正在扩容的tab[8]位置的元素的hash设置为moved
             *  1、如果B线程在put元素的时候,正好是要放到tab[8]这个位置的,那此时就没办法插入了,因为A线程正在迁移这个位置的元素,所以:
             *  干脆B线程就帮助A线程一起去迁移整个数组
             *      等数组迁移完成了,B线程就会再来一遍循环,此时获取到的table,就是新的table,就可以进行加锁、put数据等
             *  2、如果B线程在put元素的时候,是要放到tab[7]位置,那此时是不受影响的(我这里只是举例子,只说思想),就可以继续对tab[7]加锁,然后进行put
             *  此时就是A线程一遍迁移tab[8]位置的元素,B线程一遍向tab[7]位置插入元素
             */
            else if ((fh = f.hash) == MOVED)
                tab = helpTransfer(tab, f);
            else {
                //4.进入到这里,表示是正常的插入
                V oldVal = null;
                synchronized (f) {
                    /**
                     * 4.1 先通过synchronized加锁
                     * 4.2 然后再判断当前i这个位置是否是f节点,因为有可能当前线程在执行的时候,其他线程修改了数组中i位置对应的结点信息
                     * 4.3 如果hashCode大于等于0,表示这是一个链表
                     *   4.3.1 从头结点开始循环,如果找到key相同的node结点,就把值放到oldVal,然后value覆盖原来的value
                     *   4.3.2 如果到最后一个结点,还没有找到key相等的,就插入到队尾
                     * 4.4 如果当前node结点是TreeBin类型的,那就是树
                     *
                     * 4.5 如果bigCount大于0,且大于链表转红黑树的阈值,就进行树的转换
                     * 如果oldValue不为null,表示是值覆盖,就返回oldValue
                     */
                    if (tabAt(tab, i) == f) {
                        if (fh >= 0) {
                            binCount = 1;
                            for (Node<K,V> e = f;; ++binCount) {
                                K ek;
                                //这里的if判断,是进行覆盖操作
                                if (e.hash == hash &&
                                    ((ek = e.key) == key ||
                                     (ek != null && key.equals(ek)))) {
                                    oldVal = e.val;
                                    if (!onlyIfAbsent)
                                        e.val = value;
                                    break;
                                }
                                Node<K,V> pred = e;
                                //如果遍历到尾结点还是没有查到相同的key,就插入到尾结点的后面
                                if ((e = e.next) == null) {
                                    pred.next = new Node<K,V>(hash, key,
                                                              value, null);
                                    break;
                                }
                            }
                        }
                        // 如果当前元素存储的是红黑树
                        else if (f instanceof TreeBin) {
                            Node<K,V> p;
                            binCount = 2;
                            if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key, 
                                                           value)) != null) {
                                oldVal = p.val;
                                if (!onlyIfAbsent)
                                    p.val = value;
                            }
                        }
                    }
                }
                if (binCount != 0) {
                    /**
                     * 5.判断是否需要进行树化
                     * 如果oldVal不为null,表示是进行了覆盖写入,直接return 即可
                     */
                    if (binCount >= TREEIFY_THRESHOLD)
                        treeifyBin(tab, i);
                    if (oldVal != null)
                        return oldVal;
                    break;
                }
            }
        }
        /**
         * 6.将concurrentHashMap维护的元素个数 + 1,在这个方法中,可能会触发扩容的逻辑
         */
        addCount(1L, binCount);
        return null;
    }
一、如果tab为空

这里对应代码中的注释1,如果tab为null,表示当前数组需要初始化,会通过initTable()来完成初始化
这里就不做过多解释了,注释写到应该还算比较清楚
在这里插入图片描述

二、如果tab[i] == null

这里对应注释的第二点,如果tab不为null,会根据当前key的hash值和当前数组长度进行 &运算,得到当前元素要插入的位置,如果这个位置为null,表示还没有元素插入,当前元素可以直接写入,在写的时候,会发现,这里采用的是cas的方式,这也是concurrentHashMap线程安全的原因之一

三、辅助扩容

辅助扩展这部分,先跳过吧,这里的细节太多了,后面单独起一篇博客,写辅助扩容的逻辑,这里大致讲下辅助扩容的逻辑:
如果tab[i]位置已经存在元素,那就会继续走这里的判断,这里的 f 是在上面获取到的tab[i]位置的node节点,这里判断,如果这个node节点的hash值为MOVED(-1),就表示当前tab[i]位置正在进行扩容,此时会通过helpTransfer()方法,进入到辅助扩容的逻辑,这里辅助扩容,用白话来讲:

  1. 当线程1插入元素时,如果发现要插入的tab[i]位置的hash值变为了MOVED,表示当前concurrentHashMap正在对tab[i]位置进行扩容,或者是已经完成了扩容,此时就不允许继续往tab[i]位置插入元素
  2. 有可能是线程2正在对tab[i]进行扩容
  3. 那此时线程1,就会暂停写入的动作,去去进行扩容,有可能线程2正在对 tab[16] - tab[31]位置的元素进行扩容,那此时线程1就会对tab[1] - tab[15]位置的元素进行扩容,可以理解为多线程并行操作,每个线程只负责一部分元素节点的扩容
  4. 这里所谓的扩容,简单来讲,就是把老的数组中的元素,迁移到新的数组中,所以在concurrentHashMap中,每个线程在进行数据迁移的时候,只会锁定一部分,处理一部分,接着锁定下一部分,继续迁移
四、对链路、红黑树进行遍历判断

如果进入到第四点的注释这里,表示,当前元素要插入的位置,已经有元素了,并且没有在进行扩容操作,所以,此时会看到,进来之后,先通过synchronized对f这个元素节点进行加锁,这个f节点,上面说过了,就是根据key的hash值和当前数组长度&运算之后,计算出来的要插入位置的root节点

在这里,实际上,需要区分链表和红黑树,虽然处理思想是相似的,但是因为两者数据结构不同,所以处理逻辑代码实现上会有所区别
这部分代码,是链表的场景下,进行元素插入的逻辑,我们看这段代码,大致的意思是:
会从链表的头结点,开始遍历,如果待插入的元素key和key的hash值和链表中的节点完全一致,表示需要进行覆盖操作,否则,继续获取next节点进行判断
如果遍历到节点尾部,依旧没有相同的节点,那就执行插入的动作,所谓的插入,就是根据key和value生成node节点,和最后一个节点的next指针关联上
在这里插入图片描述
这里是红黑树的处理逻辑,对于红黑树的插入逻辑,putTreeVal()方法里面的细节没有完全搞懂,先跳过,总之这个putTreeVal()方法,如果没有插入新的节点,而是进行了覆盖操作,会把被覆盖的old节点返回
在这里插入图片描述

五、判断是否需要转换为红黑树

这里主要和插入链表有关系,会看到,上面在遍历链表的时候,会依次累加bitCount的值,在这里,会根据bitCount的值,判断是否需要树化,如果超过了指定阈值,就会把红黑树转换为链表

treeifyBin的逻辑大致是这个样子的

  1. 在树化前的链表,是单向链表,只有next指针
  2. 在树化的时候,会将单向链表转换为双向链表,分别有next和prev指针
  3. 在转换的过程中,会依次向红黑数中插入元素,在插入的过程中,红黑树的root节点,可能会发生变化,当root节点发生变化时,会把最新的root节点,移动到双向链表的头部
  4. 直到最后一个节点插入到树中完毕,就会形成下图右边的两个数据结构
  5. 转换之后,tab[i]位置存放的就是root节点这一个元素,根据这个元素,可以很快的从树中找到要使用的元素

在这里插入图片描述

六、判断是否需要进行扩容

在addCount()方法中,会把当前hashmap中统计总元素个数的值 + 1,这里需要知道,如果在put的时候,做的是覆盖操作,在第五步判断是否需要进行树化的时候,就会return,在这里插入图片描述
如果oldVal不为null,表示是覆盖操作,直接return即可

如果没有覆盖,而是插入了一个新的元素,那就需要执行addCount()的逻辑,这个逻辑,也不贴代码了,这里后面单起博客说明
这里addCount()的方法,也是线程安全的表现,在对volatile修饰的baseCount进行+1时,使用的是cas方法来完成的
这里的逻辑,其实和LongAdder的思想是类似的

  1. 通过cas,对baseCount进行+1,如果cas成功,return,如果cas失败,表示有其他线程对baseCount也在进行操作
  2. 此时会通过额外的一个数组来完成+1,这个数组中的元素,也是volatile修饰的,会根据key,与这个额外的数据,进行&运算,计算一个值,假设为n
  3. 会取这个额外数组中n位置的元素,然后通过cas进行+1
  4. 所以我们会发现,concurrentHashMap,我们通过方法获取元素个数的时候,并不是直接return了baseCount,而是需要把数组中的元素值也加上
    在这里插入图片描述

这里的counterCells就是我们上面说的额外的数组
在这里插入图片描述

总结

所以,总结来看,concurrentHashMap线程安全的原因有以下几点

  1. 会通过cas,对tab[i]位置进行赋值
  2. 在插入元素的时候,会通过synchronized来加锁
  3. 在统计hashmap中元素个数的时候,通过volatile修饰的变量,以及cas操作来完成+1
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值