[Java Concurrency in Practice]第十五章 原子变量与非阻塞同步机制

原子变量与非阻塞同步机制

Java.util.concurrent包中的许多类,如Semaphore 和 ConcurrentLinkedQueue,都提供了比使用synchronized更好的性能和可伸缩性,这些性能提升的原始来源是:原子变量和非阻塞的同步机制。

非阻塞算法用底层的原子机器指令(例如比较并交换指令)代替锁来确保数据在并发访问中的一致性。非阻塞算法被广泛地用于在操作系统和JVM中实现线程 / 进程调度机制、垃圾回收机制以及锁和其他并发数据结构。

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

从Java5.0开始,可以使用原子变量类(例如AutomicInteger和AutomicReference)来构建高效地非阻塞算法

即使原子变量没有用于非阻塞算法的开发,它们也可以用做一种“更好的volatile类型变量”。原子变量提供了与volatile类型变量相同的内存语义,此外还支持原子的更新操作,从而使它们更加适用于实现计数器、序列发生器和统计数据收集等,同时还能比基于锁的方法提供更高的可伸缩性。

15.1 锁的劣势

现代的许多JVM都对非竞争锁获取和锁释放等操作进行了极大地优化,但如果有多个线程同时请求锁,那么JVM就需要借助操作系统的功能。如果出现了这种情况,那么一些线程将被挂起并且在稍后恢复运行。当线程恢复执行时,必须等待其他线程执行完它们的时间片以后,才能被调度执行。在挂起和恢复线程等过程中存在着很大的开销,并且通常存在着较长时间的中断。如果在基于锁的类中包含有细粒度的操作(例如同步容器类,在其大多数方法中只包含了少量操作),那么当在锁上存在激烈的竞争时,调度开销与工作开销的比值会非常高。

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

锁定还存在其他一些缺点。当一个线程正在等待锁时,它不能做任何其他事情。如果一个线程在持有锁的情况下延迟执行(例如发生了缺页错误、调度延迟,或者其他类似情况),那么所有需要这个锁的线程都无法执行下去。如果被阻塞线程的优先级较高,而持有锁的线程优先级较低,那么这将是一个严重的问题——也被称为优先级反转。即使高优先级的线程可以抢先执行,但仍然需要等待锁被释放,从而导致它的优先级会降至低优先级线程的级别。如果持有锁的线程被永久地阻塞(例如由于出现了无限循环,死锁,活锁或者其他的活跃性故障),所有等待这个锁的线程就永远无法执行下去。

锁定方式对于细粒度的操作(例如递增计数器)来说仍然是一种高开销的机制。在管理线程之间的竞争时应该有一种粒度更细的技术,类似于volatile变量的机制,同时还要支持原子的更新操作。幸运的是,在现代处理器中提供了这种机制。

15.2 硬件对并发的支持

加锁对于细粒度的操作而言,仍然是重量级的,因为它们还是会引起少量的不必要的线程上下文切换与短暂的延时。

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

对于细粒度的操作(即只有很少几条指令,如i++),有另外一种选择通常更加有效——乐观的解决方法。通过这种方法可以在不发生干扰的情况下完成更新操作。这种方法需要借助冲突检查机制来判断在更新过程中是否存在来自其他线程的干扰,如果存在,这个操作将失败,并且可以重试(也可以不重试)。

在针对多处理器操作而设计的处理器中提供了一些特殊指令,用于管理对共享数据的并发访问。在早期的处理器中支持原子的测试并设置(Test-and-Set),获取并递增(Fetch-and-Increment)以及交换(Swap)等指令,这些指令足以实现各种互斥体,而这些互斥体又可以实现一些更复杂的并发对象。现在,几乎所有的现代处理器中都包含了某种形式的原子读-改-写指令,例如比较并交换(Compare-and-Swap)或者关联加载 / 条件存储(Load-Linked/Store-Conditional)。操作系统和JVM使用这些指令来实现锁和并发地数据结构,但在Java 5.0之前,在Java类中还不能直接使用这些指令。

15.2.1 比较并交换

在大多数处理器架构(包括IA32和Sparc)中采用的方法是实现一个比较并交换(CAS)指令。(在其他处理器中,例如PowerPC,采用一对指令来实现相同的功能:关联加载与条件存储。)CAS包含了3个操作数——需要读写的内存位置V、进行比较的值A和拟写入的新值B。当且仅当V的值等于A时,CAS才会通过原子方式用新值B来更新V的值,否则不会执行任何操作。无论位置V的值是否等于A,都将返回V原有的值。(这种变化形式被称为比较并设置,无论操作是否成功都会返回。)CAS的含义是:“我认为V的值应该为A,如果是,那么将V的值更新为B,否则不修改并告诉V的值实际为多少”。CAS是一项乐观的技术,它希望能成功地执行更新操作,并且如果有另一个线程在最近一次检查后更新了该变量,那么CAS能检测到这个错误。

下面程序说明了CAS语义(而不是实现或性能):

@ThreadSafe
public class SimulatedCAS {//模拟CAS操作
    @GuardedBy("this") private int value;

    public synchronized int get() { return value; }

    public synchronized int compareAndSwap(int expectedValue,
                                           int newValue) {//相当于处理器的CAS原子化操作
        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的典型使用模式是:首先从V中读取值A,并根据A计算新值B,然后再通过CAS以原子方式将V中的值由A变成B(只要在这期间没有任何线程将V的值修改为其他值)。由于CAS能检测到来自其他线程的干扰,因此即使不使用锁也能够实现原子的读-改-写操作序列。

15.2.2 非阻塞的计数器

CasCounter利用CAS实现了线程安全的计数器功能(实际中我们使用AtomicInteger就可以了)。递增操作采用了标准形式——读取旧的值,根据它计算出新值(加1),并使用CAS来设置这个新值。如果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;
    }
}

虽然Java语言的锁定语法比较简洁,但JVM和操作在管理锁时需要完成的工作却并不简单。在实际锁定时需要遍历JVM中一条非常复杂的代码路径,并可能导致操作系统级的锁定、线程挂起以及上下文切换等操作。在最好的情况下,在锁定时至少需要一次CAS,因此虽然在使用锁时没有用到CAS,但实际上也无法节约任何执行开销。另一个方面,在程序内部执行CAS时不需要执行JVM代码、系统调用或线程调度操作。在应用级上看起来越长的代码路径,如果加上JVM和操作系统中的代码调用,那么事实上却变得更短。CAS的主要缺点是,它将使调用者处理竞争问题(通过重试、回退、放弃),而在锁中能自动处理竞争问题(线程在获得锁之前将一直阻塞)。

CAS的性能会随着处理器数量的不同而变化很大。在单CPU系统中,CAS通常只需要很少的时钟周期,因为不需要处理器之间的同步。非竞争的CAS在多CPU系统中至少需要10至150个时钟周期的开销。CAS的执行性能不仅在不同体系架构之间变化很大,甚至在相同处理器的不同版本之间也会发生改变。一个很管用的经验法则是:在大多数处理器上,在无竞争的锁获取和锁释放的“快速代码路径”上的开销,大约是CAS开销的两倍。

15.2.3 JVM对CAS的支持

Java代码如何确保处理器执行CAS操作?在Java5.0之前,如果不编写明确的代码,那么就无法执行CAS。在Java5.0中引入了底层的支持,在int、long和对象的引用等类型上都公开了CAS操作,并且JVM把它们编译为底层硬件提供的最有效方法。在支持CAS的平台上,运行时把它们编译为相应的(多条)机器指令。在最坏的情况下,如果不支持CAS指令,那么JVM将使用自旋锁。在原子变量类(例如java.util.concurrent.atomic中的AutomicXxx)中使用了这些底层的JVM支持为数字类型和引用类型提供了一种高效的CAS操作,而在java.util.concurrent中的大多数类在实现时则直接或间接地使用了这些原子变量类。

15.3 原子变量类

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

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

共有12个原子变量类,可分为4组:标量类(Scalar)、更新器类、数组类以及复合变量类。最常用的原子变量就是标量类:AtomicInteger、AutomicLong、AutomicBoolean以及AtomicReference。所有这些都支持CAS,此外,AtomicInteger和AtomicLong还支持算术运算。(要想模拟其他基本类型的原子变量,可以将short或byte等类型与int类型进行转换,以及使用floatToIntBits或doubleToLongBits来转换浮点数。)

原子数组类(AtomicIntegerArray、AtomicLongArray、AtomicReferenceArray<E>)(只支持Integer、Long和Reference版本)中的元素可以实现原子更新。原子数组类为数组的元素提供了volatile类型的访问语义,这是普通数组所不具备的特性——volatile类型的数组仅在数组引用上具有volatile语义,而在其元素则没有。)

尽管原子的标量类扩展了Number类,但并没有扩展一些基本类型的包装类,例如Integer或Long。事实上,它们也不能进行扩展:基本类型的包装类是不可修改的,而原子变量类是可修改的。在原子变量中同样没有重新定义hashCode或equals方法,每个实例都是不同的。与其他可变对象相同,它们也不宜用做基于散列的容器中的键值。

另外,仅为那些通常在应用程序中使用的类型才提供这样的原子类。例如,没有表示 byte 的原子类。这种情况不常见,如果要这样做,可以使用 AtomicInteger 来保持 byte 值,并进行适当的强制转换。也可以使用 Float.floatToIntBits 和 Float.intBitstoFloat 转换来保持 float 值,使用 Double.doubleToLongBits 和 Double.longBitsToDouble 转换来保持 double 值。

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

通过对指向不可变对象(其中保存了下界和上界)的引用进行原子更新以避免竞态条件。下面程序的CasNumberRange中使用了AtomicReference和IntPair来保存状态,并通过使用compare-AndSet,使它在更新上界和下界时能避免NumberRange的竞态条件。

使用CAS避免多元的不变约束:

public class CasNumberRange {
     private static class IntPair {
        // 约束条件: lower <= upper
        final int lower;
        final int upper;

        public IntPair(int lower, int upper) {
            this.lower = lower;
            this.upper = upper;
        }
    }
    //使用AtomicReference对引用进行原子化操作
    private final AtomicReference<IntPair> values =
            new AtomicReference<IntPair>(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'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)的几种不同实现。在PRNG中,在生成下一个随机数字时需要用到上一个数字,所以在PRNG中必须记录前一个数值并将其作为状态的一部分。

@ThreadSafe
public 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();
        }
    }
}
@ThreadSafe
public 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 + n;
            }
        }
    }
}

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

从这些图中可以看出,在高度竞争的情况下,锁的性能将超过原子变量的性能,但在更真实的情况下,原子变量的性能将超过锁的性能。这是因为锁在发生竞争时会挂起线程,从而降低了CPU的使用率和共享内存总线上的同步通信量。另一方面,如果使用原子变量,那么发出调用的类负责对竞争进行管理。与大多数基于CAS的算法一样,AtomicPseudoRandom在遇到竞争时将立即重试,这通常是一种正确的方法,但在激烈地竞争环境下却导致了更多的竞争。

图15-1中的竞争级别过高而有些不切实际:任何一个真实的程序都不会除了竞争锁或原子变量,其他任何工作都不做。在实际情况下,原子变量在可伸缩性上要高于锁,因为在应对常见的竞争程度时,原子变量的效率会更高。

锁与原子变量在不同竞争程度上的性能差异很好地说明了各自的优势和劣势。在中低程度的竞争下,原子变量能提供更好的可伸缩性,而在高强度的竞争下,锁能够更有效地避免竞争。(在单CPU的系统上,基于CAS的算法在性能上同样会超过基于锁的算法,因为CAS在单CPU的系统上通常能成功,只有在偶然情况下,线程才会在执行读-改-写的操作过程中被其他线程抢占执行。)

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

15.4 非阻塞算法

在基于锁的算法中可能会发生各种活跃性故障。如果线程在持有锁时由于阻塞I/O,内存页缺失或其他延迟而导致推迟执行,那么很可能所有线程都不能继续执行下去。

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

无竞争的CAS通常都能执行成功,并且如果有多个线程竞争同一个CAS,那么总会有一个线程在竞争中胜出并执行下去。在非阻塞算法中通常不会出现死锁和优先级反转问题(但可能会出现饥饿和活锁问题,因为在算法中会反复地重试)。在许都常见的数据结构中都可以使用非阻塞算法,包括栈、队列、优先队列以及散列表等,而要设计一些新的这种数据结构,最好还是由专家们来完成。

15.4.1 非阻塞的栈

在实现相同功能的前提下,非阻塞算法通常比基于锁的算法更为复杂。创建非阻塞算法的关键在于,找出如何将原子修改的范围缩小到单个变量上,同时还要维护数据的一致性。在链式容器中(例如队列),有时候无须将状态转换操作表示为对节点链接的修改,也无须使用AtomicReference来表示每个必须采用原子操作来更新的链接。

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

public class ConcurrentStack<E> {//非阻塞栈
       //栈顶元素,永远指向栈顶,入栈与出栈都只能从栈顶开始
       AtomicReference<Node<E>> top = new AtomicReference<Node<E>>();

       //非阻塞的入栈操作
       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));
       }

       //非阻塞的出栈操作
       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));
              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中,当构成表示新元素的Node时,我们希望当把这个新节点压入栈时,其next引用的值仍然是正确的,同时也准备好在发生竞争的情况下重新尝试。

在像ConcurrentStack这样的非阻塞算法中能确保线程安全性,因为compareAndSet像锁定机制一样,既能提供原子性,又能提供可见性。当一个线程需要改变栈的状态时,将调用compareAndSet,这个方法与写入volatile类型的变量有着相同的内存效果。当线程检查栈的状态时,将在同一个AtomicReference上调用get方法,这个方法与读取volatile类型的变量有着相同的内存效果。因此,一个线程执行的任何修改结构都可以安全地发布给其他正在查看状态的线程。并且,这个栈是通过compareAndSet来修改的,因此将采用原子操作来更新top的引用,否则在发现存在其他线程干扰的情况下,修改操作将失败。

15.4.2 非阻塞的链表

到目前为止,已经看到了两个非阻塞算法,计数器和栈,它们很好地说明了CAS的基本使用模式:在更新某个值时存在不确定性,以及在更新失败时重新尝试。构建非阻塞算法的技巧在于:将执行原子修改的范围缩小到单个变量上。这在计数器中很容易实现,在栈中也很简单,但对于一些更复杂的数据结构,例如队列、散列表或树,则要复杂地多。

一个链表队列比栈更加复杂,因为它需要支持首尾(从尾插入,从首取出)的快速访问,为了实现,它会维护独立的队首指针和队尾指针。

有两个指针指向位于尾部的节点:当前最后一个元素的next指针,以及尾节点。当成功地插入一个新元素时,这两个指针都需要采用原子操作来更新。初看起来,这个操作无法通过原子变量来实现。在更新这两个指针时需要不同的CAS操作,并且如果第一个CAS成功,但第二个CAS失败,那么队列将处于不一致的状态。而且,即使这两个CAS都成功了,那么在执行这两个CAS之间,仍可能有另一个线程会访问这个队列。因此,在为链表队列构建非阻塞算法时,需要考虑到这两种情况。

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

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

下面给出了非阻塞链表队列算法中的插入部分:

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<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();
                     /*
                      * 当前队尾元素的next域,一般为null,但有可能不为null,
                      * 因为有可能其他线程已经上一语句与下一语句间添加了新
                      * 的元素,即此时队列处于中间状态
                      */
                     Node<E> tailNext = curTail.next.get();
                     /*
                      * 再一次检查上面两行语句的操作还是否有效,因为很有可在此刻尾指针已经
                      * 向后移动了(比如其他线程已经执行了B 或 D 处语句),所以下面的操作都
                      * 是要基于尾节点是curTail才可以。(想了一下,其实这里不需要这个判断
                      * 也是可以的,因为下面执行到 B 或 C 时自然会失败,这样做只是为了提高
                      * 成功的效率)
                      */
                     if (curTail == tail.get()) {

                            if (tailNext != null) {// A
                                   /*
                                    *  队列处于中间状态,尝试调整队尾指针,这里
                                    *  需要使用compareAndSet原子操作来进行,因为
                                    *  有可以在进行时 D 处已经调整完成
                                    */
                                   tail.compareAndSet(curTail, tailNext);// B
                            } else {
                                   // 队列处于稳定状态,尝试在队尾插入新的节点
                                   if (curTail.next.compareAndSet(null, newNode)) {// C
                                          /*
                                           *  插入尝试成功,再开始尝试调整队尾指针,这里完全
                                           *  有可能不需要再调整了,因为上面  B 行已经帮这里调
                                           *  整过了
                                           */
                                          tail.compareAndSet(curTail, newNode);// D
                                          return true;
                                   }
                            }
                     }
              }
       }
}

在CurrentLinkedQueue中使用的正是该算法。在许多队列算法中,空队列通常都包含一个“哨兵节点”或者“哑节点”,并且头节点和尾节点在初始化时都指向该哨兵节点。尾节点通常要么指向哨兵节点(如果队列为空),即队列的最后一个元素,要么(当有操作正在执行更新时)指向倒数第二个元素。下图给出了一个处于正常状态(或者说稳定状态)的包含两个元素的队列:


具有两个元素的稳定队列

但插入一个新的元素时,需要更新两个指针,首先更新当前最后一个元素的next指针,将新节点链接到列表队尾,然后更新尾节点,将其指向这个新元素。在这两个操作之间,队列处于一种中间状态,如下如所示:


插入期间,队列处于中间状态

在第二次更新完成后,队列将再次处于稳定状态,如下图所示:


插入完成后,队列再一次回到稳定状态

队尾指针tail可能指向哨兵节点(如果队列为空)、也可能指向最后一个节点(队列处于非空且稳定状态时)、也可能指向倒数第二个元素(队列处于插入时中间状态)。

实现这个两个技巧的关键点在于:对队列处于稳定状态时,尾节点的next域将为空,如果队列处于中间状态,那么tail.next将为非空。因此,任何线程都能够通过检查tail.next来获取队列当前的状态。而且,当队列处于中间状态时,可以通过将尾节点向前移动一个节点,从而结束其他线程正在执行的插入元素操作,并使得队列恢复为稳定状态。

LinkedQueue.put方法在插入新元素之前,将首先检查队列是否处于中间状态(步骤A),如果是,那么有另一个线程正在插入元素(在步骤C和D之间)。此时线程不会等待其他线程执行完成,而是帮助它完成操作,并将尾节点向前推进一个节点(步骤B)。然后,它将重复执行这种检查,以免另一个线程已经开始插入新元素,并继续推进尾节点,直到它发现队列处于稳定状态后,才会开始执行自己的插入操作。

由于步骤C中的CAS将把新节点链接到队列尾部,因此如果两个线程同时插入元素,那么这个CAS将失败。在这样的情况下,并不会造成破坏:不会发生任何变化,并且当前的线程只需重新读取尾节点并再次重试。如果步骤C成功了,那么插入操作将生效,第二个CAS(步骤D)被认为是一个“清理操作”因为它既可以由执行插入操作的线程来执行,也可以由其他任何线程来执行。如果步骤D失败,那么执行插入操作的线程将返回,而不是重新执行CAS,因为不再需要重试——另一个线程已经在步骤B中完成了这个工作。这种方式能够工作,因为在任何线程尝试将一个新节点插入到队列之前,都会首先通过检查tail.next是否非空来判断是否需要清理队列。如果是,它首先会推进为尾节点(可能需要执行多次),知道队列处于稳定状态。

15.4.3 原子的域更新器

上面LinkedQueue阐释了类库中的ConcurrentLinkedQueue类的算法,但是真正的实现与它略有区别,ConcurrentLinkedQueue并未使用原子化的引用(AtomicReference),而是使用普通的volatile引用来代替下一个节点next字段,并通过基于反射实现的AtomicReferenceFieldUpdater来进行更新next字段的:

private class Node<E> {
    private final E item;
    private volatile Node<E> next;

    public Node(E item) {
        this.item = item;
}

boolean casNext(Node<E> cmp, Node<E> val) {
      return nextUpdater.compareAndSet(this, cmp, val);
}

void setNext(Node<E> val) {
     nextUpdater.set(this, val);
}
}

private static AtomicReferenceFieldUpdater<Node, Node> nextUpdater//用来对next字段进行更新
        = AtomicReferenceFieldUpdater.newUpdater(Node.class, Node.class, "next");

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

在ConcurrentLinkedQueue中,使用nextUpdater的compareAndSet方法来更新Node的next域。这个方法有点繁琐,但完全是为了提升性能。对于一些频繁分配并且生命周期短暂的对象,例如队列的链接节点,如果能去掉每个Node的AtomicReference的创建过程,那么将极大地降低插入操作的开销。然而,几乎在所有情况下,普通原子变量的性能都很不错,只有在很少的情况下才需要使用原子的域更新器。(如果在执行原子操作的同时还需要维护现有类的串行化形式,那么原子的域更新器将非常有用。即当你想要保留现有类的序列化模式时)

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变量”,从而为整数和对象引用提供原子的更新操作。

非阻塞算法在设计和实现时非常困难,但通常能够提供更高的可伸缩性,并能更好地防止活跃性故障的发生。在JVM从一个版本升级到下一个版本的过程中,并发性能的主要提升都来自于(在JVM内部以及平台类库中)对非阻塞算法的使用。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
森林防火应急联动指挥系统是一个集成了北斗定位/GPS、GIS、RS遥感、无线网络通讯、4G网络等技术的现代化智能系统,旨在提高森林火灾的预防和扑救效率。该系统通过实时监控、地图服务、历史数据管理、调度语音等功能,实现了现场指挥调度、语音呼叫通讯、远程监控、现场直播、救火人员生命检测等工作的网络化、智能化、可视化。它能够在火灾发生后迅速组网,确保现场与指挥中心的通信畅通,同时,系统支持快速部署,适应各种极端环境,保障信息的实时传输和历史数据的安全存储。 系统的设计遵循先进性、实用性、标准性、开放性、安全性、可靠性和扩展性原则,确保了技术的领先地位和未来的发展空间。系统架构包括应急终端、无线专网、应用联动应用和服务组件,以及安全审计模块,以确保用户合法性和数据安全性。部署方案灵活,能够根据现场需求快速搭建应急指挥平台,支持高并发视频直播和大容量数据存储。 智能终端设备具备三防等级,能够在恶劣环境下稳定工作,支持北斗+GPS双模定位,提供精确的位置信息。设备搭载的操作系统和处理器能够处理复杂的任务,如高清视频拍摄和数据传输。此外,设备还配备了多种传感器和接口,以适应不同的使用场景。 自适应无线网络是系统的关键组成部分,它基于认知无线电技术,能够根据环境变化动态调整通讯参数,优化通讯效果。网络支持点对点和点对多点的组网模式,具有低功耗、长距离覆盖、强抗干扰能力等特点,易于部署和维护。 系统的售后服务保障包括安装实施服务、系统维护服务、系统完善服务、培训服务等,确保用户能够高效使用系统。提供7*24小时的实时故障响应,以及定期的系统优化和维护,确保系统的稳定运行。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值