ConcurrentHashMap的扩容流程
概括
触发扩容的时机
- addCount方法
- putVal中间遇到MOVE节点,参与helpTransfer
- putAll批量插入
步骤
- 将
sizeCtl设为(resizeStamp(table.length) << 16) + 2 - 计算每个线程一次需要迁移的元素数量
stride,最小为16 - 如果table为空,说明是第一个线程进入扩容,新建一个哈希桶
hashTable,将transferIndex指向table中最后一个节点 - 初始化迁移任务的范围最大值
i = transferIndex - 1和最小值bound = transferIndex - stride,设置transferIndex = transferIndex - stride - 根据i判断扩容是否结束,如果结束,将
table指向新的map,sizeCtl加1 - 遇到空位直接标记上占位对象,遇到占位对象就标记当前桶已经迁移完毕,继续回到第5步迁移下一个桶,否则继续下一步
- 迁移元素,判断当前节点后每个元素的高位,为1的放置到后半部分,为0的放置到前半部分
- 回到第5步循环
源码解读
putVal触发扩容,先进入addCount增加map容量,同时传入>=0的check值,触发扩容校验
/** Implementation for put and putIfAbsent */ final V putVal(K key, V value, boolean onlyIfAbsent) { ... addCount(1L, binCount); return null; } /** * 增加元素数量,如果需要扩容则开始扩容,如果已经在扩容中,就帮助一起扩容 * check参数用于判断是否需要检查扩容条件,比如putVal这种操作就需要检查 */ private final void addCount(long x, int check) { long s; ... s = sumCount(); ... if (check >= 0) { Node<K,V>[] tab, nt; int n, sc; // 判断当前元素数量是否超过sizeCtl,且tab大小小于最大容量 // while一是为了自旋,二是为了在扩容完成后再次判断是否需要扩容,因为扩容是一个很久的操作,可能会不断有元素再被添加进来 while (s >= (long)(sc = sizeCtl) && (tab = table) != null && (n = tab.length) < MAXIMUM_CAPACITY) { int rs = resizeStamp(n) << RESIZE_STAMP_SHIFT; // sc < 0 说明扩容进行中 if (sc < 0) { // 如果并发扩容的线程数达到上限,或扩容已经结束,直接break if (sc == rs + MAX_RESIZERS || sc == rs + 1 || (nt = nextTable) == null || transferIndex <= 0) break; // CAS加入到扩容线程中 if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1)) transfer(tab, nt); } // rs + 2为首个进入的线程设置的特定值,后续扩容时根据此判断是否为最后一个线程 else if (U.compareAndSwapInt(this, SIZECTL, sc, rs + 2)) // 开始扩容,并初始化nextTab transfer(tab, null); s = sumCount(); } } }
transfer执行具体的扩容操作
private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) { int n = tab.length, stride; // 计算每个线程一次需要迁移的元素数量stride = (map长度 ÷ 8) ÷ CPU核数 // 最小为16 if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE) stride = MIN_TRANSFER_STRIDE; // subdivide range if (nextTab == null) { // initiating // 第一个扩容线程负责创建nextTab try { @SuppressWarnings("unchecked") Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n << 1]; nextTab = nt; } catch (Throwable ex) { // try to cope with OOME sizeCtl = Integer.MAX_VALUE; return; } nextTable = nextTab; // transferIndex指向原数组末尾 transferIndex = n; } // nextTab的长度 int nextn = nextTab.length; // 新建一个占位对象,该占位对象的 hash 值为 -1 该占位对象存在时表示集合正在扩容状态,key、value、next 属性均为 null ,nextTable 属性指向扩容后的数组 // 该占位对象主要有两个用途: // 1、占位作用,用于标识数组该位置的桶已经迁移完毕,处于扩容中的状态。 // 2、作为一个转发的作用,扩容期间如果遇到查询操作,遇到转发节点,会把该查询操作转发到新的数组上去,不会阻塞查询操作。 ForwardingNode<K,V> fwd = new ForwardingNode<K,V>(nextTab); // 该标识用于控制是否继续处理下一个桶,为 true 则表示已经处理完当前桶,可以继续迁移下一个桶的数据 boolean advance = true; // 该标识用于控制扩容何时结束,该标识还有一个用途是最后一个扩容线程会负责重新检查一遍数组查看是否有遗漏的桶 boolean finishing = false; // to ensure sweep before committing nextTab // 这个循环用于处理一个 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; while (advance) { int nextIndex, nextBound; //每处理完一个hash桶就将 i 进行减 1 操作 if (--i >= bound || finishing) advance = false; else if ((nextIndex = transferIndex) <= 0) { // transferIndex <= 0 说明数组的hash桶已被线程分配完毕,没有了待分配的hash桶,将 i 设置为 -1 ,后面的代码根据这个数值退出当前线程的扩容操作 i = -1; advance = false; } // 只有首次进入for循环才会进入这个判断里面去,更新transferIndex,设置 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) { int sc; // 扩容结束后做后续工作,将 nextTable 设置为 null,表示扩容已结束,将 table 指向新数组,sizeCtl 设置为扩容阈值即1.5*n 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; i = n; // recheck before commit } } // 空节点直接放置占位对象 else if ((f = tabAt(tab, i)) == null) advance = casTabAt(tab, i, null, fwd); // 数组上遇到hash值为MOVED,即-1,表名该位置已经被其他线程迁移,设置advance为true,后续继续迁移下一个桶 else if ((fh = f.hash) == MOVED) advance = true; // already processed else { synchronized (f) { // 做双重检查 if (tabAt(tab, i) == f) { Node<K,V> ln, hn; // 当前节点的hash值,-1表示MOVED,-2表示TREEBIN,-3表示RESERVED,大于零表示是正常的链表 if (fh >= 0) { // 拿到一个高位的比特位,如果n为16则为第4位 int runBit = fh & n; Node<K,V> lastRun = f; // 遍历整条链表,找出 lastRun 节点 for (Node<K,V> p = f.next; p != null; p = p.next) { int b = p.hash & n; // 比较高位,找到和前一个不一样的,找到最后一个,因为如果有连续的相同高位的节点,一定是放在同一个链表中的 if (b != runBit) { runBit = b; lastRun = p; } } // 根据 lastRun 节点的高位标识(0 或 1),首先将 lastRun设置为 ln 或者 hn 链的末尾部分节点,后续的节点使用头插法拼接 if (runBit == 0) { ln = lastRun; hn = null; } else { hn = lastRun; ln = null; } // 使用高位和低位两条链表进行迁移,使用头插法拼接链表 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) // Node构造函数的最后一个参数是next,所以这里是头插法,将自己作为新节点的next,并成为新节点 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); // 使用 volatile 的方式将 hn 链设置到新数组下标为 i + n(n为原数组长度) 的位置上 setTabAt(nextTab, i + n, hn); // 迁移完成后使用 volatile 的方式将占位对象设置到该 hash 桶上,该占位对象的用途是标识该hash桶已被处理过,以及查询请求的转发作用 setTabAt(tab, i, fwd); // advance 设置为 true 表示当前 hash 桶已处理完,可以继续处理下一个 hash 桶 advance = true; } // 当前节点为红黑树 else if (f instanceof TreeBin) { 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(nextTab, i, ln); setTabAt(nextTab, i + n, hn); setTabAt(tab, i, fwd); advance = true; } } } } } }
扩展问题
sizeCtl在扩容时被赋的值到底有什么含义?为什么不是初始化为-2并依次递减呢?
一开始resizeStamp(table.length) << 16,先根据map长度计算出一个版本号,保证了每次扩容版本号不同的同时保证第16位一定为1,再次向左移16位,就可以把这个值存到sizeCtl的高16位上。
再次加2,就可以让低16位的值等于1,表示一个线程正在扩容。
这样一来,高16位就是一个唯一的扩容版本号,低16位就是当前参与扩容的线程数量。
参考来源
- https://blog.csdn.net/ZOKEKAI/article/details/90051567
- https://blog.csdn.net/shichimiyasatone/article/details/131572989

浙公网安备 33010602011771号