JAVA.UTIL.CONCURRENT 同步框架

摘要

        J2SE1.5版本提供了AbstractQueuedSynchronizer类和以此类为基础的同步框架。java.util.concurrent(简称juc)包中的大部分同步器(locks,barriers等)都是利用AbstractQueuedSynchronizer类为基础的同步框架构建的。该框架提供了实现同步框架的几种通用机制:同步状态的原子化管理、block/unblock线程以及排队。本文描述了该框架的原理、设计、实现、用法和性能。

关键字
同步 Java

1.引言

        Java(TM) 版本J2SE1.5引入了java.util.concurrent包——一个支持中等并发的类的集合(基于Java Community Porcess(JCP)的Java Specification Request(JSR)166)。这些组件中包含一组同步器——同步器的状态和操作可以抽象出来形一个抽象数据类型(ADT),ADT维护一个state(比如表示一个锁是locked还是unlocked),一组更新和检测state的操作以及至少一个可以使调用者线程block的方法和一个可以使blocked线程unblock的操作。这组同步器包括各种各样的互斥锁、读写锁、信号量、barrier、future、event indicator 以及handoff队列。

        众所周知,几乎任何同步器都可以彼此实现。比如你可以利用重入锁构建信号量,也可以利用信号量构建重入锁。然而这样做非常复杂、低效和僵化,充其量只能是个二流工程选项。此外,这在概念上也毫无吸引力。既然从本质上讲没有一个同步器(锁、信号量等)比其它的同步器更基础,那就不应该强迫开发者肆意的选择其中一个为基础去实现其他的。JSR166引入了以AbstractQueuedSynchronizer为中心的框架,该框架提供的通用机制被包中提供的大部分同步器所采用,当然用户也可以通过该框架实现自己的同步器。

        本文的其余部分讨论这个框架的要求,其设计和实现的主要依据,用法示例以及性能度量方法。

2.需求

2.1功能需求

        同步器拥有两种类型的方法:
  1. acquire方法:该方法可以block调用者线程。除非state状态未锁定允许线程不阻塞,否则线程一直阻塞到state发生变化,允许它unblock。
  2. release方法:该方法可以改变state值,从而允许一个或多个被阻塞的线程unblock。

        Java.util.concurrent包没有为同步器定义统一的API。有些同步器比如锁(Lock)定义了通用的接口,有些只包括具体的实现类方法名称。所以,acquire和release操作在不同的类中有不同的名字和形式。框架中acquire类似的方法名字包括:Lock.lock、Semaphore.acquire、CountDownLatch.await和FutureTask.get等。方法名虽然不同,但是在框架都保持一致的语义以支持通用的用法。每个同步器都支持:

  1. 非阻塞版本(比如tryLock)和阻塞版本
  2. 可选的超时控制,这样应用可以放弃等待
  3. 通过中断可取消,acquire操作一般包括可取消版本和不可取消的版本。
同步器根管理的状态类型分为两种:互斥的和共享的。互斥意味着在同一时间只有一个线程可以通过阻塞点;共享意味着多个线程可以(至少在某些时刻可以)通过阻塞点。通常,锁仅仅维护互斥的状态;而计数信号量维护共享状态——可以同时被多个线程(count个)获取信号量。框架必须支持这两种模式,以提高适用性。
        Java.util.concurrent包还定义了Condition接口以支持类似监控器的await/signal的操作。Condition要关联在一个互斥锁上,并且Condition的实现本质上和它们关联的互斥相互交织在一起。

2.2性能目标

        Java内置锁(通过synchronized方法或者synchronized块访问)在性能上一直受到诟病(从J2SE 6开始已经进行了改进,性能不弱于同步器框架提供的Lock)。有数量可观的论文讨论内置锁的实现,然而这些论文主要集中讨论的是空间的优化(每个java对象都可以当做一个锁)和单处理器单线程上下文时间的优化。同步器实现的关注焦点不在这里。程序员只有在需要的时候才使用同步器,压缩空间优化的提升有限,而且同步器几乎完全用于多线程环境,尤其是越来越多的应用于多处理器环境下,偶尔的竞争是不可避免的。因此,通常的JVM优化策略——主要针对零竞争的情况,其他情况采用不可预见的slow paths方式,对使用java.util.concurrent包的典型的多线程服务器应用就很不合适了。

        对同步器来说,主要的性能目标是高可扩展性:保持可预见的效率,尤其是同步器在竞争条件下的效率。理想情况下,不管多少线程正在试图通过同步点,线程通过同步点的开销都是常量级的。其中主要目标之一是最小化总的时间量,也就是多个线程允许通过同步点之后真正通过同步点之前的等待时间。当然,这必须要和资源(CPU时间、内存通信、线程调度开销等)的开销做一个平衡。例如,自旋锁获取锁的时间通常要比阻塞锁少,但是却浪费大量的CPU周期空转并产生大量的内存竞争,因而不合适。

        这些目标涵盖了两种通用的使用场景。大多数应用应该容忍可能出现的饥饿以最大化总吞吐量。但是在一些资源控制的应用中,维护公平性要远比总吞吐量更加重要。框架要具有灵活性,不应该替用户决定吞吐量和公平性哪个更重要,所以应该提供不同的公平策略以供用户选择。

        然而不管它们内部的实现有多么精巧,在某些应用中,同步器都会带来性能瓶颈。如此,框架必须提供基本的监控和检测手段以便用户能够发现和缓解这些瓶颈。提供一个可以确定有多少线程被阻塞是最小需求(最有用的)。

3设计和实现

        同步器的基本思路是简单而直接的。

        操作acquire的语义如下:

        while (synchronization state does not allow acquire) {
                enqueue current thread if not already queued;
                possibly block current thread;
        }
        dequeue current thread if it was queued;

        操作release的语义如下:
       update synchronization state;
       if (state may permit a blocked thread to acquire)
                unblock one or more queued threads;

       实现上述操作需要以下三个基本组件的相互协作:

  1. 同步状态的原子化管理
  2. 阻塞、唤醒线程
  3. 维护队列

        可以创建一个框架,使这三个组件独立变化,但这样做既不是有效的,也不是非常有用的。保存在节点队列的信息和唤醒操作需要的信息是交织在一起的。导出方法的签名取决于同步状态是互斥还是共享的。

        同步框架的主要设计决策是选取三个组件中的一个进行具体的实现,并且使用方式上仍然允许一个广泛的选项。这故意限制它的使用范围,但是却提供一个足够高效的框架,以致于在实际适用的场景中没有理由拒绝重新发明轮子。

3.1同步状态

        AbstractQueuedSynchronizer类通过一个32位的整形值维护同步状态state,并且提供了getState、setState和compareAndSetState方法访问和更新该状态值。这些方法的实现依赖于ava.util.concurrent.atomic类提供的原子读写操作,这些读写操作语义与JSR133(Java 内存模型)兼容,类似volatile,并通过原生的compare-and-swap或者load-linked/store-conditional指令实现compareAndSetState操作——当且仅当state值与期望值相等时才将state更新为新值。
        把同步状态state限制为32位是一个符合实际的决定。尽管JSR166提供了64位字段的原子操作,但是这一定是通过内部锁模拟实现的,这样实现的同步器表现不佳(?) 。将来,一个新的专门为64位状态使用的基础类将会加入进来。然而目前并没有一个迫切的理由要把它立即加入进来,32位已经足够满足于大多数应用了。只有一个同步类(CyclicBarrier)需要更多的位维护同步状态,所以改用locks,其他更高级的工具也是如此。
        具体的同步器实现——AbstractQueuedSynchronizer的子类必须定义tryAcquire和tryRelease方法完善需要暴露的方法,以实现acquire/release方法。如果同步状态是acquired的,tryAcquire必须返回true;同样当tryRelease返回true之后,同步状态必须是acquired的。这两个方法都有一个int类型的参数,可以用来传递期望的状态值。比如在可重入锁中,当一个线程从条件等待队列转移到锁队列并再次获取锁时,可以重新设置递归数。大部份同步器是不需要这个参数的,选择直接忽略它。

3.2阻塞

        JSR166之前,Java 没有提供可以block/unblock线程的API,所以除了内置的监视器,没有方式可以创建同步器。仅有block/unblock线程的方法Thread.suspend和Thread.resume由于存在难以解决的竞争问题,比如,如果线程A在一个线程B调用suspend之前调用了B的resume方法,B.resume方法是无效的。这两个方法目前已经被废弃。

        LockSupport类被引入java.util.concurrent.locks包,可以解决这个问题。LockSupport.park方法将会block当前线程,除非另外一个线程之前已经调用了LockSupport.unpark方法,否则当前线程一直阻塞到其他线程调用LockSupport.unpark。Unpark方法没是有计数的,所以在线程调用park之前多次调用unpark方法也只能unblock一次线程。另外,该方法是per-thread的,不是per-synchronizer的。假设线程A调用了以线程B为参数的unpark方法,那么线程B在一个同步器上调用park方法会立即返回。虽然可以显式的清除状态,却无必要。如果碰巧需要的话,调用多次park方法更有效率。

        这个简单的机制和Solaris-9线程库、WIN32 "consumable events"和Linux的NPTL线程库在某种层面是相似的,所以在相应的平台上可以高效的映射。park方法还支持可选的超时时间限制(可以是相对时间或者绝对时间)并和JVM的Thread.interrupt支持集成在一起 — interrupting a thread unparks it.

3.3队列

        该框架的核心是维护一个blocked线程的队列,这个队列在这里限制为FIFO队列,因此,该框架不支持基于优先级的同步。

        最近,大家终于达成一致:实现同步队列的最合适的数据结构应该是非阻塞的。这个结构本身不需要低级的锁操作。这其中有两个主要选择:MCS锁(Mellor-Crummey and Scott )的变种和CLH(Craig, Landin, and Hagersten )锁的变种。历史上,CLH锁一直用来实现自旋锁。然而,由于更容易实现取消和超时操作,CLH锁看起来比MCS锁更适合用于实现同步框架的阻塞线程队列。最终的设计远远不是最初的CLH结构可以解释的,需要更详细的描述。

        CLH队列非常不像队列,因为它的入队和出队操作和它的加锁/解锁的操作紧密结合在一起。 它包含两个节点head和tail,这两个节点都需要原子化的操作,初始化时指向同一个假的节点。


JAVA.UTIL.CONCURRENT 同步框架_第1张图片

        

        新的节点通过一个原子化的入队操作加入队列:

          do { 
                pred = tail;
          } while(!tail.compareAndSet(pred, node));
        节点关联的线程是否可以通过同步点(获取锁)取决于该节点的前驱节点的状态,所以自旋锁的“自旋“如下:

          while (pred.status != RELEASED) ; // spin
        出队操作仅仅将head节点指向获取锁的节点(通过自旋获取了锁的线程):

         head = node;

        CLH锁有如下几个有点:(1)入队和出队操作快速、无锁、无障碍(即使在竞争环境下,一个线程也能快速赢得入队的竞争,完成操作);(2)检测是否有线程正在等待锁的操作也非常快(只要检查head和tail是否指向同一个节点);(3)释放锁是去中心化的,减少了内存竞争。

        CLH锁的最初版本中,甚至没有链接把节点链接起来。在一个自旋锁中,pred可以是本地变量。然而,Scott and Scherer证明在节点中显示的维护pred链接,CLH锁可以解决超时和其它形式的取消操作:如果一个节点的前驱被取消了,节点可以判断前驱节点的前驱状态。

        为了把CLH锁实现为阻塞锁,另一个重要的改变是要提供方法使一个节点能够快速的找到它的后继结点。在自旋锁中,一个节点只要改变它自己的状态,它的后继自旋节点在下一次自旋检测时会自动发现状态的改变,所以不需要指向后继节点的链接。但是在阻塞的同步器中,节点需要显示的唤醒后继节点关联的线程。

        AbstractQueuedSynchronizer中的队列节点包含一个next域指向它的后继节点。但是,通过compareAndSet操作无法提供双向链表的无锁原子性插入,next链接的设置不是作为原子行插入操作的一部分,它只是插入操作完成后一个简单的赋值:

        pred.next = node;
这反应在所有的使用中。next链接只是作为一种优化,如果一个节点的后继节点看起来不存在(可能被取消了),总可以通过tail和pred遍历queue找到真正的后继节点。

        另一个不同点是在每个node节点中保持一个status字段决定线程是blocking或者spinning。在同步框架中,一个排队的线程只有它调用tryAcquire时返回true才可以通过acquire操作,tryAcquire方法由子类实现。一个单独的"released"位是不够的。框架仍然确保只有处于head的节点可以调用tryAcquire操作,它有可能失败并再次block,这就不需要在每个node节点保持一个标志,只要检查当前节点的前驱节点是否是head就可以了。和自旋锁的情况不同,这里不需要为了保证读头节点去复制而进行过多的内存竞争。然而,取消字段还是要保存在每个节点中的。

        节点的status字段也可以用来避免不必要的park和unpark操作。尽管这些方法和阻塞原语一样快,但是从Java跨越到JVM跨越到OS还是存在一些开销是可以避免的。一个线程在调用park之前,要设置一个"signal-me"位(位于前驱节点),然后再次检查同步状态和节点状态然后才调用park。release操作会清除状态。这使得线程不必重复地试图block自己,特别是在锁中,等待下一个合适的线程获取锁的时间,加剧了锁的竞争。这也使得一个释放锁的线程不必一定去判定它的后继节点,除非它的后继节点设定了“signal”位,这也使得线程不必因为要唤醒一个明显为null的后继节点要遍历整个队列,除非唤醒和与取消同时发生。

        同步框架中的CLH锁和其他语言中的主要区别或许是节点内存的回收依赖于垃圾回收机制,这避免了一些复杂性和开销。然而依赖GC机制,当连接字段确定不再需要时也要将它设置为null才行,这可以和出队操作一起进行。否则,那些没用的节点不会被回收,因为它们仍然是可达的。

        其他一些细小的优化,比如当第一次竞争锁时才初始化head和tail的“假”节点,在J2SE1.5的源码文档中有详细的描述。

        抛开这些细节,基本require操作的实现的通常的方式(互斥的、不可中断的、无超时的)如下:

        if (!tryAcquire(arg)) {
            node = create and enqueue new node;
            pred = node's effective predecessor;
            while (pred is not head node || !tryAcquire(arg)) {
                if (pred's signal bit is set)
                    park();
                else
                    compareAndSet pred's signal bit to true;
                pred = node's effective predecessor;
            }
            head = node;
        }
        基本的release操作如下:

        if (tryRelease(arg) && head node's signal bit is set) {
            compareAndSet head's signal bit to false;
            unpark head's successor, if one exists
        }
        Acquire主循环的迭代次数依赖于tryAcquire的实现(不同具体类中该方法的实现方式不同)。不考虑取消操作和操作系统调用park时的调度开销,每个同步器的require和release操作都是O(1)的。

        要支持“取消”操作,需要在主循环中,每次从park返回时检测中断和超时。一个因中断或者超时被取消的线程会设置它的状态并唤醒后继节点,这样后继节点可以重新设置它的pred域。“取消”操作中查找前驱和后继节点以及重置status需要一个O(n)复杂度的遍历操作。取消之后,该线程再也不会被阻塞,链接和status可以快速设置完成。

3.4条件队列

        同步框架提供了ConditionObject类,以供同步器实现互斥同步并遵循Lock接口。一个Lock对象可以附加多个Condition对象,实现经典的监视器类型的await/signal/signalAll操作、await/signal/signalAll操作的可超时版本、以及一些检测和监控方法。
        通过固化一些设计决策,ConditionObject可以将条件和其他的同步操作有效的集成在一起,该类仅支持Java-Stype的监视器访问规则:只有当线程首先持有锁,才能执行附加在该锁上的条件相关的操作。这样,ConditionObject附加在重入锁之上,可以达到内置监视器(Object.wait/Object.notify)同样的功能。不同的是它们提供了不同的方法名,ConditionObject提供了更多的操作以及一个锁对象之上可以附加多个条件对象。
        ConditionObject和同步器使用同样的内部队列节点,但是维护一个单独的条件队列。Signal操作的实现是通过将一个节点从条件队列转移到锁队列,在线程获取所之前,不需要唤醒线程。
        基本的await操作如下:
        create and add new node to condition queue;
        release lock;
        block until node is on lock queue;
        re-acquire lock;
        基本的signal操作如下:
        transfer the first node from condition queue to lock queue;

        这些操作都是在持有锁的情况下执行的,所以可以直接使用顺序链表操作维护条件队列。转移操作也是简单的将条件队列的第一个节点解除链接,然后用CLH方式插入到锁队列中。

        由超时或者中断引起的取消条件等待的操作是主要复杂之处。取消(cancellation)和唤醒(signal)操作的同时发生将会发生竞争。JSR133规范对此做了说明:如果中断产生在signal之前,那么await方法在重新获取锁之后要抛出InterruptedException;如果中断发生在signal之后,await不会抛出InterruptedException,只是设置中断状态。

        为了维护顺序,节点中保持一位记录节点是否被转移或者正在转移。Signal代码和Cancel都会尝试用compareAndSet方式修改这个标志位。如果signal操作失败,它会转移下一个节点,如果存在的话。如果cancellation操作失败,它会立即终止,然后等待重新获取锁。后者引入了一个潜在的无限期自旋。在节点被成功的插入到锁队列之前,“取消”操作无法获取锁,一直自旋等到signaling线程通过compareAndSet 执行的CLH插入操作成功。自旋很少发生,并且自旋中调用Thread.yield方法提示操作系统进行线程调度让其他线程(理想情况下是要执行signal的线程)执行。 虽然为“取消”实现一个帮助策略来插入节点是可能的,但是却无必要增加这个开销,因为这种情况实在太少。在所有其他情况下,该机制都不需要自旋也不需要yields,在单处理器上有合理的性能。

4用法

        AbstractQueuedSynchronizer类将上面提到的功能整合在一起,作为基类提供模板方法供子类实现具体的同步器。子类只要定义状态检测和更新的方法,控制acquire和release操作。然而,AbstractQueuedSynchronizer的子类本身并不适合作为同步器,因为它们不可避免的暴露了内部控制acuqire/release策略的方法,这些对同步器的使用者是应该不可见的。所有的java.util.concurrent包中的同步器都定义了一个私有的内部AbstractQueuedSynchronizer子类并把所有的同步操作委托给内部私有子类。这也可以让同步器方法命名合适的名字。
        下面以一个简单的Mutex类为例说明。该类利用同步状态0表示未锁,1表示已锁。该类也不必提供同步方法的参数,所以使用0,实现里直接忽略。
class Mutex {
    class Sync extends AbstractQueuedSynchronizer {
        public boolean tryAcquire(int ignore) {
            return compareAndSetState(0, 1);
        }
        public boolean tryRelease(int ignore) {
            setState(0); return true;
        }
    }
    private final Sync sync = new Sync();
    public void lock() { sync.acquire(0); }
    public void unlock() { sync.release(0); }
}

        完整的例子包括其他使用指南可以参见J2SE文档。当然也有许多其它的变种。例如,tryAcquire在改变状态值之前可以利用“test-and-test-and-set”方式检查当前值。

        或许让人感到吃惊,一个性能敏感的互斥锁竟然通过使用代理和虚方法组合来定义。然而,这是现代动态编译器长期关注的面向对象设计的构造方法。它们在优化掉负载尤其是经常调用同步器的代码里表现良好。

        AbstractQueuedSynchronizer还为同步器提供了实现控制策略的方法。比如,它提供了基础acquire方法的超时和可中断版本。目前为止我们讨论的都是互斥同步器比如锁,AbstractQueuedSynchronizer还提供了一些不同的方法(比如acquireShared),tryAcquireShared和tryReleaseShared方法使得框架根据返回值可以让多个线程通过同步点,最终也可以通过级联使得多个线程被唤醒。

        尽管序列化(持久化或者传输)同步器通常是没有意义,但是同步器经常用来构造其他类,比如线程安全的集合类,它们通常需要序列化。AbstractQueuedSynchronizer和ConditionObject提供了序列化同步状态的方法,但是不会序列化被阻塞的线程和其他标记。尽管如此,大多数同步器反序列化时也仅仅初始化同步状态,像内置锁一样反序列化使同步器处于未锁状态。这相当于一个空操作(a no-op),但是必须显示的支持以使final字段可以反序列化。

4.1 公平策略

         尽管是基于FIFO队列的,同步器却不一定公平。注意基础的acquire算法,tryAcquire的执行在入队之前,这样有可能是新产生的线程获取锁,而不是本该获取锁的在队列中等待的第一个线程。

        Barging FIFL策略通常比其他技术提供更高的总吞吐量。在这样一种情况下,当锁是可用的,但是要使用的线程还在处于解除阻塞的过程中,这会造成时间浪费。Barging FIFL策略可以消除这种时间浪费。与此同时,它避免了过度的,无效率的只允许排队(第一个)的线程通过release被唤醒并试图获取锁。创建同步器的开发人员可以在定义tryAcquire时简单地前重试几次然后再返回,会进一步加剧barging效果。

        Barging FIFL策略存在概率性的公平性问题。一个被唤醒的线程和一个新来的barging线程拥有相同的机会去获取锁,如果竞争失败只能重新阻塞后者重试等。然而,如果产生新线程的速度比被唤醒的过程要快,那么对列中的第一个线程很少有机会赢得竞争,所以总是会重新阻塞,队列中的后续线程也将会一直阻塞。对于短时持有的同步器来说,当队列中第一个线程时解除封锁时,更多的线程会barging,尤其在多处理器上。如下图所示,净效应是维持一个或多个线程高进展的同时还至少有一定的概率避免饥饿。


                                                                                         


        当需要更公平的策略时,也不难实现。程序员可以定义严格的公平方法:如果当前线程是不是在队列的头,那么tryAcquire立即返回失败。getFirstQueuedThread可以用来检测当前线程是不是在队列头部,这是框架提供的少数检查方法之一。

        更快的但是不那么严格的变种可以这样实现,如果队列(暂时)空,允许tryAcquire成功。在这种情况下,多个线程遇到一个空队列时会发生竞争谁第一个得到机会获取锁,这样至少有一个线程不需要排队。这种公平策略在所有的java.util.concurrent支持公平的同步器中采用。

        尽管他们在实践中是有用的,但公平性仍无法保证,因为Java语言规范没有调度方面的保证。例如,即使是严格公平的同步,如果线程之间不需要阻塞等待对方,JVM可以决定运行一组线程纯粹顺序。在实践中,在单处理器上,一些线程可能在上下文切换前运行了一段时间。如果这样一个线程持有一个排它锁,它很快就会被切换回来,仅仅释放该锁并阻塞,因为它知道其它线程需要锁,这进一步增加了锁空闲的时间(锁可用但是没有被获取)。在多处理器上,公平性设置的影响更大,因为交错执行的可能性更大,一个线程发现其他线程需要锁的机会也更大。

        尽管在高竞争环境下保护短暂持有的代码变现不加,公平锁仍可以有效工作。例如,当它们保护的代码相对较长或者锁间的间隔相对较长时,barging提供的性能优势很小,风险很大——无限期推迟。框架可以由用户自己决定用哪种方式。

4.2同步器

        下面简要描述下java.util.concurrent包是如何领用该框架定义同步器的:
        ReentrantLock类用同步状态保存获取锁的次数(可以重入)和获取锁的线程。保存持有锁的当前线程可以实现可重入以及当其他线程释放别的线程持有的锁时抛出异常。该类还利用ConditionObject提供监控和检测方法。ReentrantLock提供可选的公平策略,内部定义了AbstractQueuedSynchronizer 的两个子类。在实例化锁时可以通过参数来选择使用哪个子类。
        ReentrantReadWriteLock使用16位表示写锁状态,16位表示读写状态. WriteLock和ReentrantLock的构建方式相同;ReadLock通过acquireShared方法允许多个读者。
        Semaphore(counting semaphore) 利用同步状态保持当前信号数量。它通过acquireShared将信号量减一,如果信号量小于或等于0,则阻塞当前线程;tryRelease将信号量加1,如果信号量大于0,则唤醒阻塞的线程。
        CountDownLatch也是利用同步状态表示当前数量。所有的线程可以通过同步点,当count值为0的时候。 
        FutureTask利用同步状态表示future的运行状态(初始化、运行、已取消、完成)。设置或者取消一个future调用release操作,并唤醒通过acquire操作阻塞的正在等待执行结果的线程。
        SynchronousQueue(a CSP-style handoff) 利用内部的等待节点表示生产者和消费者,当一个消费者拿走一个item之后,用同步状态表示允许生产者执行。

        当然,用户也可以自定义他们自己的同步器。

5性能分析

6总结


转载请注明出处: http://blog.csdn.net/zdq0394/article/details/9207633

你可能感兴趣的:(Java,并发)