大家好,我是 Skow
大家看到标题是不是第一反应,怎么可能 JDK8 的 ConcurrentHsahMap 会有 bug,肯定是个标题党
且慢,的确我们 JDK8 中存在这个 Bug,但是这个 Bug 不影响使用,并且在 JDK12 中对其进行了修复
文章图片
在这个JDK 的 Bug 收集论坛上,我们可以看到这个报告指出的是在 ConcurrentHsahMap 进行 addCount() 的时候会有问题存在,我们先看一下具体在 JDK8 和 JDK12中该方法的体现
JDK8
private final void addCount(long x, int check) {
//...
if (check >= 0) {
Node[] tab, nt;
int n, sc;
while (s >= (long)(sc = sizeCtl) && (tab = table) != null &&
(n = tab.length) < MAXIMUM_CAPACITY) {
int rs = resizeStamp(n);
if (sc < 0) {
if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 ||
sc == rs + MAX_RESIZERS || (nt = nextTable) == null ||
transferIndex <= 0)
break;
//...
}
//...
}
}
}
JDK12
private final void addCount(long x, int check) {
//...
if (check >= 0) {
Node[] tab, nt;
int n, sc;
while (s >= (long)(sc = sizeCtl) && (tab = table) != null &&
(n = tab.length) < MAXIMUM_CAPACITY) {
int rs = resizeStamp(n) << RESIZE_STAMP_SHIFT;
if (sc < 0) {
if (sc == rs + MAX_RESIZERS || sc == rs + 1 ||
(nt = nextTable) == null || transferIndex <= 0)
break;
//...
}
//...
}
}
}
我们仔细对比一下代码,就可以发现在 JDK12 中去除了
(sc >>> RESIZE_STAMP_SHIFT) != rs
这个条件判断,那问题就来,这个条件出现了什么问题?
Doug Lea 这么细的人当初为什么没考虑到,欢迎走入今天的文章,揭秘一下这个 BUG 的前世今生,对 ConcurrentHashMap 扩容和计数逻辑熟悉的同学,可直接跳转第三部份看问题分析即可
文章图片
基本定义
默认小伙伴对 ConcurrentHashMap 有初步的了解,以及清楚 CAS 算法等基础知识,此文不做展开说明想要知道 ConcurrentHashMap 中的扩容和计数逻辑,我们需要先明白一些定义,不然直接开始读源码,我们会有点吃力
接下来会罗列一下一会我们 addCount 方法中会用到的一些基本定义,我们先混个眼熟
- sizeCtl
sizeCtl 的定义有点复杂,但是也有点重要
- sizeCtl < -1表示当前有 N-1 个线程正在执行扩容操作,如 -2 就表示有 2-1 个线程正在扩容
- sizeCtl = -1 表示当前正在初始化数组
- sizeCtl = 0 默认状态,表示数组还没有被初始化
- sizeCtl > 0 记录下一次需要扩容的大小。
- counterCells
计数单元,非空的时候,大小通常为2次幂
- RESIZE_STAMP_SHIFT = 32 - RESIZE_STAMP_BITS
用于生成记录扩容线程个数的戳记的移位数
- MAX_RESIZERS = (1 << (32 - RESIZE_STAMP_BITS)) - 1
辅助扩容的最大线程数
- 【什么(JDK8的ConcurrentHashMap 有 Bug)】transferIndex
扩容任务分配的进度标示。初始为n,逆序扩容,每次减一个步长的位置,最终减至<= 0,表示整个扩容任务分配结束
第一反应是不是使用 volatile 或者 synchroieed 来保证数据相加的准确性,或许会有同学想到用volatile修饰变量然后利用 CAS进行相加,性能会不会较优
但是在并发激烈的情况下,CAS也可能会导致部份线程一直处于自旋的情况
为了避免这种极端的并发激烈情况,Doug Lea 采取了一种分而治之的思想,来尽可能避免这种竞争的发生
那就是利用 baseCount + CounterCell
baseCount顾名思义就是一个基础的计数变量,counterCells 上面我们有简短说明,它是一个计数单元,非空的时候,大小通常为2次幂,在未发生竞争的时候,cas 修改会直接去修改 baseCount,假设 CAS 修改失败也不会进行自旋,而是以哈希映射的方式在 counterCells 中获取到一个数组下标的位置来进行计数,若依然没法找到对应合适的位置会继续重复这个操作,多次失败后就会针对 counterCells 进行扩容,继续孜孜不倦的寻找合适的格子来计数
了解了 addCount 的大体设计思想,我们来看下源码
/**
* Adds to count, and if table is too small and not already
* resizing, initiates transfer. If already resizing, helps
* perform transfer if work is available.Rechecks occupancy
* after a transfer to see if another resize is already needed
* because resizings are lagging additions.
*
* @param x the count to add
* @param check if <0, don't check resize, if <= 1 only check if uncontended
*/
private final void addCount(long x, int check) {
CounterCell[] as;
long b, s;
// 这边 compareAndSwapxxx 都是 CAS操作
// 此处的意思就是如果 counterCells 不为 null,就直接往 counterCells 里操作,反之则直接操作 baseCount
if ((as = counterCells) != null ||
!U.compareAndSwapLong(this, BASECOUNT, b = baseCount, s = b + x)) {
CounterCell a;
long v;
int m;
// 默认线程安全,没有竞争
boolean uncontended = true;
if (as == null || (m = as.length - 1) < 0 ||
// ThreadLocalRandom.getProbe() 取当前线程的 hash 值
// 此处的 compareAndSwapLong 做的事情是找到不为 null 的格子,进行 value+x,CAS 失败就进入 fullAddCount
(a = as[ThreadLocalRandom.getProbe() & m]) == null ||
!(uncontended =
U.compareAndSwapLong(a, CELLVALUE, v = a.value, v + x))) {
fullAddCount(x, uncontended);
return;
}
if (check <= 1)
return;
s = sumCount();
}
// 扩容代码暂时省略
}
简单的逻辑大致在代码里都已经注释了,摘出来两点和大家在分享一下
fullAddCount 这个方法,其实就是尽可能去保证计数成功,里面的逻辑不展开说明,是一堆 if lese,感兴趣的小伙伴可以去进一步阅读
第二点,此处 check 方法的判断
文章图片
check 的值,我们可以理解为链表的长度,会根据不同的调用,入参进行变化
如果我们是在 put 方法中调用的 addCount 那么 check 一定大于等于 2,如果是在 computeIfAbsent 方法中调用的,那么 check 为 可能为1
当 check <= 1 就不会进行扩容检查操作,也是为了保障性能,所以直接 return
扩容逻辑 话外音:咋还不说 bug,都看了这么久了
小伙伴们别着急,只有明白了这个计数和扩容逻辑,我们才能理解到这个 bug 的精髓所在
ConcurrentHashMap 的扩容逻辑,我认为是 ConcurrentHashMap 较为核心的逻辑之一,我们来细细品鉴一下
private final void addCount(long x, int check) {
// 只有当外部传进来的 check 大于等于0的时候,才会开始检查扩容
if (check >= 0) {
Node[] tab, nt;
int n, sc;
// sizeCtl 会被赋值为下一次扩容的大小,,此处比较 s 和 下一次扩容的大小
// tab 未发生改变并且数组长度未达到阈值
while (s >= (long)(sc = sizeCtl) && (tab = table) != null &&
(n = tab.length) < MAXIMUM_CAPACITY) {
int rs = resizeStamp(n);
if (sc < 0) {
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);
}
else if (U.compareAndSwapInt(this, SIZECTL, sc,
(rs << RESIZE_STAMP_SHIFT) + 2))
transfer(tab, null);
s = sumCount();
}
}
}
进入 while 循环后,我们可以看到 resizeStamp 这个方法,虽然和我们的 bug 理解无关,但是这个方法很有意思,作为拓展我们展开说下,不感兴趣的小伙伴可以往下扒拉扒拉
/**
* Returns the stamp bits for resizing a table of size n.
* 返回值作为正在扩容的数组的长度n的一个标志位
* Must be negative when shifted left by RESIZE_STAMP_SHIFT.
* 通过 RESIZE_STAMP_SHIFT 左移 16 位时一定是一个负数
*/
static final int resizeStamp(int n) {
return Integer.numberOfLeadingZeros(n) | (1 << (RESIZE_STAMP_BITS - 1));
}
numberOfLeadingZeros 这个方法主要的目的就是为了得到 n 转成二进制后的最高非 0 位前的 0 的个数。
小张:啥玩意,你这说的,听不懂
上例子
假设传入的 n 为 16,numberOfLeadingZeros 得到结果即如图所示
文章图片
然后在当前 JDK8 版本中 RESIZE_STAMP_BITS 默认为 16
1 << (RESIZE_STAMP_BITS - 1) 等于 1 << 15 等于 32678
27 | 32678 = 32795 二进制就是:1000 0000 0001 1011
利用 rs 即可反推出,当前需要扩容的数组长度!切记,切记,切记。后文理解 bug 需要用到
理解了 resizeStamp 的意图之后我们继续往下看,当 sc 小于 0 的时候,表示 ConcurrentHashMap 已经开始扩容,会去判断是否扩容完成,是否需要线程去辅助继续帮助扩容
反之则说明还没有开始扩容,则触发第一个扩容线程,进行 cas 修改,这边是 rs << RESIZE_STAMP_SHIFT) + 2,为什么是 + 2,而不是 +1
我们又要回到我们刚才算出来的 rs 的作用是什么
- 高 16 位代表当前扩容的标记
- 低 16 位代表了扩容的线程数
因为我们的 rs 最终都是要赋值给 sizeCtl 的,而 sizeCtl 负数才代表扩容,而将 rs 左移 16 位就刚好使得最高位为 1,此时低 16 位全部是 0,而因为低 16 位要记录扩容线程数,按常理 +1 即可,但是 sizeCtl 中 -1 这个数值已经被使用了,用来代替当前有线程准备扩容,所以只好 +2
问题分析 前面铺垫完,我们进入正题
我们来看下扩容的五个条件判断
if (check >= 0) {
Node[] tab, nt;
int n, sc;
while (s >= (long)(sc = sizeCtl) && (tab = table) != null &&
(n = tab.length) < MAXIMUM_CAPACITY) {
int rs = resizeStamp(n);
if (sc < 0) {
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);
}
else if (U.compareAndSwapInt(this, SIZECTL, sc,
(rs << RESIZE_STAMP_SHIFT) + 2))
transfer(tab, null);
s = sumCount();
}
}
- sc == rs + 1
最后一个线程正在处理扩容工作,扩容工作即将结束
- sc == rs + MAX_RESIZERS
当前扩容线程数已满,无需辅助帮助扩容
- (nt = nextTable) == null
扩容完成之后把 nextTable 置为 null
- transferIndex <= 0
当前可供扩容的下标已经全部分配完毕,也代表了当前线程扩容结束
这个条件也是我们的 bug 所在,我们理解这个条件为 将 sc 右移 16 位 判断是否不等于我们的 rs
sc = sizeCtl,即扩容完成后 sc 会被赋值为 扩容后的数组的大小
rs 即刚才我们分析的扩容时间戳
那么当进入这个 if 判断,其实一定意味着 我们的 tab 被发生的改变
那么一定有另一个线程正在使 ConcurrentHashMap 处于扩容中,或者处于扩容结束
- 当处于扩容中 (nt = nextTable) == null 可能为 true
private final void transfer(Node
[] tab, Node [] nextTab) { ...if (finishing) { nextTable = null; // table changed after "nextTable = null"。 table = nextTab; sizeCtl = (n << 1) - (n >>> 1); return; } ... }
- 当扩容结束,这个 sizeCtl 已被改变,则和当前线程拿到的 sizeCtl 不符,即 (sc >>> RESIZE_STAMP_SHIFT) != rs 这个条件判断永远为 false, (sc == rs + 1 || sc == rs + MAX_RESIZERS ) 永远不会为 true,扩容线程数永远不受限制
当这个条件判断为 false 的时候,进入第二个 if又是一个 cas 判断,这个 cas 判断将会失败
并且当前线程在下一个迭代中将会 break 出 while 循环 或者继续尝试去帮助扩容
虽然设计的思路略有偏差,但是好在不影响实际使用
小结 至此,我们的分析已经结束,这个 bug 比较隐蔽的,但是我们理解 ConcurrentHashMap 的扩容逻辑后,也还是可以去 get 到 Bug 的产生
我们简单归纳下,就是判断扩容的时候的一个条件判断写错,导致扩容的线程数不受控制,但是不影响实际使用,因为在真正的扩容逻辑 transfer 方法中,参与扩容的线程也会发现其实扩容已经结束,不参与扩容即可
本文没有分析 transfer 的真正逻辑,感兴趣的同学可以去看下
推荐阅读
- 单元测试|Java 单元测试指北
- cloud|JavaScript 28个常用字符串方法及使用技巧
- C语言与C++编程|C语言中三块难啃的硬骨头
- 如何将JSON对象从Java返回到javascript(cordova)(2)
- 如何使用Java AWT Toolkit检索屏幕尺寸(宽度,高度)和分辨率
- JavaSE|【JavaSE】枚举类——规范的类
- Zookeeper|[Zookeeper-3.6.2源码解析系列]-9-日志清理定时任务的启动
- 【校招VIP】课程专栏|【校招VIP】出品(在线实习“校跑腿”接口文档和使用介绍)
- JavaSE|Java实现简易版的【图书管理系统】