近年来,在并发算法领域的大多数研究都侧重于非阻塞算法,这种算法用底层的原子机器指令 (例如比较和交换指令)代替锁来确保数据在并发访问中的一致性。非阻塞算法被广泛的用于在操作系统和JVM中实现线程/进程调度机制,垃圾回收机制以及锁和其他并发数据结构。
与基于锁的方案相比,非阻塞算法在设计和实现上都要复杂的多,但它们在可伸缩性和活跃性上有着巨大的优势。由于非阻塞算法可以使多个线程在竞争相同的数据时不会发生阻塞,因此它能在粒度更细的层次上进行协调,并且极大地减少调度开销。而且,在非阻塞算法中不存在死锁和其他活跃性问题。在基于锁的算法中,如果一个线程在休眠或者自旋的同时持有一个锁,那么其他线程都无法执行下去,而非阻塞算法不会受到单个线程失败的影响。
从java 5.0开始,可以使用原子变量类(例如AtomicInteger
和AtomicReference
)来构建高效的非阻塞算法。
即使原子变量没有用于非阻塞算法的开发,它们也可以用作一种更好的volatile类型变量。原子变量提供了与volatile类型变量相同的内存语义。还支持原子的更新操作,从而使它们更加适用与实现计数器
、序列发生器
和统计数据收集
等,同时还能比基于锁的方法提供更高的可伸缩性。
锁的劣势
通过使用一致的锁定协议来协调对共享状态的访问,可以确保无论那个线程持有守护变量的锁,都能采用独占方式来访问这些变量,并且对变量的任何修改对随后获得这个锁的其他线程都是可见的。
许多JVM都对非竞争锁获取和锁释放等操作进行了极大的优化,但如果有多个线程同时请求锁,那么JVM就会借助操作系统的功能,如果出现了这种情况,那么一些线程将被挂起并且在稍后恢复运行(有的JVM不一定是挂起,会根据锁的持有时间长短来判断是挂起还是自旋)。当线程恢复执行时,必须等待其他线程执行完它们的CPU周期以后,才能被调度执行。在挂起和恢复线程等过程中存在这很大的开销,并且通常存在着较长时间的中断。
与锁相比,volatile就轻量了许多,因为在使用volatile变量的时候不会发生上下文切换或线程调度等操作。然而,volatile也同样存在一些局限:虽然volatile提供了相似的可见性保证,但是不能用于构建原子的复合操作,所以当变量依赖于其他变量时,或者当变量的新值依赖旧值时,不能使用volatile。
锁还有一些其他的缺点:
- 当一个线程在等待锁时,它不能做任何事。如果一个线程在持有锁的情况下被延迟执行(例如发生了缺页错误,调度延迟等),那么所需要这个锁的线程都无法执行下去。
- 如果被阻塞线程的优先级较高,而持有锁的线程线程优先级较低,那么会产生一个严重的问题:
优先级反转(Priority Inversion)
:即使高优先级线程可以抢先执行,但仍然需要等待低优先级线程释放锁,从而导致性能降低,甚至可能永久性阻塞。
硬件对并发的支持
独占锁是一种悲观技术 -- 它假设最坏的情况,并且只有确保在其他线程不会造成干扰的情况下才能执行下去。
对于细粒度的操作,还有另外一种高效的办法,也是乐观的方法,通过这种方法可以在不发生干扰的情况下完成更新操作:借助冲突检查机制来判断在更新过程中是否存在来自其他线程的干扰,如果存在,这个操作将失败,并且可以选择是否重试。
比较并交换
在大多数处理器架构中采用的的方法是实现一个比较并交换(CAS)指令
(在其他处理器中,是利用一对指令来实现相同的功能:关联加载和条件储存)。CAS中包含了3个操作数
:
- 需要读写的内存位置V
- 进行比较的旧值A
- 拟写入的新值B
CAS操作的逻辑语义
:当且仅当V的值等于A时,CAS才通过原子方式用新值B来更新V的值,否则不进行任何的操作。无论位置V的值是否等于A,都将返回A原有的值。
CAS是一项乐观锁技术
,它希望能成功执行更新操作,并且如果有另一个线程在最近一次检查后更新了变量,那么CAS将能成功地检查到这个错误。
模拟CAS操作
public class SimulatedCAS {
private int value;
public synchronized int get(){
return value;
}
public synchronized int compareAndSwap(int expectedValue, int newValue){
int oldValue = value;
if(oldValue == expectedValue){
value = newValue;
return oldValue;
}
}
public synchronized boolean compareAndSet(int expectedValue, int newValue){
return (expectedValue == compareAndSwap(expectedValue, newValue));
}
}
当多个线程尝试使用CAS同时更新同一个变量时,只有一个能更新变量的值,而其他线程都将失败。然而,失败的线程不会被挂起。这与获取锁的情况不同(获取锁失败时,线程将被挂起),而是被告知在这次竞争中失败,并可以再次尝试。由于一个线程在竞争CAS时失败不会阻塞,因此它可以决定是否重新尝试,或者执行一些恢复操作,或者不执行任何操作(大部分情况下,不执行任何操作是一个明智的选择。因为当CAS失败时,意味着其他线程已经完成了你想要执行的操作)。
非阻塞的计数器
基于CAS实现的非阻塞计算器
public class CasCounter{
private SimulatedCAS value;
public int getValue(){
return value.get();
}
public int increment(){
int v;
do {
v = value.get();
}
while(v != value.compareAndSwap(v, v + 1));
return v + 1;
}
}
看起来CAS的计数器比基于锁的计数器在性能上更差一点,因为它执行了更多的操作和更复杂的控制流,并且还依赖看似复杂的CAS操作。但实际上,当竞争程度不高时,CAS在性能上远超内置锁。如果竞争程度不高,CAS在大多数情况下都能成功执行,因此硬件能够正确的预测while循环中的分支,从而把复杂逻辑控制逻辑的开销降到最低。
JVM在实现锁定时需要遍历JVM中一条非常复杂的代码路径,并可能导致操作系统级的锁定、线程挂起以及上下文切换等操作。在最好的情况下也至少需要执行一次CAS。
CAS的主要缺点是要调用者来处理竞争问题(重试、还是回退、还是放弃),而在锁中,调用者不需要关心这些问题(在获得锁之前将被一直阻塞)。
JVM对CAS的支持
JVM运行时把它们便以为相应的(多条)机器指令。最坏的情况下,如果不支持CAS指令,那么JVM将使用自旋锁(主流都支持)。在原子变量类中,使用了底层JVM致辞作为数字类型和引用类型提供一种高效的CAS操作,而在concurrent中的大多数类在实现时则直接或间接的使用了这些原子变量类。
原子变量类
原子变量比锁的粒度更细,量级更轻,并且对于在多处理器系统上实现高性能的并发代码来说是非常关键的。
更新原子变量的快速(非竞争)路径不会比获取锁的快速路径慢,而它的慢速路径肯定比锁的慢速路径快,因为它不需要挂起或者重新调度线程。
原子变量类相当于一种泛化的volatile变量
,能够支持原子的和有条件的读-改-写操作。
AtomicInteger 表示一个int类型的值,并提供了get/set方法,这些volatile类型的int变量在读取和写入上有这相同的内存语义。还提供了一个原子的compareAndSet方法(如果该方法执行成功,那么将实现与读取/写入一个volatile变量相同的内存效果),以及原子的添加、递增、递减等方法。AtomicXXX
在发生竞争的情况下能提供更高的可伸缩性,因为它直接利用了硬件对并发的支持。
一共有12个原子变量类:
-
标量类(Scalar)
:都支持CAS,其中AtomicInteger和AtomicLong还支持算数运算AtomicInteger
AtomicLong
AtomicBoolean
AtomicReference
更新器类
数组类
复合变量类
尽管原子的标量类拓展了Number类,但并没有拓展包装类,例如Integer。事实上它们也不能进行拓展:基本类型的包装类是不可修改的,而原子变量是可修改的。在原子变量中同样没有重新定义hashcode和equals方法,每个实例都是不同的,与其他可变对象相同,它们也不宜用作基于散列容器中的键值。
原子变量是一种"更好的volatile"
通过CAS来维持包含多个变量的不变性条件
public class CasNumberRange {
private static class IntPair {
// 不变性条件 : lower <= upper0
final int lower;
final int upper;
}
private final AtomicReference values = new AtomicReference(new IntPair(0, 0));
public void getLower(){
return values.get().lower;
}
public int getUpper(){
return values.get().upper;
}
public void setLower(int i){
while(true){
IntPair oldv = values.get();
if(i > oldv.upper{
throw new IllegalArgumentException ("lower不能大于upper");
}
IntPair newv = new IntPair(i, oldv.upper);
if(value.compareAndSet(oldv, newv)){
return;
}
}
}
}
通过compareAndSet这个原子操作来避免竞态条件,如果设置不成功则重试。
非阻塞算法
在某种算法中,一个线程的失败或挂起不会导致其他线程也失败或挂起,那么这种算法就被称为非阻塞算法。
如果在算法的每个步骤中都存在某个线程能执行下去,那么这种算法也被称为无锁算法(Lock-Free)
(不用锁的情况下实现多线程之间的变量同步)。
无竞争的CAS通常都能执行成功,并且如果有多个线程竞争同一个CAS,那么总会有一个线程在竞争中胜出并执行下去。在非阻塞算法中通常不会出现死锁和优先级反转的问题(但是可能出现饥饿或活锁,因为程序在不断重试)。
在实现相同功能的前提下,非阻塞算法通常比基于锁的算法更加复杂,创建非阻塞算法的关键在于,找出如何将原子修改的范围缩小到单个变量上,同时还要维护数据的一致性。
非阻塞的栈
使用Treiber算法构造的非阻塞栈
public class ConcurrentStack {
AtomicReference> top = new AtomicReference>();
public void push(E item){
Node newhead = new Node(item);
Node oldHead;
do{
oldHead = top.get();
newHead.next = oldHead;
}while(!top.compareAndSet(oldHead, newHead));
}
public E pop(){
Node oldHead;
Node newHead;
do{
oldHead = top.get();
if(oldHead == null){
return null;
}
newHead = oldHead.next;
}while(!top.compareAndSet(oldHead, newHead));
}
private static class Node {
public final E item;
public Node next;
public Node(E item){
this.item = item;
}
}
}
在ConcurrentStack中能确保线程的安全性,因为compareAndSet像锁定机制一样,既能提供原子性,又能提供可见性。当一个线程需要改变栈的状态时,将调用compareAndSet,这个方法与写入volatile类型的变量有着相同的内存效果。当线程检查栈的状态时,将在同一个AtomicReference上调用get方法,这个方法与读取volatile类型变量有着相同的效果。并且这个栈是通过compareAndSet来修改的,因此将采用原子操作来更新top的引用,或者在发现其他线程干扰的情况下,修改操作将失败。
非阻塞的链表
链接队列比栈更为复杂,因为它必须支持对头节点和尾节点的快速访问。因此它需要单独维护头指针和尾指针。有两个指针指向位于尾部的节点:当前最后一个元素的next指针,以及尾节点。当成功插入一个新元素时,这两个指针都需要采用原子操作来更新。看起来如果通过CAS无法同时原子操作两个变量,实际上可以通过一些技巧实现:
- 即使在一个包含多个步骤的更新操作中,也要确保数据结构总处于一种一致的状态。这样,当B到达时,如果发现线程A正在执行更新,那么B可以知道A已经部分完成,并且不能立即开始执行自己的更新。B在这里等待(通关循环检查队列的状态)并直到A完成更新。但是如果A在更新操作中失败了,那么其他线程将无法继续访问队列。
- 如果B到达,发现A在修改数据结构,那么在数据结构中应该有足够多的信息,能够使B能完成A的更新操作,当B“帮助”A完成了更新操作,那么B就可以执行自己的操作。
非阻塞算法中的插入算法
public class LinkedQueue{
private static class Node{
final E item;
final AtomicReference> next;
public Node(E item, Node next){
this.itme = item;
this.next = new AtomicReference>(next);
}
}
private final Node dummy = new Node(null, null);
private final AtomicReference> head = new AtomicReference>(dummy);
private final AtomicReference> tail = new AtomicReference>(dummy);
private boolean put(E item){
Node newNode = new Node(item ,null);
while(true){
Node curTail = tail.get();
Node tailNext = curTail.next.get();
if(curTail == tail.get()){
if(tailNext != null){
// 队列处于中间状态,推进尾节点
tail.compareAndSet(curTail, tailNext);
} else {
// 队列处于稳定状态,尝试插入新结点
if(curTail.next.compareAndSet(null, newNode)){
// 插入操作成功,尝试推进尾结点
tail.compareAndSet(curTail, newNode);
return true;
}
}
}
}
}
}
实现这个技巧的关键点在于:当队列处于稳定状态时,尾节点的next域将为空,如果队列处于中间状态,那么tail.next将为非空。因此,每个线程都可以通过检查tail.next来获取队列的当前状态。从而帮助其他结点完成第二步操作,使队列恢复为稳定状态。
实际上这就是concurrentLinkedQueue使用的算法,但是在实际的实现中略有区别
原子的域更新器
在实际上ConcurrentLinkedQueue
并没有使用原子引用来表示每个Node,而是使用普通的volatile类型引用并通过基于反射的AtomicReferenceFieldUpdater
来进行更新
ConcurrentLinkedQueue中使用原子的域更新器
private class Node {
private final E item;
private volatile Node next;
public Node(E item){
this.item = item;
}
private static AtomicReferenceFieldUpdater nextUpdater
= AtomicReferenceFieldUpdater.newUpdater(Node.class, Node.class, "next");
原子的域更新器类表示现有volatile域的一种基于反射的“视图”,从而能在已有的volatile域上使用CAS。在更新器类中没有构造函数,要创建一个更新器对象,可以调用newUpdater工厂方法
,并制定类和域的名字。域更新器类没有与某个特定的实例关联在一起,因而可以更新目标类的任意实例中的域。
在ConcurrentLinkedQueue中,使用nextUpdater的compareAndSet方法来更新Node的next域。这个方法有点繁琐,但是完全是为了提升性能。对于一些频繁分配并生命周期短暂的对象,例如队列的链接结点,如果能去调每个Node的AtomicReference创建过程,那么能极大地降低插入操作的开销。然而,几乎在所有情况下,普通原子变量的性能都很不错,只有在很少的情况下才需要使用原子的域更新器。(如果在执行原子更新的同时还需要维持现有的串行化形式,那原子的域更新器将非常有用)
ABA问题
ABA问题:如果在算法中的结点可以被循环使用,那么在使用CAS指令时就可能出现问题。CAS的操作是判断V的值是否仍然为A?,在大多数情况下,这种判断是足够的。然而,有时候还需要知道自从上次看到V的值以来,这个值是否发生过变化?在某些算法中,如果V的值先由A变成B,再由B变成A,那么仍然是被认为发生了变化,并需要重新执行算法中的某些步骤。
如果在算法中采用自己的方式来管理结点对象的内存,那么可能出现ABA问题。如果通过垃圾回收期来管理链表结点仍然无法避免ABA问题,那么有个相对简单的解决办法:更新引用的同时更新版本号。即使这个值由A变成B,然后又变回A,版本号也将是不同的。AtomicStampedReference
(以及AtomicMarkableReference
)支持在两个变量上执行原子的条件更新。
-
AtomicStampedReference
将更新一个对象 - 引用
二元组,通过在引用上加上版本号
,从而避免ABA问题。 -
AtomicMarkableReference
将更新一个对象引用 - 布尔值
二元组,在某些算法中将通过这种二元组使节点保存在链表中同时又将其标记为“已删的节点”。
小结
非阻塞算法通过底层的并发原语(例如CAS而不是锁)来维持线程的安全性。这些底层的原语通过原子变量向外公开,这些类也用作一种更好的volatile变量,从而为整数和对象引用提供原子的更新操作。
非阻塞的算法在设计和实现时非常困难,但通常能够提供更高的可伸缩性,并能更好的防止活跃性故障的发生。在JVM从一个版本升级到下一个版本的过程中,并发性能的主要提升都在于JVM内部和平台类库中的对非阻塞算法的使用。