关注公众号【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个变量(红框已标注)。
正餐开始之前先做个简单说明,我们把并发的级别先分为3个等级,1,2,3,并发越来越高。
假设我们的代码是在 4 核 CPU 的机器上运行,执行的是自增方法。
| 一级并发场景
和 AtomicLong 等效。在 base 这个变量上 CAS 自增就可以。
此时两个线程竞争的只是在 base 值上 CAS 操作自增,对应源码的逻辑是这里:
CAS 成功,这次自增操作就会结束。
| 二级并发场景(实例化 cells )
此时同时来了 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 加锁成功之后的操作。
从源码中可以看到,是直接实例化了 cells,并把当前要累加的值包装成一个 cell,直接放入 cells 中,然后释放锁,thread-2 的这次累加结束。
我们继续看 thread-3 失败后又去做什么了?
thread-3 竟然走了回头路,继续对 base CAS 累加操作。
不得不佩服作者的思路:既然你们都去竞争 cells 了,那么 base 的竞争会不会小一些?不防再试一次 base 。假设 caseBase 成功,thread-3 本次累加操作结束,对应图中第二层最右侧。
如果这里还不成功,thread-3 就会自旋进入下一次循环,重新竞争 cells 。
继续看图中第三层,这一层描述的场景是, cells 已经实例化,同时来了2个线程,thread-1 casBase 成功,本次累加结束。thread-2 失败,转而去操作 cells,cells 上无竞争,直接找到位置放入,thread-2 本次累加结束。对应源码如下:
| 三级并发场景(cells 扩容)
这张图有点复杂,我们一步步拆解,重点看一下非常倒霉的 thread-3,先看图上第一层的逻辑。
假设 thread-2、thread-3 casBase 失败后都来竞争 cells[1],thread-2 竞争成功,cells[1]++,本次累加结束。
thread-3 竞争 cells[1] 失败后,且 cells 的容量不大于 CPU 核数,会执行到下面的代码:
这里只会把扩容标记置为 true,最下面 rehash 之后会进入下一次循环。
看图中的第二层,thread-3 rehash 后被分配到了 cells[0],碰巧 thread-5 又来和它竞争,还没竞争过人家,对应图中的 ① 。
悲催的 thread-3 只能继续往下走,这时发现上一次竞争失败后已经把扩容标记置为 true 了,于是就开启了扩容之旅。
扩容要先对 cellBusy 加锁(任何 cell 操作之前,都要先加锁),好在这次直接加锁成功,对应图中的 ② 。下面就可以执行扩容的逻辑了,就是图中的 ③ ,扩容完之后释放锁,直接进入下一次循环竞争。对应源码如下:
继续看图中的第三层,由于扩容之后,thread-3 没有 rehash ,所以下一次循环竞争的还是 cells[0],这次就照顾一下它,不安排其他线程捣乱了,对 cell[0] CAS 累加成功,thread-3 本次累加结束。
| 分久必合
分开累加的确提升了性能,但获取当前值的时候就要麻烦些,每次都需要把 base 和 cells 里的值累加到一起,而且得到的只是一个快照值。
再看一下源码,为了追求极致性能,累加时都没有加锁。
好了,整个过程到此结束。
扯两句
分而治之的方法论,很多场景都适用,一旦拆分,带来的问题也是指数级的
要抓住核心问题去解决,其他次要问题可以适当取舍
留个思考:为什么扩容之后没有像初始化时那样,直接再 rehash 一次把值放到 cells 里,而是继续下一次循环呢?
欢迎大家留言讨论!
原创不易,如有收获,一键三连,感谢支持!