文章目录
非阻塞算法设计和实现上要复杂的多,但在可伸缩性和活跃性上拥有巨大的优势。
原子变量提供了与volatile变量相同的语义,此外还支持原子的更新操作,从而更适用于实现计数器、序列发生器和统计数据收集等。
1.锁的劣势
多个线程请求锁时,一些线程将被挂起并且在稍后恢复运行。当线程恢复执行时,必须等待其他线程执行完他们的时间片后才能被调度执行。挂起和恢复线程等过程存在很大的开销,并且通常存在较长时间的中断。
volatile更轻量级的同步机制,不会发生上下文切换或线程调度等操作。局限:虽然提供了可见性,但不能用于构建原子的复合操作。因此当一个变量依赖其它变量或新值依赖于旧值时,就不能用volatile(例如,++i 包含了3个独立的操作–获取变量当前值,加1,写入新值,整个过程必须是原子的)。
锁定时,当一个线程等待锁时,它不能做其它的事情。如果一个线程在持有锁时被延迟执行(如缺页错误、调度延迟等),所有需要这个锁的其它线程都无法执行。如果持有锁的线程永久阻塞,那么程序将永远无法继续执行。
2.硬件对并发的支持
独占锁是一种悲观锁–假设最坏的情况
对于细粒度的操作,有一种乐观的方法,可以不发生干扰的情况下完成更新操作。这种方法需要借助冲突检查机制来判断在更新过程中是否存在来自其他线程的干扰,如果存在,这个操作将失败,并且可以重试(CAS等)。
2.1 比较并交换
CAS包含了3个操作数–需要读写的内存位置V、进行比较的值A和拟写入的新值B。当且仅当V的值等于A时,CAS才会用原子方式用新值B来更新V的值,否则不会执行任何操作。无论位置V的值是否等于A,都将返回V原有的值。
CAS含义:我认为V的值应该为A,如果是,那么将V的值更新为B,否则不修改并告诉V的值实际为多少。
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));
}
}
多个线程尝试更新同一个变量时,只有一个线程能更新变量的值,而其他线程都将失败。失败的线程不会被挂起,而是会再次尝试。由于线程竞争失败后不会阻塞,因此他可以决定是否重新尝试,或者执行一些恢复操作,或者不执行任何操作。
2.2 非阻塞的计数器
使用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;
}
}
}
通常,反复的重试是一种合理的策略,但一些竞争激烈的情况下,更好的方式是在重试之前首先等待一段时间或者回退,从而避免造成活锁问题。
CasCounter 不会阻塞,如果其他线程同时更新计数器,那么会多次执行重试操作。(如果仅需要一个计数器或序列生成器,那么可以直接使用AtomicInteger或AtomicLong,他们能提供原子的递增方法)
竞争程度不高时,基于CAS的计数器在性能上远胜于基于锁的计数器。
3.原子变量类
原子变量类相当于一种泛化的volatile变量,能够支持原子的和有条件的读-改-写操作。AtomicInteger表示一个int类型的值,并提供了get和set方法。发生竞争时有很好的可伸缩性。
共12个原子变量类,分为4组:标量类,更新器类,数组类以及复合变量类。
最常用的原子变量就是标量类:AtomicInteger、AtomicLong、AtomicBoolean以及AtomicReference。他们都支持CAS。
原子数组类(只支持Integer、Long、Reference版本)中的元素可以实现原子更新。原子数组类为数组的元素提供了volatile类型的访问语义,这是普通数组不具备的特性–volatile类型的数组仅在数组引用上具有volatile,在其元素上没有。
原子的标量类扩展了Number类,但并没有扩展一些基本类型的包装类,如Integer或Long,因为基本类型的包装类是不可修改的,而原子变量类是可修改的。
3.1 原子变量是一种“更好的volatile”
public class CasNumberRange {
private static class IntPair {
//不变形条件: lower <= upper
final int lower;
final int upper;
private IntPair(int lower, int upper) {
this.lower = lower;
this.upper = upper;
}
}
//使用AtomicReference和IntPair来保存状态
private final AtomicReference<IntPair> values = new AtomicReference<>(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();
if (i > oldv.upper) {
throw new IllegalArgumentException("Can not set lower to " + i + " > upper");
}
IntPair newv = new IntPair(i, oldv.upper);
//CAS 避免竞态
if (values.compareAndSet(oldv, newv)) {
return;
}
}
}
}
3.2 性能比较:锁与原子变量
低竞争情况下,原子变量的性能高于锁,高竞争情况下,锁性能高于原子变量
但实际情况中,原子变量在可伸缩性上要高于锁,因为在应对常见的竞争程度时,原子变量的效率会更高。
4.非阻塞算法
4.1 非阻塞的栈
非阻塞算法通常比基于锁的算法更复杂。算法关键在于,找出如何将原子修改的范围缩小到单个变量上,同时还要维护数据一致性。
栈时最简单的链式数据结构, 每个元素只指向一个元素
public class ConcurrentStack<E> {
AtomicReference<Node<E>> top = new AtomicReference<>();
public void push(E item) {
Node<E> newHead = new Node<>(item);
Node<E> oldHead;
do {
oldHead = top.get();
//新节点的next指向当前栈顶
newHead.next = oldHead;
//使用CAS把新节点放到栈顶
//如果开始插入节点前,栈顶没有发生变化,CAS就会成功更新栈顶
//如果栈顶发生变化(被其他线程修改),CAS会失败,并根据新的栈状态来更新节点
} while (top.compareAndSet(oldHead,newHead));
}
private static class Node<E> {
public final E item;
public Node<E> next;
public Node(E item) {
this.item = item;
}
}
}
非阻塞算法特性:某工作的完成具有不确定性,必须重新执行。
CAS既能提供原子性,又能提供可见性,所以非阻塞算法是线程安全的。
4.2 非阻塞的链表
链表需要单独维护头指针和尾指针来快速访问。有两个指针指向位于尾部的节点:当前最后一个元素的next指针,以及尾节点。当插入一个元素时,这两个指针都需要采用原子操作来更新。
技巧
- 即使在一个包含多个步骤的更新操作中,也要确保数据结构总是处于一致的状态。B线程到达时,如果A正在执行更新操作,B不能立即开始自己的更新操作。然后B可以等待(通过反复检查队列的状态)并直到A完成
- 如果B到达时发现A正在修改数据结构,那么在数据结构中应该有足够多的信息,使得B能完成A的更新操作。如果B帮助A完成了更新操作,那么B可以执行自己的操作,而不用等A操作完成。当A恢复后视图完成其操作时,会发现B已经替他完成了。
空队列通常包含一个“哨兵节点(Sentinel)”或者“哑节点(Dummy)”,并且头结点和尾结点在初始化时都指向该哨兵节点。尾节点通常要么指向哨兵节点(如果队列为空),即队列的最后一个元素,要么(当有操作正在执行更新操作时)指向倒数第二个元素。
public class LinkedQueue<E> {
private static class Node<E> {
final E item;
final AtomicReference<Node<E>> next;
private Node(E item, AtomicReference<Node<E>> next) {
this.item = item;
this.next = next;
}
}
private final Node<E> dummy = new Node<>(null,null);
private final AtomicReference<Node<E>> head = new AtomicReference<>(dummy);
private final AtomicReference<Node<E>> tail = new AtomicReference<>(dummy);
//插入元素需要更新两个指针
public boolean put(E item) {
Node<E> newNode = new Node<>(item, null);
while (true) {
Node<E> curTail = tail.get();
Node<E> 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指针,将新节点链接到队尾,然后更新尾结点,将其指向这个新的元素。
当队列处于稳定状态,尾结点的next域降为空,如果队列处于中间状态,那么tail.next将为非空。因此任何线程都能通过检查tail.next来获取队列当前的状态。
4.3 ABA问题
CAS判断值相等的间隙,值是否发生过改变
解决方法:改变引用值的时候,增加一个版本号。每次改变的操作都更新版本号。