大家好,我是程序员牛肉。
昨天又从朋友那里倒腾过来一个好的题材:“JUC中的高性能计数器工具类LongAdder”。初步在网上搜索之后,发现其实阿里巴巴的Java开发手册中也有对于这个工具包的推荐:
JUC包下的所有工具类我都很感兴趣,因此昨晚加急看完了源码,在感叹其设计精巧的同时也决定写这篇文章分享给大家。
在高并发的场景下如果有统计类的需求的话,一开始我们使用的计数器是AtomicLong。但是AtomicLong的性能会随着并发量的上升而急剧下降,让我在代码层面看一看为什么会这样。
AtomicLong的增值操作调用的是getAndIncrement方法:
这个方法的底层走的是unsafe的CAS操作:
上面这段代码的逻辑很简单:手动写了一个死循环比较期望值,如果持有值与期望值相等就进行交换。
坏就坏在这里了:AtomicLong做自增操作的时候使用CAS+自旋会导致大量的线程在这里频繁的比较失败和自旋,这在大并发量的背景下,对整体项目的性能更是迎头重击。
所以JDK1.8中引入了一个新的原子操作类来解决这个问题。而它就是我们今天要介绍的LongAdder类:
Lon``gAdder的整体设计思想并不难。我们照着AtomicLong的缺陷推理就能推理出来。
前面我们说过:AtomicLong的性能瓶颈主要在于高并发环境下会有大量的线程进入“比较失败和自旋”的漩涡中。
那我们就对这一部分进行优化,整体采用“分治+求和”思想。
A``tomic``Long不是多个线程争夺一个value嘛?现在我们在LongAdder中就创建多个临时的value来供线程进行增值操作,而真正的value值等于这些临时value的值求和。
通过增加临时value的操作,我们大大减轻了高并发的场景下多线程在CAS操作中的竞争激烈度。
[LongAdder
的基本思路就是分散热点,将value
值的新增操作分散到一个数组中,不同线程会命中到数组的不同槽中,各个线程只对自己槽中的那个value
值进行CAS
操作,这样热点就被分散了,冲突的概率就小很多。]
我们可以尝试起一千个线程,每个线程都分别基于AtomicLong和LongAdder执行自增操作一万次。看一看AtomicLong和LongAdder的性能差距:
无需多言,这已经是单方面的羞辱了。现在知道LongAdder的性能有多高了吧?
如果你能理解我上面说的这些东西,那恭喜你已经掌握LongAdder的基本原理了,刷会抖音休息一下吧。
休息过后,让我们尝试来深入LongAdder的源码部分,学习其中精妙的设计思想。
严格的说LongAdder中的具体操作流程应该是这样的:
让我们来看看LongAdder中最重要的add方法,注释我一行行都写清了,这块一定要看:
public void add(long x) {
//cs是来分摊竞争压力的cell数组
Striped64.Cell[] cs;
//b是base的期望值
long b;
//如果cell数组已经存在或者cas写base的时候写失败
if ((cs = this.cells) != null || !this.casBase(b = this.base, b + x)) {
//随机的获取一个数组作为cell数组的下标
int index = getProbe();
//设置存在竞争
boolean uncontended = true;
//期望值
long v;
//cell数组的长度
int m;
Striped64.Cell c;
//1.((cs == null || (m = cs.length - 1) < 0 )
//true->说明此时cell还未初始化。此时的背景是多线程写base的时候出现竞争了。
//false->说明此时cell已经初始化,现在线程应该根据前面分配的index往cell数组中的指定位置写值
if (cs == null || (m = cs.length - 1) < 0
||
// 2.(c = cs[index & m]) == null
//true->这个下标位置为空,需要创建一个longAccumulate进行累加操作
//false->不为空,下一步就需要在该位置上进行累加操作
(c = cs[index & m]) == null
||
//3.这里有取反操作,所以要注意:
//true->说明这个cell内有竞争
//false->说明cas操作成功
!(uncontended = c.cas(v = c.value, v + x))) {
//也就是说一共会有以下操作会走到这里:
//1.true->说明此时cell还未初始化。此时的背景是多线程写base的时候出现竞争了。
//2.cell对应的下标位置为空,需要创建一个longAccumulate进行累加操作
//3.这个cell内有竞争,需要进行重试
this.longAccumulate(x, (LongBinaryOperator)null, uncontended, index);
}
}
}
这一长串代码,实际上可以被总结为:
-
首先尝试更新
base
值。 -
如果更新失败(或
cells
数组已存在),则获取一个随机索引,并尝试更新对应Cell
的值。 -
如果
Cell
未初始化、为空或更新失败(存在竞争或者cell数组在扩容),则调用longAccumulate
方法处理更复杂的累加逻辑
让我们继续来看longAccumulate这个方法,先介绍一下传入的参数:
让我们继续来看代码:
这里的逻辑是:如果index的位置为0(未分配位置),就给这个index重新取一个值。并且设置wasUncontended为“未竞争”。
这么做的意义是因为如果当前线程的hash值h=getProbe()为0,0与任何数取模都是0,会固定到数组第一个位置,所有使用0作为初始 index
的线程都会尝试更新 Cell
数组的第一个位置,这会导致激烈的线程竞争。所以这里做了优化,使用ThreadLocalRandom
为当前线程重新计算一个hash
值。
最后设置wasUncontended = true
,这里含义是重新计算了当前线程的hash
后认为此次不算是一次竞争。hash
值被重置就好比一个全新的线程一样,所以设置了竞争状态为true
。
之后就开始进入逆天if循环了,首先在最外围写一个while死循环。实现“自旋”。
为了防止有些同学看懵逼,我们用文字串一下逻辑。在这一过程中,我们要明白:无论是多少层if嵌套循环,它本质上代表的还是一种情况而已。我们只要慢慢梳理就是可以梳理出来的。
最顶层一共有三种情况,分别是:
情况一:cells数组已经初始化了,接下来的操作就是尝试把线程当前的值写到对应的cell中。
具体内部的条件有点复杂,我们后面再讲。先说完顶层的大分支再说。
情况二:cell数组还没有初始化(所以情况1走不通),接下来的操作就是先持有锁,再去初始化cell数组。
这个代码就很简单,我们可以看到我们创建了一个叫rs的cell对象。之后将[index&1]的位置赋值为一个初始值为x的cell对象。
[INDEX&1 实际上是(index & (数组长度-1),只不过此时的数组长度是2,做完减一操作之后为1,直接写死在这里了。
我们可以把(index & (数组长度-1))看作是一个取余操作,思想和Hashmap中的设计一摸一样。这里不多作介绍]
情况三:走到这里说明当前的cell数组正在被初始化。因此它争取不到cellsBusy锁。开始尝试调用casBase操作来往base中写值。如果成功就结束本次循环。
顶层的分支介绍完了,现在就只剩下情况一的小分支了。让我们继续看:
情况1.1:cell数组虽然存在,但是内部对应的下标位置无cell。因此我们就要创建出来一个cell填充到cell数组中。
具体一行一行的代码逻辑下面会作解释的
情况1.2:说明在走到1.1中的this.cellsBusy的时候获取锁失败。在这个我们要修改竞争条件为“有竞争”。
情况1.3:cell数组存在,而且对应的下标位置中cell也不为空。开始尝试给cell做加值操作。
当累加完毕之后,开始走扩容操作。如果当前的cell数组长度小于当前电脑的CPU核心数并且cell的引用没有发生变化的话,就开始尝试扩容。
至此所有的大逻辑就已经讲完,具体的逐行代码讲解如下:
//while死循环做自旋
while(true) {
Cell[] cs;
//cell数组的长度
int n;
//V表示期望值
long v;
//case1:cell已经初始化了,当前线程就要把值写到对应的cell数组中
if ((cs = this.cells) != null && (n = cs.length) > 0) {
Cell c;
//case1.1:如果当前线程对应的cell是空,就走初始化的这一套
if ((c = cs[n - 1 & index]) == null) {
//当前锁未被占用
if (this.cellsBusy == 0) {
//用当前待插入的X值去初始化cell
Cell r = new Cell(x);
//再次判断锁是否被占用,如果没有被占用就使用casCellsBusy来获取锁
if (this.cellsBusy == 0 && this.casCellsBusy()) {
try {
Cell[] rs;
//m代表cell数组长度
int m;
//j代表当前线程命中的索引下标
int j;
//如果cell数组的引用又发生变化或者cell数组rs对应的位置中不为空。说明已经有线程进行过更改了。为了避免覆盖旧值,我们放弃本次更改。
if ((rs = this.cells) == null || (m = rs.length) <= 0 || rs[j = m - 1 & index] != null) {
continue;
}
//在cell数组的j位置上填充上cell r,结束本次循环
rs[j] = r;
break;
} finally {
//释放当前的锁
this.cellsBusy = 0;
}
}
}
//修改扩容意向为false。
collide = false;
}
//case1.2: 只有可能出现在cell初始化之后,当前线程竞争修改失败才会是false。
elseif (!wasUncontended) {
wasUncontended = true;
} else {
//case1.3: 当前线程rehash过,新命中的cell不为空,开始走写cell操作。
//true->写新的cell写成功
//fasle->写新的cell的时候又存在竞争,开始走下一轮自旋
if (c.cas(v = c.value, fn == null ? v + x : fn.applyAsLong(v, x)))
break;
}
//这里的Ncpu的意思是你的电脑的cpu核心数。
//case1.4: 如果cell数组长度小于Ncup并且当前线程持有的仍然是cs这个引用的话。(其他线程没有对cs进行扩容等操作)
if (n < NCPU && this.cells == cs) {
//将扩容标志位从false设置为true。
if (!collide) {
collide = true;
}
//开始走真正的扩容,先去判断锁的情况(cellsBusy),如果没有线程持有锁的情况下,在使用casCellsBusy来持有锁
elseif (this.cellsBusy == 0 && this.casCellsBusy()) {
//再次判断一下当前线程持有的是不是cs这个数组。
try {
if (this.cells == cs) {
//使用copyof方法来将cs数组扩容为原线程的两倍长度。
this.cells = (Cell[])Arrays.copyOf(cs, n << 1);
}
}
//释放锁
finally {
this.cellsBusy = 0;
}
//由于已经进行过扩容,因此在这里修改扩容意向为false。
collide = false;
continue;
}
}
//case1.5: 由于当前的cell数组的长度已经超过当前电脑的CPU核心数了。因此不进行扩容。
// 由于当前线程持有的不再是cs这个cell数组了。说明已经有其他线程做了扩容操作,因此不再进行扩容
else {
collide = false;
}
}
//重置线程的hash值,相当于是重新给线程找了一个索引下标。
index = advanceProbe(index);
}
//case2:cell数组还没初始化,
// 当前的cell没被修改,仍然指向cs
// 获取锁成功(CellsBusy :0为未持有,1为已持有)
elseif (this.cellsBusy == 0 && this.cells == cs && this.casCellsBusy()) {
try {
//再次判断,避免线程并发下的问题:两个线程都进行了初始化,导致丢数据的情况
if (this.cells == cs) {
Cell[] rs = new Cell[2];
rs[index & 1] = new Cell(x);
this.cells = rs;
break;
}
} finally {
this.cellsBusy = 0;
}
}
//case3: 当前的cell是加锁状态
// cell被其他线程初始化了。导致当前线程找不到对应的cell,当前线程需要把数据累加到base中。
elseif (this.casBase(v = this.base, fn == null ? v + x : fn.applyAsLong(v, x))) {
break;
}
}
}
基于这种操作,我们就实现了LongAdder中的“分摊竞争压力”。不得不说,这段代码真的是简洁高效且逻辑复杂。
一遍看不懂是正常的,我非常推荐你自己去看一看。下来我们看一看这个cell数组中的cell对象:
这个@Contended注解比较有意思一点:
Cell注定要被多线程所共享,所以在这一过程中数组中的每一个cell对象都使用了@Contended注解来避免伪共享问题。
[伪共享是指多个线程访问不同的变量,但这些变量恰好位于同一个缓存行中。由于缓存行是共享的,当一个线程修改其中一个变量时,整个缓存行的状态会被标记为“脏”(dirty),其他线程必须重新加载整个缓存行,即使它们访问的是缓存行中的其他变量。这种不必要的缓存行刷新和重新加载会导致性能下降。]
假设我们有以下代码:
public class Counter {
int a = 0;
int b = 0;
public void incrementA() {
a++;
}
public void incrementB() {
b++;
}
}
在多线程环境中,线程 1 调用 incrementA()
,线程 2 调用 incrementB()
。a
和 b
是两个独立的变量,但它们可能位于同一个缓存行中。当线程 1 修改 a
时,整个缓存行会被标记为脏,线程 2 也需要重新加载缓存行,尽管它只关心 b
。这就是伪共享问题。
最后``我们再来看一看LongAdder中是如何对base和cells数组的值进行求和的:
没啥好``讲的,直接硬遍历了。需要注意的是这里的sum并不一保证能拿到精确值。
这是因为当多个线程同时更新 `LongAdder` 时,它们可能正在修改 `base` 值或者 `Cell`
数组中的```值。由于这些更新操作是并发进行的,所以在调用
sum()` 方法时,可能有些更新尚未完成,从而导致返回的总和不是最新的精确值。``
在读完这个类之后,其实越来越发现JUC的底层很多类的设计思想是共通的。LongAdder中所体现出来的“分治”的思想,其实就有点像“分段锁”。
那今天关于“LongAdder”的文章就介绍到这里了。相信通过我的介绍,你已经大致了解了LongAdder。希望我的文章可以帮到你。
关于JUC中的各种类,你有什么想分享的嘛?欢迎在评论区留言。
关注我,带你了解更多技术干货。