想写这个系列很久了,对自己也是个总结与提高。原来在学JAVA时,那些JAVA入门书籍会告诉你一些规律还有法则,但是用的时候我们一般很难想起来,因为我们用的少并且不知道为什么。知其所以然方能印象深刻并学以致用。
本篇文章针对JAVA中的原子类以及JDK1.8新增的LongAdder进行对比,探究它们的原理以及为何LongAdder在多线程环境下比较快。
本文基于JDK 1.8
性能对比:
测试程序,对比同步锁,Atomic还有LongAdder:
public class StressTest {
static long syncTest = 0;
static AtomicLong atomicTest = new AtomicLong(0);
static LongAdder longAdderTest = new LongAdder();
static int currentThreadNum;
public static void main(String[] args) throws InterruptedException {
test(1);
test(5);
test(10);
test(50);
test(100);
}
static void test(int threadNum) throws InterruptedException {
currentThreadNum = threadNum;
SyncIncrement[] syncIncrements = new SyncIncrement[threadNum];
AtomicIncrement[] atomicIncrements = new AtomicIncrement[threadNum];
LongAdderIncrement[] longAdderIncrements = new LongAdderIncrement[threadNum];
for (int i = 0; i < threadNum; i++) {
syncIncrements[i] = new SyncIncrement();
atomicIncrements[i] = new AtomicIncrement();
longAdderIncrements[i] = new LongAdderIncrement();
}
System.out.println("---------Thread Number: " + currentThreadNum + "---------");
long start = System.currentTimeMillis();
for (int i = 0; i < threadNum; i++) {
syncIncrements[i].start();
}
for (int i = 0; i < threadNum; i++) {
syncIncrements[i].join();
}
System.out.println("Synchronized Lock time elapsed: " + (System.currentTimeMillis() - start) + "ms");
start = System.currentTimeMillis();
for (int i = 0; i < threadNum; i++) {
atomicIncrements[i].start();
}
for (int i = 0; i < threadNum; i++) {
atomicIncrements[i].join();
}
System.out.println("Atomic Lock time elapsed: " + (System.currentTimeMillis() - start) + "ms");
start = System.currentTimeMillis();
for (int i = 0; i < threadNum; i++) {
longAdderIncrements[i].start();
}
for (int i = 0; i < threadNum; i++) {
longAdderIncrements[i].join();
}
System.out.println("Long adder time elapsed: " + (System.currentTimeMillis() - start) + "ms");
}
static class SyncIncrement extends Thread {
@Override
public void run() {
for (int i = 0; i < 10000000/currentThreadNum; i++) {
synchronized (SyncIncrement.class) {
syncTest++;
}
}
}
}
static class AtomicIncrement extends Thread {
@Override
public void run() {
for (int i = 0; i < 10000000/currentThreadNum; i++) {
atomicTest.incrementAndGet();
}
}
}
static class LongAdderIncrement extends Thread {
@Override
public void run() {
for (int i = 0; i < 10000000/currentThreadNum; i++) {
longAdderTest.increment();
}
}
}
}
输出:
---------Thread Number: 1---------
Synchronized Lock time elapsed: 205ms
Atomic Lock time elapsed: 133ms
Long adder time elapsed: 126ms
---------Thread Number: 5---------
Synchronized Lock time elapsed: 1906ms
Atomic Lock time elapsed: 648ms
Long adder time elapsed: 65ms
---------Thread Number: 10---------
Synchronized Lock time elapsed: 1964ms
Atomic Lock time elapsed: 635ms
Long adder time elapsed: 53ms
---------Thread Number: 50---------
Synchronized Lock time elapsed: 2157ms
Atomic Lock time elapsed: 682ms
Long adder time elapsed: 52ms
---------Thread Number: 100---------
Synchronized Lock time elapsed: 2057ms
Atomic Lock time elapsed: 602ms
Long adder time elapsed: 71ms
AtomicLong的提速思路
假设我们要统计接口调用次数,一般我们会用AtomicLong
每次接口被调用,我们调用
AtomicLong.incrementAndGet()
想看当前调用次数,就直接调用
AtomicLong.get()
统计类应用一般是,写入多,读取少,读取可能远小于写入
我们觉得AtomicLong还不够好,想进一步提高性能,尤其是写入性能
可能我们会想到空间换时间,一个AtomicLong性能不够,我们用多个。
假设一共N个AtomicLong,代码变成:
接口被调用时:AtomicLong[随机数(或者递增数)%N].incrementAndGet()
获取统计总数:
for(int i=0;i<N;i++) {
count += AtomicLong[i].get();
}
这样在获取统计总数时,如果有其他线程写入,可能统计结果不准确,但这对于统计来说其实可以忽略
写入的时候有取余运算,取余运算太低效,我们利用取余的特性:
对于2的n次方取余相当于对2的n次方减一取与运算。
我们规定N必须为2的n次方
这时我们的计数代码就变成了:
AtomicLong[随机数(或者递增数)&(2^n-1)].incrementAndGet()
然后我们想到,我们用的是数组,内存上是连续的,有可能会发生什么?
那就是falseSharing!这货严重影响我们的性能!
可以参考我的另一篇文章:
怎么解决FalseSharing?
Disruptor框架用了long填充
Java 8之后有@Contended注解
我们重写一个AtomicInteger类,增加一个value,在这个value上面打上@Contended注解就行啦
至此,我们就把LongAdder给实现了
LongAdder源代码分析
Cell类
我们先来看被改写的基础AtomicLong类,就是Cell。
//Contended注解代表需要缓存行填充,会对于value前后进行缓存行填充,防止falseSharing导致的性能下降
@sun.misc.Contended
static final class Cell {
//代表原来的AtomicLong中的记录值
volatile long value;
Cell(long x) { value = x; }
//CAS更新,原来AtomicLong中的CAS调用的也是UNSAFE.compareAndSwapLong
final boolean cas(long cmp, long val) {
return UNSAFE.compareAndSwapLong(this, valueOffset, cmp, val);
}
// Unsafe类, 用于内存操作
private static final sun.misc.Unsafe UNSAFE;
// value在这个类中的偏移量,用于cas更新使用
private static final long valueOffset;
static {
try {
UNSAFE = sun.misc.Unsafe.getUnsafe();
Class<?> ak = Striped64.Cell.class;
//通过Unsafe类确认value的偏移
valueOffset = UNSAFE.objectFieldOffset
(ak.getDeclaredField("value"));
} catch (Exception e) {
throw new Error(e);
}
}
}
说一说@Contended注解
@Contended
注解可以用于class上还有字段上。
用于class上,则在类中所有域前后加上缓存行填充,例如:
@Contended
public static class ContendedTest2 {
private Object plainField1;
private Object plainField2;
private Object plainField3;
private Object plainField4;
}
假设使用的是128bytes的填充(2倍于大多数硬件缓存行的大小 – 来避免相邻扇区预取导致的伪共享冲突。),在内存中的分布就是(@140表示字段在类中的地址偏移):
TestContended$ContendedTest2: field layout
Entire class is marked contended
@140 --- instance fields start ---
@140 "plainField1" Ljava.lang.Object;
@144 "plainField2" Ljava.lang.Object;
@148 "plainField3" Ljava.lang.Object;
@152 "plainField4" Ljava.lang.Object;
@288 --- instance fields end ---
@288 --- instance ends ---
为什么是从140开始?首先对象头在64位虚拟机中,如果启用压缩对象头的话,占用12位。之后,我们使用的是128bytes的填充,所以偏移128bytes。12bytes+128bytes=140bytes。之后四个Object指针每个占用4bytes,最后由于我们使用的是128bytes的填充所以需要128bytes的偏移,到现在一共是284bytes。JVM虚拟机内存分布是8bytes对齐,所以这里一共需要288bytes来满足8bytes对齐(这里涉及到的概念可以参考我的另一篇文章:https://blog.csdn.net/zhxdick/article/details/61916359)。
用于字段上,被注释的字段将和其他字段隔离开来,会被加载在独立的缓存行上。在字段级别上,@Contended还支持一个“contention group”属性(Class-Level不支持),同一group的字段们在内存上将是连续,但和其他他字段隔离开来。例如:
public static class ContendedTest1 {
@Contended
private Object contendedField1;
private Object plainField1;
private Object plainField2;
private Object plainField3;
private Object plainField4;
}
在内存中的分布是:
TestContended$ContendedTest1: field layout
@ 12 --- instance fields start ---
@ 12 "plainField1" Ljava.lang.Object;
@ 16 "plainField2" Ljava.lang.Object;
@ 20 "plainField3" Ljava.lang.Object;
@ 24 "plainField4" Ljava.lang.Object;
@156 "contendedField1" Ljava.lang.Object; (contended, group = 0)
@288 --- instance fields end ---
@288 --- instance ends ---
12bytes的对象头,在所有字段后跟着由@Contended
注解修饰的字段,由于使用的是128bytes的填充,开始位置是24+4+128=156.最后由于我们使用的是128bytes的填充所以需要128bytes的偏移,到现在一共是284bytes。JVM虚拟机内存分布是8bytes对齐,所以这里一共需要288bytes来满足8bytes对齐。
如果注解多个字段,则分别被填充:
public static class ContendedTest4 {
@Contended
private Object contendedField1;
@Contended
private Object contendedField2;
private Object plainField3;
private Object plainField4;
}
内存分布:
TestContended$ContendedTest4: field layout
@ 12 --- instance fields start ---
@ 12 "plainField3" Ljava.lang.Object;
@ 16 "plainField4" Ljava.lang.Object;
@148 "contendedField1" Ljava.lang.Object; (contended, group = 0)
@280 "contendedField2" Ljava.lang.Object; (contended, group = 0)
@416 --- instance fields end ---
@416 --- instance ends ---
在某些情况,你会想对字段进行分组,同一组的字段会和其他字段有访问冲突,但是和同一组的没有。例如,(同一个线程的)代码同时更新2个字段是很常见的情况。如果同时把2个字段都添加@Contended注解是足够的(翻译注:但是太足够了),但我们可以通过去掉他们之间的填充,来优化它们的内存空间占用。为了区分组,我们有一个参数“contention group”来描述:
public static class ContendedTest5 {
@Contended("updater1")
private Object contendedField1;
@Contended("updater1")
private Object contendedField2;
@Contended("updater2")
private Object contendedField3;
private Object plainField5;
private Object plainField6;
}
内存分布是:
TestContended$ContendedTest5: field layout
@ 12 --- instance fields start ---
@ 12 "plainField5" Ljava.lang.Object;
@ 16 "plainField6" Ljava.lang.Object;
@148 "contendedField1" Ljava.lang.Object; (contended, group = 12)
@152 "contendedField2" Ljava.lang.Object; (contended, group = 12)
@284 "contendedField3" Ljava.lang.Object; (contended, group = 15)
@416 --- instance fields end ---
@416 --- instance ends ---
LongAdder类的核心方法add
public static class LongAdder extends Striped64 implements Serializable {
private static final long serialVersionUID = 7249069246863182397L;
public LongAdder() {
}
/**
* 核心方法,加x
* @param x 加数
*/
public void add(long x) {
Cell[] as; long b, v; int m; Cell a;
//cells不为null,代表初始化结束,已经进入cell更新逻辑,证明已经有过锁争用情况,之后就一致通过cell更新
//如果cell是null,就通过base更新。如果对于base cas更新失败,才会进入Cell更新的逻辑
//也就是在没有争用的情况下,只会对于base进行更新,不会进入后面cell复杂的更新逻辑
if ((as = cells) != null || !casBase(b = base, b + x)) {
boolean uncontended = true;
//如果as是null,代表第一次进入cell,调用longAccumulate进行cell更新
//如果as不是null,as的长度不大于零,证明没有初始化完,调用longAccumulate进行cell更新
//如果as不是null且as的长度大于零,通过当前线程标识对于m取与运算(利用对于2^n取余相当于对于2^n-1取与运算)获取对应的cell,如果这个cell是null证明没有初始化,调用longAccumulate进行cell更新
//如果初始化了,就cas更新这个cell,更新失败的话,调用longAccumulate进行cell更新
if (as == null || (m = as.length - 1) < 0 ||
(a = as[getProbe() & m]) == null ||
!(uncontended = a.cas(v = a.value, v + x)))
longAccumulate(x, null, uncontended);
}
}
/**
* 加一
*/
public void increment() {
add(1L);
}
/**
* 减一(就是加-1)
*/
public void decrement() {
add(-1L);
}
/**
* 返回当前值,将每个cell加在一起,不加任何锁,所以可能会有并发统计问题
* @return the sum
*/
public long sum() {
Cell[] as = cells; Cell a;
long sum = base;
if (as != null) {
for (int i = 0; i < as.length; ++i) {
if ((a = as[i]) != null)
sum += a.value;
}
}
return sum;
}
/**
* 重置为零,同样没锁,同样会有并发竞争问题
*/
public void reset() {
Cell[] as = cells; Cell a;
base = 0L;
if (as != null) {
for (int i = 0; i < as.length; ++i) {
if ((a = as[i]) != null)
a.value = 0L;
}
}
}
/**
* 先统计后重置,同样没锁,同样会有并发竞争问题
*/
public long sumThenReset() {
Cell[] as = cells; Cell a;
long sum = base;
base = 0L;
if (as != null) {
for (int i = 0; i < as.length; ++i) {
if ((a = as[i]) != null) {
sum += a.value;
a.value = 0L;
}
}
}
return sum;
}
}
这里我们弄明白了更新的逻辑,那么细节的核心方法longAccumulate
是怎么回事呢?来看Striped64类
Striped64类
abstract static class Striped64 extends Number {
//CPU个数,限制cell数组最大数量
static final int NCPU = Runtime.getRuntime().availableProcessors();
// cell数组,长度一样要是2^n
//原因呢,因为对于2^n取余相当于对2^n-1取与运算,提高代码性能
transient volatile Cell[] cells;
// 累积器的基本值,在两种情况下会使用:
// 1、没有遇到并发的情况,直接使用base,速度更快;
// 2、多线程并发初始化table数组时,必须要保证table数组只被初始化一次,因此只有一个线程能够竞争成功,这种情况下竞争失败的线程会尝试在base上进行一次累积操作
// 注意,累加值是base加上每个cell的值
transient volatile long base;
// 自旋标识,在对cells进行初始化,或者后续扩容时,
// 需要通过CAS操作把此标识设置为1(busy,忙标识,相当于加锁),
// 取消busy时可以直接使用cellsBusy = 0,相当于释放锁
transient volatile int cellsBusy;
Striped64() {
}
// 使用CAS更新base的值,其实还是用的unsafe类,可以把base理解为一个基础的AtomicLong
final boolean casBase(long cmp, long val) {
return UNSAFE.compareAndSwapLong(this, BASE, cmp, val);
}
// 使用CAS将cells自旋标识更新为1,相当于加锁
// 更新为0时可以不用CAS,直接使用cellsBusy = 0,相当于释放锁
final boolean casCellsBusy() {
return UNSAFE.compareAndSwapInt(this, CELLSBUSY, 0, 1);
}
// probe是ThreadLocalRandom里面的一个属性,通过ThreadLocalRandom.current()可以初始化这个属性
// 可以认为probe是线程标识
static final int getProbe() {
return UNSAFE.getInt(Thread.currentThread(), PROBE);
}
// 相当于rehash,重新算一遍线程的hash值,用于标识线程
static final int advanceProbe(int probe) {
probe ^= probe << 13; // xorshift
probe ^= probe >>> 17;
probe ^= probe << 5;
UNSAFE.putInt(Thread.currentThread(), PROBE, probe);
return probe;
}
final void longAccumulate(long x, LongBinaryOperator fn, boolean wasUncontended) {
int h;
// 这个if相当于给线程生成一个非0的hash值
if ((h = getProbe()) == 0) {
//如果为零,调用ThreadLocalRandom.current()初始化
ThreadLocalRandom.current();
//之后就能获取到正常的线程标识
h = getProbe();
//进入到这里肯定是第一次进入,未初始化过,设置wasUncontended为true
wasUncontended = true;
}
//标识上次循环获取到的cell是不是null,可以理解为是否需要扩容
boolean collide = false;
for (;;) {
Cell[] as; Cell a; int n; long v;
//如果cells不为null并且长度大于0,代表已经初始化了
if ((as = cells) != null && (n = as.length) > 0) {
//取余,获取当前线程对应的cell,如果还为null
if ((a = as[(n - 1) & h]) == null) {
//如果cellsBusy标记为零,代表未上锁(也就是没有其他线程在执行扩容)
if (cellsBusy == 0) {
//初始化这个槽的cell,用需要加的数x初始化,如果加入槽成功相当于就已经加了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) {
//赋值,相当于就已经加了x
rs[j] = r;
created = true;
}
} finally {
//无论如何都要释放锁
cellsBusy = 0;
}
//如果加入槽成功,证明已经加上了x,可以退出了
if (created)
break;
//加入槽失败,证明不是自己加上的x,失败重试
continue;
}
}
//只是获取锁失败,并且为了减少冲突,先不考虑扩容
collide = false;
}
//wasUncontended代表的是外部cas更新对应槽位是否成功,如果是失败,并且该槽位不为空,则考虑重新给线程生成唯一标识避免冲突
//设置wasUncontended为true之后会走到h = advanceProbe(h)重新生成唯一标识
else if (!wasUncontended)
wasUncontended = true;
//尝试CAS更新槽内cell的值
else if (a.cas(v = a.value, ((fn == null) ? v + x : fn.applyAsLong(v, x))))
//成功则退出
break;
else if (n >= NCPU || cells != as) // cell数组已经是最大的了,或者中途发生了扩容操作。因为NCPU不一定是2^n,所以这里用 >=
//长度n是递增的,执行到了这个分支,说明n >= NCPU会永远为true,下面两个else if就永远不会被执行了,也就永远不会再进行扩容
// CPU能够并行的CAS操作的最大数量是它的核心数(CAS在x86中对应的指令是cmpxchg,多核需要通过锁缓存来保证整体原子性),当n >= NCPU时,再出现几个线程映射到同一个Cell导致CAS竞争的情况,那就真不关扩容的事了,完全是hash值的锅了
collide = false;
// 映射到的Cell单元不是null,并且尝试对它进行累积时,CAS竞争失败了,这时候把扩容意向设置为true
// 下一次循环如果还是跟这一次一样,说明竞争很严重,那么就真正扩容
// 把扩容意向设置为true,只有这里才会给collide赋值为true,也只有执行了这一句,才可能执行后面一个else if进行扩容
else if (!collide)
collide = true;
// 最后再考虑扩容,能到这一步说明竞争很激烈,尝试加锁进行扩容 -
else if (cellsBusy == 0 && casCellsBusy()) { ----- 标记为分支B
try {
//检查下是否被别的线程扩容了(CAS更新锁标识,处理不了ABA问题,这里再检查一遍)
if (cells == as) {
// 执行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;
}
// 重新给线程生成一个hash值,降低hash冲突,
h = advanceProbe(h);
}
// cells没有被加锁,并且它没有被初始化,那么就尝试对它进行加锁,加锁成功进入这个else if
else if (cellsBusy == 0 && cells == as && casCellsBusy()) {
boolean init = false;
try {
if (cells == as) {
// 初始化时只创建两个单元
Cell[] rs = new Cell[2];
// 对其中一个单元进行累积操作,另一个不管,继续为null
rs[h & 1] = new Cell(x);
cells = rs;
init = true;
}
} finally {
cellsBusy = 0;
}
if (init)
break;
}
else if (casBase(v = base, ((fn == null) ? v + x : fn.applyAsLong(v, x))))
break;
}
}
}