ConcurrentHashMap 的 addCount 和 fullAddCount 阅读

阿全啊

ConcurrentHashMap 源码目前在网络上已有众多解析。本文章主要关注其数量 size 的相关并发实现,试图自己解析该实现,如有错漏,请指正(预警:本文较啰嗦)。
ConcurrentHashMap#size 的关注点在于并发下结构性变更导致的数目统计,是如何实现高性能、高准确性的。实际上,ConcurrentHashMap 目前使用的算法与 LongAddr 相同,均是分段技术。代码上不能说一模一样,只能说是直接复制粘贴。

1.相关概念

1.1 在无并发的情况下,使用单一的属性 baseCount 进行累计(CAS),一旦操作不成功,进入并发场景。

1.2 在并发情况下,使用多个分区,将增加或减少的个数累计到相应的分区,可很大程度避免多线程操作同一对象的并发问题,最后在获取时,将各分区的值进行相加,得到一个弱一致性的数量值。
1147597-20210315224010044-980758941.jpg
1.3 CounterCells 是一个数组,线程需要与其 length 进行 & 操作,以 hash 到具体的 CounterCell 中,所以为线程增加了属性 probe,该属性由位操作实现,得到一个随机的值。同时该值可以被更新。

CounterCell counterCell = cs[ThreadLocalRandom.getProbe() & m])

1.4 除非显式调用其他 API,否则 Thread 的 probe 的初值是 0。显式 API 有:

java.util.concurrent.ThreadLocalRandom#current 
public static ThreadLocalRandom current() { 
    if (U.getInt(Thread.currentThread(), PROBE) == 0)
            localInit(); 
    return instance;
}
java.util.concurrent.ThreadLocalRandom#localInit 
static final void localInit() { 
    int p = probeGenerator.addAndGet(PROBE_INCREMENT); 
    int probe = (p == 0) ? 1 : p; // skip 0
    long seed = mix64(seeder.getAndAdd(SEEDER_INCREMENT));
    Thread t = Thread.currentThread();
    U.putLong(t, SEED, seed);
    U.putInt(t, PROBE, probe);
}

1.5 线程随机数 probe 的重新赋值,是以 2 的倍数操作,与 ConcurrentHashMap 的扩容、大小等 2 的倍数设定匹配

2. addCount 解析

2.1 数量变更的入口

入口是 addCount,方法签名如下:

private final void addCount(long x, int check)     

外部引用的方法有:
241616066968_.pic_hd.jpg
主要是结构性变更的操作,其中:

  • 当增加时,binCount > 0;
  • 当减少时,binCount < 0;
  • 当 binCount = 0时,实际上不调用 addCount,因为数量不变(存在于方法 replaceNode 等方法中)。

通过代码分析,binCount 作为 addCount 中 check 的实际传参,在外部方法中是对结构性变更导致影响个数的记录。在新增操作中,

  • 对普通节点,即 Node 的链表节点,操作如果需要遍历,则 check = 遍历个数;
  • 对树结构,check 总是等于 2。

    2.2 方法实现

进入方法后,addCount 实现分为两部分:

  • 对数量的并发处理
  • 因外部变更性行为导致的数量变动,进一步检查变动后是否需要对 ConcurrentHashMap 进行扩容操作

这里就需要关注:并发处理是如何实现的;扩容检查的触发机制是如何的。

2.2.1 数量并发处理
private final void addCount(long x, int check) {
    // s -> size ; b -> baseCount
    CounterCell[] cs; long b, s;
    // 如果 counterCells 不为空,则表示有多线程使用过了 cell,
    // 一旦用过 counterCells 则后续都要用它来计数
    // 如果没有用过,说明未遇到过并发,继续使用最 baseCount 增加个数,失败则转入多线程处理方式
    if ((cs = counterCells) != null ||
        !U.compareAndSetLong(this, BASECOUNT, b = baseCount, s = b + x)) {
        CounterCell c; long v; int m;
        // uncontended : 不受欢迎的、不建议的,这里表示没有竞争(预想没有)
        // 标记是因为 CounterCells 为空才进入 fullAddCount 还是因为 CAS 并发替换失败才进入 fullAddCount
        // uncontended = true 即表示由无并发进入,否则为 CAS 并发失败进入
        boolean uncontended = true;
        if (cs == null || (m = cs.length - 1) < 0 ||
            (c = cs[ThreadLocalRandom.getProbe() & m]) == null ||
            !(uncontended =
              U.compareAndSetLong(c, CELLVALUE, v = c.value, v + x))) {
            // 只有此方法为 counterCells 赋值
            // 只有 CAS 替换失败时,uncontended 才为 false,否则都为 true
            // CAS 替换这里,因为 ThreadLocalRandom#getProbe 的原因,还存在以下情况
            // 1. Thread 的 Probe 默认情况下是不会初始化的,所以只有初值 0 ,只有通过 ThreadLocalRandom#current 才会强制初始化
            // 也就是说,多线程情况下,如果线程都没有初始化过 Probe,此时如果 CAS 都替换成功,则值都增加到同一个 CounterCell[0] 中,
            // 这种情况并不影响并发考虑,因为 CAS 替换的最终也只是一个值,而不是一个队列或数组,不会过分增加负担
            // 另外两种情况在方法内被处理
            fullAddCount(x, uncontended);
            // 并发导致执行 fullAddCount 后,直接退出,不再考虑扩容检查
            return;
        }
    // ... 省略
}

这里的操作步骤也与前面描述类似,按以下次序:CounterCells 是否为空 > CAS(baseCount) > CAS(CounterCells[n]) > fulllAddCount

addCount 有一种思想,即当一个集合发生过并发时,其后续发生的并发可能性也越高,所以会先判断 CounterCells 是否为空,如果不为空,则后续不再考虑在 baseCount 上操作。这种思想应当应用在大部分场景中

addCount 对 CounterCells 的操作不多,实际上,对 CounterCells 操作主要在方法 fulllAddCount 中,而 addCount 负责无并发处理及收集进入 fullAddCount 所需的条件和判断。其中,uncontented 就表示:进入 fullAddCount 的原因,是否因为并发修改同一个 CounterCell。

2.2.2 数量变动引发的扩容检查

ConcurrentHashMap 的并发性支持的一个主要重点就是扩容,而数量变动是一个检查扩容的主要入口。

private final void addCount(long x, int check) {
    CounterCell[] cs; long b, s;
    if ((cs = counterCells) != null ||
        !U.compareAndSetLong(this, BASECOUNT, b = baseCount, s = b + x)) {
        // ...省略,前述已有代码
        fullAddCount(x, uncontended);
        // 并发导致执行 fullAddCount 后,直接退出,不再考虑扩容检查
        return;
    }
    // 仅在无竞争条件下进行检查(只有一个则不需要进行检查)
    // 影响数 <= 1,减少扩容的并发检查
    if (check <= 1)
        return;
    // 而此操作则是在无并发的情况下的处理(外部 CAS(CounterCells[n]) 成功)
    s = sumCount();
}
if (check >= 0) {
    // nt -> nextTable
    // n -> num,sc -> sizeCtl
    Node<K,V>[] tab, nt; int n, sc;
    // 当前存储大于 75%,且总大小小于最大容量
    while (s >= (long)(sc = sizeCtl) && (tab = table) != null &&
           (n = tab.length) < MAXIMUM_CAPACITY) {
        // resizeStamp 纯粹只是移位来保证右 16 位为0,可用来控制作为线程最大数
        // 左 16 位实际并没有保留太多信息(因为明显:resizeStamp(4)、resizeStamp(5)、resizeStamp(6)、(7) 是相同的结果
        int rs = resizeStamp(n) << RESIZE_STAMP_SHIFT;
        if (sc < 0) {
            // 限制线程的最大或最小,当达到最大 65535(默认) 或 1 条时,则直接跳出
            // rs + 1 --> 最少线程数(相当于不正确的情况了,因为起始时最少是 rs + 2)
            // rs + MAX_RESIZERS --> 最多线程数
            // 或其他情况,则不再辅助转移,如:nextable 已为 null 或 transferIndex <= 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);
        }
        // 如果 sc > 0,说明是刚开始,因为 sc < 0 时,表示有多少条线程在进行转移是:sc-1
        // 所以这里要 rs + 2
        else if (U.compareAndSetInt(this, SIZECTL, sc, rs + 2))
            transfer(tab, null);
        s = sumCount();
    }
}

可见,check 是影响扩容检查的主要元素。而前面说过,check 是外部修改影响到的个数 —— 可通过查看方法 putValue 等,check 实际传参为 binCount。

  • 对普通节点,即 Node 的链表节点,操作如果需要遍历,则 check = 遍历个数;
  • 对树结构,check 总是等于 2。

同时,check 在此方法中,起作用总是在非并发情况下的线程,并发 CAS 失败的将进入 fullAddCount 然后直接退出。
查看方法实现,可见由 check 导致扩容检查的作用范围是:

  • 非并发情况,直接 CAS(baseCount);
  • 并发但是 CAS(CounterCell[n]) 成功的线程,且操作的 Node 子节点需大于 1 或为树节点,而其他 CAS 失败将不会导致扩容检查。

  这也将导致,部分 Node 链表的变更操作(putVal等)不会导致扩容检查——只变更了一个的情况;而树结构,因为总是 2,所以只要其 CAS 成功,则该线程辅助扩容检查。

猜测 check 的逻辑目的,一方面想要尽可能保证扩容检查,一方面又不想太过频繁导致性能影响,所以,

  • 当无并发情况,则负载正常,扩容检查压力也较小(扩容的压力应该来自线程,而非内存或其他压力);
  • 当有并发情况时,因为是 CAS 操作,只考虑其中一条线程来保证,而非全部线程都执行此逻辑,毕竟并发情况下,负载已高(其导致的扩容情况也更频繁),再让所有线程都执行此逻辑,可能得不偿失。

为什么树形结构总是为 2, 如果它只更新了一个节点呢?
实际上,出现了树形结构,应该是该 Map 有扩容的考虑期望在里面,因为默认都是链表不足则先扩展大小,如果扩展大小后(大于64)依旧有新元素导致树化,即该 Map 具有较多的元素变更操作,可更关注于树形结构变更。这也近似于前面说到的思想,就好像跟偏向锁一样,总是偏向于某一种预定情况,而扩容检查则偏向于并发度高,操作频繁的。

  剩下的扩容操作已有其他文章解析,此处不再赘述。

3.fullAddCount 解析

fullAddCount 的功能:负责 CounterCells 的主要操作,即:初始化、更新、扩展、并发控制

3.1 先看初始化

  外部方法 addCount 在 CAS( baseCounter ) 失败后,会进入 fullAddCount 初始化 CounterCells。为了保证安全初始化 CounterCells,这里的做法是:死循环+CAS ( cellsBusy )。cellsBusy=0 为正常状态,cellsBusy=1 表示 CounterCells 正在被修改,通过 CAS 保证并发安全。

// See LongAdder version for explanation
private final void fullAddCount(long x, boolean wasUncontended) {
    int h;
    if ((h = ThreadLocalRandom.getProbe()) == 0) {
        ThreadLocalRandom.localInit();      // force initialization
        h = ThreadLocalRandom.getProbe();
        wasUncontended = true;
    }
    // collide : 碰撞,是否冲突(这属于进入方法 rehash 后的重新一次冲突检测)
    boolean collide = false;                // True if last slot nonempty
    for (;;) {
        if ((cs = counterCells) != null && (n = cs.length) > 0) {
            // ... 省略
        }
        // cellsBusy 为 0 且 counterCells == cs 相等(cs 在前面被赋值:cs=counterCells),CAS 替换 cellsBusy 成功,进入初始化
        else if (cellsBusy == 0 && counterCells == cs &&
                 U.compareAndSetInt(this, CELLSBUSY, 0, 1)) {
            // 是否初始化完成
            boolean init = false;
            try {                           // Initialize table
                if (counterCells == cs) {
                    // 默认创建 2 个 Cell
                    CounterCell[] rs = new CounterCell[2];
                    // 哈希线程,取其中一个 Cell
                    rs[h & 1] = new CounterCell(x);
                    counterCells = rs;
                    // 初始化完成,通过 break 跳出返回
                    init = true;
                }
            } finally {
                cellsBusy = 0;
            }
            if (init)
                break;
        }
    }
}

  CounterCells 初始大小默认为 2 个,通过与线程 Probe 进行 & 操作后,hash 到其中一个,并使用外部传入的变更数量 x 来初始化,然后标记完成。注意,这里初始化了 2 个,但只赋值了一个,所以另外一个 CounterCells[n]=null,这将匹配到另外一个分支去执行。最后,通过 finally 块将 cellBusy 赋回 0。

3.2 变更操作、并发控制、扩容

private final void fullAddCount(long x, boolean wasUncontended) {
    int h;
    if ((h = ThreadLocalRandom.getProbe()) == 0) {
        ThreadLocalRandom.localInit();      // force initialization
        h = ThreadLocalRandom.getProbe();
        wasUncontended = true;
    }
    // collide : 碰撞,是否冲突(这属于进入方法 rehash 后的重新一次冲突检测)
    boolean collide = false;                // True if last slot nonempty
    for (;;) {
        CounterCell[] cs; CounterCell c; int n; long v;// 非空,且 size 大于 0,通过线程的 hash & size ,定位所在的 CounterCell
        if ((cs = counterCells) != null && (n = cs.length) > 0) {
            // 第一步:解决问题 c = cs[ThreadLocalRandom.getProbe() & m]) == null,即初始化或扩容产生的新 CounterCell
            if ((c = cs[(n - 1) & h]) == null) {
                if (cellsBusy == 0) {            // Try to attach new Cell
                    // 初始化值,赋值完成后就 break 了
                    CounterCell r = new CounterCell(x); // Optimistic create
                    // CAS 替换,相当于获取锁,只有一条线程能够进入
                    if (cellsBusy == 0 &&
                        U.compareAndSetInt(this, CELLSBUSY, 0, 1)) {
                        // 创建完毕
                        boolean created = false;
                        try {               // Recheck under lock
                            CounterCell[] rs; int m, j;
                            if ((rs = counterCells) != null &&
                                (m = rs.length) > 0 &&
                                rs[j = (m - 1) & h] == null) {
                                rs[j] = r;
                                created = true;
                            }
                        } finally {
                            // 释放锁
                            cellsBusy = 0;
                        }
                        if (created)
                            break;
                        continue;           // Slot is now non-empty
                    }
                }
                collide = false;
            }
            else if (!wasUncontended)       // CAS already known to fail
                // 此处只有 CAS 失败的才会进入,将为 CAS 尝试做一次 rehash 操作,然后重新尝试
                wasUncontended = true;      // Continue after rehash
            else if (U.compareAndSetLong(c, CELLVALUE, v = c.value, v + x))     // 如果已存在值,则进行 CAS 替换,不成功继续死循环
                break;
            else if (counterCells != cs || n >= NCPU)
                // 边界判断,如果没有正在扩容,则判断是否超过大小边界,是则进行 rehash,否则扩容
                // 正处于无效状态或 CounterCell 大小已超过 CPU 个数,此时对 Probe 进行重置(翻倍)
                collide = false;            // At max size or stale
            else if (!collide)
                collide = true;
            else if (cellsBusy == 0 &&
                     U.compareAndSetInt(this, CELLSBUSY, 0, 1)) {
                try {
                    // 翻倍
                    if (counterCells == cs) // Expand table unless stale
                        counterCells = Arrays.copyOf(cs, n << 1);
                } finally {
                    cellsBusy = 0;
                }
                collide = false;
                continue;                   // Retry with expanded table
            }
            h = ThreadLocalRandom.advanceProbe(h);
        }
        // cellsBusy 为 0 且 counterCells == cs 相等(cs 在前面被赋值:cs=counterCells),CAS 替换 cellsBusy 成功,进入初始化
        else if (cellsBusy == 0 && counterCells == cs &&
                 U.compareAndSetInt(this, CELLSBUSY, 0, 1)) {
            //...省略
        }
        else if (U.compareAndSetLong(this, BASECOUNT, v = baseCount, v + x))
            // 如果上述两个步骤无法进入,认为出现了错误,使用 CAS(BaseCount) 作为备用/保障选项
            break;                          // Fall back on using base
    }
}

fullAddCount 对 cs[ThreadLocalRandom.getProbe() & m]) == null 提供了支持,即赋值初始化。这不算并发影响。
fullAddCount 有种思想:对于 CAS 失败的,总是存了善心,给他们改过的机会 —— 进行 rehash。

CAS 失败有以下情况:
 外部原因:即 wasUncontended 标志

  1. 因为线程从未初始化过 Probe,它们的初值都为0,导致都被 hash 到 CounterCells[0] 并 CAS 失败的,给他们的 Probe 重新赋值,这对应入口方法;
  2. 线程 Probe 已赋值过,但 CAS 失败的,给他们的 Probe 进行 rehash,这对应分支 if ( !wasUncontended );

  内部原因:即 collide 标志。

  1. 如果外部失败进入后,在此方法中又 CAS 失败的,则 collide = true,为内部 CAS 冲突失败,它们也需要被 rehash。

如果内部 CAS 失败,则尝试扩容,扩容的边界是 CounterCells # length < NCPU(CPU核数),因为理想情况下,一个 CPU 核数执行一个线程,而每个线程能够到 hash 到各自的 cell 上,冲突性低。如果超出 NCPU,核数全开,也只能帮你最多 NCPU 个线程,所以并不是扩容越大越好。扩容前还需先判断当前的状态,如果是正在扩容,则采取 rehash,守株待兔即可。
扩容操作比较简单,在 cellBusy 的保护下,对数组进行翻倍,然后重新循环重试。

负责扩容的线程不再进行 rehash,而不负责扩容的线程,则进行 rehash,提升 Probe,其内部实现也是将 Probe 进行翻倍,与 HashMap 的树的两倍扩容、高低位有异曲同工之妙。

方法大体解析完毕,不过还剩下:wasUncontended 标志、collide 标志。
纵观全实现,感觉这两个参数的作用不大,却在每个分支上都有他们的身影。代码实现上,将他们取消并直接实现为线程 advanceProbe,似乎也并无不妥。
  
wasUncontended 标志、collide 标志 在某种意义上,具有划分、明确上下文的作用,表示线程的 CAS 冲突性是由外部引起还是内部引起,反过来观察 collide,它的注释是: "True if last slot nonempty",只有在所有的 CounterCell 非空下,才能为 true,也就是说,当 CAS 失败但发现 CounterCells#length < NCPU,不认为属于冲突,可以通过扩容来解决,当扩容到最大值了,此时 CAS 失败,才认为产生了冲突。并且在各种分支中,会根据分支来重置表达含义。

另外一个理解思路是:wasUncontended、collide 在某种程度上,具有保护性能的作用。毕竟,如果 CAS 失败立即 advanceProbe 并重新进入 CAS,在一个高并发的场景中不见得能够比延缓操作要好。按照这种理解,可能需要自己重新修改源码然后编译一份做并发测试才能确认。

3.3 逃生门

除了对 CounterCells 的控制,fullAddCount 还考虑到如果 CounterCell 发生了未知情况无法处理时,后备 baseCount 来保证数据正确。

else if (U.compareAndSetLong(this, BASECOUNT, v = baseCount, v + x))
     // 如果上述两个步骤无法进入,使用 CAS(BaseCount) 作为备用/保障选项
     break;  

  也就是说,一开始以为 CounterCells 不为空就不再操作 baseCount 是错误的,baseCount 作为后备保证了流程的完整性和正确性。

总结

以上,是对 ConcurrentHashMap 的方法 addCount 和 fullAddCount 阅读后的一些理解,总的来说,对并发这块的解析不多,因为与 LongAddr 接近,主要是理解一些像 trick 的代码,一开始疑窦丛生,后面又自圆其说,如果错漏,感谢指出。

参考:
ThreadLocalRandom 随机数
关于 wasUncontended 的讨论

阅读 1.2k

GC:很多对象年纪轻轻就会死

35 声望
1 粉丝
0 条评论
你知道吗?

GC:很多对象年纪轻轻就会死

35 声望
1 粉丝
宣传栏