本文主要记录 JDK 12 中 ConcurrentHashMap 的扩容实现。首先需要关注的为 sizeCtl 变量,其定义如下:
1 | /** |
根据注释我们知道,sizeCtl 主要用于底层 table 数组的初始化和扩容控制。当该值为负数时,底层 table 数组正在初始化或扩容,否则,当 table 数组为空时,持有需要初始化 table 数组的大小,使用默认构造函数时 sizeCtl 为 0,在 table 数组初始化后,sizeCtl 存储的为触发扩容的元素个数阈值。我们根据源码来理解上述注释,首先看默认构造函数:
1 | /** |
此时实例化后 sizeCtl 被初始化为默认值 0,我们再看非默认构造函数:
1 | /** |
可以看出,会根据传入的参数将 sizeCtl 对齐至 2 的 n 次幂,比如我们使用 new ConcurrentHashMap(17) 进行实例化,此时 sizeCtl 会被赋值为 32。当第一个线程调用 put 方法时,会触发 initTable() 方法尝试初始化底层 table 数组:
1 | /** |
此时会尝试 CAS 设置 sizeCtl 字段为 -1,即注释中提到的正在初始化的状态,CAS 成功的线程会进入 else if 代码块内部,对 table 数组是否为空进行双重检查,确认未被初始化后申请数组并赋值给 table 变量,然后通过 sc = n - (n >>> 2) 计算扩容阈值并在 finally 代码块中赋值给 sizeCtl,此时 sizeCtl 存储的为触发扩容的阈值。当 ConcurrentHashMap 由默认构造函数创建时,初始化的 table 数组大小为 DEFAULT_CAPACITY,该静态变量定义如下:
1 | /** |
可知扩容阈值 sc = n - (n >>> 2) = 16 - (16 >>> 2) = 16 - 4 = 12,即对应着负载因子 0.75。扩容操作是在添加元素后的 addCount 方法中触发的,源码如下:
1 | /** |
该方法中的第一个 if 代码块用于计数,我们主要分析第二个 if 代码块的扩容实现,即首先比较当前哈希表实际元素数量 s 是否大于等于扩容阈值 sizeCtl,当哈希表实际元素数量达到 12 时,即会进入 while 循环内部,首先执行的是 resizeStamp(n) << RESIZE_STAMP_SHIFT 这段逻辑,我们先看 resizeStamp 方法实现:
1 | /** |
以上代码是什么意思呢?我们结合二进制表示来看,在 ConcurrentHashMap 使用默认构造函数的情况下,创建的 table 数组长度为 DEFAULT_CAPACITY 即 16,即 resizeStamp 方法的参数 n 为 16,此时 Integer.numberOfLeadingZeros(n) 计算 n 的二进制表示中前导零的数量,这么做的目的是什么呢?根据我的理解,此处主要是压缩表示 n 值所需的比特数,当 n 用 int 进行表示时,需要 32 个比特,因为 n 一定为 2 的 x 次幂这一特性,我们知道 n 的二进制表示中一定只有一位为 1,比如 n 为 16 时的二进制表示为 00000000 00000000 00000000 00010000,且易知 Integer.numberOfLeadingZeros(n) 计算出的 n 的二进制表示中前导零的数量最大为 30(table 数组容量最小为 2),当 n 为 16 时,Integer.numberOfLeadingZeros(n) 的返回值为 27,即 00000000 00000000 00000000 00010000 中前导零的数量,此时我们再使用 27 的二进制表示:00000000 00000000 00000000 00011011,即我们用前导零数量的二进制表示的方式把 n 这个数值压缩为使用 5 个比特即可表示,为何要进行压缩呢?因为 ConcurrentHashMap 提供了并发扩容最大线程数的限制,sizeCtl 还会存储当前参与扩容的线程数。我们回到 resizeStamp 方法,或运算右侧的表达式为 1 << (RESIZE_STAMP_BITS - 1),其中 RESIZE_STAMP_BITS 定义如下:
1 | /** |
容易得出 1 << (RESIZE_STAMP_BITS - 1) = 1 << (16 - 1) = 1 << 15,对应的二进制表示为 00000000 00000000 10000000 00000000。然后我们将两值做或运算:
1 | Integer.numberOfLeadingZeros(16): |
可以看出 resizeStamp 方法主要做了两个操作,第一个操作为将当前 table 数组的大小用前导零数量的二进制表示进行存储,第二个操作为将第十六位比特设置为 1,这样做的目的是什么呢?我们接着往下分析,根据源码我们知道 resizeStamp(n) 方法返回后会左移 RESIZE_STAMP_SHIFT,执行的代码为:
1 | /** |
即左移 16 位,二进制表示如下:
1 | resizeStamp(n): |
即 rs 变量的二进制表示为 10000000 00011011 00000000 00000000。可以看出,符号位现在为 1,即 rs 为一个负数,且 table 数组的长度使用前导零数量的二进制表示存储在了高 16 位中,现在低 16 位全部为 0,我们回顾一下上面的代码:
1 | if (check >= 0) { |
此时 sc = sizeCtl 即值为 12,会执行 else if 中的代码尝试使用 CAS 将 sizeCtl 更新为 rs + 2,在 sizeCtl 的注释中提到:When negative, the table is being initialized or resized: -1 for initialization, else -(1 + the number of active resizing threads). 从代码来看,个人认为这段注释描述不是很准确,如果我们不看高 16 位中存储的前导零数值的二进制部分,那么这段描述是正确的。因为高 16 位中存储了前导零数值的二进制部分,那么 -(1 + the number of active resizing threads) 这段描述就有误,比如首个 CAS 成功的线程将设置 sizeCtl 的值为 10000000 00011011 00000000 00000010,此时代表有一个线程正在进行扩容操作,sizeCtl 转换为负数时为 -2145714174 而并不为注释中提到的 -(1 + 1)。所以,个人认为这段注释想要表达的其实是当 sizeCtl 为负数时,即符号位为 1 时,此时哈希表正在初始化或者扩容,当正在初始化时,低 16 位对应的数值为 1,当正在扩容时,低 16 位对应的数值表示当前并发扩容的线程数量加上一,为什么要加上一呢?因为低 16 位为 00000000 00000001 这种情况被刚提到的正在初始化状态占用了,这就是首次 CAS 需要将 sizeCtl 更新为 rs + 2 的原因。当首个 CAS 成功的线程将 sizeCtl 更新为负数后,后续的线程在进行 CAS 操作时都是调用的 U.compareAndSetInt(this, SIZECTL, sc, sc + 1) 将 sc 加一,即增加一个并发扩容的线程数。CAS 成功后将进入扩容的方法 transfer:
1 | /** |
在源码上已经加上了注释,DEBUG 跟踪一遍代码对运行机制理解更加深入,理解了 transfer 方法再回头去看 addCount 方法中触发 break 的判断逻辑就很容易理解了。为什么用 JDK 12 的源码来记录呢?因为 JDK 8 中的代码扩容时控制并发线程数的逻辑存在 bug,这个 bug 在 JDK 12 中已经被修复了,而一直没有人将这个 bug 反向移植至 JDK 8,所以至今 JDK 8 中的扩容代码都是存在 bug 的,个人认为这容易对学习源码的人造成障碍,特别是在不知情的情况下尝试理解代码的实现时,你会发现无法理解这段代码,为什么无法理解呢?因为这段代码本来就是错的。
2022-08-20
JDK-8214427 已反向移植至 JDK8,自 OpenJDK 8u352 起包含该修复,自 Oracle JDK 8u361(预计 2023-01-17 发布) 起包含该修复。
References
Why does ConcurrentHashMap prevent null keys and values? - Stack Overflow
Java Concurrent Hashmap initTable() Why the try/finally block? - Stack Overflow
8214427: probable bug in logic of ConcurrentHashMap.addCount() by tianshuang · Pull Request #18 · openjdk/jdk8u-dev · GitHub
How to contribute a fix - OpenJDK Wiki
JDK Releases
PARITY REPORT FOR JDK: 8