第十五章 原子变量与非阻塞同步机制(CAS)

与基于锁的方案相比,非阻塞在设计和实现上都要复杂得多,但他们在可伸缩性和活跃性上却有着巨大优势。由于非阻塞算法可以使多个线程在竞争相同的数据时不会发生阻塞,因此它能在颗粒度更细的层次上进行协调,并且极大地减少开销。而且,在非阻塞算法中不存在死锁和其他活跃性问题。

15.1 锁的劣势

通过使用一致的锁定协议来协调对共享状态的访问,可以确保无论哪个线程持有守护变量的锁,都能采用独占方式来访问这些变量,并且对变量的任何修改对随后获得这个锁的其他线程都是可见的。

与锁相比,volatile变量是一种更轻量级的同步机制,因为在使用这些变量时不会发生上下文切换或线程调度等操作。

然而volatile同样存在一些局限:虽然提供了相似的可见性保证,却不能用于构建原子的复合操作。因此当一个变量依赖其他的变量时,或者当变量的新值依赖与旧值时,就不能使用volatile变量,因此它无法用来实现计数器或互斥体。

锁还存在其他一些缺点:当一个线程正在等待锁时,它不能做其他任何事情。

如果被阻塞线程的优先级较高,而持有锁的线程优先级较低,那么这是严重的,被称为优先级反转(Priority Inversion).

15.2 硬件对并发的支持

独占锁是一项悲观技术—它假设最坏的情况,并且只有在确保其他线程不会造成干扰的情况下才能执行。

对于细粒度的操作,还有另一种更高效的方法:借助冲突检查机制来判断在更新过程中是否有来自其他线程的干扰,如果存在,这个操作将失败,并且可以重试(也可以不重试)。

15.2.1 比较并交行

在大多数处理器结构中采用的方法是实现一种比较并交换(compareAndSwap ,CAS)指令。

CAS包含了3个操作数——需要读写的内存位置V,进行比较的值A和拟写入的新值B。当且仅当V的值等于A时,CAS才会通过原子方式用新值B来更新V的值,否则不会执行任何操作。
无论位置V的值是否等于A,都将返回V原有的值(这种变化形式被称为比较并设置,无论操作是否成功都会返回)。

CAS的含义是:“我认为V的值应该是A,如果是,那么将V的值更新为B,否则不修改并告诉V的实际为多少”。

15-1说明了CAS语义(而不是实现或性能)

//      15-1  模拟CAS操作
@ThreadSafe
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){
       //如果相同就将值设置为newValue。并返回true
       return (expectedValue==compareAndSwap(expectedValue, newValue)); 
   } 
}

当多个线程尝试使用CAS同时更新同一个变量时,只有其中一个线程能更新变量的值,而其他线程都将失败。然而,失败的线程并不会被挂起(这与获取锁不同:当获取锁失败时,线程将被挂起),而是被告知在这次竞争中失败,并可以再次尝试。

由于一个线程在竞争CAS时失败不会阻塞,因此它可以决定是否重新尝试,或者执行一些恢复操作,或者不执行任何操作(在非阻塞算法中,当CAS失败时,意味着其他线程已经完成了你想执行的操作)。 这种灵活性就达达减少了与锁相关的活跃性风险。

CAS的典型使用模式是:首先从V中读取值A,并根据A计算新值B,然后再通过CAS以原子方式将V中的值由A变成B(只要这期间没有其他任何线程将V的值修改为其他值)。
由于CAS能检查到来自其他线程的干扰,因此即使不用锁也能实现原子的读-改-写操作序列。

15.2.2 非阻塞的计数器

15-2使用CAS实现了一个线程安全的计数器。
递增操作采用了标准形式——读取旧值,根据它计算出新值(加1),并使用CAS来设置这个新值。如果CAS失败,那么该操作就立即重试。

通常,反复地重试是一种合理的策略,但在一些竞争很激烈的情况下,更好的方式是在重试之前首先等待一段时间或回退,从而避免造成活锁问题。

@ThreadSafe
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的计数器更没有效率。

虽然java中锁定语法比较简洁,但JVM和操作在管理锁时需要完成的工作却并简单。
在实现锁定时需要遍历JVM中一条非常复杂的代码路径,并可能导致操作系统级别的锁定、线程挂起以及上下文切换等操作。

CAS的缺点主要在于:它将使调用者处于竞争问题(通过重试、回退、放弃),而在锁中能自动处理竞争问题(线程在获取锁之前将一直阻塞)。CAS最大的缺陷在于难以围绕着CAS正确地构建外部算法。

在大多数处理器上,在无竞争的锁获取和释放的“快速代码路径”上的开销,大约是CAS开销的两倍。

15.2.3 JVM对CAS的支持

在java5.0之前,如果不编写明确的代码,那么就无法执行CAS。

在java5.0之后引入了底层的支持,在int、long和对象的引用等类型上都公开了CAS操作,并且JVM把他们编译为底层硬件提供的最有效方法。

在最坏的情况下,如果不支持CAS指令,那么JVM将使用自旋锁。

15.3 原子变量类

原子变量比锁的粒度更细,量级更轻,并且对于在多处理器系统上实现高性能的并发代码来说是非常关键的。
原子变量将发生竞争的范围缩小到单个变量上,这是你获得的粒度最小的情况。
更新原子变量的快速(非竞争)路径比获取锁的快速路径块,而慢速路径也一样,因为它不需要挂起或重新调度线程。
在使用基于原子变量而非锁的算法中,线程在执行时更不易出现延迟,并且如果遇到竞争,也更容易恢复过来。

原子变量相当于一种泛化的volatile变量,能够支持原子的和有条件的读-该-写操作。

共有12个原子变量类,可分为4组:标量类(scalars),更新器类(field updaters),数组类以及复合变量类(compound variables)。
其中最常用的原子变量就是标量类:AtomicInteger,AtomicLong,AtomicBoolean以及AtomicReference。所有类都支持CAS,此外,AtomicInteger和AtomicLong还支持算法运算。

原子数组类(只支持Integer,Long和Reference)中的元素可以实现原子更新。原子数组类为数组的元素提供了volatile类型的访问语义。volatile类型的数组仅在数组引用上具有volatile语义

基本变量类是不可修改的,而原子变量时可修改的。在原子变量类中同样没有重新定义hashCode或equals方法,每个实例都是不同的。

15.3.1 原子变量是一种“更好的volatile”

在3.4.2节中,我们使用了一个指向不可变对象volatile引用来原子地更新多个状态变量,这个示例依赖于“先检查再运行”。
在多数情况下,“先检查再运行”可能破坏数据的一致性。

可以将OneValueCache(3.4)中的技术与原子引用结合起来,并且通过对指向不可变对象(其中保存了上界和下界)的引用进行原子更新以避免竞态条件。
15-3的使用了AtomicReference和IntPair来保存状态,并通过使用compareAndSet,使它在更新上界或下界时能避免NumberRange的竞态条件。

//   15-3   通过CAS来维持包含多个变量的不变性条件
@ThreadSafe
public class CasNumberRange {
    private static class IntPair{
        final int lower;   //不变性条件,lower<upper
        final int upper;

        public IntPair(int lower,int upper){
            this.lower=lower;
            this.upper=upper;
        }
    }

    private final AtomicReference<IntPair> values=
            new AtomicReference<IntPair>(new IntPair(0, 0));

    public int getLower(){
        return values.get().lower; //get得到引用,IntPair类型
    }

    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't set lower to " + i + " > upper");
            IntPair newv=new IntPair(i, oldv.upper);
            if(values.compareAndSet(oldv, newv))
                return;
        }
    }
    public void setUpper(int i) {
        while (true) {
            IntPair oldv = values.get();
            if (i < oldv.lower)
                throw new IllegalArgumentException("Can't set upper to " + i + " < lower");
            IntPair newv = new IntPair(oldv.lower, i);
            if (values.compareAndSet(oldv, newv))
                return;
        }
    } 
}
15.3.2 锁与原子变量性能比较

在实际情况中,原子变量在可伸缩性上要高于锁,因此在应对常见的竞争程度时,原子变量的效率会更高。

在中低程度的竞争下,原子变量能提供更高的可伸缩性,而在高强度的竞争下,锁能更有效地避免竞争。

如果能避免使用共享状态,那么开销将更小。
我们可以通过提高处理竞争的效率来提高可伸缩性,但只有完全消除竞争,才能实现真正的可伸缩性。

15.4 非阻塞算法

在基于锁的算法中可能会发生各种活跃性故障。

如果在某种算法中,一个线程的失败或挂起不会导致其他线程也失败或挂起,这种算法被称为非阻塞算法。
如果在算法的每个步骤中都存在某个线程能执行下去,那么这种算法也被称为无锁算法(Lock-Free)算法。
如果在算法中仅将CAS用于协调线程之间的操作,并且能正确正确地实现,那么它既是一种无阻塞算法,又是一种无锁算法。

在非阻塞算法中通常不会出现死锁和优先级反转问题(但可能出现饥饿和活锁问题,因为在算法中会反复尝试)

15.4.1 非阻塞的栈

在实现相同功能的前提下,非阻塞算法通常比基于锁的算法更为复杂。

创建非阻塞算法的关键在于,找出如何将原子修改的范围缩小到单个变量上,同时还要维护数据的一致性。

栈是最简单的链式数据结构,每个元素仅指向一个元素,并且每个元素也只被一个元素引用。15-6给出了如果通过原子引用来构建栈的示例。
栈是由Node元素构成的一个链表,其中栈顶作为根节点,并且在每个元素中都包含了一个值以及指向下一个元素的链接。push方法创建一个新的节点,该节点的next域指向当前的栈顶,然后使用CAS把这个新节点放入栈顶。如果在开始插入节点时,位于栈顶的节点没有发生变化,那么CAS就会成功。如果栈顶节点发生了变化(例如由于其他线程在本线程开始之前插入或移除了元素),那么CAS将会失败,而push方法会根据栈的当前状态来更新节点,并且再次尝试。无论哪种情况,在CAS执行完成后,后栈仍会处于一致的状态。

//   15-6  使用Treiber算法构造de非阻塞栈
@ThreadSafe
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类型的变量有着相同的内存效果。

因此,一个线程的任何修改结构都可以安全地发布给其他正在查看状态的线程。并且,这个栈时通过CAS来修改的,因此将采用原子操作来更新top的引用,或者在发现存在其他线程干扰的情况下,修改操作将失败。

15.4.2 非阻塞的链表

链接队列比栈复杂,因为它必须支持对头结点和尾结点的快速访问。因此,它需要单独维护的头指针和尾指针。
有两个指针指向位于尾部的节点:当前最后一个元素的next指针,以及尾结点。当成功地插入一个新元素时,这两个指针都需要使用原子操作来更新。

在更新这两个指针时需要不同的CAS操作,并且如果第一个CAS成功,但第二个失败,那么队列将处于不一致的状态。而且,即使两个CAS都成功了,在执行两者之间仍有可能有另一个线程会访问这个队列。

我们需要一些技巧。
第一个技巧是,即使在一个包含多个步骤的更新操作中,也要确保数据结构总是处于一致的状态。这样,当线程B到达时,如果发现线程A正在执行更新,B不能立即开始执行自己的更新操作,等待(通过反复检查队列的状态)并直到A完成更新,从而使两个线程不会互相干扰。

这有可能有一个线程更新操作失败了,其他的线程都无法访问队列。要使该算法成为一个非阻塞的方法,必须确保一个线程失败时不会妨碍其他线程继续执行下去。
因此,第二个技巧是,如果当B到达时发现A正在修改数据结构,那么在数据结构中应该有足够多的信息,使得B能完成A的更新操作。如果B”帮助”A完成了更新操作,那么B可以执行自己的操作,而不用等待A的操作完成。当A恢复后在试图完成其操作时,发现B已经替它完成了。

15-7给出了非阻塞链接队列算法中的插入部分。在许多队列算法中,空队列通常包含一个“哨兵(Sentinel)”节点或“哑(Dummy)节点”,并且头节点和尾节点在初始化时都指向该哨兵节点。尾节点通常要么指向哨兵节点(如果队列为空),即队列的最后一个元素,要么(当有操作正在执行更新时)指向倒数第二个元素。
下图给出一个处于正常(稳定)状态的包含两个元素的队列
在这里插入图片描述

//   15-7  非阻塞算法中的插入算法
@ThreadSafe
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是否非空来判断是否需要清理队列。如果是,它首先会推进尾节点(可能需要执行多次),直到队列处于稳定状态。

15.4.3 原子的域更新器

ConcurrentLinkedQueue没有使用原子引用来表示每个Node,而是使用普通的volatile类型引用,并通过基于反射的AtomicReferenceFieldUpdater来进行更新,如15-8

//       在ConcurrentLinkedQueue中使用原子的域更新器
private class Node<E>{
   private final E item;
   private volatile Node<E> next;
   public Node(E item){
      this.item=item;
   }
}
//在更新器类中没有构造函数,要创建一个更新器对象,可以调用newUpdater方法
private static AtomicReferenceFieldUpdater<Node,Node> nextUpdater=
	AtomicReferenceFieldUpdater.newUpdater(Node.class,Node.class,"next");

原子的域更新器类AtomicReferenceFieldUpdater表示现有volatile域的一种基于反射的“视图”,从而能够在已有的volatile域上使用CAS。在更新器类中没有构造函数,要创建一个更新器对象,可以调用newUpdater方法,并制定类与域的名字。域更新器类没有与某个特定的实例关联在一起,因而可以更新目标类的任意实例中的域。更新器类提供的原子性保证比普通原子类更弱一些,因为无法保证底层的域不被直接修改——CAS以及其他算法方法只能确保其他使用原子域更新器方法的线程的原子性。

然而,几乎所有情况下,普通原子变量的性能都很不错,只有在很少的情况下才需要使用原子的域更新器。(如果在执行原子更新的同时还需要维持现有类的串行化形式,那么原子的域更新器将非常有用)。

15.4.4 ABA问题

15.4.4 ABA问题
ABA问题:如果在算法中的节点可以被循环使用,那么在使用”比较并交换”指令时就可能出现这种问题(主要在没有垃圾回收机制的环境中)。在CAS操作中将判断“V的值是否仍为A”,并且如果是的话就继续执行更新操作。有时候还需直到“自从上次看到V的值为A以来,这个值是否发生了变化”。在某些算法中,如果V的值首先由A变成B,再由B变成A,那么仍然被认为是发生了变化,并需要重新执行算法中的某些步骤。

如果在算法中采用自己的方式来管理节点对象的内存,那么可能出现ABA问题。在这种情况下,即使链表的头节点仍然指向之前观察到的节点,那么也不足与说明链表的内容没有发生变化。
如果通过垃圾回收器来管理链表节点仍然无法避免ABA问题,那么还有一个相对简单的方案:不是更新某个引用的值,而是更新两个值,包括一个引用和一个版本号。即使这个值由A变B再变A,版本号也是不同的。AtomicStampedReference(以及AtomicMarkableReference)支持这两个变量上执行原子的条件更新。
AtomicStampedReference将更新一个”对象-引用”二元组,通过在引用上加上“版本号”,避免ABA问题。
AtomicMarkableReference将更新一个“对象引用-布尔值”二元组。

小结

非阻塞算法通过底层的并发原语(例如比较交换而不是锁)来维持线程安全性。这些底层的原语通过原子变量类向外公开,这种类也用做一种”更好的volatile变量”,从而为整数和对象引用提供原子的更新操作。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值