JDK核心JAVA源码解析(6) - 原子类AtomicLong与LongAdder对比

想写这个系列很久了,对自己也是个总结与提高。原来在学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性能不够,我们用多个。
image

假设一共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!这货严重影响我们的性能!
可以参考我的另一篇文章:

image
怎么解决FalseSharing?
Disruptor框架用了long填充

image

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;
        }
    }


}
发布了194 篇原创文章 · 获赞 266 · 访问量 145万+

猜你喜欢

转载自blog.csdn.net/zhxdick/article/details/82756913
今日推荐