JUC之ConcurrentHashMap源码之扩容

Posted fondwang

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了JUC之ConcurrentHashMap源码之扩容相关的知识,希望对你有一定的参考价值。

一、扩容的基本思路

  JDK1.8中,ConcurrentHashMap最复杂的部分就是扩容/数据迁移,涉及多线程的合作和rehash。

扩容思路

Hash表的扩容包含的两个步骤:

① table数据的扩容

  table数组的扩容,一般就是新建一个2倍大小的桶数组,这个过程通过一个单线程完成,且不允许出现并发。

② 数据迁移

  所谓数据迁移,就是把旧table中的各个桶中的节点重新分配到新table中。比如,单线程情况下,可以遍历原来的table,然后put到新table中。

  上面的过程涉及到桶中key的rehash,因为key映射到桶的位置与table的大小有关,新table的大小变了,key映射的位置一般也会变化。

  ConcurrentHashMap在处理rehash时,并不会重新计算每个key的hash值,而是利用了一种很巧妙的方法。

  我们在上一篇说过,ConcurrentHashMap内部的table数组的大小必须是2的幂次:

原因①:是让key均匀分布,减少冲突;

原因②:当table数组的大小为2的幂次时,通过key.hash & table.length-1这种方式计算出的索引i,当table扩容后(2倍),新的索引要么在原来的位置i,要么是i+n

  这种处理方式非常利于扩容时多个线程同时进行的数据迁移操作,因为旧table的各个桶中的节点迁移不会互相影响,所以就可以用“分治”的方式,将整个table数组划分为很多部分,每一部分包含一定区间的桶,每个数据迁移线程处理个区间中的节点,对多线程同时进行数据迁移非常有利。

扩容时机

  当往Map中插入节点时,如果链表的节点数目超过临界值时,就会触发链表转换红黑树的操作

if (binCount >= TREEIFY_THRESHOLD)

   treeifyBin(tab, i); // 链表 -> 红黑树 转换

现在分析下treeifyBin这个操作

// 尝试进行 链表 -> 红黑树 的转换.
private final void treeifyBin(Node<K, V>[] tab, int index) {
    Node<K, V> b;
    int n, sc;
    if (tab != null) {
        // CASE 1: table的容量 < MIN_TREEIFY_CAPACITY(64)时,直接进行table扩容,不进行红黑树转换
        if ((n = tab.length) < MIN_TREEIFY_CAPACITY)
            tryPresize(n << 1);
            // CASE 2: table的容量 ≥ MIN_TREEIFY_CAPACITY(64)时,进行链表 -> 红黑树转换
        else if ((b = tabAt(tab, index)) != null && b.hash >= 0) {
            synchronized (b) {
                if (tabAt(tab, index) == b) {
                    TreeNode<K, V> hd = null, tl = null;
                    // 遍历链表,建立红黑树
                    for (Node<K, V> e = b; e != null; e = e.next) {
                        TreeNode<K, V> p = new TreeNode<K, V>(e.hash, e.key, e.val, null, null);
                        if ((p.prev = tl) == null)
                            hd = p;
                        else
                            tl.next = p;
                        tl = p;
                    }
                    // 以TreeBin类型包装,并链接到table[index]中
                    setTabAt(tab, index, new TreeBin<K, V>(hd));
                }
            }
        }
    }
}

 

上诉第一个分支中,还会再对table数组的长度进行一次判断。

如果table长度小于临界值 MIN_TREEIFY_CAPACITY——默认64,则会调用tryPresize方法把数组长度扩大到原来的两倍。

链表 -> 红黑树这一转换并不是一定会进行的,table长度较小时,CurrentHashMap会首先选择扩容,而非立即转换成红黑树。

putAll 批量插入或者插入节点后发现链表长度达到8个或以上,但数组长度为64以下时触发的扩容会调用到这个方法

第一种扩容:对table数组进行扩容。源码如下

// 尝试对table数组进行扩容. size为待扩容大小
private final void tryPresize(int size) {
    // 视情况将size调整为2的幂次
    int c = (size >= (MAXIMUM_CAPACITY >>> 1)) ? MAXIMUM_CAPACITY : tableSizeFor(size + (size >>> 1) + 1);
    int sc;
   // 如果不满足条件,也就是sizeCtl < 0, 说明有其他线程正在扩容当中,这里也就不需要自己取扩容了,结束该方法
while ((sc = sizeCtl) >= 0) { Node<K, V>[] tab = table; int n; //CASE 1: table还未初始化,则先进行初始化 if (tab == null || (n = tab.length) == 0) { n = (sc > c) ? sc : c;
       // 初始化时将sizeCtl 设置为 -1,保证单线程的初始化
if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) { try { if (table == tab) { Node<K, V>[] nt = (Node<K, V>[]) new Node<?, ?>[n]; table = nt; sc = n - (n >>> 2); } } finally { sizeCtl = sc;//初始化完成后 sizeCtl 用于记录当前集合的负载容量值,也就是触发集合扩容的临界值 } } } // CASE2: c <= sc说明已经被扩容过了;n >= MAXIMUM_CAPACITY说明table数组已达到最大容量 else if (c <= sc || n >= MAXIMUM_CAPACITY) break; // CASE3: 进行table扩容 else if (tab == table) { int rs = resizeStamp(n); // 根据容量n生成一个随机数,唯一标识本次扩容操作 if (sc < 0) { // sc < 0 表明此时有别的线程正在进行扩容 Node<K, V>[] nt; // 判断扩容是否结束或者并发扩容线程数是否已达到最大值,如果是的话直接结束while循环 if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 || sc == rs + MAX_RESIZERS || (nt = nextTable) == null || transferIndex <= 0) break; // 扩容还未结束,并且允许扩容线程加入,此时加入扩容大军中 if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1)) transfer(tab, nt); } // sc置为负数, 当前线程自身成为第一个执行transfer(数据转移)的线程 // 这个CAS操作可以保证,仅有一个线程会执行扩容,
//(rs << RESIZE_STAMP_SHIFT) + 2 为首个扩容线程所设置的特定值,后面扩容时会根据线程是否为这个值来确定是否为最后一个线程
else if (U.compareAndSwapInt(this, SIZECTL, sc, (rs << RESIZE_STAMP_SHIFT) + 2)) transfer(tab, null); } } }

 

前两个分支没什么好说的,看下注释很容易理解,关键看第三个分支——CASE3:进行table扩容。 CASE3其实分为两种情况:

1. 已经有其他线程正在执行扩容,则当前线程会尝试协助 “数据迁移”;(多线程并发)

2. 没有其他线程正在执行扩容,则当前线程自身发起扩容。(单线程)

注意:这两种情况都是调用了transfer方法,通过第二个入参nextTab进行区分:nextTab表示扩容后的新table数组,如果为null,表示首次发起扩容

二、扩容的原理

transfer方法,可以被多个线程同时调用,也是 “数据迁移” 的核心操作方法:

调用该扩容方法的地方有:

 

java.util.concurrent.ConcurrentHashMap#addCount 向集合中插入新数据后更新容量计数时发现到达扩容阈值而触发的扩容
java.util.concurrent.ConcurrentHashMap#helpTransfer 扩容状态下其他线程对集合进行插入、修改、删除、合并、compute 等操作时遇到 ForwardingNode 节点时触发的扩容
java.util.concurrent.ConcurrentHashMap#tryPresize putAll批量插入或者插入后发现链表长度达到8个或以上,但数组长度为64以下时触发的扩容

 

/**
 * 数据转移和扩容.
 * 每个调用tranfer的线程会对当前旧table中[transferIndex-stride, transferIndex-1]位置的结点进行迁移
 * @param tab 旧table数组, @param nextTab 新nextTab数组*/
private final void transfer(Node<K, V>[] tab, Node<K, V>[] nextTab) {
    int n = tab.length, stride;

     // stride可理解成“步长”,即数据迁移时,每个线程要负责旧table中的多少个桶
     //计算每条线程处理的桶个数,每条线程处理的桶数量一样,如果CPU为单核,则使用一条线程处理所有桶
    //每条线程至少处理16个桶,如果计算出来的结果少于16,则一条线程处理16个桶
    if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)
        stride = MIN_TRANSFER_STRIDE;

    if (nextTab == null) {           // 首次扩容
        try {
            // 创建新table数组
            Node<K, V>[] nt = (Node<K, V>[]) new Node<?, ?>[n << 1];
            nextTab = nt;
        } catch (Throwable ex) {      // 处理内存溢出(OOME)的情况
            sizeCtl = Integer.MAX_VALUE;
            return;
        }
        nextTable = nextTab;
     //将 transferIndex 指向最右边的桶,也就是数组索引下标最大的位置 transferIndex
= n; // [transferIndex-stride, transferIndex-1]表示当前线程要进行数据迁移的桶区间 } int nextn = nextTab.length;

     //新建一个占位对象,该占位对象的 hash 值为 -1 该占位对象存在时表示集合正在扩容状态,key、value、next 属性均为 null ,nextTable 属性指向扩容后的数组
    //该占位对象主要有两个用途:
    // 1、占位作用,用于标识数组该位置的桶已经迁移完毕,处于扩容中的状态。
    // 2、作为一个转发的作用,扩容期间如果遇到查询操作,遇到转发节点,会把该查询操作转发到新的数组上去,不会阻塞查询操作

// ForwardingNode结点,当旧table的某个桶中的所有结点都迁移完后,用该结点占据这个桶
    ForwardingNode<K, V> fwd = new ForwardingNode<K, V>(nextTab);
    //该标识用于控制是否继续处理下一个桶,为 true 则表示已经处理完当前桶,可以继续迁移下一个桶的数据
    boolean advance = true;
    // 该标识用于控制扩容何时结束,该标识还有一个用途是最后一个扩容线程会负责重新检查一遍数组查看是否有遗漏的桶
    boolean finishing = false;

   //这个循环用于处理一个 stride 长度的任务,i 后面会被赋值为该 stride 内最大的下标,而 bound 后面会被赋值为该 stride 内最小的下标
   //通过循环不断减小 i 的值,从右往左依次迁移桶上面的数据,直到 i 小于 bound 时结束该次长度为 stride 的迁移任务
   //结束这次的任务后会通过外层 addCount、helpTransfer、tryPresize 方法的 while 循环达到继续领取其他任务的效果

    for (int i = 0, bound = 0; ; ) {
        Node<K, V> f;
        int fh;
        // 每一次自旋前的预处理,主要是定位本轮处理的桶区间
        // 正常情况下,预处理完成后:i == transferIndex-1,bound == transferIndex-stride
        while (advance) {
            int nextIndex, nextBound;
//每处理完一个hash桶就将 bound 进行减 1 操作
if (--i >= bound || finishing) advance = false; else if ((nextIndex = transferIndex) <= 0) {
          //transferIndex <= 0 说明数组的hash桶已被线程分配完毕,没有了待分配的hash桶,将 i 设置为 -1 ,后面的代码根据这个数值退出当前线的扩容操作 i
= -1; advance = false; }
        //只有首次进入for循环才会进入这个判断里面去,设置 bound 和 i 的值,也就是领取到的迁移任务的数组区间
        else if (U.compareAndSwapInt(this, TRANSFERINDEX, nextIndex,nextBound = (nextIndex > stride ? nextIndex - stride : 0))) { bound = nextBound; i = nextIndex - 1; advance = false; } } if (i < 0 || i >= n || i + n >= nextn) { // CASE1:当前是处理最后一个tranfer任务的线程或出现扩容冲突 int sc;

//扩容结束后做后续工作,将 nextTable 设置为 null,表示扩容已结束,将 table 指向新数组,sizeCtl 设置为扩容临界值
if (finishing) { // 所有桶迁移均已完成 nextTable = null; table = nextTab; sizeCtl = (n << 1) - (n >>> 1); return; } // //每当一条线程扩容结束就会更新一次 sizeCtl 的值,进行扩容数减 1 操作 if (U.compareAndSwapInt(this, SIZECTL, sc = sizeCtl, sc - 1)) { // 判断当前线程是否是本轮扩容中的最后一个线程,如果不是,则直接退出 if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT) return; finishing = advance = true; /** * 最后一个数据迁移线程要重新检查一次旧table中的所有桶,看是否都被正确迁移到新table了: * ①正常情况下,重新检查时,旧table的所有桶都应该是ForwardingNode; * ②特殊情况下,比如扩容冲突(多个线程申请到了同一个transfer任务),此时当前线程领取的任务会作废,那么最后检查时, * 还要处理因为作废而没有被迁移的桶,把它们正确迁移到新table中 */ i = n; // recheck before commit } } else if ((f = tabAt(tab, i)) == null) // CASE2:旧桶本身为null,不用迁移,直接尝试放一个ForwardingNode advance = casTabAt(tab, i, null, fwd); else if ((fh = f.hash) == MOVED) // CASE3:该旧桶已经迁移完成,直接跳过 advance = true; else { // CASE4:该旧桶未迁移完成,进行数据迁移 synchronized (f) { if (tabAt(tab, i) == f) { Node<K, V> ln, hn; if (fh >= 0) { // CASE4.1:桶的hash>0,说明是链表迁移 /** * 下面的过程会将旧桶中的链表分成两部分:ln链和hn链 * ln链会插入到新table的槽i中,hn链会插入到新table的槽i+n中 */ int runBit = fh & n; // 由于n是2的幂次,所以runBit要么是0,要么高位是1 Node<K, V> lastRun = f; // lastRun指向最后一个相邻runBit不同的结点 for (Node<K, V> p = f.next; p != null; p = p.next) { int b = p.hash & n; if (b != runBit) { runBit = b; lastRun = p; } } if (runBit == 0) { ln = lastRun; hn = null; } else { hn = lastRun; ln = null; } // 以lastRun所指向的结点为分界,将链表拆成2个子链表ln、hn for (Node<K, V> p = f; p != lastRun; p = p.next) { int ph = p.hash; K pk = p.key; V pv = p.val; if ((ph & n) == 0) ln = new Node<K, V>(ph, pk, pv, ln); else hn = new Node<K, V>(ph, pk, pv, hn); }

                //setTabAt方法调用的是 Unsafe 类的 putObjectVolatile 方法
                //使用 volatile 方式的 putObjectVolatile 方法,能够将数据直接更新回主内存,并使得其他线程工作内存的对应变量失效,达到各线程数据及时同步的效果
                //使用 volatile 的方式将 ln 链设置到新数组下标为 i 的位置上

                        setTabAt(nextTab, i, ln);               // ln链表存入新桶的索引i位置
               //使用 volatile 的方式将 hn 链设置到新数组下标为 i + n(n为原数组长度) 的位置上 setTabAt(nextTab, i + n, hn); // hn链表存入新桶的索引i+n位置
               //迁移完成后使用 volatile 的方式将占位对象设置到该 hash 桶上,该占位对象的用途是标识该hash桶已被处理过,以及查询请求的转发作用 setTabAt(tab, i, fwd); // 设置ForwardingNode占位
               // advance 设置为 true 表示当前 hash 桶已处理完,可以继续处理下一个 hash 桶 advance = true; // 表示当前旧桶的结点已迁移完毕 } else if (f instanceof TreeBin) { // CASE4.2:红黑树迁移 // 下面的过程会先以链表方式遍历,复制所有结点,然后根据高低位组装成两个链表;然后看下是否需要进行红黑树转换,最后放到新table对应的桶中 TreeBin<K, V> t = (TreeBin<K, V>) f;
//lo 为低位链表头结点,loTail 为低位链表尾结点,hi 和 hiTail 为高位链表头尾结点 TreeNode
<K, V> lo = null, loTail = null; TreeNode<K, V> hi = null, hiTail = null; int lc = 0, hc = 0;
               // 同样也是使用高位和低位两条链表进行迁移
               // 使用for循环以链表方式遍历整棵红黑树,使用尾插法拼接 ln 和 hn 链表
for (Node<K, V> e = t.first; e != null; e = e.next) { int h = e.hash;
                 // 这里形成的是以 TreeNode 为节点的链表 TreeNode
<K, V> p = new TreeNode<K, V> (h, e.key, e.val, null, null); if ((h & n) == 0) { if ((p.prev = loTail) == null) lo = p; else loTail.next = p; loTail = p; ++lc; } else { if ((p.prev = hiTail) == null) hi = p; else hiTail.next = p; hiTail = p; ++hc; } } // 判断是否需要进行 红黑树 <-> 链表 的转换

                       //形成中间链表后会先判断是否需要转换为红黑树:
               //1、如果符合条件则直接将 TreeNode 链表转为红黑树,再设置到新数组中去
               //2、如果不符合条件则将 TreeNode 转换为普通的 Node 节点,再将该普通链表设置到新数组中去
               //(hc != 0) ? new TreeBin<K,V>(lo) : t 这行代码的用意在于,如果原来的红黑树没有被拆分成两份,那么迁移后它依旧是红黑树,可以直接使用原来的 TreeBin 对象

                         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方法调用的是 Unsafe 类的 putObjectVolatile 方法
//使用 volatile 方式的 putObjectVolatile 方法,能够将数据直接更新回主内存,并使得其他线程工作内存的对应变量失效,达到各线程数据及时同步的效果
//使用 volatile 的方式将 ln 链设置到新数组下标为 i 的位置上

                        setTabAt(nextTab, i, ln);
               //使用 volatile 的方式将 hn 链设置到新数组下标为 i + n(n为原数组长度) 的位置上 setTabAt(nextTab, i
+ n, hn);
               //迁移完成后使用 volatile 的方式将占位对象设置到该 hash 桶上,该占位对象的用途是标识该hash桶已被处理过,以及查询请求的转发作用 setTabAt(tab, i, fwd);
// 设置ForwardingNode占位
               //advance 设置为 true 表示当前 hash 桶已处理完,可以继续处理下一个 hash 桶 advance = true; // 表示当前旧桶的结点已迁移完毕 } } } } } }

 

三、扩容图解

触发扩容的操作

技术图片

总结一下:

(1) 元素个数达到扩容阈值。

(2) 调用 putAll 方法,但目前容量不足以存放所有元素时。

(3) 某条链表长度达到8,但数组长度却小于64时。

CPU核数与迁移任务hash桶数量分配的关系

技术图片

 

 

 单线程下线程的任务分配与迁移操作

技术图片

 

 

多线程如何分配任务?

技术图片

 

 

 普通链表如何迁移?

技术图片

 

 

什么是 lastRun 节点?

技术图片

 

 

 红黑树如何迁移?

技术图片

 

 

hash桶迁移中以及迁移后如何处理存取请求?

技术图片

 

 

多线程迁移任务完成后的操作

技术图片

技术图片

 

 

 

 

感谢:https://segmentfault.com/a/1190000016124883

  https://blog.csdn.net/ZOKEKAI/article/details/90051567

以上是关于JUC之ConcurrentHashMap源码之扩容的主要内容,如果未能解决你的问题,请参考以下文章

JUC系列并发容器之ConcurrentHashMap(JDK1.8版)

JUC系列并发容器之ConcurrentHashMap(JDK1.8版)扩容图解说明

并发容器ConcurrentHashMap原理解析及应用

这一文道尽JUC的ConcurrentHashMap

这一文道尽JUC的ConcurrentHashMap

源码之ConcurrentHashMap