一、数值的意义
先看源码里关于sizeCtl的注释:
/**
* Table initialization and resizing control. When negative, the
* table is being initialized or resized: -1 for initialization,
* else -(1 + the number of active resizing threads). Otherwise,
* when table is null, holds the initial table size to use upon
* creation, or 0 for default. After initialization, holds the
* next element count value upon which to resize the table.
*/
private transient volatile int sizeCtl;
结合代码翻译下,sizeCtl根据正负将值被划分为以下4种情况:
1)sizeCtl > 0,容器容量
2) sizeCtl = 0,默认初始值
3)sizeCtl = -1,表示table正在初始化
/**
* Initializes table, using the size recorded in sizeCtl.
*/
private final ConcurrentHashMap.Node<K,V>[] initTable() {
ConcurrentHashMap.Node<K,V>[] tab; int sc;
while ((tab = table) == null || tab.length == 0) {
// 发生竞争,多线程并发初始化
if ((sc = sizeCtl) < 0)
Thread.yield(); // lost initialization race; just spin
else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
try {
if ((tab = table) == null || tab.length == 0) {
// 此时sc为0 或 构造map时赋的初始值
int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
@SuppressWarnings("unchecked")
ConcurrentHashMap.Node<K,V>[] nt = (ConcurrentHashMap.Node<K,V>[])new ConcurrentHashMap.Node<?,?>[n];
table = tab = nt;
// 等效于n * 3/4,逻辑与HashMap的加载因子(默认0.75)相同
// 证明:∵n>>2 == n/4, ∴n - (n >>> 2) == n - n/4 == n * (1 - 1/4) == n * 3/4
sc = n - (n >>> 2);
}
} finally {
sizeCtl = sc;
}
break;
}
}
return tab;
}
4)sizeCtl < -1,容器正在扩容;高16位存储SizeStamp(可以理解为扩容版本号,用于区分连续的多次扩容),低16位代表着有n-1个线程正在参与扩容。
/**
* The number of bits used for generation stamp in sizeCtl.
* Must be at least 6 for 32bit arrays.
*/
private static int RESIZE_STAMP_BITS = 16;
/**
* Returns the stamp bits for resizing a table of size n.
* Must be negative when shifted left by RESIZE_STAMP_SHIFT.
*/
static final int resizeStamp(int n) {
// 扩容版本号rs = n的前置0个数 | 2^15
// 2^15 = 0000000000000000 1000000000000000(注意此时低16位的首位为1)
return Integer.numberOfLeadingZeros(n) | (1 << (RESIZE_STAMP_BITS - 1));
}
private final void addCount(long x, int check) {
// 省略与本文无关的统计代码
...
// 通常情况下为true
if (check >= 0) {
ConcurrentHashMap.Node<K,V>[] tab, nt; int n, sc;
while (s >= (long)(sc = sizeCtl) && (tab = table) != null &&
(n = tab.length) < MAXIMUM_CAPACITY) {
// 根据当前容器大小,计算扩容版本号
// 当n(table.length)不同时,rs将不同
int rs = resizeStamp(n);
if (sc < 0) {
// 省略并发控制代码,将在下节详细介绍
....
}
// 将rs存入sizeCtl的高16位,并+2(表示1个线程在扩容)
// 因为rs第16位为1,再次左移16位后使得高16位首位为1,即首位符号位为负
else if (U.compareAndSwapInt(this, SIZECTL, sc,
(rs << RESIZE_STAMP_SHIFT) + 2))
// 进行扩容
transfer(tab, null);
// 省略与本文无关的统计代码
....
}
}
}
下面将围绕第4种情况,讨论sizeCtl < 0时值在并发扩容时是如何变化的。
二、如何变化
要想探讨这个问题,我们需要再回到刚刚的addCount()方法,我们的关注点仍集中在第二个if条件中,观察sizeCtl何时成为负数的,与resizeStamp的关系是什么:
private final void addCount(long x, int check) {
ConcurrentHashMap.CounterCell[] as; long b, s;
if ((as = counterCells) != null ||
!U.compareAndSwapLong(this, BASECOUNT, b = baseCount, s = b + x)) {
ConcurrentHashMap.CounterCell a; long v; int m;
boolean uncontended = true;
if (as == null || (m = as.length - 1) < 0 ||
(a = as[ThreadLocalRandom.getProbe() & m]) == null ||
!(uncontended =
U.compareAndSwapLong(a, CELLVALUE, v = a.value, v + x))) {
fullAddCount(x, uncontended);
return;
}
// 未发生hash冲突,暂时不进行扩容
if (check <= 1)
return;
// 获取容器内实际元素个数
s = sumCount();
}
// 扩容相关逻辑从此处开始
if (check >= 0) {
ConcurrentHashMap.Node<K,V>[] tab, nt; int n, sc;
// 常规的自旋CAX操作
// s >= sizeCtl,需要进行扩容
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;
// 允许并发扩容,sc + 1赋值给sizeCtl,用于累积并发扩容线程数
if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1))
// 扩容逻辑
transfer(tab, nt);
}
// 容器还未进行扩容,将sizeCtl置负,让其他并发线程可以通过sizeCtl感知到扩容发生
else if (U.compareAndSwapInt(this, SIZECTL, sc,
(rs << RESIZE_STAMP_SHIFT) + 2))
// 扩容逻辑
transfer(tab, null);
s = sumCount();
}
}
}
通过阅读源码,我们能清楚的了解到:
1)当扩容发生时,sizeCtl会被设置成负数,并且高位与低位有不同的含义
2)当sc < 0时,线程可以根据 rs 与 sc 的关系,判断是否参与扩容,具体为:
1. (sc >>> RESIZE_STAMP_SHIFT) != rs
sc高16位存储的rs是否与当前计算的一致,意味着当前table.length是否一致
2. sc == rs + 1
结束扩容时,sc = sc - 1,当前是否已经不存在扩容线程,表示着扩容即将结束
3. sc == rs + MAX_RESIZERS
sc随着并发线程的加入而递增,判断是否超过最大大小
4. (nt = nextTable) == null || transferIndex <= 0)
当扩容完成,nextTable会被抛弃,标记待扩容下标的transferIndex也会被清0
不知道看到这里,你是更清楚还是更迷惑了?
对于sc < 0的情况,上面1、4应该好理解,问题是2、3怎么看都不会是true啊!
sc在扩容阶段是负数,rs是正数
在不超过值范围的情况下,正数+正数是不可能等于负数的!
如果你有和我相同的疑问,那恭喜你,发现了一个源码中的bug!
这个bug直到2018才被人发现并修复,目前大多数人用的JDK中可能依旧存在此bug。
三、隐藏的bug
上面说了,sizeCtl的高16位用于存放扩容版本号(即resizeStamp),低16位用于存放扩容时的并发线程数,所以代码本意应为:
(sc >>>RESIZE_STAMP_SHIFT) == rs + 1
(sc >>>RESIZE_STAMP_SHIFT) == rs + MAX_RESIZERS
这样这个复杂的if条件就好理解了,sizeCtl在其中的作用:
1)存储扩容版本号
2)提前感知扩容结束
3)控制并发线程数量
回到bug本身,由于条件编写错误,该if后半段条件失效,也就是线程无法提前感知扩容结束、无法通过sizeCtl控制并发线程数量。换句话说就是,只要扩容版本号相同,线程就会参与并发扩容。
更可怕的是,相同的代码,在helpTransfer()方法中还有一份!
作为保证并发安全的集合,如此bug最终会产生什么影响呢?
要想探讨这个问题,需要对接下来的transfer()方法有所了解,方法内实现了具体的分段扩容策略,使用transferIndex下标隔离并发参与扩容的线程。受限于篇幅,这里不做过多介绍,不了解的可以自行查阅资料。
先看线程进入并发扩容后如何退出的:
private final void transfer(ConcurrentHashMap.Node<K,V>[] tab, ConcurrentHashMap.Node<K,V>[] nextTab) {
// 省略
....
// i为未扩容下标,i < 0表示无空余槽位需要扩容,标志着扩容结束
if (i < 0 || i >= n || i + n >= nextn) {
int sc;
// 最后一个完成扩容的线程
if (finishing) {
// 清除nextTable
nextTable = null;
// 完成table转移
table = nextTab;
// 将sc设置为容器容量
sizeCtl = (n << 1) - (n >>> 1);
return;
}
// 其余线程退出时计数sc - 1
if (U.compareAndSwapInt(this, SIZECTL, sc = sizeCtl, sc - 1)) {
// 如果不是最后一个线程,直接返回
if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT)
return;
// 最后一个线程需要额外做一次循环,
// 回到上面的 if (finishing) 中设置sizeCtl的值,并进行其他扫尾工作
finishing = advance = true;
i = n; // recheck before commit
}
}
// 省略
....
}
可以发现,方法返回与 i 的值有关,也就是与扩容进度有关。
扩容时,线程按一定的步长(stride),从集合的尾部开始转移槽点数据,扩容的边界(transferIndex)与 i 的值随着扩容的进行将会越来越小。正常情况下,当 i < 0时,此时扩容已经完成,计数后准备返回。
private final void transfer(ConcurrentHashMap.Node<K,V>[] tab, ConcurrentHashMap.Node<K,V>[] nextTab) {
// 省略
....
while (advance) {
int nextIndex, nextBound;
// 当前分段已完成 || 整体扩容已完成
if (--i >= bound || finishing)
advance = false;
// 无槽位需要扩容
else if ((nextIndex = transferIndex) <= 0) {
i = -1;
advance = false;
}
// 按步长分配扩容分段,修改transferIndex值
else if (U.compareAndSwapInt
(this, TRANSFERINDEX, nextIndex,
nextBound = (nextIndex > stride ?
nextIndex - stride : 0))) {
// 此处分段扩容的终点为下一分段的起点
bound = nextBound;
i = nextIndex - 1;
advance = false;
}
}
// 省略
....
}
到这,我们可以放下心来了。
得益于“(nextIndex = transferIndex) <= 0”的存在,在分段被分配完时transferIndex = 0,多余的线程会在经过一系列多余的计算后“悻悻而归”,并不会影响其他正在扩容的线程。
不过这个bug真的没有危害吗?
答案是否定的。在特殊条件下,因为并发扩容没了限制,会有更多的线程参与扩容。当线程数超过MAX_RESIZERS(2^16 - 1),将影响sizeCtl的值。因为此时线程数超过了低16位的容量,累加进位后将影响高16的扩容版本号。
但这是种极其罕见的现象,没人项目里会出现2^16 - 1个线程。
最后我们再来看一眼这个bug是如何修复的:
if (check >= 0) {
Node<K,V>[] tab, nt; int n, sc;
while (s >= (long)(sc = sizeCtl) && (tab = table) != null &&
(n = tab.length) < MAXIMUM_CAPACITY) {
// rs先位移到高位再使用
int rs = resizeStamp(n) << RESIZE_STAMP_SHIFT;
if (sc < 0) {
if (sc == rs + MAX_RESIZERS || sc == rs + 1 ||
(nt = nextTable) == null || transferIndex <= 0)
break;
if (U.compareAndSetInt(this, SIZECTL, sc, sc + 1))
transfer(tab, nt);
}
else if (U.compareAndSetInt(this, SIZECTL, sc, rs + 2))
transfer(tab, null);
s = sumCount();
}
}
为了方便对比,再补张文本对比
四、总结
综上,变量sizeCtl根据值的不同拥有不同的意义,具体为:
- 容器容量
- 标志正在初始化
- 标志正在扩容
- 存储扩容版本号
- 记录并发扩容线程数
- 控制并发扩容上限
参考:
Bug in the logic of ConcurrentHashMap.addCount() when used in Threads——Doug Lea