原文点击这里
摘要
J2SE1.5 java.util.concurrent包中大部分的同步工具(locks,barriers等等)都是基于AbstractQueuedSynchronizer类(下面简称AQS)构建的。这个类提供了用来管理"同步器(Synchronizer)"状态,阻塞和唤醒线程以及管理同步队列的通用机制。本文介绍了该类的一些基本设计思路、实现、用法以及一些性能方面的考量。
1.简介
J2SE1.5 引入了J.U.C包,提供了一系列用来支持并发操作的"同步器"。
这些"同步器"都基于一个基础框架:1.包含一个同步状态;2.提供修改和查看同步状态的方法;3.提供阻塞当前线程和唤醒等待锁的线程的方法;
这些"同步器"包括:mutex locks,read-write locks,semaphores,barriers,futures,event indicators,SynchronousQueue等.
几乎每种类型的"同步器"都可以用其他类型的"同步器"来实现,例如,semaphores可以实现reentrant locks,反之亦然。但是,这么做通常比较复杂,性能也比较差,不够灵活。所以JSR166提供了一个很小但是又很全面的基础类AbstractQueuedSynchronizer,提供了以上这些"同步器"需要的大部分机制的实现,"同步器"只需补充实现各自的特性即可。
本文剩下的部分讨论下AQS的功能需求,设计和实现的主要思路,一些简单的用法,还有一些测试用来展示它在性能方面的特征。
2.需求
2.1 功能
"同步器"至少提供两种类型的方法:1.至少一个acquire操作,这个操作获得锁,如果”锁“已被其他线程占用,则阻塞当前线程等待持有"锁"的线程唤醒。2.至少一个release操作,改变同步状态,并且唤醒一个或多个等待的其他线程。
J.U.C包没有定义一个统一的API来规定这些"同步器"的方法和功能。所以它们有的是通过实现一个通用接口(如Lock),有些是直接实现了特定的功能。所以,acquire和release在不同的"同步器"里可能会有不同的名字。例如,Lock.lock,Semaphore.acqure,CountDownLatch.await,和Future.get 都是与acquire类似的方法。
但是,J.U.C包也保持了一些一致的惯例。每种"同步器"必须支持:
- 既支持阻塞也支持非阻塞(tryLock)
- 可选的超时时间:以便应用可以放弃等待
- 响应线程中断:通常提供两个不同的acquire方法,一个支持中断一个不支持
"同步器"既可以是排他的,也可以是共享的。例如,ReentrantLock是排他的,而Semaphore是共享的。为了适用性更广,AQS必须同时支持这两种模式的"同步器"。
J.U.C包还定义了一个接口Condition,配合ReentrantLock实现"monitor"风格的"await/signal"操作。
2.2 性能
Java内建的monitor长期以来一直在性能方面有很多考量,但是这些考量主要集中在单核单线程环境下的最小化内存使用和最小化时间消耗上。这些其实不是很重要:1.程序员们只有在需要使用的时候才会使用锁,所以对于内存的优化没有很大意义;2.越来越多的程序是运行在多核多线程的环境下,在这种环境下资源竞争是不可避免的。但是过去的JVM对于锁优化的策略主要基于没有资源争用的情形,而对其他的情形并没有很好的可控的优化手段。(没有研究过JVM monitor实现,这段翻译的可能不是很准确QAQ)
而我们这里的主要性能目标是可扩展性:即使在资源竞争激烈的情况下,也要保持可预测的性能。最理想的情形是无论有多少线程请求这个"锁",它们的处理时间都是常量级的。在这些目标中有一个是要尽量减少那些可以获得锁但是还未完成的线程的处理时间的总和(减少"锁"的空闲时间)。当然,这里也需要在总的CPU耗时,内存负载以及线程调度的耗费之间作出一些权衡。例如,自旋锁通常可以缩短获取锁的时间,但是会让CPU空跑,也会产生一些内存争用,所以应用的场景比较少。
另外,我们需要考虑两种不同类型的使用场景。第一种是大部分应用的需求,需要最大化吞吐量,而对于可能产生的"线程饥饿"可以容忍。另一种是对于那些资源控制类型的应用,需要保证每个线程获取资源的公平性。没有一种策略可以同时满足这俩种互相冲突的目标,所以AQS需要提供这两种不同的公平性策略。
无论框架设计得多么好,在一些实际的应用中,一定会存在性能瓶颈。因此,框架需要提供方法来监控和检查一些基本操作,让使用者可以发现并且缓解性能瓶颈。例如,至少得提供个方法让用户可以知道有多少线程在阻塞。
设计与实现
"同步器"的基本思路非常简单。
有一个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;
想要支持这两个操作需要三个基本组件的配合:
- 原子地更新同步状态
- 阻塞和唤醒线程
- 管理线程队列
也许可以设计一个框架将这三个组件分开各自实现,但是这并不高效也没有必要。比如,唤醒某个线程依赖于队列中该线程所在节点的状态,而开放出来的方法(public或protected)也依赖于同步状态。
这里需要做的主要决定是为这三个组件每个选择一个具体的实现,可能牺牲了一些适用性,但是能保证足够的效率,所以当实际使用的时候,如果场景合适,几乎没有理由不用它而去自己从头到尾实现一个。
3.1 同步状态
AQS用一个int值来表示同步状态,并且开放了getState,setState和compareAndSetState方法来获取和更新它。这个状态值是volatile的,符合JSR133中volatile的语义。并且compareAndSetState基于CAS指令实现了该状态的原子更新。
将状态限定为int是个务实的决定。JSR166也提供了对于long类型的原子操作类,但是在很多平台上这些操作在JVM内部实现的时候有一些lock,性能会差一些。未来,有可能会提供另一个基于long型状态值的"AQS"。但是,目前还没有足够的必要性去添加这个特性,在绝大部分应用场景下,int已经足够满足需求----只有一个CyclicBarrier需要更多的字节去表示状态,所以我们直接使用了其他的”锁”来实现(用多个其他"锁"的实例来代理状态,而不是直接实现或使用AQS,J.U.C中大部分的高级API都是如此)。
基于AQS的同步器实现类必须实现两个方法tryAcquire和tryRelease,通过使用开放出来的状态检查和更新方法来实现acquire和release操作。如果获取"锁"成功,tryAcquire方法必须返回true;如果成功"解锁",release方法也必须返回true。这些方法都接收一个int类型参数,用来沟通需要的状态。例如,在Reentrant Lock中,如果某个线程acquire成功,则它在持有"锁"的过程中可以利用此参数一次性acquire多个或多次acquire,也可以利用此参数一次性release多个或多次release。很多"同步器"实现不需要这个参数,直接忽略即可。
3.2 阻塞和唤醒线程
直到JSR166,还没有可用的Java API用来阻塞和唤醒线程。唯一的候选者是Thread.suspend和Thread.resume,但是他们不能解决一个问题:如果一个活动线程在suspend之前调用resume,则这次resume操作没有任何作用。
J.U.C包引入了一个LockSupport类来解决这个问题。LockSupport.park方法可以阻塞当前线程直到LockSupport.unpark方法来唤醒它(包括虚假唤醒)。unpark不会被"计数",所以在park之前无论调用多少次unpark只能唤醒一次park。另外,这俩方法都是针对线程而不是"同步器"的。一个在新的"同步器"上调用了park的线程可能会立即返回,因为有可能之前还有个"剩余"的unpark,它的下一次park才会阻塞。虽然可以显式地清除unpark的状态,但是更优雅的方法是多次调用park方法。
这种机制类似于Solaris-9的线程库,win32的“可消费事件”和linux NPTL的线程库,所以它能很好地映射到这些平台库上,提供良好的运行性能(但是,目前的Sun HotSpot JVM在Solaris和Linux上的实现实际上使用了一个pthread条件变量,因为需要适配现存的运行时设计)。park也支持可选的相对或者绝对的超时时间,并且响应JVM的Thread.interrupt。
3.3 同步队列
AQS的核心是对同步队列的管理,且队列已被限定为先入先出队列。所以,AQS不支持基于优先级的同步。
毫无争议,最适合用来做同步队列的数据结构是那些不需要依赖更低级的"同步器"的。所以,现在有两个主要的候选者:MCS 和CLH。历史上,CLH只被用于过自旋锁。但是,它看起来比MCS要更经得起考验,因为它可以很轻松地处理取消和超时,所以我们选择CLH作为同步队列的基础。
CLH队列不是一个"单纯"的队列,因为它的出队和入队操作都与它的作为"同步器"的功能紧密联系。它是一个linked队列,包含两个原子更新的属性head和tail,它们一开始都指向一个无意义的节点。
新的节点可以通过原子操作入队:
do {
pred = tail;
} while(!tail.compareAndSet(pred, node));
当前节点的release状态由它的前置节点管理,所以,"锁"的自旋如下:
while (pred.status != RELEASED) ; // spin
在自旋之后的出队操作就是简单地把head设置成当前获得"锁"的节点即可:
head = node;
CLH最主要的优势在于出队和入队非常快,而且没有任何锁的开销,也不需要阻塞线程(即时是在有竞争的情况下,因为总会有一个线程插入成功);检查有没有线程在等待也很简单(只需要判断head和tail是否是一样的即可);而且release状态是非中心化的,避免了内存争用。
在原始版本的CLH中,节点之间甚至没有连接。在自旋锁中,前置节点被作为一个局部变量。但是,Scott和Schere发现通过显式地将前置节点与当前节点连接,CLH可以处理超时或者其他形式的取消操作。因为如果一个节点的前置节点被取消了,这个节点可以继续找到"前置节点"的"前置节点"的状态。
AQS对于CLH最主要的改动在于提供了一个有效的方法可以使一个节点找到它的后继节点。在自旋锁中,一个节点只要修改了它的状态,就会被它的后继节点在下一次循环时注意到,所以不需要将当前节点与它的后继节点连接起来;但是在阻塞的"同步器"中,一个节点必须显式地唤醒(unpark)它的后继节点。
AQS的节点有一个next属性关联到它的后继节点。但是没有什么技术可以"无锁地""原子地"向双向队列中插入一个节点,所以这个属性没有作为原子性插入节点的一部分(pred与next只需要保证一个原子更新成功即可,另一个直接赋值);只需要在节点插入之后简单地赋值即可:
pred.next = node;
next连接通常只被当做一种优化的访问路径。如果一个节点的后继节点不存在了(可能被取消了),可以通过从tail遍历每个节点的pred属性找到一个实际存在的节点。
第二个改动是在每个节点中使用一个status属性来控制线程的阻塞和唤醒,而不是一直自旋。在AQS中,队列中的节点只有在tryAcquire返回true且acquire成功才能返回,所以仅仅只有一个release位是不够的(release状态只能支持acquire操作,tryAcquire需要依赖其他状态)。而且需要保证一个活动的线程只有当它在队首的时候才可以tryAcquire,也可以通过检查当前节点的前置节点是否是head来确定。
final boolean acquireQueued(final Node node, int arg) {
boolean failed = true;
try {
boolean interrupted = false;
for (;;) {
final Node p = node.predecessor();
if (p == head && tryAcquire(arg)) {
setHead(node);
p.next = null; // help GC
failed = false;
return interrupted;
}
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
interrupted = true;
}
} finally {
if (failed)
cancelAcquire(node);
}
}
与自旋锁相比,这样减少了读取head的内存争用(只读取一次,而自旋锁在一直循环读取)。但是,取消的状态还是需要在节点中维护。
队列中节点的状态还被用来避免对park和unpark不必要的调用。虽然这些方法跟线程的阻塞原语(操作系统级别)性能相当,但还是不可避免地有一些横亘在Java代码和JVM运行时之间的耗费。所以在调用park之前,线程先设置一个signal me状态位,然后检查一次同步状态和节点状态,最后再park。release操作会清除这个状态位(所以,如果某个线程在设置signal me和park之间,持有锁的线程调用了release,并且正好unpark了这个线程,则节省了一次线程阻塞和唤醒的消耗)。这种做法可以避免很多不必要的阻塞,尤其是对于那些耗费了很多时间在等待下一个合适的线程获得锁的"同步器"的实现中(对于这类"同步器",阻塞应该尽量避免,因为阻塞了之后,等待下个线程来唤醒的话可能会比较耗时)。这样还可以避免一些release操作,因为release需要节点设置了signal状态,所以我们可以跳过那些cancelled的节点,除非signal和cancel同时发生了。
也许AQS与其他语言的CLH变种之间的最大区别是依赖垃圾回收器管理节点的内存回收,这避免了一些复杂性和性能消耗。然而,虽然有GC,但是还是需要将确定不再用到的属性置为null。这个在节点出队的时候显然肯定会做到,但是,对于那些已经取消的节点却仍然可以被访问到,导致他们不能被回收(需要手动置为null,help gc)。
还有一些更进一步的小优化,包括将head和tail的初始化延迟到第一次有线程等待时。
忽略这些细节,最终对于基本的acquire(排他,不响应中断,不超时)的大致实现:
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的不同实现的话,acquire和release中的单个操作分摊到每个线程中都是常量时间O(1)。
对于取消的支持主要是在park的时候需要响应中断和检查超时。一个由于超时或中断被取消的线程会设置节点的状态,并且唤醒它的后继线程,所以它有可能会重置这些连接。因为有被取消节点的存在,找到有效的前置节点或者后继节点并修改状态可能会花费O(n)次的遍历(n是队列长度)。如果某个操作因为超时或中断被取消了,通常直接返回false或者抛异常,不会再去relock,所以节点的连接和状态属性可以很快重建好。
3.4 条件队列
AQS提供了一个ConditionObject类来实现排它锁,并且遵循Lock接口的API。一个锁可以绑定任意多的条件对象(condition object),可以提供monitor风格的await,signal和signalAll操作,包括这些操作的超时版本和一些监控和检查的方法(详见J.U.C的Lock接口)。
ConditionObject类可以用来与其他同步器配合,修复一些设计缺陷。这个类支持monitor风格的访问,当且仅当当前线程拥有锁的时候。将ConditionObject与ReentrantLock结合起来可以完成与Java内建monitor相同的操作(像Object.wait等),只是在名称上略有不同,而且还有个额外的功能:用户可以对一个锁声明多个条件对象(内建只有一个)。
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;
因为这些操作只在当前线程持有"锁"时才会发生,所以不需要考虑线程同步的问题,只需要按顺序将每个节点连接起来即可(在节点中用一个nextWaiter属性)。transfer操作只需要将条件队列中的第一个节点转移到它所关联的锁的同步队列尾部即可。
实现这些操作最复杂的部分是处理超时和中断引起的等待取消。一个取消操作如果与signal操作发生的时间非常接近的话,可能会产生竞争,它们的竞争结果符合Java内建monitor的规范。JSR133修订版中规定,如果中断发生在signal之前,那么await方法必须在被唤醒时抛出InterruptedException;如果中断发生在signal之后,那么await必须无异常地返回,但是需要设置它的线程中断状态。
为了保证合适的处理顺序,节点有一个”transferred“状态(或者正在被transfer)(waitStatus里没有详细指出来,看代码应该是直接用初始状态0代替了)。signal和cancel都会去CAS这个状态。如果signal失败了(cancel先发生),则它会继续signal下一个节点(如果有的话);如果cancel失败(signal先发生),则放弃这次cancel,重新去获取锁(如果是超时引起的cancel,显然,因为signal在超时之前,所以超时不成立;如果是中断的话,中断状态被设置就可以了(更底层的代码已经处理过了),如上节所述)。后者可能会自旋很久:只有在signal过程完成之后,"cancelled"的节点才可以去重新请求锁。这种情况很少,而且使用了Thread.yield让出取消节点的线程的CPU时间片,最理想的情况是正好被signal的节点的线程轮转到,那么显然自旋很快就会结束。虽然可以实现一些策略来帮助解决取消节点的自旋,但是这种情形太少见了,没有多大意义,反而会增加些额外的消耗。在其他任何情形下,都不会有自旋或yield,所以在单核的情况下能够保证不错的性能。
(这里看了下代码,详细解释一下。首先对比一下lock和condition的实现。lock对应acquire和release,condition对应await和signal。acquire和await都有不响应中断(准确地说只是不抛异常,还是会唤醒并设置中断状态)和超时、只响应中断(抛异常,下同)、同时响应中断和超时三种版本,功能也基本相似。但是release的实现与signal的实现有所不同,release直接唤醒等待线程,如果将要唤醒的节点因为超时或中断取消了或正在取消,其实线程已经是运行状态了,可以忽略这次release;而signal有个transfer阶段,将需要signal的节点转移到Lock的同步队列中,而await可能会发生的cancel(超时和中断)也有一个transfer阶段(也是转移节点到同步队列),这两个transfer显然会产生竞争(如上节所述),所以不能像release那样直接忽略就可以了(ParkSupport.unpark一个活动的线程会立即返回,但是可能会导致下次的park被抵消掉,所以park需要自旋(见3.2))。
4. 使用
AQS类将上述的功能结合在一起并用"模板模式"作为所有"同步器"的基础类。子类只需要实现状态的检查和更新操作来控制acquire和release即可。但是,这些子类不会直接继承AQS,因为AQS类有一些公开的方法,这些方法用来控制acquire和release的策略,不能直接暴露给普通用户。所以J.U.C中的"同步器"都声明了一个继承AQS的内部类,"同步器"的功能用这个内部类来代理。这样不仅对用户屏蔽掉了复杂的实现细节,而且还可以根据需要定义任意名称、任意功能的方法。
例如,下面是个最小的Mutex类,用状态是否为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); }
完整版本的Mutex例子和用法可以在J2SE文档中找到,当然可能会有一些变化。例如,tryAcquire可能会使用"test-and-test-and-set"来更新状态。
你可能会有些惊讶一个性能敏感的"同步器"竟然会用"代理"和"虚方法"的组合方式来实现(代码比较繁琐)。但实际上,这些都是现代动态编译器长时间聚焦的面向对象编程的方法。他们擅长于优化并消弭掉这些性能损耗,尤其在这些被频繁使用的"同步器"上。
AQS类还提供了很多方法可以帮助各种"同步器"控制同步策略。例如,它同时支持了不响应中断(准确地说只是不抛异常,还是会唤醒并设置中断状态)和超时、只响应中断(抛异常,下同)、同时响应中断和超时三种版本的acquire方法。还有,虽然讨论到现在的一直是排他锁,但是AQS同样包含了一系列支持共享锁的方法(如acquireShared和releaseShared),它们使得可以允许多个线程acquire,并且可以级联地唤醒多个等待的线程。
虽然通常不会直接序列化一个"同步器",但是在配合构成其他类例如线程安全的集合类的时候,序列化是很有必要的。AQS类和ConditionObject类提供了一些方法序列化"同步器"的状态,但是没有序列化阻塞的线程或者其他的一些瞬时状态。实际上,大部分的"同步器"反序列化的时候都是重置状态到初始值,这与内建monitor的反序列化策略一致:总是反序列化到未锁的状态。
4.1 公平性
尽管AQS是基于先进先出队列的,但并不一定是公平的。在acquire入同步队列之前会先tryAcquire。所以如果恰好在解锁的过程中有一个线程tryAcquire,则会“窃取”这次加锁的机会。
这种策略叫做"barging FIFO",通常可以提供更高的吞吐量。它主要利用了"解锁过程"这一段时间,在这期间允许其他线程"窃取"锁,无需入队出队,从而减少了整体的入队出队时间。这种策略在每个线程只需要短暂地持有"锁"的应用中尤其有效,相反地,如果每个线程需要持有很长时间的"锁",这种策略则会造成很多"解锁"失败的情况,可能会起负面作用。一个简单的衡量方法是看平均"解锁时间"与平均"持锁时间"的比例,如果大于1的话这种策略效果显著。不过这种策略也会产生一个问题,如果新线程进入的频率太快,则可能会造成同步队列的head节点陷入"unpark-acquire failed-park"循环中,永远拿不到锁,造成“饥饿”。在多核环境下,这种"短暂持有锁"的场景中,解锁期间出现多次"窃取"和release是很平常的。而即使是在那些非常耗时的IO操作的"锁"中,实际上也没有办法完全避免"窃取"和"饥饿"。(但是,在多核环境下,"barging"的策略显然可以更高效地利用CPU,不至于让一个核在解锁的过程中,其他核只能干等待(假设没有其他应用的情况下))
当需要严格控制公平性的时候,处理起来反而简单一些。程序员可以定义tryAcquire方法,如果当前线程恰好是head节点中的线程才可以acquire,这样就避免了其他线程来"窃取"锁。
另一种更快的、更宽松的变种是当队列(暂时)是空的时候tryAcquire也可以成功。这种情况下,很多线程会同时遇到空队列,并且去同时acquire,通常它们中至少有一个不用出队入队。J.U.C中的所有"同步器"在公平模式下都是采用这种策略。
虽然"公平锁"在实践中看起来很有用,但其实是没有办法彻底保证公平的,因为JLS并没有提供线程调度层面的保证。例如,尽管使用一个严格公平的”锁“,但是JVM也可以简单地顺序运行这些线程。实际上,单核环境下,这些线程每个都会运行一段时间片,如果某个线程持有一个排它锁,也会很快被切换到后台,只有释放锁并阻塞的时候才感知到另一个线程需要锁,因此线程调度实际上进一步增加了锁的”空闲“时间(类似之前讨论的”解锁“时间,这里是调度时间,当其他线程去请求锁的时候,实际上可能持有锁的线程已经完成所需的操作,不再需要持有锁,但是因为没有调度到它,就使得其他线程还需要去阻塞)。锁的公平性在多核的环境下看起来表现得好一些,因为多核之间会有很多同步操作,从而有更多的机会使得一个线程发现另一个线程正在请求锁。(这节真的没看懂,瞎翻的- -!)
尽管在"短暂持锁"和竞争激烈的场景中,公平锁的性能可能比较差,但是在其他场景下,表现得都不错。例如,在"长期持锁"的场景中,"barging FIFO"几乎没有性能优势,反而会增加"饥饿"的风险,而公平锁则无此虞。用户可以根据具体场景选择适合的"同步器"类型。
4.2 锁
这里是J.U.C中所有"同步器"的概览。
ReentrantLock类使用同步状态记录lock次数(递归)。当锁被acquired,它也会记录当前持有锁的线程的id,用来检查递归调用,或者请求解锁的线程是否与当前线程一致。这个类还提供了ConditionObject类和一些检查和更新锁状态的方法。这个类也提供两种类似的锁--公平和非公平的。
ReentrantReadWriteLock类使用同步状态的16位记录读锁的状态,剩下的16位记录写锁的状态。它的实现与ReentrantLock类基本一致。ReadLock类使用acquireShared方法支持多个读者。
Semaphore类使用同步状态记录一个计数。它定义了acquireShared方法去减少这个计数,或者当计数非正时,阻塞当前线程;tryRelease方法增加计数,如果计数为正,则可能解锁当前线程。
CountDownLatch类使用同步状态记录一个计数,当计数为0时所有等待线程acquire成功。
FutureTask类使用同步状态表示Future的运行状态。用release设置或者取消future,用acquire唤醒等待future值的线程。
SynchronousQueue类使用内建的"等待节点"匹配生产者和消费者。它使用同步状态允许一个生产者在一个消费者消费了一个item之后继续生产下一个item,反之亦然。
用户当然也可以根据J.U.C的类实现自定义的锁。例如,很多曾经被考虑过但是没有被采纳进J.U.C的类,它们提供了与 WIN32 events, binary latches, centrally managed locks, and tree-based barriers 类似的语义和功能。
5.性能
虽然除"锁"之外,J.U.C也提供了很多其他风格的"同步器",但是"锁"的性能是最容易衡量和对比的。有很多方法都可以衡量"锁"的性能。下面的实验揭示了"锁"的耗时和吞吐量。
在每个测试中,每个线程都重复地去更新一个nextRandom(int seed)产生的伪随机数:
int t = (seed % 127773) * 16807 – (seed / 127773) * 2836;
return (t > 0)? t : t + 0x7fffffff;
在线程的每个更新循环中,线程有S的概率去更新一个加了排它锁的共享的值,或者更新一个无锁的局部变量(1-S)。这样会使得需要加锁的代码块很小,尽量减少无关的影响(譬如线程正持有锁,但是被其他线程抢占了CPU)。这个随机数方法提供了两个功能:1.可以用来决定是否需要加锁;2.可以使得循环的代码不会被JVM优化。
该实验会比较四种锁:内建monitor(用synchronized关键字);Mutex(使用简单的Mutex类,就像前面提到的那样);Reentrant(使用ReentrantLock类);和Fair(使用ReentrantLock类的公平模式)。所有的测试基于J2SE 1.5 JDK 的 build46 版本(与beta2版本几乎完全相同)的"server"模式。测试程序会在正式收集数据前零竞争地运行20次,消除"热身"的影响。除了公平模式只会循环一百万次以外,其他测试中每个线程都会循环一千万次。
测试程序跑在四个x86的机器和四个UltraSparc的机器上。所有x86机器都运行基于RedHat NPTL-based 2.4内核的Linux系统。所有的UltraSparc机器都运行Solaris-9系统。所有系统在测试时都没有其他负载。"4P"的意思是支持超频,可以将双核的机器表现得像四核一样。这里没有试图去统一这些机器配置。因为主要看的是相对耗时。
5.1 耗时
零竞争的耗时只用一个线程去测量(避免任何竞争),用S1(S=1,下同)的耗时减去S0的耗时。表2展示了这些数据。Mutex类的S0与S1的差距是最小的。ReentrantLoc的额外耗时在于需要记录当前线程和错误检查,公平模式的额外耗时在于检查队列是否为空。
表2也展示了tryAcquire和内建monitor的"fast-path"之间的耗时对比。这里的区别主要反映了不同机器上不同锁使用的原子操作指令和内存屏障的耗时。在多核环境下,这些指令(的耗时)会显著多于其他指令。内建monitor与其他锁之间的区别主要是内建monitor在加锁和解锁的时候都使用CAS,而基于AQS的其他锁只在加锁时CAS,而在release时使用volatile来保证状态的可见性即可。它们的相对和绝对耗时在不同的机器上可能会有不同。
表3展示了在另一个极端(S1,256个同步线程)的情况下,每次加锁的时间。在这种充分竞争的情况下,"barging-FIFO"比内建monitor大约少了一个数量级的耗时(而且吞吐量也更高)比公平锁少了两个数量级的耗时。这显示了在竞争激烈的环境下"barging-FIFO"仍然可以提供相对高效的处理性能。
表3还显示了虽然线程调切换只需要很少的内部耗时,但是在公平锁中却几乎完全决定了锁的性能。列出的(公平锁的)耗时与不同平台上的阻塞线程和唤醒线程耗时成正比。而且,后续的实验(只在4P上做的)还发现在"短暂持锁"的情况下,公平锁的整体耗时波动性很小。每个线程的运行时间被记录下来作为一个粗略的衡量波动性的变量。在"4P"上公平锁的标准差与平均值的比值为0.7%,而ReentrantLock是6.0%。作为对比,模拟下"长期持锁"的情况,每个线程在持锁过程中生成16k个随机数。总的来说运行时间没什么差别(公平模式9.79s,非公平模式9.72s)。公平模式的波动性还是很小,标准差与平均值的比值为0.1%,而非公平模式是29.5%。
5.2 吞吐量
大部分的"同步器"使用场景都是在零竞争与饱和竞争之间变化的。可以在两个维度上去测试:1.保持线程集合不变,调整线程的同步概率S;2.保持同步概率不变,调整线程集合。为了量化这些影响,我们用ReentrantLock做了很多不同同步概率S或不同线程集合的测试。下面的图表用了一个slowdown指标:
t是总的执行时间,b是没有竞争或者同步的基线,n是线程数。p是核数,S是同步概率。slowdown值是观测到的执行时间与用Amdahl定律计算出的对于执行串行和并行组合任务的理想执行时间的比值(没有同步耗费,线程阻塞等)。但是,在非常低的竞争下,有一些任务结果表现得比最理想的状态还要好一点,可能是因为贯穿基线与测试的一些优化手段,管道等。
这些图取的是原始值的对数值(以2为底)。使用的同步概率从1/128(0.008)到1,线程数从1到1024。
通常来说,在单核环境下,性能随着竞争的加剧而不是线程数的增加而下降。多核环境下,性能随竞争加剧下降得会更快。多核环境下的性能图表显示,性能会很快到达一个顶峰,虽然只有很少的线程在竞争。这反映了一个过渡的性能区域,在这个区域内,"barging"和"signal"有相同的机会获取到锁,因此频繁地互相阻塞。在大部分情况下,这之后是个比较平滑的曲线,因为"锁"基本上都不可用了,造成类似于单核的串行运行,在多核的机器上到这一节点要晚一点。还可以看到,例子中竞争饱和的性能图表显示出在更少的处理器的环境下相对更低的slowdown值。
从实验结果可以看出,如果可以优化park/unpark操作,使耗费在线程切换上的时间减少的话,应该可以提高框架的性能。而且,如果可以优化多核环境中"短暂持锁"且竞争激烈场景下的行为,尽可能避免失败的话,也能提供一定的性能提升。虽然适配不同场景下的"自旋"是非常困难的,但是用户可以根据不同场景利用J.U.C来构建自定义的锁,从而解决特定的问题。