本文主要记录 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 发布) 起包含该修复。
Reference
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