Atomic系列之LongAdder的底层原理(分段锁提升并发性能)
这一章节我们就来讲解CAS带来的另外一个问题,在并发激烈的时候,产生大量的自旋,空耗CPU的问题,以及怎么使用分段锁机制解决这个问题的,我们以LongAdder这个原子类来举例讲解。
CAS空耗CPU问题
AtomicInteger的缺陷:并发竞争激烈时导致大量线程自旋
为什么要设计出LongAddr?
在并发非常高的时候,使用AtomicInteger、AtomicLong底层的CAS操作竞争非常激烈,会导致大量线程CAS失败而不断自旋,耗费CPU的性能。
unsafe类的getAndAddInt方法,源码如下:
public final int getAndAddInt(Object o, long valueOffset, int x) { int expected; // CAS的失败的线程,会不断在do... while循环里重试,知道成功为止 do { expected = this.getIntVolatile(o, valueOffset); // while条件判断这里的compareAndSwapInt每次只有一个线程成功 } while(!this.compareAndSwapInt(o, valueOffset, expected, expected + x)); return expected; }
(1)我们看一下上述 do…while 循环,假设线程有10000个线程并发的操作,由于CAS操作能保证原子性(之前的文章讲过哦,CAS的底层原理),一个时刻只会有一个线程执行compareAndSwapInt方法成功
(2)失败的另外9999个线程,进入下一次循环,然后成功一个,剩下9998个进入下一层循环…,这种方式啊,竞争太过激烈导致**大量线程****在循环里面自旋重试,此时是不会释放CPU资源的,大大降低CPU的利用率,降低了并发的性能。
分段锁思想减少并发竞争
LongAdder采用分段锁的思想**,去减少并发竞争的;我打个比方还是上面10000个线程并发操作,但是LongAdder内部可能有10个锁,不同的线程可能去竞争不同的锁,平均下来可能是1000个线程竞争1个锁这样;并发性能这样比起AtomicInteger可能就**提升了10倍。
下面我以银行办事大厅多窗口机制来给你将什么是分段锁
如下图所示:
(1)银行大厅有一个常规窗口,一堆备用窗口;每个窗口有个计数器value记录了当前窗口的访客人数
(2)当人数很少的时候,不存在竞争,通常办事大厅只启用常规窗口就足以应对了
(3)当银行某个经理想知道总的访客人数的时候,需要将所有窗口的value访问人数累加就可以了
当访客人数很少,比如一两个的时候只开放常规窗口就可以了。
同时每个窗口内部有一个访客计数器,窗口每次接待完一个访客,value就加1,当银行经理想看一下整个办事大厅的来访总人数的时候,就把所有的窗口的访客计数器value累加就可以了。
多窗口处理减少竞争
(1)在访客非常多的情况下,只开启常规窗口是不行的**,由于一个窗口同一时间只能处理一个访客的请求,并发竞争非常激烈(**这个就是上述说的AtomicInteger的缺陷,所有人竞争通过一个窗口)
(2)所以这个时候启用备用窗口,假如说有4个备用窗口,分别为A、B、C、D窗口**,每个用户自己有一个id,比如用户2(id=4)来了之后看到常规窗口有人了,就会根据 id % 备用窗口大小 =》 4 % 4 = 0,就会派到第一个备用窗口,也就是**窗口A
(3)同时并发非常激烈的时候,同一个备用窗口也是存在竞争的,比如**用户3(id=5)、用户6(id=9)根据 id % 备用窗口大小 = 2,所以都竞争备用窗口B;**用户4(id=6)和用户7(id=10)**同样会竞争备用窗口C
(4)使用了备用窗口列表,相当于将不同的用户派遣到不同的窗口,减少了竞争,这样并发性能自然就提升上去了。
上面的这种机制啊,其实就是类似将锁拆成锁多个段;其中每个窗口就是一段锁,只有被派到同一个窗口的用户才会存在竞争,使用分段锁大大减少了竞争,提升了并发性能。
这个时候如果银行经理想要知道办事大厅的总访问人数,只需要将多个窗口(多个段)内的value人数累加起来就可以了。
LongAdder分段索实现原理
首先,我给你说LongAdder之前得给你介绍一下Striped64这个类,Striped64这个类是分段锁的基础,实现分段锁的功能,而LongAdder继承Striped64,只是在Striped64基础之上做了简单的封装而已。
Cell单元(窗口)
Cell {
// 计数器,当前访问人数多少
volatile long value;
// 构造函数
Cell(long x) { value = x; }
// CAS竞争同一个窗口,可能多个用户CAS竞争这个窗口
final boolean cas(long cmp, long val) {
return UNSAFE.compareAndSwapLong(this, valueOffset, cmp, val);
}
}
基础窗口
直接用一个base变量表示基础窗口
transient volatile long base;
争抢基础窗口的方法,casBase底层源码,直接cas修改base变量在内存的值
final boolean casBase(long cmp, long val) {
return UNSAFE.compareAndSwapLong(this, BASE, cmp, val);
}
用户id生成方法
这里就是通过线程id,经过系列运算得到用户id
static final int getProbe() {
return UNSAFE.getInt(Thread.currentThread(), PROBE);
}
LongAdder内部源码分析
首先看下LongAdder提供的加减方法:
public void increment() {
add(1L);
}
public void decrement() {
add(-1L);
}
我们看到,increment和decrement方法底层调用的都是add方法,只不过传入的是正数和负数的区别而已。
看一下设计极为简洁和精妙的add方法:
public void add(long x) {
// as就类似我们上述说的备用窗口列表
Cell[] as;
// 这里的b就是常规窗口的值,v是你被分派到的那个窗口的value值
long b, v;
// m是备用窗口的长度-1,
// 上面我们讲过getProbe()方法就是获取用户id的方法
// getProbe() & 其实就是 用户id % 窗口总数,窗口分派的算法
int m;
// a就是你被派遣到的那个窗口
Cell a;
// 1.首先如果cells==null,说明备用窗口没有开放,
// 全都执行casBase争抢常规窗口,cas成功则争抢成功,然后办完事就退出了
// 如果争抢失败 casBase == false,则会进入if代码内重试
// 2. 如果cells != null,说明备用窗口开发了,不用去争抢常规窗口了,
// 直接就进入争抢备用窗口
if ((as = cells) != null || !casBase(b = base, b + x)) {
boolean uncontended = true;
// 3. as == null || as.length - 1 < 0 说明备用窗口列表尚未开放
if (as == null || (m = as.length - 1) < 0 ||
// 4. as[getProbe() & m] 是你被派遣到的那个备用窗口
// (a = as[getProbe() & m]) == null 你被派遣到的那个备用窗口还没有人来
(a = as[getProbe() & m]) == null ||
// 5. a.cas() 就是你被分派到窗口a后,去尝试争抢窗口a的权限
// 如果 uncontented就是你争抢的结果,如果!uncnotented == true,说明你争抢失败了
!(uncontended = a.cas(v = a.value, v + x)))
// 相当于上面操作都失败之后的一种兜底方案
longAccumulate(x, null, uncontended);
}
}
Strimped64分段索实现源码
继续查看LongAccumulate的方法源码:
longAccumulate里面做了几件事情,先从大角度分析一下:
(1)进入方法,黄色部分,首先就是获取一下用户id,如果为0,先强制初始化一下用户id
(2)然后就是进入for死循环里面,只有用户的请求成功处理了,才会退出循环
(3)然后就是下面比较重要的三个大的分支条件
进入备用窗口处理
// cells是备用窗口列表,如果备用窗口不等于null,
// 并且是length>0 说明备用窗口开启了
// 则用户进入这个条件,去备用窗口列表里面处理
if ((as = cells) != null && (n = as.length) > 0)
初始化备用窗口
如果上面的分支不满足,说明 cells == null 或者 cells.length <= 0 说明备用窗口没开启啊,这个时候就要开启一下备用窗口,即进行备用窗口列表数组的初始化工作:
// cellsBusy 是备用窗口列表是否在初始化或者扩容标志
// cellsBusy == 0 说明尚未有人初始化或者扩容
// 这时候执行到这里的线程,就要进行对备用窗口初始化了
// 比如说线程1走到这里,发现没初始化,就要执行一下casCellBusy告诉别人正在初始化
else if (cellsBusy == 0 && cells == as && casCellsBusy())
竞争常规窗口
如果cellsBusy == 1 说明有人在初始化备用窗口或者对备用窗口列表进行扩容,这个时候备用窗口列表不可用,只能去常规窗口争抢了
// 直接对常规窗口争抢,如果成功就退出死循环了
else if (casBase(v = base, ((fn == null) ? v + x :
fn.applyAsLong(v, x))))
break;
这里画个图看下
(1)比如用户1进入了这里之后,首先判断分支1,发现备用窗口没有启用;然后直接****进入分支2,去初始化备用窗口
(2)然后用户2也进来了,发现备用窗口没启用,同时发现有人在初始化备用窗口,直接进入分支3,去争抢常规窗口了
(3)备用窗口初始化好了,开启了之后;用户的请求进来都走到分支1,去备用窗口列表去竞争去了。
分支2的源码:初始化备用窗口
else if (cellsBusy == 0 && cells == as && casCellsBusy()) {
// init是否初始化完成,最开始设置为false
boolean init = false;
try { // Initialize table
// 然后这里就没什么复杂的,就是创建一个数组
// 但是这个时候数组里面没有元素,每个元素都是空的
if (cells == as) {
Cell[] rs = new Cell[2];
// 创建一个新的窗口,把自己的操作数x交给新创建的窗口
rs[h & 1] = new Cell(x);
cells = rs;
// 然后设置初始化完成表示为true
init = true;
}
} finally {
// 重新设置cellsBusy标识,操作完成,没人在初始化或者扩容了
cellsBusy = 0;
}
// 如果初始化完成,退出循环
if (init)
break;
}
如果cells备用窗口时null,则初始化一下;同时如果自己被派遣到的窗口格子是空的,则创建一个窗口格子(相当于叫一个工作人员过来)
分支1的源码:备用窗口已经开启,准备去备用窗口:
if ((as = cells) != null && (n = as.length) > 0) {
// 如果自己被派到的那个备用窗口为null,说明窗口还么人工作,则叫一个工作人员过来负责
if ((a = as[(n - 1) & h]) == null) {
// 如果没人在初始化,下面则创建一个窗口(叫一个工作人员过来处理)
if (cellsBusy == 0) {
// 创建一个新的窗口,同时提交自己的请求x
Cell r = new Cell(x);
// 如果没人在初始化
if (cellsBusy == 0 && casCellsBusy()) {
boolean created = false;
try {
Cell[] rs; int m, j;
// 再次检查备用窗口列表初始化好了
if ((rs = cells) != 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;
}
// 如果到这里已经知道cas操作失败了,则重新设置一下失败标识,进入下一循环重试
else if (!wasUncontended) // CAS already known to fail
wasUncontended = true; // Continue after rehash
// 根据 用户id % 备用窗口总数 算法,自己应该争抢哪个备用窗口
// 如果争抢成功,则操作结束了,如果失败进入下一循环重试
else if (a.cas(v = a.value, ((fn == null) ? v + x :
fn.applyAsLong(v, x))))
break;
else if (n >= NCPU || cells != as)
collide = false;
else if (!collide)
collide = true;
// 走到这里,说明上面的操作都失败了,备用窗口竞争还是很激烈
// 所以需要扩容了,多增加一些备用窗口,缓解竞争激烈的情况
else if (cellsBusy == 0 && casCellsBusy()) {
try {
// 这里就是具体的扩容操作了
if (cells == as) { // Expand table unless stale
// 新的备用窗口是原来的2倍
Cell[] rs = new Cell[n << 1];
// 原来已经存在的备用窗口重新赋值一下
for (int i = 0; i < n; ++i)
rs[i] = as[i];
cells = rs;
}
} finally {
cellsBusy = 0;
}
collide = false;
continue; // Retry with expanded table
}
// 在这里重新计算一下自己的用户id,
// 使得用户id尽量的分散到不同的窗口,减少竞争
h = advanceProbe(h);
}