`
boriszhang78
  • 浏览: 20478 次
  • 性别: Icon_minigender_1
  • 来自: 南京
社区版块
存档分类
最新评论

JAVA-CAS

    博客分类:
  • JVM
 
阅读更多

非阻塞算法简介

http://www.ibm.com/developerworks/cn/java/j-jtp04186/  

 

在不只一个线程访问一个互斥的变量时,所有线程都必须使用同步,否则就可能会发生一些非常糟糕的事情。Java 语言中主要的同步手段就是synchronized 关键字(也称为内在锁),它强制实行互斥,确保执行 synchronized 块的线程的动作,能够被后来执行受相同锁保护的synchronized 块的其他线程看到。在使用得当的时候,内在锁可以让程序做到线程安全,但是在使用锁定保护短的代码路径,而且线程频繁地争用锁的时候,锁定可能成为相当繁重的操作。

在 “流行的原子” 一文中,我们研究了原子变量,原子变量提供了原子性的读-写-修改操作,可以在不使用锁的情况下安全地更新共享变量。原子变量的内存语义与 volatile 变量类似,但是因为它们也可以被原子性地修改,所以可以把它们用作不使用锁的并发算法的基础。

非阻塞的计数器

清单 1 中的 Counter 是线程安全的,但是使用锁的需求带来的性能成本困扰了一些开发人员。但是锁是必需的,因为虽然增加看起来是单一操作,但实际是三个独立操作的简化:检索值,给值加 1,再写回值。(在 getValue 方法上也需要同步,以保证调用 getValue 的线程看到的是最新的值。虽然许多开发人员勉强地使自己相信忽略锁定需求是可以接受的,但忽略锁定需求并不是好策略。)

在多个线程同时请求同一个锁时,会有一个线程获胜并得到锁,而其他线程被阻塞。JVM 实现阻塞的方式通常是挂起阻塞的线程,过一会儿再重新调度它。由此造成的上下文切换相对于锁保护的少数几条指令来说,会造成相当大的延迟。

清单 1. 使用同步的线程安全的计数器
public final class Counter {
    private long value = 0;
    public synchronized long getValue() {
        return value;
    }
    public synchronized long increment() {
        return ++value;
    }
}

清单 2 中的 NonblockingCounter 显示了一种最简单的非阻塞算法:使用 AtomicInteger 的 compareAndSet() (CAS)方法的计数器。compareAndSet() 方法规定 “将这个变量更新为新值,但是如果从我上次看到这个变量之后其他线程修改了它的值,那么更新就失败”(请参阅 “流行的原子” 获得关于原子变量以及 “比较和设置” 的更多解释。)

清单 2. 使用 CAS 的非阻塞算法
public class NonblockingCounter {
    private AtomicInteger value;
    public int getValue() {
        return value.get();
    }
    public int increment() {
        int v;
        do {
            v = value.get();
        while (!value.compareAndSet(v, v + 1));
        return v + 1;
    }
}

原子变量类之所以被称为原子的,是因为它们提供了对数字和对象引用的细粒度的原子更新,但是在作为非阻塞算法的基本构造块的意义上,它们也是原子的。非阻塞算法作为科研的主题,已经有 20 多年了,但是直到 Java 5.0 出现,在 Java 语言中才成为可能。

现代的处理器提供了特殊的指令,可以自动更新共享数据,而且能够检测到其他线程的干扰,而 compareAndSet() 就用这些代替了锁定。(如果要做的只是递增计数器,那么 AtomicInteger 提供了进行递增的方法,但是这些方法基于 compareAndSet(),例如NonblockingCounter.increment())。

非阻塞版本相对于基于锁的版本有几个性能优势。首先,它用硬件的原生形态代替 JVM 的锁定代码路径,从而在更细的粒度层次上(独立的内存位置)进行同步,失败的线程也可以立即重试,而不会被挂起后重新调度。更细的粒度降低了争用的机会,不用重新调度就能重试的能力也降低了争用的成本。即使有少量失败的 CAS 操作,这种方法仍然会比由于锁争用造成的重新调度快得多。

NonblockingCounter 这个示例可能简单了些,但是它演示了所有非阻塞算法的一个基本特征 —— 有些算法步骤的执行是要冒险的,因为知道如果 CAS 不成功可能不得不重做。非阻塞算法通常叫作乐观算法,因为它们继续操作的假设是不会有干扰。如果发现干扰,就会回退并重试。在计数器的示例中,冒险的步骤是递增 —— 它检索旧值并在旧值上加一,希望在计算更新期间值不会变化。如果它的希望落空,就会再次检索值,并重做递增计算。

 

非阻塞堆栈

非阻塞算法稍微复杂一些的示例是清单 3 中的 ConcurrentStackConcurrentStack 中的 push() 和 pop() 操作在结构上与NonblockingCounter 上相似,只是做的工作有些冒险,希望在 “提交” 工作的时候,底层假设没有失效。push() 方法观察当前最顶的节点,构建一个新节点放在堆栈上,然后,如果最顶端的节点在初始观察之后没有变化,那么就安装新节点。如果 CAS 失败,意味着另一个线程已经修改了堆栈,那么过程就会重新开始。

清单 3. 使用 Treiber 算法的非阻塞堆栈
public class ConcurrentStack<E> {
    AtomicReference<Node<E>> head = new AtomicReference<Node<E>>();
    public void push(E item) {
        Node<E> newHead = new Node<E>(item);
        Node<E> oldHead;
        do {
            oldHead = head.get();
            newHead.next = oldHead;
        } while (!head.compareAndSet(oldHead, newHead));
    }
    public E pop() {
        Node<E> oldHead;
        Node<E> newHead;
        do {
            oldHead = head.get();
            if (oldHead == null) 
                return null;
            newHead = oldHead.next;
        } while (!head.compareAndSet(oldHead,newHead));
        return oldHead.item;
    }
    static class Node<E> {
        final E item;
        Node<E> next;
        public Node(E item) { this.item = item; }
    }
}

性能考虑

在轻度到中度的争用情况下,非阻塞算法的性能会超越阻塞算法,因为 CAS 的多数时间都在第一次尝试时就成功,而发生争用时的开销也不涉及线程挂起和上下文切换,只多了几个循环迭代。没有争用的 CAS 要比没有争用的锁便宜得多(这句话肯定是真的,因为没有争用的锁涉及 CAS 加上额外的处理),而争用的 CAS 比争用的锁获取涉及更短的延迟。

在高度争用的情况下(即有多个线程不断争用一个内存位置的时候),基于锁的算法开始提供比非阻塞算法更好的吞吐率,因为当线程阻塞时,它就会停止争用,耐心地等候轮到自己,从而避免了进一步争用。但是,这么高的争用程度并不常见,因为多数时候,线程会把线程本地的计算与争用共享数据的操作分开,从而给其他线程使用共享数据的机会。(这么高的争用程度也表明需要重新检查算法,朝着更少共享数据的方向努力。)“流行的原子” 中的图在这方面就有点儿让人困惑,因为被测量的程序中发生的争用极其密集,看起来即使对数量很少的线程,锁定也是更好的解决方案。

 

非阻塞的链表

目前为止的示例(计数器和堆栈)都是非常简单的非阻塞算法,一旦掌握了在循环中使用 CAS,就可以容易地模仿它们。对于更复杂的数据结构,非阻塞算法要比这些简单示例复杂得多,因为修改链表、树或哈希表可能涉及对多个指针的更新。CAS 支持对单一指针的原子性条件更新,但是不支持两个以上的指针。所以,要构建一个非阻塞的链表、树或哈希表,需要找到一种方式,可以用 CAS 更新多个指针,同时不会让数据结构处于不一致的状态。

在链表的尾部插入元素,通常涉及对两个指针的更新:“尾” 指针总是指向列表中的最后一个元素,“下一个” 指针从过去的最后一个元素指向新插入的元素。因为需要更新两个指针,所以需要两个 CAS。在独立的 CAS 中更新两个指针带来了两个需要考虑的潜在问题:如果第一个 CAS 成功,而第二个 CAS 失败,会发生什么?如果其他线程在第一个和第二个 CAS 之间企图访问链表,会发生什么?

对于非复杂数据结构,构建非阻塞算法的 “技巧” 是确保数据结构总处于一致的状态(甚至包括在线程开始修改数据结构和它完成修改之间),还要确保其他线程不仅能够判断出第一个线程已经完成了更新还是处在更新的中途,还能够判断出如果第一个线程走向 AWOL,完成更新还需要什么操作。如果线程发现了处在更新中途的数据结构,它就可以 “帮助” 正在执行更新的线程完成更新,然后再进行自己的操作。当第一个线程回来试图完成自己的更新时,会发现不再需要了,返回即可,因为 CAS 会检测到帮助线程的干预(在这种情况下,是建设性的干预)。

这种 “帮助邻居” 的要求,对于让数据结构免受单个线程失败的影响,是必需的。如果线程发现数据结构正处在被其他线程更新的中途,然后就等候其他线程完成更新,那么如果其他线程在操作中途失败,这个线程就可能永远等候下去。即使不出现故障,这种方式也会提供糟糕的性能,因为新到达的线程必须放弃处理器,导致上下文切换,或者等到自己的时间片过期(而这更糟)。

清单 4 的 LinkedQueue 显示了 Michael-Scott 非阻塞队列算法的插入操作,它是由 ConcurrentLinkedQueue 实现的:

清单 4. Michael-Scott 非阻塞队列算法中的插入
public class LinkedQueue <E> {
    private static class Node <E> {
        final E item;
        final AtomicReference<Node<E>> next;
        Node(E item, Node<E> next) {
            this.item = item;
            this.next = new AtomicReference<Node<E>>(next);
        }
    }
    private AtomicReference<Node<E>> head
        = new AtomicReference<Node<E>>(new Node<E>(null, null));
    private AtomicReference<Node<E>> tail = head;
    public boolean put(E item) {
        Node<E> newNode = new Node<E>(item, null);
        while (true) {
            Node<E> curTail = tail.get();
            Node<E> residue = curTail.next.get();
            if (curTail == tail.get()) {
                if (residue == null) /* A */ {
                    if (curTail.next.compareAndSet(null, newNode)) /* C */ {
                        tail.compareAndSet(curTail, newNode) /* D */ ;
                        return true;
                    }
                } else {
                    tail.compareAndSet(curTail, residue) /* B */;
                }
            }
        }
    }
}

像许多队列算法一样,空队列只包含一个假节点。头指针总是指向假节点;尾指针总指向最后一个节点或倒数第二个节点。图 1 演示了正常情况下有两个元素的队列:

图 1. 有两个元素,处在静止状态的队列

有两个元素,处在静止状态的队列

如 清单 4 所示,插入一个元素涉及两个指针更新,这两个更新都是通过 CAS 进行的:从队列当前的最后节点(C)链接到新节点,并把尾指针移动到新的最后一个节点(D)。如果第一步失败,那么队列的状态不变,插入线程会继续重试,直到成功。一旦操作成功,插入被当成生效,其他线程就可以看到修改。还需要把尾指针移动到新节点的位置上,但是这项工作可以看成是 “清理工作”,因为任何处在这种情况下的线程都可以判断出是否需要这种清理,也知道如何进行清理。

队列总是处于两种状态之一:正常状态(或称静止状态,图 1 和 图 3)或中间状态(图 2)。在插入操作之前和第二个 CAS(D)成功之后,队列处在静止状态;在第一个 CAS(C)成功之后,队列处在中间状态。在静止状态时,尾指针指向的链接节点的 next 字段总为 null,而在中间状态时,这个字段为非 null。任何线程通过比较 tail.next 是否为 null,就可以判断出队列的状态,这是让线程可以帮助其他线程 “完成” 操作的关键。

图 2. 处在插入中间状态的队列,在新元素插入之后,尾指针更新之前

处在插入中间状态的队列,在新元素插入之后,尾指针更新之前

插入操作在插入新元素(A)之前,先检查队列是否处在中间状态,如 清单 4 所示。如果是在中间状态,那么肯定有其他线程已经处在元素插入的中途,在步骤(C)和(D)之间。不必等候其他线程完成,当前线程就可以 “帮助” 它完成操作,把尾指针向前移动(B)。如果有必要,它还会继续检查尾指针并向前移动指针,直到队列处于静止状态,这时它就可以开始自己的插入了。

第一个 CAS(C)可能因为两个线程竞争访问队列当前的最后一个元素而失败;在这种情况下,没有发生修改,失去 CAS 的线程会重新装入尾指针并再次尝试。如果第二个 CAS(D)失败,插入线程不需要重试 —— 因为其他线程已经在步骤(B)中替它完成了这个操作!

图 3. 在尾指针更新后,队列重新处在静止状态

在尾指针更新后,队列重新处在静止状态

幕后的非阻塞算法

如果深入 JVM 和操作系统,会发现非阻塞算法无处不在。垃圾收集器使用非阻塞算法加快并发和平行的垃圾搜集;调度器使用非阻塞算法有效地调度线程和进程,实现内在锁。在 Mustang(Java 6.0)中,基于锁的 SynchronousQueue 算法被新的非阻塞版本代替。很少有开发人员会直接使用 SynchronousQueue,但是通过 Executors.newCachedThreadPool() 工厂构建的线程池用它作为工作队列。比较缓存线程池性能的对比测试显示,新的非阻塞同步队列实现提供了几乎是当前实现 3 倍的速度。在 Mustang 的后续版本(代码名称为 Dolphin)中,已经规划了进一步的改进。

 

结束语

非阻塞算法要比基于锁的算法复杂得多。开发非阻塞算法是相当专业的训练,而且要证明算法的正确也极为困难。但是在 Java 版本之间并发性能上的众多改进来自对非阻塞算法的采用,而且随着并发性能变得越来越重要,可以预见在 Java 平台的未来发行版中,会使用更多的非阻塞算法。

 

流行的原子 https://www.ibm.com/developerworks/cn/java/j-jtp11234/

 

十五年前,多处理器系统是高度专用系统,要花费数十万美元(大多数具有两个到四个处理器)。现在,多处理器系统很便宜,而且数量很多,几乎每个主要微处理器都内置了多处理支持,其中许多系统支持数十个或数百个处理器。

要使用多处理器系统的功能,通常需要使用多线程构造应用程序。但是正如任何编写并发应用程序的人可以告诉你的那样,要获得好的硬件利用率,只是简单地在多个线程中分割工作是不够的,还必须确保线程确实大部分时间都在工作,而不是在等待更多的工作,或等待锁定共享数据结构。

问题:线程之间的协调

如果线程之间 需要协调,那么几乎没有任务可以真正地并行。以线程池为例,其中执行的任务通常相互独立。如果线程池利用公共工作队列,则从工作队列中删除元素或向工作队列添加元素的过程必须是线程安全的,并且这意味着要协调对头、尾或节点间链接指针所进行的访问。正是这种协调导致了所有问题。

标准方法:锁定

在 Java 语言中,协调对共享字段的访问的传统方法是使用同步,确保完成对共享字段的所有访问,同时具有适当的锁定。通过同步,可以确定(假设类编写正确)具有保护一组给定变量的锁定的所有线程都将拥有对这些变量的独占访问权,并且以后其他线程获得该锁定时,将可以看到对这些变量进行的更改。弊端是如果锁定竞争太厉害(线程常常在其他线程具有锁定时要求获得该锁定),会损害吞吐量,因为竞争的同步非常昂贵。(Public Service Announcement:对于现代 JVM 而言,无竞争的同步现在非常便宜。

基于锁定的算法的另一个问题是:如果延迟具有锁定的线程(因为页面错误、计划延迟或其他意料之外的延迟),则 没有要求获得该锁定的线程可以继续运行。

还可以使用可变变量来以比同步更低的成本存储共享变量,但它们有局限性。虽然可以保证其他变量可以立即看到对可变变量的写入,但无法呈现原子操作的读-修改-写顺序,这意味着(比如说)可变变量无法用来可靠地实现互斥(互斥锁定)或计数器。

使用锁定实现计数器和互斥

假如开发线程安全的计数器类,那么这将暴露 get()、 increment() 和 decrement() 操作。清单 1 显示了如何使用锁定(同步)实现该类的例子。注意所有方法,甚至需要同步 get(),使类成为线程安全的类,从而确保没有任何更新信息丢失,所有线程都看到计数器的最新值。


清单 1. 同步的计数器类

				
        
public class SynchronizedCounter {
    private int value;
    public synchronized int getValue() { return value; }
    public synchronized int increment() { return ++value; }
    public synchronized int decrement() { return --value; }
}
      

 

increment() 和 decrement() 操作是原子的读-修改-写操作,为了安全实现计数器,必须使用当前值,并为其添加一个值,或写出新值,所有这些均视为一项操作,其他线程不能打断它。否则,如果两个线程试图同时执行增加,操作的不幸交叉将导致计数器只被实现了一次,而不是被实现两次。(注意,通过使值实例变量成为可变变量并不能可靠地完成这项操作。)

许多并发算法中都显示了原子的读-修改-写组合。清单 2 中的代码实现了简单的互斥, acquire() 方法也是原子的读-修改-写操作。要获得互斥,必须确保没有其他人具有该互斥( curOwner = Thread.currentThread()),然后记录您拥有该互斥的事实( curOwner = Thread.currentThread()),所有这些使其他线程不可能在中间出现以及修改 curOwner field


清单 2. 同步的互斥类

				
        
public class SynchronizedMutex {
    private Thread curOwner = null;
    public synchronized void acquire() throws InterruptedException {
        if (Thread.interrupted()) throw new InterruptedException();
        while (curOwner != null) 
            wait();
        curOwner = Thread.currentThread();
    }
    public synchronized void release() {
        if (curOwner == Thread.currentThread()) {
            curOwner = null;
            notify();
        } else
            throw new IllegalStateException("not owner of mutex");
    }
}
      

 

清单 1 中的计数器类可以可靠地工作,在竞争很小或没有竞争时都可以很好地执行。然而,在竞争激烈时,这将大大损害性能,因为 JVM 用了更多的时间来调度线程,管理竞争和等待线程队列,而实际工作(如增加计数器)的时间却很少。您可以回想 上月专栏中的图,该图显示了一旦多个线程使用同步竞争一个内置监视器,吞吐量将如何大幅度下降。虽然该专栏说明了新的 ReentrantLock 类如何可以更可伸缩地替代同步,但是对于一些问题,还有更好的解决方法。

锁定问题

使用锁定,如果一个线程试图获取其他线程已经具有的锁定,那么该线程将被阻塞,直到该锁定可用。此方法具有一些明显的缺点,其中包括当线程被阻塞来等待锁定时,它无法进行其他任何操作。如果阻塞的线程是高优先级的任务,那么该方案可能造成非常不好的结果(称为 优先级倒置的危险)。

使用锁定还有一些其他危险,如死锁(当以不一致的顺序获得多个锁定时会发生死锁)。甚至没有这种危险,锁定也仅是相对的粗粒度协调机制,同样非常适合管理简单操作,如增加计数器或更新互斥拥有者。如果有更细粒度的机制来可靠管理对单独变量的并发更新,则会更好一些;在大多数现代处理器都有这种机制。

 

硬件同步原语

如前所述,大多数现代处理器都包含对多处理的支持。当然这种支持包括多处理器可以共享外部设备和主内存,同时它通常还包括对指令系统的增加来支持多处理的特殊要求。特别是,几乎每个现代处理器都有通过可以检测或阻止其他处理器的并发访问的方式来更新共享变量的指令。

比较并交换 (CAS)

支持并发的第一个处理器提供原子的测试并设置操作,通常在单位上运行这项操作。现在的处理器(包括 Intel 和 Sparc 处理器)使用的最通用的方法是实现名为 比较并转换或 CAS 的原语。(在 Intel 处理器中,比较并交换通过指令的 cmpxchg 系列实现。PowerPC 处理器有一对名为“加载并保留”和“条件存储”的指令,它们实现相同的目地;MIPS 与 PowerPC 处理器相似,除了第一个指令称为“加载链接”。)

CAS 操作包含三个操作数 —— 内存位置(V)、预期原值(A)和新值(B)。如果内存位置的值与预期原值相匹配,那么处理器会自动将该位置值更新为新值。否则,处理器不做任何操作。无论哪种情况,它都会在 CAS 指令之前返回该位置的值。(在 CAS 的一些特殊情况下将仅返回 CAS 是否成功,而不提取当前值。)CAS 有效地说明了“我认为位置 V 应该包含值 A;如果包含该值,则将 B 放到这个位置;否则,不要更改该位置,只告诉我这个位置现在的值即可。”

通常将 CAS 用于同步的方式是从地址 V 读取值 A,执行多步计算来获得新值 B,然后使用 CAS 将 V 的值从 A 改为 B。如果 V 处的值尚未同时更改,则 CAS 操作成功。

类似于 CAS 的指令允许算法执行读-修改-写操作,而无需害怕其他线程同时修改变量,因为如果其他线程修改变量,那么 CAS 会检测它(并失败),算法可以对该操作重新计算。清单 3 说明了 CAS 操作的行为(而不是性能特征),但是 CAS 的价值是它可以在硬件中实现,并且是极轻量级的(在大多数处理器中):


清单 3. 说明比较并交换的行为(而不是性能)的代码

				
        
public class SimulatedCAS {
     private int value;

     public synchronized int getValue() { return value; }

	public synchronized int compareAndSwap(int expectedValue, int newValue) {
         int oldValue = value;
         if (value == expectedValue)
             value = newValue;
         return oldValue;
     }
}
      

 

使用 CAS 实现计数器

基于 CAS 的并发算法称为 无锁定算法,因为线程不必再等待锁定(有时称为互斥或关键部分,这取决于线程平台的术语)。无论 CAS 操作成功还是失败,在任何一种情况中,它都在可预知的时间内完成。如果 CAS 失败,调用者可以重试 CAS 操作或采取其他适合的操作。清单 4 显示了重新编写的计数器类来使用 CAS 替代锁定:


清单 4. 使用比较并交换实现计数器

				
        
public class CasCounter {
    private SimulatedCAS value;
    public int getValue() {
        return value.getValue();
    }
    public int increment() {
        int oldValue = value.getValue();
        while (value.compareAndSwap(oldValue, oldValue + 1) != oldValue)
            oldValue = value.getValue();
        return oldValue + 1;
    }
}
      

 

 

无锁定且无等待算法

如果每个线程在其他线程任意延迟(或甚至失败)时都将持续进行操作,就可以说该算法是 无等待的。与此形成对比的是, 无锁定算法要求仅 某个线程总是执行操作。(无等待的另一种定义是保证每个线程在其有限的步骤中正确计算自己的操作,而不管其他线程的操作、计时、交叉或速度。这一限制可以是系统中线程数的函数;例如,如果有 10 个线程,每个线程都执行一次CasCounter.increment() 操作,最坏的情况下,每个线程将必须重试最多九次,才能完成增加。)

再过去的 15 年里,人们已经对无等待且无锁定算法(也称为 无阻塞算法)进行了大量研究,许多人通用数据结构已经发现了无阻塞算法。无阻塞算法被广泛用于操作系统和 JVM 级别,进行诸如线程和进程调度等任务。虽然它们的实现比较复杂,但相对于基于锁定的备选算法,它们有许多优点:可以避免优先级倒置和死锁等危险,竞争比较便宜,协调发生在更细的粒度级别,允许更高程度的并行机制等等。

原子变量类

在 JDK 5.0 之前,如果不使用本机代码,就不能用 Java 语言编写无等待、无锁定的算法。在 java.util.concurrent.atomic 包中添加原子变量类之后,这种情况才发生了改变。所有原子变量类都公开比较并设置原语(与比较并交换类似),这些原语都是使用平台上可用的最快本机结构(比较并交换、加载链接/条件存储,最坏的情况下是旋转锁)来实现的。 java.util.concurrent.atomic 包中提供了原子变量的 9 种风格( AtomicInteger; AtomicLong; AtomicReference; AtomicBoolean;原子整型;长型;引用;及原子标记引用和戳记引用类的数组形式,其原子地更新一对值)。

原子变量类可以认为是 volatile 变量的泛化,它扩展了可变变量的概念,来支持原子条件的比较并设置更新。读取和写入原子变量与读取和写入对可变变量的访问具有相同的存取语义。

虽然原子变量类表面看起来与清单 1 中的 SynchronizedCounter 例子一样,但相似仅是表面的。在表面之下,原子变量的操作会变为平台提供的用于并发访问的硬件原语,比如比较并交换。

更细粒度意味着更轻量级

调整具有竞争的并发应用程序的可伸缩性的通用技术是降低使用的锁定对象的粒度,希望更多的锁定请求从竞争变为不竞争。从锁定转换为原子变量可以获得相同的结果,通过切换为更细粒度的协调机制,竞争的操作就更少,从而提高了吞吐量。

ABA 问题

因为在更改 V 之前,CAS 主要询问“V 的值是否仍为 A”,所以在第一次读取 V 以及对 V 执行 CAS 操作之前,如果将值从 A 改为 B,然后再改回 A,会使基于 CAS 的算法混乱。在这种情况下,CAS 操作会成功,但是在一些情况下,结果可能不是您所预期的。(注意, 清单 1 和 清单 2 中的计数器和互斥例子不存在这个问题,但不是所有算法都这样。)这类问题称为 ABA 问题,通常通过将标记或版本编号与要进行 CAS 操作的每个值相关联,并原子地更新值和标记,来处理这类问题。 AtomicStampedReference 类支持这种方法。

java.util.concurrent 中的原子变量

无论是直接的还是间接的,几乎 java.util.concurrent 包中的所有类都使用原子变量,而不使用同步。类似 ConcurrentLinkedQueue 的类也使用原子变量直接实现无等待算法,而类似 ConcurrentHashMap 的类使用 ReentrantLock 在需要时进行锁定。然后, ReentrantLock 使用原子变量来维护等待锁定的线程队列。

如果没有 JDK 5.0 中的 JVM 改进,将无法构造这些类,这些改进暴露了(向类库,而不是用户类)接口来访问硬件级的同步原语。然后,java.util.concurrent 中的原子变量类和其他类向用户类公开这些功能。

 

使用原子变量获得更高的吞吐量

上月,我介绍了 ReentrantLock 如何相对于同步提供可伸缩性优势,以及构造通过伪随机数生成器模拟旋转骰子的简单、高竞争示例基准。我向您显示了通过同步、 ReentrantLock 和公平 ReentrantLock 来进行协调的实现,并显示了结果。本月,我将向该基准添加其他实现,使用 AtomicLong 更新 PRNG 状态的实现。

清单 5 显示了使用同步的 PRNG 实现和使用 CAS 备选实现。注意,要在循环中执行 CAS,因为它可能会失败一次或多次才能获得成功,使用 CAS 的代码总是这样。


清单 5. 使用同步和原子变量实现线程安全 PRNG

				
        
public class PseudoRandomUsingSynch implements PseudoRandom {
    private int seed;
    public PseudoRandomUsingSynch(int s) { seed = s; }
    public synchronized int nextInt(int n) {
        int s = seed;
        seed = Util.calculateNext(seed);
        return s % n;
    }
}
public class PseudoRandomUsingAtomic implements PseudoRandom {
    private final AtomicInteger seed;
    public PseudoRandomUsingAtomic(int s) {
        seed = new AtomicInteger(s);
    }
    public int nextInt(int n) {
        for (;;) {
            int s = seed.get();
            int nexts = Util.calculateNext(s);
            if (seed.compareAndSet(s, nexts))
                return s % n;
        }
    }
}
      

 

下面图 1 和图 2 中的图与上月那些图相似,只是为基于原子的方法多添加了一行。这些图显示了在 8-way Ultrasparc3 和单处理器 Pentium 4 上使用不同数量线程的随机发生的吞吐量(以每秒转数为单位)。测试中的线程数不是真实的;这些线程所表现的竞争比通常多得多,所以它们以比实际程序中低得多的线程数显示了 ReentrantLock 与原子变量之间的平衡。您将看到,虽然ReentrantLock 拥有比同步更多的优点,但相对于 ReentrantLock,原子变量提供了其他改进。(因为在每个工作单元中完成的工作很少,所以下图可能无法完全地说明与 ReentrantLock 相比,原子变量具有哪些可伸缩性优点。)


图 1. 8-way Ultrasparc3 中同步、ReentrantLock、公平 Lock 和 AtomicLong 的基准吞吐量
8-way Ultrasparc3 吞吐量 

图 2. 单处理器 Pentium 4 中的同步、ReentrantLock、公平 Lock 和 AtomicLong 的基准吞吐量
Uniprocessor Pentium4 吞吐量 

大多数用户都不太可能使用原子变量自己开发无阻塞算法 — 他们更可能使用 java.util.concurrent 中提供的版本,如ConcurrentLinkedQueue。但是万一您想知道对比以前 JDK 中的相类似的功能,这些类的性能是如何改进的,可以使用通过原子变量类公开的细粒度、硬件级别的并发原语。

开发人员可以直接将原子变量用作共享计数器、序号生成器和其他独立共享变量的高性能替代,否则必须通过同步保护这些变量。

 

结束语

JDK 5.0 是开发高性能并发类的巨大进步。通过内部公开新的低级协调原语,和提供一组公共原子变量类,现在用 Java 语言开发无等待、无锁定算法首次变为可行。然后, java.util.concurrent 中的类基于这些低级原子变量工具构建,为它们提供比以前执行相似功能的类更显著的可伸缩性优点。虽然您可能永远不会直接使用原子变量,还是应该为它们的存在而欢呼。

 

分享到:
评论

相关推荐

Global site tag (gtag.js) - Google Analytics