3 张图带你看透 LongAdder

 关注公众号【1024个为什么】,及时接收最新推送文章! 

本文内容:

1、AtomicLong 解决并发的方案?

2、AtomicLong 的不足是什么?

3、LongAdder 又是怎么弥补这一不足的?

在我的过往经历中,用到原子类的地方不多,就算用到了,AtomicInteger 也能满足业务场景,其它几个原子类从来没用过。

为什么研究 AtomicLong 和 LongAdder ?

一是前段时间组内一起学习并发相关知识的过程中涉及到了原子类;

二是我也好奇大神解决高并发的思路。

||  AtomicLong 解决并发的方案

有关 AtomicLong 的基本信息,本文不再赘述。直接通过一个核心方法,看看其内部是如何实现的。

| 核心方法

我们只看自增这一个方法:

public final long getAndIncrement() {
    return unsafe.getAndAddLong(this, valueOffset, 1L);
}

可以看到,内部调用的是 Unsafe 类的 getAndAddLong 方法,继续看这个方法内的逻辑:

public final long getAndAddLong(Object o, long offset, long delta) {
    long v;
    do {
        v = getLongVolatile(o, offset);
    } while (!compareAndSwapLong(o, offset, v, v + delta));
    return v;
}

不难发现,核心操作就是 while 条件里的 compareAndSwapLong()。

也就是我们常说的 CAS。

所以 AtomicLong 解决并发的核心思想就是CAS + 自旋。

||  AtomicLong 的不足是什么

| 高并发下效率低

没有那种解决方案是万能的,AtomicLong 也有它的局限性。

从源码不难看出,在高并发下 CAS 会大量失败,而且在while 循环里,会一直占用CPU资源。

如果每次自增都要经过很多次 CAS 失败,效率自然很低。

||  LongAdder 又是怎么弥补这一不足的

| 分而治之

思想很简单,都往一个变量上累加,效率不仅低,还会随着并发增加而更低。

那能不能多整几个变量,假设提供10个变量(a0 ... a9),根据线程ID末位数字,找到与其对应的变量进行累加。

这样一来,理论上效率至少能提高9倍。

当然这里只是举个例子,Doug Lea 考虑的场景要复杂的多,接下来就通过一系列的图,来阐述LongAdder的设计思想。

先来看一下 LongAdder 的类结构,其核心逻辑主要都在父类 Striped64 的 longAccumulate() 方法中,还要重点关注父类的3个变量(红框已标注)。

934af87eaf1d74ddf8ce0248fcc3ab4a.png

正餐开始之前先做个简单说明,我们把并发的级别先分为3个等级,1,2,3,并发越来越高。

假设我们的代码是在 4 核 CPU 的机器上运行,执行的是自增方法。

| 一级并发场景

和 AtomicLong 等效。在 base 这个变量上 CAS 自增就可以。

2fddea805225a9ba7eb93a572bbd1801.png

此时两个线程竞争的只是在 base 值上 CAS 操作自增,对应源码的逻辑是这里:

35273434d46b567ccf1d3bc780e3f9c4.png

CAS 成功,这次自增操作就会结束。

| 二级并发场景(实例化 cells )

dbcbc5b7118a122a595bfc5e246f0814.png

此时同时来了 3 个线程,thread-1 对 base CAS 累加成功,thread-2、thread-3 失败,也就是图中第一层表示的含义。

thread-2、thread-3 都会进入 Striped64#longAccumulate() 方法中,也就是图中的第二层。两个线程都要往 cells  中累加,此时的 cells 还是 null 。

两个线程要先对 cells 实例化后才能放值,实例化前又要对 cellBusy 加锁 ,否则 2 个线程都实例化有相互覆盖的可能。

假设 thread-2 对 cellBusy 加锁成功,thread-3 失败,我们看一下 thread-2 加锁成功之后的操作。

b1851aa92cb954216b8ccc9733d8e0e6.png

从源码中可以看到,是直接实例化了 cells,并把当前要累加的值包装成一个 cell,直接放入 cells 中,然后释放锁,thread-2 的这次累加结束。

我们继续看 thread-3 失败后又去做什么了?

2353fe59d42c3f862a12fef4aded6ed3.png

thread-3 竟然走了回头路,继续对 base CAS 累加操作。

不得不佩服作者的思路:既然你们都去竞争 cells 了,那么 base 的竞争会不会小一些?不防再试一次 base 。假设 caseBase 成功,thread-3 本次累加操作结束,对应图中第二层最右侧。

如果这里还不成功,thread-3 就会自旋进入下一次循环,重新竞争 cells 。

继续看图中第三层,这一层描述的场景是, cells 已经实例化,同时来了2个线程,thread-1 casBase 成功,本次累加结束。thread-2 失败,转而去操作 cells,cells 上无竞争,直接找到位置放入,thread-2 本次累加结束。对应源码如下:

782a291894e2adb20e53dd25b1bb377f.png

| 三级并发场景(cells 扩容)

cde84b3082cfa3782c57ee4d0823787b.png

这张图有点复杂,我们一步步拆解,重点看一下非常倒霉的 thread-3,先看图上第一层的逻辑。

假设 thread-2、thread-3 casBase 失败后都来竞争 cells[1],thread-2 竞争成功,cells[1]++,本次累加结束。

thread-3 竞争 cells[1] 失败后,且 cells 的容量不大于 CPU 核数,会执行到下面的代码:

de958e745936c6a6a9525dbffd7247a3.png

这里只会把扩容标记置为 true,最下面 rehash 之后会进入下一次循环。

看图中的第二层,thread-3 rehash 后被分配到了 cells[0],碰巧 thread-5 又来和它竞争,还没竞争过人家,对应图中的 ① 。

悲催的 thread-3 只能继续往下走,这时发现上一次竞争失败后已经把扩容标记置为 true 了,于是就开启了扩容之旅。

扩容要先对 cellBusy 加锁(任何 cell 操作之前,都要先加锁),好在这次直接加锁成功,对应图中的 ② 。下面就可以执行扩容的逻辑了,就是图中的 ③ ,扩容完之后释放锁,直接进入下一次循环竞争。对应源码如下:

30b158ba41d7359ec73b58ce494ad008.png

继续看图中的第三层,由于扩容之后,thread-3 没有 rehash ,所以下一次循环竞争的还是 cells[0],这次就照顾一下它,不安排其他线程捣乱了,对 cell[0] CAS 累加成功,thread-3 本次累加结束。

| 分久必合

分开累加的确提升了性能,但获取当前值的时候就要麻烦些,每次都需要把 base 和 cells 里的值累加到一起,而且得到的只是一个快照值。

再看一下源码,为了追求极致性能,累加时都没有加锁。

0854f74067530f2a93aa280e22487668.png

好了,整个过程到此结束。

扯两句

分而治之的方法论,很多场景都适用,一旦拆分,带来的问题也是指数级的

要抓住核心问题去解决,其他次要问题可以适当取舍

留个思考为什么扩容之后没有像初始化时那样,直接再 rehash 一次把值放到 cells 里,而是继续下一次循环呢?

欢迎大家留言讨论!

原创不易,如有收获,一键三连,感谢支持!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值