CAS与锁的应用之:原子类、LongAdder、阻塞队列详解

84 阅读12分钟

CAS与锁的应用(一)

CAS与锁是保证原子性的基石,在这之上诞生了许多有用的工具类,今天就来看看这些类是如何实现的,有什么好用的功能。

关于CAS与锁可以参考:

1、Atomic包:原子类

以AtomicInteger为例:

AtomicInteger:原子整数

private volatile int value;

提供了各种修改的API,举个例子:

    public final int addAndGet(int delta) {
        return unsafe.getAndAddInt(this, valueOffset, delta) + delta;
    }
    public final int getAndAddInt(Object var1, long var2, int var4) {
        int var5;
        do {
            var5 = this.getIntVolatile(var1, var2);
        } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));
​
        return var5;
    }

无锁,自旋CAS去做的修改,从而实现原子性

AtomicReference:原子引用

AtomicReference用类泛型表示对象类型

private volatile V value;

原子修改value

public final boolean compareAndSet(V expect, V update) {
    return unsafe.compareAndSwapObject(this, valueOffset, expect, update);
}

也是CAS自旋

解决ABA问题:stamped时间戳

AtomicStampedReference利用时间戳的方案解决了CAS的ABA问题

private static class Pair<T> {
    final T reference;
    final int stamp;
    private Pair(T reference, int stamp) {
        this.reference = reference;
        this.stamp = stamp;
    }
    static <T> Pair<T> of(T reference, int stamp) {
        return new Pair<T>(reference, stamp);
    }
}
​
private volatile Pair<V> pair;

AtomicReference是封装一个对象,而AtomicStampedReference是封装一个pair键值对,键值对内包含了:

  • 原本的数据对象
  • 一个时间戳

在CAS的时候,也是利用compareAndSwapObject这个方法,来保证stamp与对象都与预期相同,才CAS成功。

通过getStamp方法,我们可以拿到Expected的时间戳,来做CAS

public int getStamp() {
    return pair.stamp;
}

小结

上文只提及了3个原子类,其实还有非常多。

  • 原子更新数组:AtomicIntegerArray、AtomicReferenceArray
  • 原子更新字段:AtomicReferenceFieldUpdater等

几乎全部采用了CAS自旋的方式保证原子性,这是有局限性的:即在很多个线程同时对AtomicInteger做修改时,会导致性能变低,下面介绍的Adder就是来解决这个问题的。

2、LongAdder:拆分思想

LongAdder的思想是:避免太多线程同时去竞争同一个变量

因此它的做法是:将一个变量分解为多个变量,这样就可以从对一个变量自旋CAS,变为对多个变量CAS。

atomic包只提供了Long/double的Adder/Accumulator四个类,它们都继承Striped64,下面以LongAdder来为例来分析,先看父类Striped64:

Striped64

核心字段
// cell数组
transient volatile Cell[] cells;
// base 基础值
transient volatile long base;
// cells数组是否在变动 比如:扩容中,初始化中等
transient volatile int cellsBusy;

Cell是对long的封装,volatile修饰,解决「内存伪共享」,以及支持CAS操作

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

伪共享:变量A和B,同处于一块CPU cache line中,线程A需要用变量A,线程B需要用变量B,但因为读取都是读一整个line的内容,因此对A和B的修改会导致另一个线程也要修改,降低效率,本来没有共享内存,但却伪共享了。用Contended注解修饰,Cell对象会被放在另外的cache line中

LongAdder没有在此基础上额外添加字段,我们有必要稍微解释一下它的工作原理:

LongAdder也是一个long值,这个值等于base + ∑[0~n]cells,在获取它的值时,调用方法sum获取。没有竞争的情况下,要累加的数通过cas累加到base上;如果有竞争的话,会将要累加的数累加到Cells数组中的某个cell元素里面。争夺同一个原子变量时候,失败并不是自旋CAS重试,而是尝试获取其他原子变量的锁。当base有竞争,会去初始化cells数组。cells数组初始化为2,后续每次大小翻倍,直到cells数组的长度大于等于当前服务器cpu的数量为止就不在扩容(因为CPU能够并行的CAS操作的最大数量是它的核心数,再多也没意义)。

下面我们来看一下它的源码:

核心方法

求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;
}
对LongAdder修改:add
public void add(long x) {
    // 1. 尝试CAS base 成功直接返回
    if ((as = cells) != null || !casBase(b = base, b + x)) {
        boolean uncontended = true;
        // 2. 再次尝试CAS 这里修改的cells数组下标是[getProbe() & m]与线程有关
        // 也就是说:一般情况下,线程只会对固定的cell下标做CAS
        // 当多个线程的[getProbe() & m]值相同,才可能发生竞争,走3的逻辑
        if (as == null || (m = as.length - 1) < 0 ||
            (a = as[getProbe() & m]) == null ||
            !(uncontended = a.cas(v = a.value, v + x)))
            // 3. 两次CAS均失败 调用longAccumulate方法
            longAccumulate(x, null, uncontended);
    }
}

longAccumulate是Striped64的方法,比较长。

总之,思想是:尽量避免同个cell被多个线程同时CAS

可能的行为有:初始化cells数组,初始化cell对象,cells数组扩容

下面直接贴出源码阅读:全方位讲解LongAdder的注释版方法,写的很详细了

final void longAccumulate(long x, LongBinaryOperator fn,
                              boolean wasUncontended) {
        //获取当前线程的threadLocalRandomProbe值作为hash值,如果当前线程的threadLocalRandomProbe为0,说明当前线程是第一次进入该方法,则强制设置线程的threadLocalRandomProbe为ThreadLocalRandom类的成员静态私有变量probeGenerator的值,后面会详细将hash值的生成;
        //另外需要注意,如果threadLocalRandomProbe=0,代表新的线程开始参与cell争用的情况
        //1.当前线程之前还没有参与过cells争用(也许cells数组还没初始化,进到当前方法来就是为了初始化cells数组后争用的),是第一次执行base的cas累加操作失败;
        //2.或者是在执行add方法时,对cells某个位置的Cell的cas操作第一次失败,则将wasUncontended设置为false,那么这里会将其重新置为true;第一次执行操作失败;
       //凡是参与了cell争用操作的线程threadLocalRandomProbe都不为0;
        int h;
        if ((h = getProbe()) == 0) {
            //初始化ThreadLocalRandom;
            ThreadLocalRandom.current(); // force initialization
            //将h设置为0x9e3779b9
            h = getProbe();
            //设置未竞争标记为true
            wasUncontended = true;
        }
        //cas冲突标志,表示当前线程hash到的Cells数组的位置,做cas累加操作时与其它线程发生了冲突,cas失败;collide=true代表有冲突,collide=false代表无冲突 
        boolean collide = false; 
        for (;;) {
            Cell[] as; Cell a; int n; long v;
            //这个主干if有三个分支
            //1.主分支一:处理cells数组已经正常初始化了的情况(这个if分支处理add方法的四个条件中的3和4)
            //2.主分支二:处理cells数组没有初始化或者长度为0的情况;(这个分支处理add方法的四个条件中的1和2)
            //3.主分支三:处理如果cell数组没有初始化,并且其它线程正在执行对cells数组初始化的操作,及cellbusy=1;则尝试将累加值通过cas累加到base上
            //先看主分支一
            if ((as = cells) != null && (n = as.length) > 0) {
                /**
                 *内部小分支一:这个是处理add方法内部if分支的条件3:如果被hash到的位置为null,说明没有线程在这个位置设置过值,没有竞争,可以直接使用,则用x值作为初始值创建一个新的Cell对象,对cells数组使用cellsBusy加锁,然后将这个Cell对象放到cells[m%cells.length]位置上 
                 */
                if ((a = as[(n - 1) & h]) == null) {
                    //cellsBusy == 0 代表当前没有线程cells数组做修改
                    if (cellsBusy == 0) {
                        //将要累加的x值作为初始值创建一个新的Cell对象,
                        Cell r = new Cell(x); 
                        //如果cellsBusy=0无锁,则通过cas将cellsBusy设置为1加锁
                        if (cellsBusy == 0 && casCellsBusy()) {
                            //标记Cell是否创建成功并放入到cells数组被hash的位置上
                            boolean created = false;
                            try {
                                Cell[] rs; int m, j;
                                //再次检查cells数组不为null,且长度不为空,且hash到的位置的Cell为null
                                if ((rs = cells) != null &&
                                    (m = rs.length) > 0 &&
                                    rs[j = (m - 1) & h] == null) {
                                    //将新的cell设置到该位置
                                    rs[j] = r;
                                    created = true;
                                }
                            } finally {
                                //去掉锁
                                cellsBusy = 0;
                            }
                            //生成成功,跳出循环
                            if (created)
                                break;
                            //如果created为false,说明上面指定的cells数组的位置cells[m%cells.length]已经有其它线程设置了cell了,继续执行循环。
                            continue;
                        }
                    }
                   //如果执行的当前行,代表cellsBusy=1,有线程正在更改cells数组,代表产生了冲突,将collide设置为false
                    collide = false;
 
                /**
                 *内部小分支二:如果add方法中条件4的通过cas设置cells[m%cells.length]位置的Cell对象中的value值设置为v+x失败,说明已经发生竞争,将wasUncontended设置为true,跳出内部的if判断,最后重新计算一个新的probe,然后重新执行循环;
                 */
                } else if (!wasUncontended)  
                    //设置未竞争标志位true,继续执行,后面会算一个新的probe值,然后重新执行循环。 
                    wasUncontended = true;
                /**
                *内部小分支三:新的争用线程参与争用的情况:处理刚进入当前方法时threadLocalRandomProbe=0的情况,也就是当前线程第一次参与cell争用的cas失败,这里会尝试将x值加到cells[m%cells.length]的value ,如果成功直接退出  
                */
                else if (a.cas(v = a.value, ((fn == null) ? v + x :
                                             fn.applyAsLong(v, x))))
                    break;
                /**
                 *内部小分支四:分支3处理新的线程争用执行失败了,这时如果cells数组的长度已经到了最大值(大于等于cup数量),或者是当前cells已经做了扩容,则将collide设置为false,后面重新计算prob的值
                else if (n >= NCPU || cells != as)
                    collide = false;
                /**
                 *内部小分支五:如果发生了冲突collide=false,则设置其为true;会在最后重新计算hash值后,进入下一次for循环
                 */
                else if (!collide)
                    //设置冲突标志,表示发生了冲突,需要再次生成hash,重试。 如果下次重试任然走到了改分支此时collide=true,!collide条件不成立,则走后一个分支
                    collide = true;
                /**
                 *内部小分支六:扩容cells数组,新参与cell争用的线程两次均失败,且符合库容条件,会执行该分支
                 */
                else if (cellsBusy == 0 && casCellsBusy()) {
                    try {
                        //检查cells是否已经被扩容
                        if (cells == as) {      // Expand table unless stale
                            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
                }
                //为当前线程重新计算hash值
                h = advanceProbe(h);
 
            //这个大的分支处理add方法中的条件1与条件2成立的情况,如果cell表还未初始化或者长度为0,先尝试获取cellsBusy锁。
            }else if (cellsBusy == 0 && cells == as && casCellsBusy()) {
                boolean init = false;
                try {                           // Initialize table
                    //初始化cells数组,初始容量为2,并将x值通过hash&1,放到0个或第1个位置上
                    if (cells == as) {
                        Cell[] rs = new Cell[2];
                        rs[h & 1] = new Cell(x);
                        cells = rs;
                        init = true;
                    }
                } finally {
                    //解锁
                    cellsBusy = 0;
                }
                //如果init为true说明初始化成功,跳出循环
                if (init)
                    break;
            }
            /**
             *如果以上操作都失败了,则尝试将值累加到base上;
             */
            else if (casBase(v = base, ((fn == null) ? v + x :
                                        fn.applyAsLong(v, x))))
                break;                          // Fall back on using base
        }
    }

与Atomic的性能比较

一般来说:

  • 在两个线程下,LongAdder性能会更差。
  • 在8个线程以上情况下,LongAdder是更好的。

4、BlockingQueue:阻塞队列

BlockingQueue提供了线程安全的队列访问方式,不用担心多线程环境下存、取共享变量的线程安全问题,并发包下很多高级同步类的实现都是基于BlockingQueue实现的。阻塞队列也是线程池非常重要的组件,也诞生了许多有意思的比如MemorySafeLinkedBlockingQueue,但LinkedBlockingQueue还是非常好理解的。

下面以LinkedBlockingQueue为例来分析一下阻塞队列的实现原理。

LinkedBlockingQueue:链表实现

LinkedBlockingQueue是最常用的BlockingQueue之一,单向链表实现。

核心字段

    // 最大容量
    private final int capacity;
    // 现在Queue内有多少个元素
    private final AtomicInteger count = new AtomicInteger();
    // 单向链表
    transient Node<E> head;
    private transient Node<E> last;
    // take锁
    private final ReentrantLock takeLock = new ReentrantLock();
    // take的等待通知Condition,名字叫notEmpty,即队列非空时唤醒该condition内的线程
    private final Condition notEmpty = takeLock.newCondition();
    // put锁
    private final ReentrantLock putLock = new ReentrantLock();
    // put的等待通知Condition
    private final Condition notFull = putLock.newCondition();

可以看到,它将put/take两种操作分成两把锁。

这里两个Condition的起名非常妙,当队列notEmpty,就notEmpty.signal。notFull同理。

构造方法

无参构造,队列的capacity为Integer.MAX_VALUE,几乎是无界的。

public LinkedBlockingQueue() {
    this(Integer.MAX_VALUE);
}

会初始化头尾节点

public LinkedBlockingQueue(int capacity) {
    this.capacity = capacity;
    last = head = new Node<E>(null);
}
头尾节点的解释

一般情况下:

  • 头节点head是虚节点,head.next是队列真正的首元素
  • 尾节点tail不是虚节点,实际指向队列真正的尾节点

当queue为空时,head = tail

核心方法:插入/取出

take:阻塞直到获取元素

    public E take() throws InterruptedException {
        // 加锁 这是ReentrantLock经典使用模型
        takeLock.lockInterruptibly();
        try {
            // 如果没有队列里没有元素 就阻塞
            while (count.get() == 0) {
                notEmpty.await();
            }
            // 拿到元素
            x = dequeue();
            // 维护count 唤醒操作
            c = count.getAndDecrement();
            if (c > 1)  notEmpty.signal();
        } finally {
            takeLock.unlock(); // 解锁
        }
        // 可能的唤醒操作
        if (c == capacity)
            signalNotFull();
        return x;
    }
    private E dequeue() {
        Node<E> h = head;
        Node<E> first = h.next;
        h.next = h; // help GC
        head = first;
        E x = first.item;
        first.item = null;
        return x;
    }

如果彻底搞懂了ReentrantLock和Condition,这个源码就非常好懂。

dequeue方法只修改了head指针,返回头节点的item,虽然可能还有线程在put,但也不需要额外的CAS/锁去保证,我们再看下put方法就可以知道:

put:阻塞直到插入元素

  public void put(E e) throws InterruptedException {
        int c = -1;
        Node<E> node = new Node<E>(e);
        putLock.lockInterruptibly();
        try {
            while (count.get() == capacity) {
                notFull.await();
            }
            enqueue(node);
            // 处理count 唤醒
            c = count.getAndIncrement();
            if (c + 1 < capacity)
                notFull.signal();
        } finally {
            putLock.unlock();
        }
        if (c == 0)
            signalNotEmpty();
    }
    private void enqueue(Node<E> node) {
        last = last.next = node;
    }

显然,enqueue也没有做额外的CAS/锁。这是因为:

  • enqueue只对链表的尾节点last操作,完全不会修改head;
  • dequeue只对链表的头节点head操作,完全不会修改last;

因此只需要限制put/get各自只有一个线程在执行即可。

其他重要的API介绍

  • add/remove :队列满/空,抛异常
  • poll:队列空,返回null;offer:队列满,返回false。可以设置超时等待
  • put/take: 始终阻塞等待直到成功

因为CAS与锁的应用实在是太多太多,本文就拣了3个讲讲,标题也挖了个坑,以后有机会再讲讲别的。

参考文档

源码阅读:全方位讲解LongAdder