Java并发编程实战(学习笔记 十四 第十五章 原子变量与非阻塞同步机制)

来源:互联网 发布:淘宝食品店铺名字 编辑:程序博客网 时间:2024/06/06 20:24

15.1 锁的劣势

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

如果有多个线程同时请求锁,那么一些线程将被挂起并在在稍后恢复运行(不一定挂起,根据之前获取操作中对锁持有时间长短来判断是挂起还是自旋等待)。当线程恢复执行时,必须等待其他线程执行完它们的时间片以后才能被调度执行。而在挂起和恢复过程中存在很大的开销,并且通常存在者较长时间的中断。

与锁相比,volatile变量是一种更轻量级的同步机制,因为在使用这些变量时不会发生上下文切换或线程调度等操作。
然而volatile同样存在一些局限:虽然提供了相似的可见性保证,却不能用于构建原子的复合操作。因此当一个变量依赖其他的变量时,或者当变量的新值依赖与旧值时,就不能使用volatile变量,因此它无法用来实现计数器或互斥体(mutex)。

锁还存在其他一些缺点:当一个线程正在等待锁时,它不能做其他任何事情。
如果一个线程在持有锁的情况下被延迟执行(例如发生了缺页错误,调度延迟,或者其他类似情况),那么所有需要这个锁的线程都无法执行下去。

如果被阻塞线程的优先级较高,而持有锁的线程优先级较低,那么这是严重的,被称为优先级反转(Priority Inversion).
即使高优先级的线程可以抢先执行,但仍需要等待锁被释放,从而导致它的优先级会降至低优先级的级别。如果持有锁的线程被永久阻塞,所有等待这个锁的线程就永远无法执行下去。

锁定(locking)方式对于细粒度的操作(例如递增计数器)来说时一种高开销的机制。


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操作@ThreadSafepublic 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失败,那么该操作就立即重试。
通常,反复地重试是一种合理的策略,但在一些竞争很激烈的情况下,更好的方式是在重试之前首先等待一段时间或回退,从而避免造成活锁问题。

@ThreadSafepublic 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的计数器在性能上远远超过了基于锁的计数器,而在没有竞争时更高。


15.3 原子变量类

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

原子变量相当于一种泛化的volatile变量,能够支持原子的和有条件的读-该-写操作。
AtomicInteger表示一个int类型的值,并提供了get和set方法,这些Volatile类型的int变量在读取和写入上有着相同的内存语义。
它还提供了一个原子的compareAndSet方法(如果该方法成功执行,那么将实现与读取、写入一个volatile变量相同的内存效果),以及原子的添加,递增递减等方法。

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

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

基本变量类是不可修改的,而原子变量时可修改的。

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

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

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

//   15-3   通过CAS来维持包含多个变量的不变性条件@ThreadSafepublic 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 性能比较:锁与原子变量

为了说明锁与原子变量之间的可伸缩性差异,我们将比较伪随机数字生成器(PRNG,pseudorandom number generator)的几种不同实现。

在PRNG中,在生成下个随机数字时需要用到上一个数组,所以在PRNG中必须记录前一个数值并将其作为状态的一部分。

15-4和15-5给出了线程安全的PRNG的两种实现,一种基于ReentrantLock,一种基于AtomicInteger。
测试程序将反复调用它们,在每次迭代中将生成一个随机数字(在此过程中将读取并修改共享的seed状态),并执行一些仅在线程本地数据上执行的“繁忙”迭代。

public class PseudoRandom {    int calculateNext(int prev) {        prev ^= prev << 6;        prev ^= prev >>> 21;        prev ^= (prev << 7);        return prev;    }}
//    15-4  基于ReentrantLock实现的随机数生成器@ThreadSafepublic class ReentrantLockPseudoRandom extends PseudoRandom {    private final Lock lock = new ReentrantLock(false);    private int seed;    ReentrantLockPseudoRandom(int seed) {        this.seed = seed;    }    public int nextInt(int n) {        lock.lock();        try {            int s = seed;            seed = calculateNext(s);            int remainder = s % n;            return remainder > 0 ? remainder : remainder + n;        } finally {            lock.unlock();        }    }}
//   15-5  基于AtomicInteger实现的RseudoRandom@ThreadSafepublic class AtomicPseudoRandom extends PseudoRandom{    private AtomicInteger seed;    AtomicPseudoRandom(int seed) {        this.seed=new AtomicInteger(seed);    }    public int nextInt(int n){        while(true){            int s=seed.get();            int nextSeed=calculateNext(s);            if(seed.compareAndSet(s, nextSeed)){                int remainder=s%n;                return remainder>0?remainder:remainder+1;            }                       }    }}

下面给出在每次迭代中工作量较低以及适中情况下的吞吐量。
如果线程本地的计算量较少,那么在锁和原子变量上的竞争将非常激烈;如果线程本地的计算量较多,那么在锁和原子变量上的竞争会降低,因为在线程中访问锁和原子变量的频率将减低。
这里写图片描述
这里写图片描述

可以看出,在高度竞争的情况下,锁的性能将超过原子变量的性能,但在更真实的情况下,原子变量的性能将超过锁的性能。
这是因为锁在发生竞争时会挂起线程,从而降低了CPU的使用率和共享内存总线上的同步信号量。(类似于生产者-消费者模式中的可阻塞生产者,它能降低消费者上的工作负载,使消费者的处理速度赶上生产者的处理速度)。
另一方面,使用原子变量,那么发出调用的类负责对竞争进行管理,与大多数基于CAS的算法一样,AtomicPseudoRandom遇到竞争时将立即重试,在激烈竞争环境会导致了更多的竞争。

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

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

图中还包含第三条曲线,它时一个使用ThreadLocal来保存PRNG状态的PseudoRandom,这种实现方法改变了类的行为,即每个线程都只能看到自己私有的伪随机数字序列,而不是所有线程共享一个随机数序列。
这说明了,如果能避免使用共享状态,那么开销将更小。
我们可以通过提高处理竞争的效率来提高可伸缩性,但只有完全消除竞争,才能实现真正的可伸缩性。

15.4 非阻塞算法(Nonblocking Algorithms)

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

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

15.4.1 非阻塞的栈

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

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

//   15-6  使用Treiber算法构造de非阻塞栈@ThreadSafepublic 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的引用,或者在发现存在其他线程干扰的情况下,修改操作将失败。

15.4.2 非阻塞的链表

CAS的基本使用模式:在更新某个值时存在不确定性时,以及在更新失败时重新尝试。

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

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

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

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

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

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

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

小结

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

阅读全文
1 0
原创粉丝点击