java并发编程(5)-CAS以及原子性操作类原理剖析

97 阅读22分钟

java并发编程系列前文

  1. java并发编程(1)-并发编程基础(上)
  2. java并发编程(2)-并发编程基础(下)
  3. java并发编程(3)-ThreadLocal原理剖析
  4. java并发编程(4)-Random以及ThreadLocalRandom原理剖析

前方字数预警。。。本篇字数较多,希望读者能坚持读完。因为字数较多的原因,如果出现错别字请及时指出并且希望理解。。。

在并发编程系列第二章的时候,我们聊过出现线程问题的主要原因是原子性,内存可见性以及有序性这三个问题,我们也在当时说过使用synchronized可以解决这三个问题。但我们也说过synchronized有个缺点那就是加锁以及释放锁时会引起上下文切换,这会造成不小的消耗。

如果在线程数较少冲突发生不频繁或者是即便发生冲突也会在短时间内释放又或者是读多写少的场景,这些场景用synchronized去实现,就有点得不偿失了。当遇到这种情况就可以尝试使用一些基于CAS的原子性操作类,可以更好的为我们解决前面所说的问题。

CAS

CAS即Compare And Swap,意为比较并交换。它是java提供的非阻塞原子性操作,它的原子性依赖硬件(处理器),如果处理器不支持,那么CAS操作就会失去原子性,但现代处理器通常都支持,所以这点不用过多担心,大家只要记得CAS的原子性是依赖硬件,由硬件提供的就可以了。

CAS实际操作流程就和它名字一样,比较并交换。比较内存中的值是否等于期望值,如果等于则将新值更新过去。

说的比较抽象,我们打个比方。比如我们有个变量i,i=0,我们需要将i改成1。CAS操作就会先去比较内存中的i是否等于期望值,如果等于期望值0那就把i更新成1。因为硬件为CAS提供了原子性和有序性,所以只会有一个线程修改成成功,当第二个线程进来就会发现i不等于期望值,所以就会更新失败。

我们在使用CAS操作还要记住CAS有经典的ABA的问题。还是拿上面这个将变量置为1的例子,第一个线程成功修改变量为1,这时候其他线程会因为i不等于期望值0而修改失败,但是如果这时候有线程又将变量从1改成0,那就会因为i等于期望值了,就又被修改成1了。在我们举的例子当中称它为010问题更合适。

当然,平时大部分场景下都是可以容忍ABA问题的发生的,如果你遇到的场景不能容忍ABA问题,可以使用AtomicStampedReference,它可以为我们解决ABA问题。

Unsafe

JAVA中的原子性操作都是由Unsafe提供的,所有原子变量操作类的底层方法调用的终点都是Unsafe类。Unsafe类中的方法都是native方法,方法内部逻辑都写在C++库中。下面我们来了解一下Unsafe提供的几个主要的方法。

Unsafe主要方法

  1. long objectFieldOffset(Field field) 返回指定变量在所属类中的内存偏移量。
  2. int arrayBaseOffset(Class arrayClass) 获取数组中第一个元素的地址。
  3. int arrayIndexScale(Class arrayClass) 获取数组中第一个元素占用的字节。
  4. boolean compareAndSwapLong(Object obj, long offset, long expect, long update) 比较对象obj中偏移量为offset的变量的值是否与expect相等,相当则更新为update,并且返回true否则返回false。
  5. public native long getLongvolatile(Object obj, long offset) 获取对象obj中偏移量为offset对应volatile语义的值。意思就是去主内存获取。
  6. void putLongvolatile(Object obj, long offset, long value) 设置对象obj中偏移量为offset的字段的值为value,支持volatile语义。意思就是直接更新主内存的值,并使其他线程工作内存中的字段失效。而且它有全内存屏障,即确保写操作之前的所有读写操作都在写操作执行之前完成,并且写操作之后的读写操作不会被指令重排序到写操作之前,在并发编程的第二章我们提到过内存屏障的内容,只是当时没使用这个名词。
  7. void putOrderedLong(Object obj, long offset, long value) 设置对象obj中偏移量为offset的字段的值为value。它只能保证写操作之前的读写操作都在写操作执行之前完成,并不能保证写操作之后的读写操作不会被指令重排序到写操作之前。并且也无法像putLongvolatile提供内存可见性。
  8. void park(boolean isAbsolute, long time) 阻塞当前线程。isAbsolute代表是否使用绝对时间,time代表阻塞时间。isAbsolute等于true,time等于0代表线程一直阻塞。isAbsolute等于false,time大于0代表线程阻塞,并在相对当前时间+time的时间后被唤醒。isAbsolute等于true,time大于0代表线程阻塞,并在绝对时间time时被唤醒。其他线程调用了被阻塞线程的interrupt方法或者其他线程调用了unpark方法,并将被阻塞的线程传进去。
  9. void unpark(Object thread) 唤醒因调用park方法后被阻塞的线程。
  10. long getAndSetLong(Object obj, long offset, long update) 获取对象obj中偏移量为offset的字段的volatile语义的值,并设置volatile语义的值为update。
  11. long getAndAddLong(Object obj, long offset, long addValue) 获取对象obj中偏移量为offset的字段的volatile语义的值,并将原值递增addValue,递增操作也是volatile语义

在本小节最后我们拿Unsafe中的getAndSetLong源码看看。

public final long getAndSetLong(Object var1, long var2, long var4) {
    long var6;
    do {
        var6 = this.getLongVolatile(var1, var2);
    } while(!this.compareAndSwapLong(var1, var2, var6, var4));

    return var6;
}

这个方法通过do While语句,先获取字段的值再通过CAS操作去修改,如果失败继续调用getLongVolatile。和我们上一章中的Random的源码有异曲同工之妙。

原子操作类

上面我们聊了CAS,可以看到这些方法还是有一点偏底层,实际使用不方便,而且不通过反射我们是无法直接使用Unsafe类的,所以java为我们提供了一些基于CAS实现的原子操作类供我们能更方便的进行原子性操作。

AtomicLong

AtomicLong是java为我们提供的一个原子操作类,从名字上就能看出它提供了对Long类型的原子操作。

public class AtomicLong extends Number implements java.io.Serializable {
    private static final long serialVersionUID = 1927816293512124184L;

    private static final Unsafe unsafe = Unsafe.getUnsafe();
    
   /**
    * 字段value的偏移量
    */
    private static final long valueOffset;
    static final boolean VM_SUPPORTS_LONG_CAS = VMSupportsCS8();

    /**
     * 判断JVM是否支持8字节的CAS操作
     */
    private static native boolean VMSupportsCS8();

    static {
        try {
            valueOffset = unsafe.objectFieldOffset
                (AtomicLong.class.getDeclaredField("value"));
        } catch (Exception ex) { throw new Error(ex); }
    }

    private volatile long value;

    public AtomicLong(long initialValue) {
        value = initialValue;
    }

    public AtomicLong() {
    }
}

可以看到AtomicLong在类加载阶段获取了value的偏移量,还有调用VMSupportsCS8方法判断JVM是否支持8字节的CAS操作。第二点其实和本章并没有什么关系,但我还是拿出来说说吧。

之所以会有这么一步,那是因为在32位的java虚拟机中,对64位类型的非volatile修饰的变量的读写操作会被分成两次32位的操作,64位下没有这个问题。所以有些地方需要判断使用VM_SUPPORTS_LONG_CAS来判断虚拟机是否支持64位的CAS操作,如果不支持则进行原子性操作就不能用CAS而是要用锁去支持。

AtomicLong主要方法

接下来我们来看看AtomicLong的几个主要的方法

public final long getAndIncrement() {
    return unsafe.getAndAddLong(this, valueOffset, 1L);
}

public final long getAndDecrement() {
    return unsafe.getAndAddLong(this, valueOffset, -1L);
}

public final long getAndAdd(long delta) {
    return unsafe.getAndAddLong(this, valueOffset, delta);
}

public final long incrementAndGet() {
    return unsafe.getAndAddLong(this, valueOffset, 1L) + 1L;
}

public final long decrementAndGet() {
    return unsafe.getAndAddLong(this, valueOffset, -1L) - 1L;
}

可以看到底层都是调用的Unsafe类中的方法,如Unsafe节所说,原子性操作类的调用终点都是Unsafe类。有了CAS小节的铺垫,这几个方法已经不需要我再多说什么,相信大家也明白方法内部逻辑是什么。

并发编程系列第4大章在讲Random的时候提到过,自旋锁也不是完美的,当出现大量线程竞争,就会因为线程之间的冲突导致大量线程进入自旋重试,这会导致CPU利用率上升。java团队因为Random的自旋锁性能问题开发了ThreadLocalRandom来避免自旋性能问题,自然也会觉得AtomicLong的自旋存在性能问题,所以就有我们接下来要讲的LongAdder了。

LongAdder

AtomicLong的性能问题在于因多个线程竞争一个自旋锁而引起的大量自旋重试,从而导致CPU利用率上升。LongAdder解决的方法也很简单,就是把一个变量变为多份,从多个线程竞争一份资源变成多个线程竞争多个资源。

AtomicLong以及LongAdder流程图.png 可以看到LongAdder内部由一个基础值base和多个Cell组成,这里为了看起来方便,只画了两个Cell,实际使用时可能会不止两个Cell。LongAdder的值是所有Cell中的值再加base的和,如果竞争一个Cell失败了,并不会一直在这个Cell上自旋重试,而是会去到其他Cell上尝试。

LongAdder类图.png 从上面的类图上可以看到LongAdder继承Striped64。Striped64中的字段我们主要关注base,cellsBusy以及cells

base就是我们在前面说的那个基础值,cells用来存储一个LongAdder中的所有Cell,而cellsBusy是用来通过CAS实现自旋锁用的,用来保证在初始化或者扩容时只有一个线程可以操作,不会出现多个线程同时调用了初始化或者扩容,只有0和1的情况,1代表有线程正在进行初始化或者扩容。

在进去LongAdder之前我们先来看一下Cell的内部结构

Cell

@sun.misc.Contended static final class Cell {
    volatile long value;
    Cell(long x) { value = x; }
    final boolean cas(long cmp, long val) {
        return UNSAFE.compareAndSwapLong(this, valueOffset, cmp, val);
    }

    // Unsafe mechanics
    private static final sun.misc.Unsafe UNSAFE;
    private static final long valueOffset;
    static {
        try {
            UNSAFE = sun.misc.Unsafe.getUnsafe();
            Class<?> ak = Cell.class;
            valueOffset = UNSAFE.objectFieldOffset
                (ak.getDeclaredField("value"));
        } catch (Exception e) {
            throw new Error(e);
        }
    }
}

Cell内部逻辑还是比较简单的,只提供了一个cas方法,类加载阶段干的一些事情也是大部分基于CAS的原子性操作类干的事情。

大家可以看到Cell是加了个sun.misc.Contended注解,这是一个性能优化点,是为了解决伪共享的问题。其实伪共享我并不想在本系列说,个人觉得这应该是偏向于计算机组成原理的内容,阅读本系列的读者并不一定都关心这些内容。但遇到了还是说一下,最起码可以让注意到这个注解的读者不需要在阅读本章的同时还要打开搜索引擎去搜索这玩意。如果你并不关心这方面,可以直接进入下一小节,这并不会影响你对本章的理解。

大家应该都知道CPU是有缓存的,L1 L2 L3 Cache这些。CPU缓存的时候是以缓存行为单位的,一个缓存行大小通常是64位,这就可能会导致一个缓存行中缓存了多个不同的变量。当我们修改了某个核心的缓存行的数据,可能会导致其他核心的缓存失效。

例如我们有个2 core的CPU。core 0因为变量B而在缓存行中缓存了A,B,C三个变量,core 1因为变量C而在缓存行中缓存了B,C,D。core 0修改了缓存行中的B,会导致core 1的缓存行也失效,因为core 1的缓存行中包含了B。

这就是所谓的伪共享,实际不共享(变量逻辑上独立,每个线程只关心自己的变量),表现共享(数据保存在一个缓存行,一个缓存行被修改,其他缓存行就会被标记为失效)

之所以一个缓存行设计大小为64位而不设计成以单个变量大小为单位缓存的原因是缓存设计中的一个经典概念空间局部性,即当我们访问某个内存地址时,很有可能会接着访问这个内存地址附近的数据,讲附近数据也一起缓存下来,就可以避免访问附近数据时再去内存中查找了。

要解决伪共享其实就是保证一个缓存行内不包含其他线程可能会用到的数据,所以解决的方法也不复杂。就是数据填充,如果变量大小不足64位,那就填充数据到64位,超过64位则填充到64位的整数倍。Contended注解就是这么做的。

所以对于这个注解大家也要适当,不要盲目去使用,否则有可能会因为失去了空间局部性反而导致性能变差。

LongAdder源码

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

sum方法内部就是把base以及所有Cell的value加在一起,因为Cell中的value是volatile修饰的,所以直接加就完事了。这个sum方法其实就相当于获取LongAdder的值。LongAdder中的longValue()以及intValue()实际调用的就是sum方法。

reset

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

reset方法就是把base以及所有Cell的值置为0。

sumThenReset

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

sumThenReset相当于sum和reset的集合体。

add

    public void add(long x) {
        Cell[] as; long b, v; int m; Cell a;
        if ((as = cells) != null || !casBase(b = base, b + x)) {
            boolean uncontended = true;
//                 (1)                (2)
            if (as == null || (m = as.length - 1) < 0 ||
//                         (3)
                (a = as[getProbe() & m]) == null ||
//                         (4)
                !(uncontended = a.cas(v = a.value, v + x)))
                longAccumulate(x, null, uncontended);
        }
    }

我们来看一下LongAdder的核心方法add()。

在第3行判断相关条件,简单的来讲,就是如果Cell数组为null,就直接对base用Cas操作来修改,因为Cell数组都为null了,没必要再去干其他多余的事情了。

注意第三行的casBase前面加了!,也就是说如果Cell数组不为null或者Cell为null但是对base的Cas操作失败才会进去后续的代码。

当第三行的条件不满足时,会先去判断条件1和条件2,也就是说Cess数组为null或者为空数组,进入longAccumulate方法。

当条件1和条件2不满足,也就是Cell数组不为空时会通过按位于去分配Cell,如果没有分配到Cell,进入longAccumulate方法。getProbe()就是去当获取当前线程对象中的threadLocalRandomProbe,是不是觉得这个字段眼熟?如果你去看我们并发编程系列的上一章就会发现,在ThreadLocalRandom初始化时这个字段也会被初始化。

当条件3不满足,也就是分配到了Cell就会尝试对Call进行Cas操作修改value值,如果失败进入longAccumulate方法。

longAccumulate

final void longAccumulate(long x, LongBinaryOperator fn,
                          boolean wasUncontended) {
    int h;
    // 当前线程还没初始化probe
    if ((h = getProbe()) == 0) {
        ThreadLocalRandom.current();
        h = getProbe();
        wasUncontended = true;
    }
    // 是否发生冲突
    boolean collide = false;
    for (;;) {
        Cell[] as; Cell a; int n; long v;
        if ((as = cells) != null && (n = as.length) > 0) {
            // 没分配到Cell,进行扩容
            if ((a = as[(n - 1) & h]) == null) {
                if (cellsBusy == 0) {
                    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;
                    }
                }
                collide = false;
            }
            else if (!wasUncontended)
                wasUncontended = true;
            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) {
                        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;
            }
            h = advanceProbe(h);
        }
        // 初始化Cell数组
        else if (cellsBusy == 0 && cells == as && casCellsBusy()) {
            boolean init = false;
            try {
                if (cells == as) {
                    Cell[] rs = new Cell[2];
                    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;
    }
}

看longAccumulate方法之前,先记住如果调用add方法到这里入参fn是null,wasUncontended是false。

从代码第5行以及第6行可以看到prode是通过ThreadLocalRandom来初始化的,由ThreadLocalRandom的初始化来带动prode的初始化。

初始化

我们按相关机制的触发顺序去分析代码,所以跳过其他代码,直接从63行开始看起,66行就是初始化的逻辑。通过cellsBusy字段判断自旋锁是否已被占用,如果没被占用则尝试Cas操作占用自旋锁。内部初始化逻辑不复杂,也就new一个长度为2的Cell数组,创建一个Cell(并且设置了初始值为本次操作的增量x)通过按位与的方式放到Cell数组中指定的位置。

在这里大家思考一个问题,从表面上来看14行对as进行了赋值,那么执行到66行的时候,cells == as这个子条件是否必定为true,这个子条件是否属于多余判断?

答案是这个判断是必须的,它并不是多余判断。因为在多线程环境,极有可能多个线程同时执行初始化,因为这里有自旋锁,只有一个线程有权执行初始化,另外一个线程会进入下一个循环,当那个线程进入63的时候正好上一个线程初始化完毕,这个时候cells和as已经不同了。所以这个条件是必须的,否则就会有线程安全问题。

如果你的答案是对的,那恭喜你!但如果你的答案是错了,那也没关系,还有第二次机会哦~~~

通过66行代码以后,此时我们获得了自旋锁并且进入了if代码块内,不可能再有其他线程能在这个时候去修改cells了。那么大家来思考第二个问题,因为我们在获取锁之前判断了cells == as,所以内部69行cells == as的条件是否必定为true,是否为多余条件?

答案依旧是这个判断是必须的,它并不是多余判断。因为还有一种更极端的情况,那就是第一个线程在第二个线程判断cellsBusy == 0后抢到锁,等到第二个线程执行完cells == as判断后,第一个线程正好完成了初始化并释放了锁,这时候第二个线程抢到锁,如果没有内部的if条件,就会有线程安全问题。虽然这种情况很极端,但也是有发生的可能性的。

未分配到Cell时扩容

看完了初始化阶段,接下来我们来看看初始化完成以后的相关逻辑。我们从第14行代码开始看起,如果通过了14行的条件,那就证明现在的Cell数组是不为空数组的。紧接着,在16行尝试分配Cell,如果没分配到Cell则需要进行扩容,我们进去16行的if代码块内看看它是如何扩容的。

首先判断自旋锁是否已被占用,如果已被占用则进入下一次循环。如果未被占用则会去尝试获取自旋锁,在获取之前再次判断自旋锁是否已被占用。

可以看到java开发团队在这里做了一个优化,先创建Cell再去尝试获取自旋锁,这样就可以将锁范围变小,从而使得能够更快的释放锁。不过这个优化点就仁者见仁,智者见智了,因为你也可以认为将19号的条件放到17行,可以减少一次if判断,也可以在减少发生竞争锁资源冲突时的一次创建对象(Cell)的消耗。但LongAdder设计初衷是解决多线程安全问题以及降低多线程下的自旋频率来优化锁冲突时带来的CPU利用率升高。所以我们的关注的方向应该是尽量少的去竞争锁(提前判断锁标识位,被占用了就不去尝试竞争了)和减少发生锁竞争的概率(减小锁范围)

我们获取到锁以后接下来就会进行判断,如果Cell数组不为空,并且没有分配到Cell就会对Cell数组扩容,这里的扩容指的是在数组指定位置插入Cell。扩容后会将created标识符设置为true(代表成功创建Cell),然后在finally代码块内释放锁。

最后判断created标识符是否为true,为true代表我们完整的走完了扩容逻辑,直接break退出死循环。如果为false则代表扩容出现问题,跳过本次循环,在下一次循环再根据相关条件进入相关逻辑处理。

正常递增

正常情况下,即LongAdder已初始化,并且分配到了Cell就会进入41行,在41行尝试对value进行cas操作,如果成功就退出死循环,否则跳过本次循环,在下一次循环再根据相关条件进入相关逻辑处理。

我们是从LongAdder进来的,fn是null,这里大家先只关心fn为null的情况,后面会提什么时候fn不是null。

发生冲突时扩容

当发生冲突时,我们会走到49行代码,内部的扩容逻辑比较简单,就是通过左移将Cell数组扩一倍。大家可以到在39行有个似乎意义不明的判断,这其实是有用的,用来判断是否为第一次竞争。

我们从LongAdder进来的时候,wasUncontended传的是false,wasUncontended为false代表已经经历过第一次冲突。如果不考虑第5-9行的代码,我们进来都会将wasUncontended标记为true,且在单次方法调用中只会设置一次。另外当Cell数组不为空时也就是满足14号的条件,在代码块最后一步(63行)都会生产一个hash值。

这么一说大家应该差不多能领会到wasUncontended用处吧,其实就是如果已经经历过一次冲突就重新hash尝试去新的Cell上操作而不在当前分配的位置上操作,减少不必要的重试。

第44行的条件其实就是控制Cell数组的长度,如果数组长度超过CPU核心数则不会触发扩容,防止Cell数组扩太大。

46行的代码的作用其实和39行的差不多。collide代表是否发生冲突需要扩容,因为在46行以前的代码中如果对Cell的操作失败,就会设置为false。所以46行代码的含义其实就是如果collide为false,则设置为true,通过63行重写hash,尝试在下一次循环中完成对Cell的操作,如果没有触发16行的扩容并且41行的Cas操作失败才会进行冲突时的扩容。其实本质上和46行的思想差不多,就是发生冲突时不会立刻处理而是尝试换个Cell操作,当依旧发生冲突时才会进入具体的处理逻辑。

LongAccumulator

LongAccumulator和LongAdder因为内部的base,Cell数组还有调用的longAccumulate方法都是父类Striped64中的。所以主要逻辑差不多,区别是相较于LongAdder,LongAccumulator可以设置计算逻辑,比如说我们不想累加想要相乘。LongAdder的base只能默认从0开始而LongAccumulator可以设置base的其实点位。

public class LongAccumulator extends Striped64 implements Serializable {
    private static final long serialVersionUID = 7249069246863182397L;

    private final LongBinaryOperator function;
    private final long identity;

    public LongAccumulator(LongBinaryOperator accumulatorFunction,
                           long identity) {
        this.function = accumulatorFunction;
        base = this.identity = identity;
    }
    ...
}

大家有没有对LongBinaryOperator有点印象?没错,在LongAdder节对longAccumulate方法内容中有出现过这个类。在longAccumulate源码中41行有个三目运算符,如果fn不为null,就会调用fn的applyAsLong也就是LongBinaryOperator的applyAsLong作为更新值。

public interface LongBinaryOperator {

    long applyAsLong(long left, long right);
}

可以看到LongBinaryOperator是个接口,只有一个方法。如果要实现我们自己想要的计算逻辑,只要实现这个方法就可以了。 比如说实现累乘逻辑

public class TestOperator implements LongBinaryOperator {

    @Override
    public long applyAsLong(long left, long right) {
        return left * right;
    }
}

总结

本章根据《java并发编程之美》编写。感觉你能读完本篇,因为想让Cas的内容以及原子性的内容告一段落,所以本篇内容较多。虽然本篇内容篇幅不小,但还是建议你能理解Cas以及相关原子性操作类的原理,这对我们后面的篇章很有帮助。