J.U.C并发框架

 J.U.C并发框架

作者:Doug Lea
SUNY Oswego
Oswego NY 13126

dl@cs.oswego.edu

翻译:书卷多情

在J2SE1.5中,java.util.concurrent包下的大部分同步工具(锁、屏障等)以AbstractQueuedSynchronizer类为基础来构建。这个框架提供了一些常用机制用于自动管理并发状态、阻塞及非阻塞线程,以及队列。本论文描述了该框架的根源、设计、实现、用法及性能。

关键字:synchronized, java

1、介绍

java发布的J2SE-1.5介绍了java.util.concurrent包,是一个通过JCP(Java Community Process)和JSR创建的一个支持中间并发类的集合。这些组件都是同步组件,由抽象数据类型(ADT)类来支持内部同步状态(比如:表示一个锁是locked还是unlocked状态),并更新及监控该状态,如果其他线程修改了状态并且状态允许,则根据该状态,至少有一个方法调用线程阻塞。例如:各种形式的互斥锁、读写锁、信号量、barriers、futures、事件指示器及队列。

众所周知(见[2]),几乎所有的同步类可以用来实现对应的其他同步类,比如,我们有可能通过重置锁创建信号量,反过来了也可以通过信号量创建重置锁。然而,这么做是挺复杂,不方便工程开发使用。再进一步说,也无美感。如果他们之间没有本质的区别,对开发人员来说也是一场灾难,因为他们就需要从中任意选出一个出来,创建另一个同步对象。所以,JSR166建立了一个以AbstractQueuedSynchronizer为中心的小框架提供给开发人员。

2、要求

2.1 功能

同步通过两个办法来执行[7]:至少一个线程受到阻塞直到同步允许的获取锁操作,及至少一个修改同步状态、允许一个或多个线程进入非阻塞状态的释放锁操作。

J.U.C包没有去定义一个统一的同步API,有些是通过常用接口(如:Lock接口)定义的,但其他的则只包括了特定的版本。所以,不同的类中定义的获取锁和释放锁的方法名也不一样。比如,Lock.lock,Semaphore.acquire, CountDownLatch.await和FutureTask.get都表示的是获取操作。但每个同步类都具有:

*非阻塞式同步(如tryLock)和阻塞式同步

*可以选择最长等待时间,以便放弃等待

*可以取消的中断。要区分开可取消的中断和不可取消的中断

同步可以根据互斥还是共享状态来划分。互斥状态下,同步一次只允许一条线程执行,阻塞后,有可能还会是同一条线程继续执行。共享状态允许在某时间内多条线程执行。通常的锁当然持有的是互斥状态,但信号量可以允许某个数量的线程来执行。为了得到最广泛的应用,该框架使用了互斥和共享锁。

J.U.C包也定义Condition接口,支持监控await/signal操作,而在互斥的Lock类中,监控的是内置锁。

2.2 性能目标

java的内置锁(使用synchronized关键字来进行阻塞),长期以来关注性能,相关的结构介绍也多如牛毛(如[1],[3]),然而关注点都考虑在单核处理器上的单线程上下文时,如何使占用的空间最小(因为每个java类都可以当做锁来使用),及消耗的时间最短。这两种办法都没有关注到同步的重点,同步的重点是:程序员只有在需要的时候才创建并发,所以没必要因担心造成浪费而压缩空间,而在多线程的设计中(越来越多的机器采用多核处理器的情况下),在竞争很少的情况下,大都使用互斥同步。所以,JVM中常规的锁优化策略都是针对零竞争的场景,对严重依赖于java.util.concurrent的典型多线程服务端应用来说,让其它场景走不易预料的“慢路径”并不是个正确的手段。

相反,最初的性能目标只是考虑稳定性。可以预测维护效率,或更特殊一点的情况,并发的竞争。理想的情况是,不管有多少个线程,在一个同步点上,同步的数量是个常量。而这些目标中的主要关键点就是让能够释放并发(锁)但是还没有释放的线程所响应的时间尽可能短。而这么做又要考虑资源分配,包括总CPU请求时间,存储路径、和线程调度响应时间。比如,自旋锁的请求时间通常比阻塞锁要短,但会浪费周期,消耗内存,所以不常应用。

(性能)目标通常考虑两种用途。很多应用应考虑最大化吞吐量、响应时间、最好还要避免线程饥饿。但在资源控制等应用中,更重要的是考虑减少线程吞吐量让线程能够公平的执行。在这些相互矛盾的目标中,没有一个框架可以决定用户的操作中到底倾向于哪一个,但必须要支持不同的公平策略。

不管他们内部设计得多么巧妙,并发在一些应用中都会产生一些瓶颈。因此,框架必须让用户能够监控内部基本操作,让用户发现和减少瓶颈。这种让瓶颈最小化的方法可以让用户决定允许多少个线程阻塞。

3、设计和实现

并发的基本思想还是比较直观的。一个获取操作如下:

while (并发状态不允许获取锁 {

如果当前线程没有入队,将当前线程入队;

可能阻塞当前线程;

}

如果当前线程在队列中,将当前线程出队;

释放操作如下:

更新并发状态

if (状态允许,允许一个阻塞线程获取锁)

将一个或多个阻塞线程进入非阻塞状态

这些操作需要3个基本条件:

1、  自动管理并发状态

2、  阻塞和唤醒线程

3、  维护FIFO队列

可以创建一个框架来独立实现这三个条件。但可能不高效也无用。因为比如在队列中的结点的信息必须与需要释放阻塞结点进入非阻塞状态的结点信息相符合,提供的接口方法签名也要具有并发状态的特质。

并发框架设计的中心就是要选择一个这三者条件的具体实现,且能够在这三个方面有很大的选择使用范围。这样应用的范围会受到限制,但避免从新创建并发类,也提供了一个效率足够高的并发框架。

3.1 并发状态

AbstractQueuedSynchronizer 类维护并发状态只用一个32位的int类型,且提供getState, setState, 和compareAndSetState 三个方法来获取和更新状态, 这些方法反过来依赖于JSR133提供的volatile提供的读写语义,compareAndSet方法则是通过本地的compare-and-swap 或loadlinked/store-conditional 指令来实现, 这些方法原子性的将状态更新为一个给定的值。

将同步状态限定为32位的int是一个具有实际意义的决定.同时JSR166也提供64位的long类型的原子操作,类似于使用内部锁,但结果并不理想。将来它可能添加long类型的状态,会成为使用64位的状态的第二选择(也就是说long类型的控制参数)。但现在还不需要在包中添加这个类。当前32位已经足够绝大部分的应用使用的了。J.U.C包中只有一个类,CyclicBarrier 有可能会用到,所以它用锁代替(这也是在该包中其他使用更高位的类的方式)。

实现了AbstractQueuedSynchronizer 的具体的类必须定义tryAcquire和tryRelease方法,这两个方法提供状态方法接口,以便于控制获取和释放操作。如果并发获取了锁,tryAaquire必须返回true,如果更新以后的并发状态允许获取,tryRelease必须返回true。这两个方法只一个int型的参数来进行通信;如在reentrant lock中,当从一个等待条件队列中重新获取锁的时候,会重新建立递归相加。很多并发类不需要这个参数,所以会忽略它。

3.2 阻塞

在JSR166之前,还没有一个JAVA API提供给一个建立在不是内置锁的基础之上的阻塞和非阻塞线程算法。而唯一提供这个算法的Thread.suspend和Thread.resume方法已经被取消,因为会产生无法解决的竞争问题:如果一个非阻塞线程在阻塞线程执行suspend方法之前调用resume方法,那么这个resume操作无效。

java.util.concurrent.locks包包括一个LockSupport类,该类里有能够解决这个问题的方法。LockSupport.part只有在LockSupport.unpart以及执行的时候才会被调用。(也允许假唤醒)。 调用unpart方法不会去计算(锁)。所以在一个park之前的多个unpark,只会释放一个park线程。而且,这是线程级别上的应用,而不是并发类级别上的应用。一个线程调用park,很可能由于前面调用了过多的unpark而立即得到相应。但在缺乏unpark的情况下,这个调用就会受到阻塞。所以需要确保这个(阻塞)状态的清除,但没必要这么做。如果需要的话,调用多次park还更加高效。

这个简单的机制类似于Solaris-9的线程,在win32下的“可消费事件”,以及linux的NPTL线程,运行效率也和java在这些平台上的运行效率一致。(但目前Sun Hotspot JVM在Solaris和Linux上的相关的实现是采用pthread condvar来实现,来满足运行时的设计要求)。park方法也提供一些可选参数支持过期,且和JVM的Thread.interrupt整合起来使用,来中断一个线程对它进行unpark.

3.3队列

该框架的核心维持的是一个阻塞线程的队列,在这里是FIFO队列。因此框架不提供优先级并发。

这些天来一直有争论的是,是否关于并发队列采用非阻塞式的数据结构是最适合的,这样他们就不需要自己创建低级别的锁。而且也有两个锁来直接使用:Mellor-Crummey和Scott(MCS)锁[9],和Craig,Landin,Hagersten(CLH)锁[5][8][10]。以前CLH锁只用于自旋锁。但在并发框架中CLH锁比MCS锁更适用,因为在处理取消和超时操作上更容易,所以会选择CLH锁。讨论的结果就是从最初的CLH锁能够根据需求扩张进行更大范围的使用。

一个CLH队列不太像一个队列,因为入队和出队操作都和它作为一个锁的操作紧密相关。这是一个链接队列,head和tail两个字段进行原子性更新,他们最初都指向一个伪节点。

3.3

一个新的节点,node,会通过如下原子性操作入队:

do { pred = tail;

} while(!tail.compareAndSet(pred, node));

每个节点的释放状态都存储在它的前一个节点上,所以一个自旋锁的“自旋“就如下:

while (pred.status != RELEASED) ; // spin

这样自旋以后,出队操作就只要简单的把头节点设为获取锁的节点

head=node;

CLH锁的优点就是入队和出队操作快速、且非阻塞(即使在竞争条件下,一个线程总是会插入竞争中不断的执行);检测到其他线程是否等待的过程也很快(只需要检测头节点和尾节点是否一样);释放状态是分散的,避免了内存的竞争。

CLH锁的原始设计是没有链接节点的。在自旋锁中的pred以局部变量存储。但Scott和Scherer通过节点的前任节点持有状态来展示,CLH锁可以处理超时和其他形式的取消操作;如果一个节点的前任节点取消了,该节点可以使用前任节点的状态属性。

做阻塞式并发只需要有效的更新CLH队列,让它提供一个节点来指向它的后继者。自旋锁中,一个节点只需要更新它的状态,通过该状态告知后继节点准备自旋,所以不需要链接。但是在阻塞式并发中,一个节点需要明确唤醒(unpark)它的后继节点。

一个AQS队列节点包括一个next属性,指向它的后继者。但是因为没有实际的技术能够使无锁的双向链表能够使用compareAndSet进行原子性插入,所以该链表在插入的时候不是原子性的,只是简单的赋值:

pred.next=node;

这在反射中都会用到。next链接只是为了优化路径。如果一个节点通过next检测到没有后继节点(或者,后继节点被取消),它会反过来从尾节点向前遍历,使用pred看看是否是只有一个节点。

第二部分的更改就是使用每个节点里的status属性来控制阻塞,不是自旋。在并发框架下,一个线程队列只能从一个子类定义的tryAcquire方法中的获取操作返回;一个单一的“释放”位并不够。但控制也要确保调用tryAcquire的头节点线程是活的;在这种情况下它还是有可能会获取不到,然后重新阻塞。这不需要每个节点的状态标识因为只需要当前节点的前任节点是头节点就可以允许。这也不像自旋锁,自旋锁没有足够的内存空间读取头节点,但在状态属性中必须由取消状态。

队列节点状态属性页用于避免无用的park和unpark调用。这两个方法相对阻塞方式来说相当快,它避免了java和JVM运行时和OS的边界交互。调用park之前,一个线程设置了“signal me”位,之后在调用park之前重新检测一次并发和节点状态。一个释放线程去清除这个状态。这样就可以避免线程进行不必要的阻塞,特别是对于需要花费时间进行等待的锁来说。这也避免了请求一个释放线程来检测后继者,除非它的后继者设置了signal位,

CLH锁和其他语言下的锁不同的地方就是GC,对GC的依赖需要在出队的时候将节点设置为Null

其他更多的优化包括CLH队列的伪结点的延迟初始化,在J2SE1.5源代码文档中有。

忽略这些细节,获取操作的一般情况如下(不包括非中断式、非超时):

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;

}

释放操作:

if (tryRelease(arg) && head node’s signal bit is set) {

compareAndSet head’s signal bit to false;

unpark head’s successor, if one exists

}

主循环迭代的次数依赖于tryAcquire。否则,在没有取消操作的情况下,每个组件获取和释放操作都是常量时间 O(1),分别计算线程相互之间的花费、不考虑park期间OS的线程调度。

可以取消操作只需要检测park内部的获取循环的中断或者超时。一个基于超时或中断的可取消线程设置它的节点属性并unpark它的后继节点,让后继节点可以重置链接。取消中的检测前任节点、后继节点和重置状态可能包括O(n)遍历(此处n是队列的长度)。因为线程永远不会阻塞一个取消操作,链接和状态属性可以快速恢复稳定。

 

3.4 条件队列

并发框架提供一个ConditionObject类来按照Lock接口维护互斥并发。一个锁对象中可以有很多个条件对象,提供经典的监控方法: await, signal 和signalAll操作,这些操作包括超时和监控内部方法。ConditionObject让条件对象和其他并发操作整合可以高效的执行,且调整了一些设计思路。这个类只支持java类型的监控原则,即只有锁持有着当前线程的条件才能够进行条件操作。因此,ReentrantLock中的ConditionObject的做法和内置监控锁是一样的(如Object.wait等),只是方法名不同而已,这更实用,因为用户可以给每个锁加入若干个条件对象。

ConditionObject使用和其他并发对象一样的内置节点队列,但是维护的条件队列不同。signal队列的操作通过从条件队列迁移到锁队列来进行,不需要重新获取锁之前唤醒signal线程。

基本的等待操作是:

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;

因为这些操作只有在持有锁的情况下才能进行,所以他们可以使用链接队列操作(使用节点上的nextWaiter属性)来维护条件队列。迁移操作指示简单的把条件队列的第一个节点释放掉,然后使用CHL捕获该节点并插入锁队列中。

实现这些操作比较复杂的是处理超时或者调用Thread.interrupt而引起的条件等待的取消。一个取消操作和获取信号操作在遇到竞争的时候花费的时间差不多,他们都要确认内置锁。在JSR133的修正版中,这些操作要求,如果一个中断发生在获取信号之前,那么await方法必须在重新获取锁之后抛出InterruptedException异常。但是如果在获取信号之后中断,那么await方法必须设置中断状态,直接返回,而不要抛出异常。

为了维护一个比较适合的顺序,队列节点状态记录了节点是否被移动。获取信号代码和取消代码都会通过compareAndSet来设置这个状态。如果一个获取信号操作没有能够在竞争中获取锁,如果还有下个节点的话,它会移动下个节点。如果取消操作失败,它必须放弃迁移,并且等待重新获取锁。后者也就是自旋无限循环的操作方法。一个取消了等待的线程不能进行获取锁操作,直到该节点成功插入锁队列,所以必须自旋等待获取信号线程成功的调用compareAndSet方法插入CHL队列中。因为自旋很少,所以使用Thread.yield来调度其他线程,理想的情况是一条线程获取信号,而不是直接运行。这样对取消插入节点就很有利,组织者情况下需要去确认插入的节点是否超标的情况极少。在其他的情况下,在单核处理器中为了维持性能,都不会使用自旋或者挂起。

4、用法

AQS类与如上讨论的方法紧密相关,并以“临时方法模式”[6]的方式作为并发的基本类。继承这个类的子类需要实现状态监控和控制获取和释放锁的方法。但AQS的子类自己不会做并发的ADT(抽象数据类型),因为子类需要提供内部控制获取和释放锁机制的方法。所有的J.U.C包下的声明并发类都会声明一个私有的继承自AQS的子类,以及该类里的所有并发方法。这样也提供了适合并发的公共方法名。如,有一个最小的类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方法会在获取锁之前使用testand-test-and-set来检测状态。

你可能会比较奇怪,构造一个对性能要求比较高的不可变的排斥锁会使用委派和虚拟方法联合的方式来构造。但这种OO设计构造的方式就是现在动态编译器长期关注的这一块。他们善于优化这种类型的负载,至少在并发类的调用里经常会用到。AQS类也提供了一系列的方法来完成并发操作。比如,在基础获取锁方法之上还加了超时获取、中断获取锁的方式。讲到这里,我们还是关注在排斥模式的并发上比如说锁,AQS也包括一系列和tryAcquireShared、tryReleaseShared方法不同的并行方法(比如acquireShared), 该方法能够通过返回值通知框架可以获取更多的锁,通过级联获取信号唤醒多个线程来达到性能最优化。虽然序列化并发不常用(持久性存储或转换),这些类通常会反过来构造其他的类,比如线程安全的集合,通常就会序列化。AQS和ConditionObject类提供序列化并发状态的方法,但不会有线程阻塞或者临时存储。尽管这样,并发类在反序列化的时候也不会将并发状态重置为初始值,因为锁总会反序列化到一个非锁定的状态。

这等同于空指令,但仍然需要显示提供final属性的反序列化。

4.1 控制公平性

尽管是基于FIFO队列,这里也不需要公平式的并发。在基础获取算法(3.3节中),tryAcquire在入队之前会检查(状态)。因此一个新入线程(barge 线程)可以绕过队列首节点的线程获取锁。FIFO的这个策略可以使得并发量比其他技术要高。它减少了锁可用情况下,下一个可获取锁的节点由于受到阻塞而等待的时间。也避免了在锁被释放之后只允许队列头被唤醒获取锁,从而减少了不必要的浪费。开发人员要给并发多一些空间,让tryAcquire在传回控制之前尝试几次都要持有锁。

FIFO并发产生公平竞争的机会很少,在队列头的非阻塞线程和其他新入的barge线程争取到锁的几率是一样的,如果获取不到,从新进入阻塞状态或者从新尝试获取锁。

4.1

但是如果进入队列的线程速度大于一个唤醒线程进入非阻塞状态的速度的时候,首节点获取锁的机会就会很小,它就会再次受到阻塞,而它的后续节点也会一直处于阻塞状态。简单说,这种并发在多核处理器环境下,在首节点线程进入非阻塞状态期间,有多个新加入的barge线程和多个释放操作情况下经常会发生的。正如下文所说,这么做的好处就是避免一个或多个线程饥饿而达到高速执行。

如果要求很公平,相对来讲要简单一些。如果不是队列头,tryAcquire只要返回false就可以了。getFirstQueuedThread方法就是一个方便的查看是否是队列头的方法。

一个更快、不太严格的方式就是如果队列是临时空队列,tryAcquire就可以允许成功返回。在这种情况下,如果有多个线程遇到一个空的队列的时候,他们竞争的就是谁会第一个获取锁,他们当中至少有一个节点不需要入队,这种策略支持J.U.C包下的“公平”模式。

尽管在实际应用中会用到,但因为java语言不提供调度公平策略,所以没有人去保证公平策略。比如,在相当严格的公平并发中,如果队列里的节点都不需要阻塞等待其他的节点,那么JVM就允许这个队列顺序执行的。实际上,在单核处理器中,这样的线程很有可能在上下文切换之前先发制人的执行一定的时间。如果这样的线程持有的是互斥锁,它会立即切回原先的上下文,只有在它自己释放锁和受到阻塞的时候才会知道其他的线程也需要锁。这就增加了可获取,但是还没有获取锁的时间。公平并发设置对多核处理器的影响更大,因为会有更多的交互,也因此,一个线程有更多的机会去发现其他的线程是否需要锁。

就算这样在高竞争中,公平锁能够很好的保护好程序,但性能会很差。比如,当他们使用内置锁锁住一段比较长的代码的时候,性能提高不明显,但是会有风险,就算会无限等待的风险。并发框架把这些实现留给它的用户。

4.2 并发类

以下讲述J.U.C并发类如何使用这个框架:

ReentrantLock用并发状态来(递归)计算锁的数量。获取锁的时候,它会记录线程身份,检测是否需要递归获取锁,如果有别的线程想要释放锁,它也会侦测这个非法的状态。这个类也用到ConditionObject,提供其他的监测和观察方法。该类内部定义了两种不同的AQS的子类来设置它的公平模式还是非公平模式,每个ReentrantLock在构造的时候就会选择合适的模式。ReentrantReadWriteLock使用16位的并发状态来持有写锁的数量,剩下的16位持有读锁的数量。WriteLock的构造和ReentrantLock一样,ReadLock使用acquiredShared方法来允许多线程读。

Semaphore类(一个计算信号的类)用并发状态来持有当前锁的数量,它定义acquireShared方法,如果锁的数量大于0时,则减少持有的锁的数量,或者,当数量为负数时,就会阻塞。以及tryRelease来增加锁的数量,如果数量大于0,可能进入非阻塞状态。

CountDownLaunch类用并发状态来持有当前锁的数量,当锁的数量为0时,所有的获取操作都可以完成。.

FutureTask用并发状态表示一个future的运行状态(初始化、运行、取消、完成状态)。设置或者取消一个future会调用release。非阻塞线程通过acquire等待计算值。

SynchronousQueue类,内部等待节点来匹配生产者和消费者。用并发状态来执行生产者,或者执行消费者。

用户也可以用J.U.C包定义自己的并发类,如果上面这些类都不适合的话。包里的类提供了各种各样的win32事件,binary latches,中心管理锁及基于树barriers。

5、性能

并发框架提供了各种形式的并发类以及不可变的排斥锁。检测和对比锁的性能非常简单。但还有很多不同的方法来进行对比。这里我们设计它的最大吞吐量。

每次测试时,每个线程使用nextRandom(int seed)方法重复更新一个随意数值:

int t=(seed %127773) * 16807 – (seed / 127773) * 2836;

return (t>0)?t:t+0x7fffffff;

线程每次迭代更新,可能会是在排斥锁下的共享迭代器,或者只更新它的局部迭代器而不用锁。这会导致短时间的锁,如果线程优先持有锁则可以让影响最小。用锁(共享变量)还是不用锁(局部变量),是不固定的,这样可以决定我是否需要锁,及让代码运行在循环之中。

对比一下四种锁:Builtin,用synchronized阻塞;Mutex,用第四节展示的简单的Mutex类;Reentrant,用ReentrantLock;Fair,用设置为“公平”模式的ReentrantLock。所有的这些测试使用Sun J2SE1.5JDK的“server”模式来建立(大致与beta2一样)。刚开始我们用20个测试项目独立运行来评估性能。测试每个线程跑一千万个迭代,公平模式下的线程执行一百万个迭代。

测试在X86的机子上,及四个UltraSparc的机子。所有的x86机子运行在linux上,使用RedHat的kernel2.4和包来运行。所有的UltraSparc机子运行Solaris-9。所有的系统在测试时至少加载过一次。测试不需要跑空。双高性能线程Xeon和4台机子运行效率一样。这里我们不准备让他们完全一样。如下所示,并发消耗的性能并不会因为处理器的个数、类型和运行速度而不同。

5

5.1 消耗

非竞争式的测量方式就是只跑一个线程,将S设置为1,将线程迭代所花费的时间减掉S=2的时间之差。表二显示每个并发代码里的锁与不使用并发的代码的时间消耗,以毫微秒为单位。Mutex类最能够测出这个基本框架的消耗。可重入锁记录当前线程,以及错误检查的额外消耗,以及公平锁第一次检查一个队列是否为空的额外消耗。

表2也显示了tryAcquire和构建内置锁的“快速路径”的对比。在这里,他们之间的不同大部分反映为在锁和机器之间使用不同的原子性指令和内存栅栏。在多核处理器中,这些指令倾向于完成所有其他指令。Builtin和并发类主要不同在于Hotspot用compareAndSet来加锁和释放锁,而这些并发类用compareAndSet进行获取锁操作和volatile写操作(比如,多核处理器上的内存栅栏,以及所有进程上都受到限制的重排)。性能消耗存在机器之间的不同。

而另一个极端,表3显示的是S=1时同时有256条线程并发运行,锁竞争非常激烈情况下的性能消耗。在完全饱和的情况下,bargingFIFO锁相对Builtin锁来说性能大大降低,比公平锁也少两个数量级。这表明bargingFIFO策略在维护线程运行,甚至是在充满竞争的环境下的优越性能。

表2 在没有竞争下每个锁的时间消耗(以毫微秒为单位)

5.1-t2

表3 锁在饱和竞争下的时间消耗(以毫微秒为单位)

5.1-t3

表3显示的是即使内部消耗很低,上下文切换所需的时间也和公平锁性能有关系。列表显示

这些阻塞和非阻塞线程在不同平台下所花费的时间的大致比例。而且,接下来的实验(只使用四核机器)显示:在这里仅仅持有锁,公平设置的锁对整个性能影响变化幅度不大。线程间执行结束时间的不同可以粗粒度的衡量锁的不同。4P的机器标准性的划分 0.7%的比例用于公平锁,6.0%的比例用于可重入锁。反过来说,类似的长时持有的锁,可以测试为:每个线程持有一个锁,然后进行16K的任意数字的计算。这样,总运行时差不多为:公平锁9.79s,可重入锁9.72s。公平模式性能不定,但比可重入锁消耗小,平均只有0.1%的比例,而可重入锁平均上升到29.5%。

5.2 输出

绝大部分的并发的使用都是介于完全没有竞争的状态和满负荷竞争的状态之间。可以通过以下两种方式进行实验测试:1,改变一个固定的线程集合的竞争比例;2,给一个固定的竞争比例的线程集合添加更多的线程。为了证明这些效果,通过运行不同的竞争比例的线程,以及不同数目的线程进行测试,所有的线程都使用可重入锁。用slowdown标识实现指数:

5.2

这里,t是可观察到的所有执行时间,b是一个线程在没有竞争和并发的情况下的基础线时间。p是进程数,S标识共享获取的比例。这个值就是按照Amdahl定理,一个混合着串行执行和并发执行的工作中,观测时间和(通常情况下无法达到的)理想执行时间之间的比例。理想时间模型表明执行的时候没有任何并发消耗,没有线程因为彼此冲突而受到阻塞。尽管这样,在非常低的竞争条件下,极少的测试结果都表明了和这个理想时间相比,消耗的时间会有一点点突然变大的情况,大概由于在基本线与测试运行之间的不同的优化、导管等 导致的微小差异导致。

用 log2N为函数。比如,N=1.0,表示测量的时间为理想时间的两倍,N=4.0,表示比理想时间慢16倍。用基于基本时间的log函数(这里基本时间表示计算任意个数的基本时间),所以不同的计算就可以显示出不同的倾向性。测试使用竞争比例从1/128(或者用“0.008”表示)到1,,每测试一次,竞争比例翻倍,使用的线程数从1到1024,每测试一次,线程数翻倍。

在单核处理器中,当增加竞争,性能就会下降,但随着线程的增多,性能的降低也会减缓。在多核处理器中,在竞争情况下性能下降更加剧烈。多进程的图像表明在只有少数线程的竞争情况下的一个早期的顶峰,也就是性能最低的地方。反映了性能的渐变区域,在这个区域barging和唤醒的线程差不多平等的获取锁,所以频繁的强制对方阻塞。在大多数情况下,这个渐变区之后是平滑的区域,因为锁几乎没有喘息之气,导致获取锁近似于多进程下的串行模式。需注意的是本例中满负荷的值(也就是“1.000”),在少数进程机器中性能下降非常快。

6-1u6-1p

6-2p6-2a

6-4p6-4u

6-8u6-24u

这些结果所展示的是,阻塞的进一步优化(park/unpark)能够减少上下文切换及其消耗,可以有效提高这个可能的性能。而且,它也能够优化多进程中并发类采用一些形式的自旋来完成简单的持有但高竞争环境下的锁的开销。但在不同的上下文切换中自旋锁性能并不好,所以在这种情况下需要用户自己建立自己的锁,各自应用的需求不同,所以也鼓励不同的实现。

6、结论

正如本文所说,J.U.C并发框架太新了,没有能够应用到实际上。不太可能把它广阔的所有用法全都提到,但在J2SE1.5中都会有,而且也会按照这个设计来提供API实现和性能。然而,在这里,框架的成功之处在于提供一个高效并发的实现。

原创文章,作者:kepupublish,如若转载,请注明出处:https://blog.ytso.com/140704.html

(0)
上一篇 2021年9月5日
下一篇 2021年9月5日

相关推荐

发表回复

登录后才能评论