原子变量与非阻塞同步机制知识梳理
1.CAS
比较并交换(CAS)包含了3个操作数——内存中已存在的数V、内存中旧值A和期望改成的新值B。当且仅当A的值等于V时,CAS才会通过原子方式用新值B来更新V的值,否则不会执行任何操作。
下面代码演示模拟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的典型使用模式是:首先读取V的值赋值给A,并根据A计算新值B,然后再通过CAS以原子的方式将V的值由A变成B(只要在这期间没有任何线程将V的值修改为其他值)。
下面示例展示使用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;
}
}
ABA问题
ABA问题是一种异常现象:如果在算法中的节点可以被循环使用,那么在使用“比较并交换”指令时就有可能出现这种问题(主要在没有垃圾回收机制的环境中)。在CAS操作中将判断“V的值是否仍然为A?”,并且如果是的话就继续执行更新操作。
在某些算法中,如果V的值首先由A变成B,再由B变成A,如果这样被认定为没有发生变化,则出现ABA问题。
解决方案:在更新元素的时候不是更新某个引用的值,而是更新两个值,包括一个引用和一个版本号。即使这个值由A变成B,然后又变为A,版本号也将是不同的。
AtomicStampedReference
(以及AtomicMarkableReference
)支持在两个变量上执行原子的条件更新。AtomicStampedReference
将更新一个“对象——引用”二元组,通过在引用上加上“版本号”,从而避免ABA问题。类似的,AtomicMarkableReference
将更新一个“对象引用——布尔值”二元组。
2.原子操作类
在Atomic包里一共提供了12个类,属于4种类型的原子更新方式,分别是:
- 原子更新基本类型
- 原子更新数组
- 原子更新引用
- 原子更新属性
2.1原子更新基本类型类
使用原子的方式更新基本类型,Atomic包提供了以下3个类:
- AtomicBoolean:原子更新布尔类型
- AtomicInteger:原子更新整型
- AtomicLong:原子更新长整型
以上3个类提供的方法几乎一模一样,所以这里仅AtomicInteger的常用方法。
- int addAndGet(int delta):以原子方式将输入的数值与实例中的值相加,并返回结果。
- boolean compareAndSet(int expect, int update):如果输入的数值等于预期值,则以原子方式将该值设置为输入的值。
- int getAndIncrement():以原子方式将当前值加1,注意,这里返回的是自增前的值。
- void lazySet(int newValue):最终会设置成newValue,使用lazySet设置值后,可能导致其他线程在之后一小段时间还是可以读到旧的值。
- int getAndSet(int newValue):以原子方式设置为newValue的值,并返回旧值。
2.2原子更新数组
通过原子的方式更新数组里的某个元素,Atomic包提供了一下3个类:
- AtomicIntegerArray:原子更新整型数组里的元素
- AtomicLongArray:原子更新长整型数组里的元素
- AtomicReferenceArray:原子更新引用类型数组里的元素
AtomicIntegerArray类主要提供原子的方式更新数组里的整型,其常用方法如下:
- int addAndGet(int i, int delta):以原子方式将输入值与数组中索引i的元素相加。
- boolean compareAndSet(int i, int expect, int update):如果当前值等于预期值,则以原子方式将数组位置i的元素设置成update值。
2.3原子更新引用类型
原子更新基本类型的AtomicInteger,只能更新一个变量,如果要原子更新多个变量,就需要使用这个原子更新引用类型提供的类。Atomic包提供了以下3个类:
- AtomicReference:原子更新引用类型
- AtomicReferenceFieldUpdater:原子更新引用类型里的字段
- AtomicMarkableReference:原子更新带有标记位的引用类型
下面示例以AtomicReference为例给出了使用方式:
public class AtomicReferenceTest {
public static AtomicReference<user> atomicUserRef = new AtomicReference<>();
public static void main(String []args) {
User user = new User("conan", 15);
atomicUserRef.set(user);
User updateUser = new User("Shinichi", 17);
atomicUserRef.compareAndSet(user, updateUser);
System.out.println(atomicUserRef.get().getName());
System.out.println(atomicUserRef.get().getOld());
}
static class User {
private String name;
private int old;
public User(String name, int old) {
this.name = name;
this.old = old;
}
public String getName() {
return name;
}
public ing getOld() {
return old;
}
}
}
2.4原子更新字段类
如果需要原子地更新某个类里的某个字段时,就需要使用原子更新字段类,Atomic包提供了以下3个类进行原子字段更新:
- AtomicIntegerFieldUpdater:原子更新整型的字段的更新器
- AtomicLongFieldUpdater:原子更新长整型字段的更新器
- AtomicStampedReference:原子更新带有版本号的引用类型。该类将整数值与引用关联起来,可用于原子的更新数据和数据的版本号,可以解决使用CAS进行原子更新时可能出现的ABA问题。
要想原子地更新字段类需要两步。第一步,因为原子更新字段类都是抽象类,每次使用的时候必须使用静态方法newUpdater()创建一个更新器,并且需要设置想要更新的类和属性。第二步,更新类的字段(属性)必须使用public volatile修饰符。
下面示例以AtomicIntegerFieldUpdater为例给出了使用方式:
public class AtomicIntegerFieldUpdaterTest {
//创建原子更新器,并设置需要更新的对象类和对象的属性
private static AtomicIntegerFieldUpdater<User> a = AtomicIntegerFieldUpdater.newUpdater(User.class, "old");
public static void main(String []args) {
//设置柯南的年龄是10岁
User conan = new User("conan", 10);
//柯南长了一岁,但是仍然输出旧的年龄
System.out.println(a.getAndIncrement(conan));
//输出柯南现在的年龄
System.out.println(a.ge(conan));
}
static class User {
private String name;
private volatile int old;
public User(String name, int old) {
this.name = name;
this.old = old;
}
public String getName() {
return name;
}
public ing getOld() {
return old;
}
}
}
3.非阻塞算法
3.1什么是非阻塞算法?
非阻塞算法:在某种算法中,一个线程的失败或挂起不会导致其他线程也失败或挂起,这种算法就称为非阻塞算法。
无锁算法:如果在算法的每个步骤中都存在某个线程能够执行下去,那么这种算法被称为无锁算法。
如果在算法中仅将CAS用于协调线程之间的操作,并且能正确地实现,那么它既是一种无阻塞算法,又是一种无锁算法。无竞争的CAS通常都能执行成功,并且如果有多个线程竞争同一个CAS,那么总会有一个线程在竞争中胜出并执行下去。在非阻塞算法中通常不会出现死锁和优先级反转问题(但可能会出现饥饿和活锁问题)。
3.2非阻塞的栈
创建非阻塞算法的关键在于,找出如何将原子修改的范围缩小到单个变量上,同时还要维护数据的一致性。
栈是最简单的链式数据结构,每个元素仅指向一个元素,并且每个元素也只被一个元素引用。15-6给出了如果通过原子引用来构建栈的示例。 栈是由Node元素构成的一个链表,其中栈顶作为根节点,并且在每个元素中都包含了一个值以及指向下一个元素的链接。push方法创建一个新的节点,该节点的next域指向当前的栈顶,然后使用CAS把这个新节点放入栈顶。如果在开始插入节点时,位于栈顶的节点没有发生变化,那么CAS就会成功。如果栈顶节点发生了变化(例如由于其他线程在本线程开始之前插入或移除了元素),那么CAS将会失败,而push方法会根据栈的当前状态来更新节点,并且再次尝试。无论哪种情况,在CAS执行完成后,后栈仍会处于一致的状态。
//使用Treiber算法构造的非阻塞栈
public class ConcurrentStack<E>{
AtomicReference<Node<E>> top=new AtomicReference<Node<E>>();
//push方法创建一个新的节点,该节点的next域指向当前的栈顶,然后使用CAS把这个新节点放入栈顶。
//如果在开始插入节点时,位于栈顶的节点没有发生变化,那么CAS就会成功。
public void push(E item){ //根据栈的当前状态来更新节点
Node<E> newHead=new Node<E>(item);
Node<E> oldHead;
do{
oldHead=top.get();
newHead.next=oldHead;
}while(!top.compareAndSet(oldHead, newHead));//如果栈顶点发生变化则CAS失败,重新执行
}
public E pop(){
Node<E> oldHead;
Node<E> newHead;
do{
oldHead=top.get();
if(oldHead==null)
return null;
newHead=oldHead.next;
}while(!top.compareAndSet(oldHead, newHead)); //如果栈顶点发生变化则CAS失败,重新执行
return oldHead.item;
}
private static class Node <E> {
public final E item;
public Node<E> next; //下一个节点
public Node(E item) {
this.item = item;
}
}
}
CasCounter和ConcurrentStack说明了非阻塞算法的所有特性: 某项工作的完成具有不确定性,
必须重新执行。像ConcurrentStack这样的阻塞算法中都能确保线程安全性,因为CAS像锁定机制一样,既能提供原子性,又能提供可见性。
当一个线程需要改变栈的状态时,将调用CAS,这个方法与写入volatile类型的变量有着相同的内存效果。
当线程检查栈的状态时,将在同一个AtomicReference上调用get方法,这个方法与读取volatile类型的变量有着相同的内存效果。
因此,一个线程的任何修改结构都可以安全地发布给其他正在查看状态的线程。并且,这个栈时通过CAS来修改的,因此将采用原子操作来更新top的引用,或者在发现存在其他线程干扰的情况下,修改操作将失败。
3.3非阻塞的链表
CAS的基本使用模式:在更新某个值时存在不确定性时,以及在更新失败时重新尝试。链接队列比栈复杂,因为它必须支持对头结点和尾结点的快速访问。因此,它需要单独维护的头指针和尾指针。 有两个指针指向位于尾部的节点:当前最后一个元素的next指针,以及尾结点。当成功地插入一个新元素时,这两个指针都需要使用原子操作来更新。
在更新这两个指针时需要不同的CAS操作,并且如果第一个CAS成功,但第二个失败,那么队列将处于不一致的状态。而且,即使两个CAS都成功了,在执行两者之间仍有可能有另一个线程会访问这个队列。
我们需要一些技巧。第一个技巧是,即使在一个包含多个步骤的更新操作中,也要确保数据结构总是处于一致的状态。这样,当线程B到达时,如果发现线程A正在执行更新,B不能立即开始执行自己的更新操作,等待(通过反复检查队列的状态)并直到A完成更新,从而使两个线程不会互相干扰。这有可能有一个线程更新操作失败了,其他的线程都无法访问队列。要使该算法成为一个非阻塞的方法,必须确保一个线程失败时不会妨碍其他线程继续执行下去。
第二个技巧是,如果当B到达时发现A正在修改数据结构,那么在数据结构中应该有足够多的信息,使得B能完成A的更新操作。如果B”帮助”A完成了更新操作,那么B可以执行自己的操作,而不用等待A的操作完成。当A恢复后在试图完成其操作时,发现B已经替它完成了。15-7给出了非阻塞链接队列算法中的插入部分。在许多队列算法中,空队列通常包含一个“哨兵(Sentinel)”节点或“哑(Dummy)节点”,并且头节点和尾节点在初始化时都指向该哨兵节点。尾节点通常要么指向哨兵节点(如果队列为空),即队列的最后一个元素,要么(当有操作正在执行更新时)指向倒数第二个元素。
下图给出一个处于正常(稳定)状态的包含两个元素的队列 :
//非阻塞算法中的插入算法
public class LinkedQueue <E>{
private static class Node<E>{
final E item;
final AtomicReference<Node<E>> next;
public Node(E item,Node<E> next){
this.item=item;
this.next=new AtomicReference<LinkedQueue.Node<E>>(next);
}
}
private final Node<E> dummy=new Node<E>(null,null); //哑结点
private final AtomicReference<Node<E>> head=
new AtomicReference<Node<E>>(dummy); //头结点
private final AtomicReference<Node<E>> tail=
new AtomicReference<Node<E>>(dummy); //尾节点
public boolean put(E item){
Node<E> newNode=new Node<E>(item,null);
while(true){
Node<E> curTail=tail.get();
Node<E> tailNext=curTail.next.get();
if(curTail==tail.get()){
if(tailNext!=null){ //A
//队列处于中间状态(即插入成功但未推进尾节点),推进尾节点
tail.compareAndSet(curTail, tailNext); //B
}else{
//处于稳定状态(tailNext==null),尝试插入新节点
//如果两个线程同时插入元素,那么这个CAS将失败。在这个情况下,并不会造成破坏,不会发生任何变化,并且当前的线程只需重新读取尾节点并再次重试。
if(curTail.next.compareAndSet(null, newNode)){ //C
//插入成功,尝试推进尾节点
tail.compareAndSet(curTail, newNode); //D
//如果步骤D失败,那么执行插入操作的线程将返回,而不是重新执行CAS,因为另一个线程在步骤B中完成了这个工作
return true;
}
}
}
}
}
}
当插入一个新元素时,需要更新两个指针。首先更新当前最后一个元素的next指针,将新节点链接到队列末尾,然后更新尾节点,将其指向这个新元素。 在这两个操作之间,队列处于一个中间状态(此时tailNext!=null), 如图:
在第二次更新完成后,队列将再次处于稳定状态(尾节点的next域为空)。在插入新元素之前,首先检查队列是否处于中间状态(步骤A),如果是,那么有另一个线程正在插入元素(步骤C和D之间)。此时当前线程不会等待其他线程执行完成,而是帮助它完成操作,并将尾节点向前推进一个节点(步骤B),然后它将重复执行这种检查,以免另一个线程已经开始插入新元素,并继续推进尾节点,直到它发现队列处于稳定状态之后,才会执行自己的插入操作。
步骤C中的CAS把新节点链接到队列尾部,因此如果两个线程同时插入元素,那么这个CAS将失败。 在这个情况下,并不会造成破坏,不会发生任何变化,并且当前的线程只需重新读取尾节点并再次重试。
如果步骤C成功,那么插入操作生效,第二个CAS(步骤D)被认为是一个”清理操作”,既可以由执行插入操作的线程来执行,也可以由其他任何线程来执行。
如果步骤D失败,那么执行插入操作的线程将返回,而不是重新执行CAS,因为另一个线程在步骤B中完成了这个工作。
这种方式能够工作,因为在任何线程尝试将一个新节点插入到队列之前,都会首先通过检查tail.next是否非空来判断是否需要清理队列。如果是,它首先会推进尾节点(可能需要执行多次),直到队列处于稳定状态。