ConcurrentHashMap源码解析,多线程扩容

前面一篇已经介绍过了 HashMap 的源码:

HashMap源码解析、jdk7和8之后的区别、相关问题分析

HashMap并不是线程安全的,他就一个普通的容器,没有做相关的同步处理,因此线程不安全主要体现在:

  • put、get 等等核心方法在多线程情况下,都会出现修改的覆盖,数据不一致等等问题。比如多个线程 put 先后的问题,会导致结果覆盖,如果一个 put 一个get,也可能会因为调度问题获取到错误的结果;
  • 多线程操作有读有写的时候,可能会出现一个典型异常:ConcurrentModificationException
  • 另外扩容的时候,hashmap1.7 的实现还有可能出现死循环的问题。

关于线程安全的哈希映射的选择有三种:

  • Hashtable;
  • SynchronizedMap对HashMap包装;
  • ConcurrentHashMap

  1. 其中,Hashtable 的效率比较低,因为他的每一个方法都是用了锁,synchronized 修饰的;
  2. 用 SynchronizedMap 对 HashMap 包装的实质也是额外加入一个对象叫做 mutex,是一个 Object,然后给对应的方法上都加上 synchronized(mutex),当然比 Hashtable 是要好一些的,因为锁对象粒度要小一些。Hashtable 采用的 synchronized 锁上方法锁定的是整个 this。

ConcurrentHashMap则是最好的选择,这里我们来看看他的源码原理。


一、ConcurrentHashMap 数据结构


其实可以想见,一个功能完全一致的容器,和原来的 HashMap 相比,肯定结构不会差到哪里去,实际上也是这样。jdk8之后 HashMap 引入红黑树的优化,ConcurrentHashMap 也有,所以我们还是分 7 和 8 来说:

1.1 jdk 7 的 ConcurrentHashMap


由 Segment 片段,HashEntry组成,和HashMap一样,仍然是枚举链表。

  • 很简单,就是 Segment 数组又分成了 HashEntry 类型的数组。
  • 那 Segment 类型是什么呢?这个内部类维护的就是HashEntry 类型的数组。
  • HashEntry 是什么呢?就是链表。

所以其实首先在容器存储的核心结构上,把当时对应的 HashMap 分成了更细的粒度。

与此同时,Segment 数组是继承了 ReentrantLock 的,再往下层 HashEntry 类型的数组 table 和 HashEntry 这个链表节点都是用 volatile 修饰的。

1.2 jdk 8 的 ConcurrentHashMap


显然是因为 jdk 8 的 HashMap 更新了结构,所以对应的 ConcurrentHashMap 也在这方面跟着改变了。

jdk 8 的实现已经摒弃了 Segment 的概念,而是直接用Node数组+链表+红黑树,和 HashMap 一模一样的数据结构来实现,虽然源码里还能看到Segment的数据结构,但是已经简化了属性,只是为了兼容旧版本。

对应的,不再是 Entry 而是 Node 节点,就是因为要转树,对应的方法操作,最重要的就是没有显式的再使用 ReentrantLock,用到了 synchronized 和 CAS 操作,Node 节点也是 volatile 修饰的。


二、ConcurrentHashMap 的内部方法


2.1 jdk7 的 put ,get ,扩容方法


这里没有源码的截图,但是过程是清晰的。

put 方法(加锁):

  1. 将当前 Segment 中的表通过 key 的哈希码定位到HashEntry。这一步会尝试获取锁,如果获取失败肯定存在其他线程存在竞争,则利用 scanAndLockForPut() 方法去获取锁。
  2. 遍历该 HashEntry,如果不为空则判断预定的 key 和当前遍历的 key 是否替代,替代则覆盖旧的值。
  3. 不为空则需要新建一个 HashEntry 并加入到 Segment 中,同时会先判断是否需要扩容。
  4. 最后会解除在 1 中所获取当前Segment的锁。

可以看到,整体就是获取锁的中间进行 put 操作,put 操作的流程也和 HashMap 的 put 是类似的。

get 方法(不加锁)

ConcurrentHashMap 的 get 操作跟 HashMap 类似,只是ConcurrentHashMap 第一次需要经过一次 hash 定位到 Segment 的位置,然后再 hash 定位到指定的 HashEntry,遍历该 HashEntry 下的链表进行对比,成功就返回,不成功就返回 null。

并且 get 的过程调用的是 Unsafe 包的 getObjectVolatile 方法,因为具体的对象是 volatile 修饰的,不用加锁,读取也可以直接读到最新的值。

rehash 方法(加锁)

ConcurrentHashMap 的扩容方法和 HashMap 也是类似的,因为外部已经对 Segment 加锁,内部的操作就是重新计算 hash 值,然后重新移动元素。

这里可以看出来,因为有 Segment 的一个粒度缩小的优化,加上一个读写分离的普遍思想,jdk 7 实现的方法比较容易理解。

下来的 jdk 8 做出的优化非常多,因此几个方法分开来讲

2.2 jdk 8 的初始化


构造方法和 HashMap 一样,是不会初始化的,而是在第一次调用 put 方法之后才会进行初始化。构造方法调用的时候只是进行了一些参数的确定。

因此我们可以先看一下ConcurrentHashMap 里面的比较重要的参数:

    //最大容量
    private static final int MAXIMUM_CAPACITY = 1 << 30;
    //默认初始化容量
    private static final int DEFAULT_CAPACITY = 16;
    //负载因子
    private static final float LOAD_FACTOR = 0.75f;
    //链表转为红黑树的临界值
    static final int TREEIFY_THRESHOLD = 8;
    //红黑树转为链表的临界值
    static final int UNTREEIFY_THRESHOLD = 6;
    //当容量大于64时,链表才会转为红黑树,否则,即便链表长度大于8,也不会转,而是会扩容
    static final int MIN_TREEIFY_CAPACITY = 64;

可以看到,以上的几个属性和 HashMap 一模一样,除此之外,比较重要的一个参数就是:

    private transient volatile int sizeCtl;

在计算出数组长度、也就是装整个 Map 的那个 Node[] table 的长度之后,是赋值给 sizeCtl 这个元素的。

如果说使用无参构造,那么初始化的时候,sizeCtl 会是 16,其他的情况会计算成为一个 2 的幂次方数,也和 HashMap 是一样的。另外,sizeCtl 这个参数的值还有别的含义:

  • 负数代表正在进行初始化或扩容操作

    • -1代表正在初始化
    • -N 表示,这个高16位表示当前扩容的标志,每次扩容都会生成一个不一样的标志,低16位表示参与扩容的线程数量
  • 正数或 0,0 代表 hash 表那个数组还没有被初始化,正数表示达到这个值需要扩容(扩容阈值,其实就等于(容量 * 负载因子),也就是数组长度*0.75)。

还有一部分基本是和扩容相关的属性,第一眼看过去可能不能理解这些什么时候会用,下面讲到扩容方法的时候就会用到:

    //扩容相关,每个线程负责最小桶个数
    private static final int MIN_TRANSFER_STRIDE = 16;
    //扩容相关,为了计算sizeCtl
    private static int RESIZE_STAMP_BITS = 16;
    //最大辅助扩容线程数量
    private static final int MAX_RESIZERS = (1 << (32 - RESIZE_STAMP_BITS)) - 1;
    //扩容相关,为了计算sizeCtl
    private static final int RESIZE_STAMP_SHIFT = 32 - RESIZE_STAMP_BITS;
    //下面几个是状态值
    //MOVED表示正在扩容
    static final int MOVED     = -1; // hash for forwarding nodes
    //-2表示红黑树标识
    static final int TREEBIN   = -2; // hash for roots of trees
    static final int RESERVED  = -3; // hash for transient reservations
    //计算Hash值使用
    static final int HASH_BITS = 0x7fffffff; // usable bits of normal node hash
    //可用CPU核数
    static final int NCPU = Runtime.getRuntime().availableProcessors();
    //用于记录容器中插入的元素数量
    private transient volatile long baseCount;

2.3 jdk 8 的 put() 方法


和 jdk 8 的 HashMap 方法一样,直接调用的是 putVal 方法去执行。

  1. 计算出 hash。(hash值的计算是通过hashCode进行spread方法的再次计算,一定是一个正数,也是后面再次计算取模得到在table中位置的依据)
  2. 判断是否需要进行初始化,也就是第一次put 的时候将容器进行初始化,初始化调用的是 initTable 方法。(这个方法里面利用到了上面的 sizeCtl 参数,通过 CAS 操作来判断是不是有别的线程同时在做初始化,保证只有一个线程在做初始化的操作,没有加锁
  3. f 即为当前 key 定位出的 Node,node 的位置就是通过括号里面的 tabAt 计算的,如果为空表示当前位置,也就是数组的这个位置是个空,可以写入数据。也是利用 CAS 尝试写入,失败则自旋保证成功,可以看到这里,因为定位到的那个 Node 是个空链表,所以就直接利用了 CAS 操作(也没有加锁
  4. 那如果不是空,就进行到下面的 else if,如果判断哈希值 == MOVED,代表数组正在扩容,那么就会进行 helperTransfer 方法进行协助扩容,因为没办法继续put了
  5. 否则进入下一个 else if ,这里是jdk11有,但是8是没有的,这里用到了OnlyIfAbsent变量,实现的是而 putIfAbsent,也就是在放入数据时,如果存在重复的key,那么putIfAbsent不会放入值(并不像put 那样覆盖)。
  6. 否则进入下一个 else,也就是不属于上面任何一个特殊情况的插入,需要遍历这里面的链表进行插入,可以看到利用了 synchronized加锁 然后,遍历链表写入数据,那如果不是链表,是树节点,就走另一个分支去遍历插入。插入完成之后,就常规的将元素个数+1 并结束,那么+1的时候调用的是 addCount 方法,这个方法就涉及到可能会扩容,下面有详细讲解。

可以看到,这种 put 将加锁的粒度又变得更小,仅仅锁到了那个,要插入的数组的位置的那个链表(或者树根节点),显然根本不会影响到其他的不在数组的这个位置的插入操作

要知道确定的那个桶里的元素本来由于 hashmap 的升级,都是很均匀的,而且 synchronized 本身的实现又有优化,所以比 7 的做法好很多。

2.4 jdk 8 的 get() 方法


  1. 同样计算出索引位置;
  2. 如果在这个索引位置的根节点获取到了直接返回;
  3. 否则如果说 eh < 0,代表是一个红黑树的状态,那么就会调用 find 方法去查找;
  4. 否则就遍历链表,找到值并返回。

可以看到get 方法因为节点的对象本身就都是 volatile 的,具有可见性,因此 get 方法直接找到返回,否则返回 null,没有任何加锁操作

2.5 jdk 8 的 addCount 方法


添加元素的相关操作之后,最后会调用 addCount 方法,也就是判断是否需要扩容,在这里面控制不同的策略。

整体思路,在圈出来的判断 check>= 0 之前的操作:是对 hashMap 的 size 进行更新,为了防止多个线程竞争更改 baseCount 的值,会将多个线程分散到一个叫 CounterCell 的数组里面,对 cell 中的value值进行更改,最后再同步给 baseCount。

然后开始判断 check :

  1. 如果新容量大于当前的阈值(大小*0.75),才扩容;
  2. 如果 sc < 0 ,说明当前是有别的线程进行扩容操作的,因此要先判断一些极端情况,然后用 CAS 操作去修改 sizCtl ,增加一个协助扩容的线程,调用 transfer 方法,这个是扩容的核心方法
  3. 否则 sc 肯定是 >=0 了,代表数组还没创建,同样用 CAS 操作创建,再去扩容。

2.6 jdk8 的扩容方法 transfer()


transfer 方法主要就是完成将扩容任务分配给多个线程去处理,根据了CPU核心数和集合 length 计算每个核一轮处理桶的个数。

然后每个线程处理的最小单位只能是一个数组的位置,这个时候扩容之后,和HashMap 一样,其实只有原位置或者 原位置+数组长度 的位置,因为仍然有可能多个线程操作之间发生哈希冲突,就用到 synchronized。

源码很长,这里的详细注释参考的是一个博客:

https://www.cnblogs.com/gunduzi/p/13651664.html

/**
* Moves and/or copies the nodes in each bin to new table. See
* above for explanation.
*
* transferIndex 表示转移时的下标,初始为扩容前的 length。
*
* 我们假设长度是 32
*/
private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
    int n = tab.length, stride;
    // 将 length / 8 然后除以 CPU核心数。如果得到的结果小于 16,那么就使用 16。
    // 这里的目的是让每个 CPU 处理的桶一样多,避免出现转移任务不均匀的现象,如果桶较少的话,默认一个 CPU(一个线程)处理 16 个桶
    if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)
        stride = MIN_TRANSFER_STRIDE; // subdivide range 细分范围 stridea:TODO
    // 新的 table 尚未初始化
    if (nextTab == null) {            // initiating
        try {
            // 扩容  2 倍
            Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n << 1];
            // 更新
            nextTab = nt;
        } catch (Throwable ex) {      // try to cope with OOME
            // 扩容失败, sizeCtl 使用 int 最大值。
            sizeCtl = Integer.MAX_VALUE;
            return;// 结束
        }
        // 更新成员变量
        nextTable = nextTab;
        // 更新转移下标,就是 老的 tab 的 length
        transferIndex = n;
    }
    // 新 tab 的 length
    int nextn = nextTab.length;
    // 创建一个 fwd 节点,用于占位。当别的线程发现这个槽位中是 fwd 类型的节点,则跳过这个节点。
    ForwardingNode<K,V> fwd = new ForwardingNode<K,V>(nextTab);
    // 首次推进为 true,如果等于 true,说明需要再次推进一个下标(i--),反之,如果是 false,那么就不能推进下标,需要将当前的下标处理完毕才能继续推进
    boolean advance = true;
    // 完成状态,如果是 true,就结束此方法。
    boolean finishing = false; // to ensure sweep before committing nextTab
    // 死循环,i 表示下标,bound 表示当前线程可以处理的当前桶区间最小下标,死循环的作用是保证拷贝全部完成。
    for (int i = 0, bound = 0;;) {
        Node<K,V> f; int fh;
        // 如果当前线程可以向后推进;这个循环就是控制 i 递减。同时,每个线程都会进入这里取得自己需要转移的桶的区间        //这个循环只是用来控制每个线程每轮最多copy的桶的个数,如果只有一个线程在扩容,也是可以完成的,只是分成多轮
        while (advance) {
            int nextIndex, nextBound;
            // 对 i 减一,判断是否大于等于 bound (正常情况下,如果大于 bound 不成立,说明该线程上次领取的任务已经完成了。那么,需要在下面继续领取任务)
            // 如果对 i 减一大于等于 bound(还需要继续做任务),或者完成了,修改推进状态为 false,不能推进了。任务成功后修改推进状态为 true。
            // 通常,第一次进入循环,i-- 这个判断会无法通过,从而走下面的 nextIndex 赋值操作(获取最新的转移下标)。其余情况都是:如果可以推进,            //将 i 减一,然后修改成不可推进。如果 i 对应的桶处理成功了,改成可以推进。
            if (--i >= bound || finishing)
                advance = false;// 这里设置 false,是为了防止在没有成功处理一个桶的情况下却进行了推进
                // 这里的目的是:1. 当一个线程进入时,会选取最新的转移下标。2. 当一个线程处理完自己的区间时,如果还有剩余区间的没有别的线程处理。再次获取区间。
            else if ((nextIndex = transferIndex) <= 0) {
                // 如果小于等于0,说明没有区间了 ,i 改成 -1,推进状态变成 false,不再推进,表示,扩容结束了,当前线程可以退出了
                // 这个 -1 会在下面的 if 块里判断,从而进入完成状态判断
                i = -1;
                advance = false;// 这里设置 false,是为了防止在没有成功处理一个桶的情况下却进行了推进
            }// CAS 修改 transferIndex,即 length - 区间值,留下剩余的区间值供后面的线程使用
            else if (U.compareAndSwapInt
                    (this, TRANSFERINDEX, nextIndex,
                            nextBound = (nextIndex > stride ?
                                    nextIndex - stride : 0))) {
                bound = nextBound;// 这个值就是当前线程可以处理的最小当前区间最小下标
                i = nextIndex - 1; // 初次对i 赋值,这个就是当前线程可以处理的当前区间的最大下标
                advance = false; // 这里设置 false,是为了防止在没有成功处理一个桶的情况下却进行了推进,这样对导致漏掉某个桶。下面的 if (tabAt(tab, i) == f) 判断会出现这样的情况。
            }
        }
        // 如果 i 小于0 (不在 tab 下标内,按照上面的判断,领取最后一段区间的线程扩容结束)
        //  如果 i >= tab.length(不知道为什么这么判断)
        //  如果 i + tab.length >= nextTable.length  (不知道为什么这么判断)
        if (i < 0 || i >= n || i + n >= nextn) {
            int sc;
            if (finishing) { // 如果完成了扩容
                nextTable = null;// 删除成员变量
                table = nextTab;// 更新 table
                sizeCtl = (n << 1) - (n >>> 1); // 更新阈值
                return;// 结束方法。
            }// 如果没完成             //说明1
            if (U.compareAndSwapInt(this, SIZECTL, sc = sizeCtl, sc - 1)) {// 尝试将 sc -1. 表示这个线程结束帮助扩容了,将 sc 的低 16 位减一。
                if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT)// 如果 sc - 2 不等于标识符左移 16 位。如果他们相等了,说明没有线程在帮助他们扩容了。也就是说,扩容结束了。
                    return;// 不相等,说明没结束,当前线程结束方法。
                finishing = advance = true;// 如果相等,扩容结束了,更新 finising 变量
                i = n; // 再次循环检查一下整张表
            }
        }
        else if ((f = tabAt(tab, i)) == null) // 获取老 tab i 下标位置的变量,如果是 null,就使用 fwd 占位。
            advance = casTabAt(tab, i, null, fwd);// 如果成功写入 fwd 占位,再次推进一个下标
        else if ((fh = f.hash) == MOVED)// 如果不是 null 且 hash 值是 MOVED。
            advance = true; // already processed // 说明别的线程已经处理过了,再次推进一个下标
        else {// 到这里,说明这个位置有实际值了,且不是占位符。对这个节点上锁。为什么上锁,防止 putVal 的时候向链表插入数据
            synchronized (f) {
                // 判断 i 下标处的桶节点是否和 f 相同
                if (tabAt(tab, i) == f) {
                    Node<K,V> ln, hn;// low, height 高位桶,低位桶
                    // 如果 f 的 hash 值大于 0 。TreeBin 的 hash 是 -2
                    if (fh >= 0) {
                        // 对老长度进行与运算(第一个操作数的的第n位于第二个操作数的第n位如果都是1,那么结果的第n为也为1,否则为0)
                        // 由于 Map 的长度都是 2 的次方(000001000 这类的数字),那么取于 length 只有 2 种结果,一种是 0,一种是1
                        //  如果是结果是0 ,Doug Lea 将其放在低位,反之放在高位,目的是将链表重新 hash,放到对应的位置上,让新的取于算法能够击中他。
                        int runBit = fh & n;
                        Node<K,V> lastRun = f; // 尾节点,且和头节点的 hash 值取于不相等
                        // 遍历这个桶                        //说明2
                        for (Node<K,V> p = f.next; p != null; p = p.next) {
                            // 取于桶中每个节点的 hash 值
                            int b = p.hash & n;
                            // 如果节点的 hash 值和首节点的 hash 值取于结果不同
                            if (b != runBit) {
                                runBit = b; // 更新 runBit,用于下面判断 lastRun 该赋值给 ln 还是 hn。
                                lastRun = p; // 这个 lastRun 保证后面的节点与自己的取于值相同,避免后面没有必要的循环
                            }
                        }
                        if (runBit == 0) {// 如果最后更新的 runBit 是 0 ,设置低位节点
                            ln = lastRun;
                            hn = null;
                        }
                        else {
                            hn = lastRun; // 如果最后更新的 runBit 是 1, 设置高位节点
                            ln = null;
                        }// 再次循环,生成两个链表,lastRun 作为停止条件,这样就是避免无谓的循环(lastRun 后面都是相同的取于结果)
                        for (Node<K,V> p = f; p != lastRun; p = p.next) {
                            int ph = p.hash; K pk = p.key; V pv = p.val;
                            // 如果与运算结果是 0,那么就还在低位
                            if ((ph & n) == 0) // 如果是0 ,那么创建低位节点
                                ln = new Node<K,V>(ph, pk, pv, ln);
                            else // 1 则创建高位
                                hn = new Node<K,V>(ph, pk, pv, hn);
                        }
                        // 其实这里类似 hashMap
                        // 设置低位链表放在新链表的 i
                        setTabAt(nextTab, i, ln);
                        // 设置高位链表,在原有长度上加 n
                        setTabAt(nextTab, i + n, hn);
                        // 将旧的链表设置成占位符
                        setTabAt(tab, i, fwd);
                        // 继续向后推进
                        advance = true;
                    }// 如果是红黑树
                    else if (f instanceof TreeBin) {
                        TreeBin<K,V> t = (TreeBin<K,V>)f;
                        TreeNode<K,V> lo = null, loTail = null;
                        TreeNode<K,V> hi = null, hiTail = null;
                        int lc = 0, hc = 0;
                        // 遍历
                        for (Node<K,V> e = t.first; e != null; e = e.next) {
                            int h = e.hash;
                            TreeNode<K,V> p = new TreeNode<K,V>
                                    (h, e.key, e.val, null, null);
                            // 和链表相同的判断,与运算 == 0 的放在低位
                            if ((h & n) == 0) {
                                if ((p.prev = loTail) == null)
                                    lo = p;
                                else
                                    loTail.next = p;
                                loTail = p;
                                ++lc;
                            } // 不是 0 的放在高位
                            else {
                                if ((p.prev = hiTail) == null)
                                    hi = p;
                                else
                                    hiTail.next = p;
                                hiTail = p;
                                ++hc;
                            }
                        }
                        // 如果树的节点数小于等于 6,那么转成链表,反之,创建一个新的树
                        ln = (lc <= UNTREEIFY_THRESHOLD) ? untreeify(lo) :
                                (hc != 0) ? new TreeBin<K,V>(lo) : t;
                        hn = (hc <= UNTREEIFY_THRESHOLD) ? untreeify(hi) :
                                (lc != 0) ? new TreeBin<K,V>(hi) : t;
                        // 低位树
                        setTabAt(nextTab, i, ln);
                        // 高位数
                        setTabAt(nextTab, i + n, hn);
                        // 旧的设置成占位符
                        setTabAt(tab, i, fwd);
                        // 继续向后推进
                        advance = true;
                    }
                }
            }
        }
    }
}

流程如下:

  1. 根据操作系统的 CPU 核数和集合 length 计算每个核一轮处理桶的个数,最小是16
  2. 修改 transferIndex 标志位,每个线程领取完任务就减去多少,比如初始大小是transferIndex = table.length = 64,每个线程领取的桶个数是16,第一个线程领取完任务后transferIndex = 48,也就是说第二个线程这时进来是从第 48 个桶开始处理,再减去16,依次类推,这就是多线程协作处理的原理
  3. 领取完任务之后就开始处理,如果桶为空就设置为 ForwardingNode ,如果不为空就加锁拷贝,只有这里用到了 synchronized 关键字来加锁,为了防止拷贝的过程有其他线程在put元素进来。拷贝完成之后也设置为 ForwardingNode节点。
  4. 如果某个线程分配的桶处理完了之后,再去申请,发现 transferIndex = 0,这个时候就说明所有的桶都领取完了,但是别的线程领取任务之后有没有处理完并不知道,该线程会将 sizeCtl 的值减1,然后判断是不是所有线程都退出了,如果还有线程在处理,就退出
  5. 直到最后一个线程处理完,发现 sizeCtl = rs<< RESIZE_STAMP_SHIFT 也就是标识符左移 16 位,才会将旧数组干掉,用新数组覆盖,并且会重新设置 sizeCtl 为新数组的扩容点。

以上过程总的来说分成两个部分:

  • 分配任务:这部分其实很简单,就是把一个大的数组给切分,切分多个小份,然后每个线程处理其中每一小份,当然可能就只有1个或者几个线程在扩容,那就一轮一轮的处理,一轮处理一份
  • 处理任务:复制部分主要有两点,第一点就是加锁,第二点就是处理完之后置为ForwardingNode来占位标识这个位置被迁移过了。

2.7 jdk8 的协助扩容 helpTransfer()方法


如果说 put 的时候发现数组正在扩容,会执行 helpTransfer 方法,也就是这个线程来帮助进行扩容。

我们还要看一下 helpTransfer 方法:

和普通的扩容方法也是类似的。

到这里,可以发现 jdk8 之后 ConcurrentHashMap 的变化优化非常复杂…因此感觉和jdk7没什么必要对比了。

posted @ 2020-10-12 16:03  Life_Goes_On  阅读(1617)  评论(0编辑  收藏  举报