[Java]ConcurrentHashMap中的sizeCtl变量

一、数值的意义

        先看源码里关于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根据值的不同拥有不同的意义,具体为:

  • 容器容量
  • 标志正在初始化
  • 标志正在扩容
  • 存储扩容版本号
  • 记录并发扩容线程数
  • 控制并发扩容上限

参考:

How the conditions ( sc == rs + 1 || sc == rs + MAX_RESIZERS) can be achieved in addCount function of ConcurrentHashMap( JDK1.8 or later)——yuqizhang

Bug in the logic of ConcurrentHashMap.addCount() when used in Threads——Doug Lea

concurrentHashMap系列之-helptransfer方法——wbpailxt

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
ConcurrentHashMapJava 的线程安全的哈希表数据结构,它允许多个线程同时读取和写入元素而不会造成数据不一致或死锁。sizectlConcurrentHashMap 的一个参数,它用于控制并发时对哈希表进行扩容的阈值。 在 ConcurrentHashMap 内部,元素被分为多个段(Segments),每个段都是一个独立的哈希表。sizectl 参数表示每个段内元素的数量阈值,当某个段内的元素数量达到 sizectl 时,该段会触发扩容。 当有新的元素插入 ConcurrentHashMap 时,会先计算元素所属的段,并对该段进行加锁,然后进行插入操作。如果插入操作后,该段内的元素数量超过 sizectl,则触发扩容操作。扩容会将原来的段内元素重新分配到新的段,以减少每个段内的元素数量。 sizectl 的值默认为 16,这意味着当某个段内的元素数量达到 16 时,该段会触发扩容。但 sizectl 的值可以根据实际情况进行调整,以适应不同的并发场景。较小的 sizectl 值可以减少并发写入时的竞争,但会增加内存开销;较大的 sizectl 值可以减少扩容的频率,但会增加并发写入时的竞争。所以在使用 ConcurrentHashMap 时,我们可以根据实际需求进行 sizectl 的调整。 总结起来,sizectlConcurrentHashMap 控制并发扩容的阈值参数,用于限制每个段内元素的数量。当某个段内元素数量达到 sizectl 时,该段会触发扩容操作。通过适当调整 sizectl 的值,可以优化 ConcurrentHashMap 的性能和内存占用。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值