java并发库(java.util.concurrent)提供了很多(相比锁)性能更优越的同步设施,比如ConcurrentLinkedQueue。本章的主题,是研究此类并发装置的性能秘密:原子变量和非阻塞同步。
锁的性能劣势
对现代JVM来说,在锁未发生竞争的情况下,JVM执行“加锁、释放锁”操作是非常快的;但是一旦发生竞争,就需要执行系统调用来挂起竞争失败的线程,等将来锁释放时再唤醒它们。
挂起和唤醒线程的性能损耗是不能忽视的,对那些需要加锁的高频操作(比如集合读写)来说,锁竞争导致的线程调度消耗的CPU,和业务逻辑操作消耗的CPU之间的比例,有可能会非常之高。换句话说,锁竞争导致CPU一直在执行线程的调入&调出,花在执行业务代码上的时间反而较少。
Volatile变量是一种轻量级的同步机制,不会阻塞线程;但Volatile不能保证变量操作的原子性,所以使用场景非常有限。
乐观锁
排他锁是一种“悲观“锁,它总是假定“坏”的事情(竞争)会发生,在未获得锁之前,不能执行任何操作。对于细粒度的操作,还有一种偏“乐观”的方式,它假定不会发生竞争,先尝试执行操作再说;但是一旦发生冲突,需要能检测到冲突。
为了实现这种“乐观”的同步机制,现代CPU都提供一种原子性的“read-modify-write”指令,最典型的是CAS(Compare and Swap)指令。JVM很早就使用了CAS指令,但一直到Java 5, java代码才能使用它。
CAS指令
CAS指令有三个参数:内存地址、期望的原值、新值,它仅当内存地址存储的值等于“期望的原值”,才将它修改为“新值”,无论成功或失败,该指令返回“指令执行之前,内存地址存储的值”。
CAS的语义,可以用下面的代码来模拟:
@ThreadSafe
public class SimulatedCAS {
@GuardedBy("this") private int value;
public synchronized int get() { return value; }
//compareAndSwap模拟CAS指令
public synchronized int compareAndSwap(int expectedValue,int newValue) {
int oldValue = value;
if (oldValue == expectedValue)
value = newValue;
return oldValue;
}
//线程用cas执行同步操作,在失败时能得到通知,不会阻塞
public synchronized boolean compareAndSet(int expectedValue,int newValue) {
return (expectedValue == compareAndSwap(expectedValue, newValue));
}
}
原子变量
原子变量是细粒度、轻量级的同步装置,是CAS指令最直接的应用。可以认为原子变量是更好的“Volatile”变量,它保留了Volatile变量的内存可见性保证,同时支持原子的“read-modify-write”操作。
Java的原子变量类型可分为四组:
- 标量:AtomicInteger, AtomicLong, AtomicBoolean, AtomicReference,LongAdder,LongAccumulator;
- field upaters:原子性地更新一个对象字段的工具,比如AtomicIntegerFieldUpdater;
- 数组:支持对数组元素执行原子操作,比如AtomicIntegerArray;
- 组合变量:AtomicStampedReference和AtomicMarkableReference,特殊用途。
Atomic变量最常用的操作就是compareAndSet,它内部调用了平台的CAS操作。Atomic类型都是对java基本类型的包裹,因为它只能为非常细粒度的操作提供原子性;它无法为多个状态字段的操作提供原子性保证。AtomicStampedReference这种所谓的组合原子变量,是通过内嵌的不可变对象来组合多个字段,本质上就是个AtomicReference。
示例:CasNumberRange
现在用Atomic变量来实现一个线程安全的range结构:
public class CasNumberRange {
@Immutable
private static class IntPair {
final int lower; // Invariant: lower <= upper
final int upper;
...
}
private final AtomicReference<IntPair> values = new AtomicReference<IntPair>(new IntPair(0, 0));
public int getLower() { return values.get().lower; }
public int getUpper() { return values.get().upper; }
public void setLower(int i) {
while (true) {
IntPair oldv = values.get();
IntPair newv = new IntPair(i, oldv.upper);
if (values.compareAndSet(oldv, newv))
return;
}
}
}
如果把lower和upper字段替换成Atomic变量是无法凑效的,因为它们并不互相独立。所以将它们封装成一个不可变对象IntPair,修改时替换成新的IntPair实例。
AtomicStampedReference和AtomicMarkableReference的实现也是这个套路。
Atomic Field Updater
Atomic field updater可以用原子操作来修改对象的某个字段,比如ConcurrentLinkedQueue的内部Node结构类似如下:
private class Node<E> {
private final E item;
private volatile Node<E> next;
public Node(E item) {
this.item = item;
}
}
Node.next的更新需要原子性,但Node.next并不是Atomic变量,而是一个普通的volatile变量,ConcurrentLinkedQueue通过AtomicReferenceFieldUpdater来更新它:
private static AtomicReferenceFieldUpdater<Node, Node> nextUpdater = AtomicReferenceFieldUpdater.newUpdater(Node.class, Node.class, "next");
Atomic Field Updater提供了对象字段的一个atomic操作工具,但是程序是有可能绕过updater来修改字段的,所以updater的原子性保证并不完整;因而,updater只能用来更新被完全封装的内部对象字段。
使用updater的动机有两个:
- 避免Atomic破坏对象的序列化;
- 减少一层对象,获得微弱的性能优势(如果next字段的类型是AtomicReference,相当于多了一层对象访问)。
所以一般的情况下,我们是没有必要使用updater的。
ABA问题
Atomic通过对比内存地址的期望值和实际值来判断是否存在冲突。存在一种极限情况:线程在获取旧值和置换新值两个操作之间,有另外一个线程将旧值修改了两次(先改成另外一个,又改回来),这就是所谓的ABA问题。在绝大多数情况下,ABA问题对业务没有任何影响,我们可以忽略它。CAS操作本身无法避开ABA问题,Atomic提供了两种类型来提供解决方案:AtomicStampedReference和AtomicMarkableReference,
AtomicStampedReference给reference加上一个int标记,每次修改,这个标记加1;AtomicMarkableReference则维护一个bool标记。
Atomic VS Lock
CAS指令消耗的CPU周期在10~150之间;而且在市场竞争的驱动下,处理器的CAS指令会越来越快。可以定性认为,在都没有发生竞争的情况下,Atomic的操作的速度大概是Lock加锁解锁的两倍;而发生竞争的情况下,Lock会导致线程挂起,Atomic不会阻塞线程,二者不具备可比性,除非Atomic变量操作陷入了“活锁“,否则性能远优于Lock。
非阻塞同步算法&数据结构
基于Atomic变量或CAS操作,可以设计出非阻塞且线程安全的算法或数据结构,由于没有阻塞,此类算法不会有死锁风险。另一方面,由于它不会锁住数据状态然后再操作,而是任由多个线程并发地操作状态,因此算法复杂度要大很多。
示例1:ConcurrentStack
现在我们使用Atomic来实现一个并发安全的Stack数据结构。
@ThreadSafe
public class ConcurrentStack <E> {
AtomicReference<Node<E>> top = new AtomicReference<Node<E>>();
public void push(E item) {
Node<E> newHead = new Node<E>(item);
Node<E> oldHead;
do {
oldHead = top.get();
newHead.next = oldHead;
//如果有其他线程并发第push或pop,top会发生变化,compareAndSet操作会失败
} while (!top.compareAndSet(oldHead, newHead));
}
public E pop() {
Node<E> oldHead;
Node<E> newHead;
do {
oldHead = top.get();
if (oldHead == null)
return null;
newHead = oldHead.next;
//如果有其他线程并发第push或pop,top会发生变化,compareAndSet操作会失败
} while (!top.compareAndSet(oldHead, newHead));
return oldHead.item;
}
private static class Node <E> {
public final E item;
public Node<E> next;
public Node(E item) {
this.item = item;
}
}
}
ConcurrentStack将元素放入Node,将Node组织为一个单链表,只需要持有栈顶节点——top,就可以了。
ConcurrentStack.push操作步骤:
- 创建出新的栈顶节点newHead;
- 获取当前栈顶的元素(oldHead),让newHead.next=oldHead,维护了单向链表;
- 通过cas操作将top设置为newHead
- 如果没有并发竞争,那么cas操作成功
- 如果发生竞争,那么oldHead会过期,那么cas操作会失败,返回步骤2重试
ConcurrentStack.push的正确性源自:Atomic提供了和volatile一样的内存可见性,且compareAndSet能检测到并发冲突。ConcurrentStack.pop的操作步骤是类似的,不再赘述。
ConcurrentStack是一个极简的并发安全数据结构,但是足以展示出此类算法的精髓,ConcurrentLinkedQueue、ConcurrentHashMap的实现原理是类似的,只不过更复杂罢了。
总结
基于硬件处理器的CAS指令,Atomic变量提供了细粒度的非阻塞的原子操作,基于Atomic,我们又可以构建很多非阻塞的并发安全数据结构(见java.util.concurrent)。相比锁,Atomic变量和并发数据结构,具备优越的并发性能。但是我们也要意识到,非阻塞同步机制不是在任何情况下可以取代锁,非阻塞同步只能保证细粒度的原子操作,它无法为涉及多个状态字段的操作提供原子性保证。一个并发数据结构是无法被锁定的,线程永远无法准确定判定一个并发集合是否empty,它的size是多少。