J2SE1.5版本提供了AbstractQueuedSynchronizer类和以此类为基础的同步框架。java.util.concurrent(简称juc)包中的大部分同步器(locks,barriers等)都是利用AbstractQueuedSynchronizer类为基础的同步框架构建的。该框架提供了实现同步框架的几种通用机制:同步状态的原子化管理、block/unblock线程以及排队。本文描述了该框架的原理、设计、实现、用法和性能。
关键字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为中心的框架,该框架提供的通用机制被包中提供的大部分同步器所采用,当然用户也可以通过该框架实现自己的同步器。
本文的其余部分讨论这个框架的要求,其设计和实现的主要依据,用法示例以及性能度量方法。
Java.util.concurrent包没有为同步器定义统一的API。有些同步器比如锁(Lock)定义了通用的接口,有些只包括具体的实现类方法名称。所以,acquire和release操作在不同的类中有不同的名字和形式。框架中acquire类似的方法名字包括:Lock.lock、Semaphore.acquire、CountDownLatch.await和FutureTask.get等。方法名虽然不同,但是在框架都保持一致的语义以支持通用的用法。每个同步器都支持:
Java内置锁(通过synchronized方法或者synchronized块访问)在性能上一直受到诟病(从J2SE 6开始已经进行了改进,性能不弱于同步器框架提供的Lock)。有数量可观的论文讨论内置锁的实现,然而这些论文主要集中讨论的是空间的优化(每个java对象都可以当做一个锁)和单处理器单线程上下文时间的优化。同步器实现的关注焦点不在这里。程序员只有在需要的时候才使用同步器,压缩空间优化的提升有限,而且同步器几乎完全用于多线程环境,尤其是越来越多的应用于多处理器环境下,偶尔的竞争是不可避免的。因此,通常的JVM优化策略——主要针对零竞争的情况,其他情况采用不可预见的slow paths方式,对使用java.util.concurrent包的典型的多线程服务器应用就很不合适了。
对同步器来说,主要的性能目标是高可扩展性:保持可预见的效率,尤其是同步器在竞争条件下的效率。理想情况下,不管多少线程正在试图通过同步点,线程通过同步点的开销都是常量级的。其中主要目标之一是最小化总的时间量,也就是多个线程允许通过同步点之后真正通过同步点之前的等待时间。当然,这必须要和资源(CPU时间、内存通信、线程调度开销等)的开销做一个平衡。例如,自旋锁获取锁的时间通常要比阻塞锁少,但是却浪费大量的CPU周期空转并产生大量的内存竞争,因而不合适。
这些目标涵盖了两种通用的使用场景。大多数应用应该容忍可能出现的饥饿以最大化总吞吐量。但是在一些资源控制的应用中,维护公平性要远比总吞吐量更加重要。框架要具有灵活性,不应该替用户决定吞吐量和公平性哪个更重要,所以应该提供不同的公平策略以供用户选择。
然而不管它们内部的实现有多么精巧,在某些应用中,同步器都会带来性能瓶颈。如此,框架必须提供基本的监控和检测手段以便用户能够发现和缓解这些瓶颈。提供一个可以确定有多少线程被阻塞是最小需求(最有用的)。操作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;
update synchronization state;
if (state may permit a blocked thread to acquire)
unblock one or more queued threads;
实现上述操作需要以下三个基本组件的相互协作:
可以创建一个框架,使这三个组件独立变化,但这样做既不是有效的,也不是非常有用的。保存在节点队列的信息和唤醒操作需要的信息是交织在一起的。导出方法的签名取决于同步状态是互斥还是共享的。
同步框架的主要设计决策是选取三个组件中的一个进行具体的实现,并且使用方式上仍然允许一个广泛的选项。这故意限制它的使用范围,但是却提供一个足够高效的框架,以致于在实际适用的场景中没有理由拒绝重新发明轮子。
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.
该框架的核心是维护一个blocked线程的队列,这个队列在这里限制为FIFO队列,因此,该框架不支持基于优先级的同步。
最近,大家终于达成一致:实现同步队列的最合适的数据结构应该是非阻塞的。这个结构本身不需要低级的锁操作。这其中有两个主要选择:MCS锁(Mellor-Crummey and Scott )的变种和CLH(Craig, Landin, and Hagersten )锁的变种。历史上,CLH锁一直用来实现自旋锁。然而,由于更容易实现取消和超时操作,CLH锁看起来比MCS锁更适合用于实现同步框架的阻塞线程队列。最终的设计远远不是最初的CLH结构可以解释的,需要更详细的描述。
CLH队列非常不像队列,因为它的入队和出队操作和它的加锁/解锁的操作紧密结合在一起。 它包含两个节点head和tail,这两个节点都需要原子化的操作,初始化时指向同一个假的节点。
新的节点通过一个原子化的入队操作加入队列:
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可以快速设置完成。
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,在单处理器上有合理的性能。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字段可以反序列化。
尽管是基于FIFO队列的,同步器却不一定公平。注意基础的acquire算法,tryAcquire的执行在入队之前,这样有可能是新产生的线程获取锁,而不是本该获取锁的在队列中等待的第一个线程。
Barging FIFL策略通常比其他技术提供更高的总吞吐量。在这样一种情况下,当锁是可用的,但是要使用的线程还在处于解除阻塞的过程中,这会造成时间浪费。Barging FIFL策略可以消除这种时间浪费。与此同时,它避免了过度的,无效率的只允许排队(第一个)的线程通过release被唤醒并试图获取锁。创建同步器的开发人员可以在定义tryAcquire时简单地前重试几次然后再返回,会进一步加剧barging效果。
Barging FIFL策略存在概率性的公平性问题。一个被唤醒的线程和一个新来的barging线程拥有相同的机会去获取锁,如果竞争失败只能重新阻塞后者重试等。然而,如果产生新线程的速度比被唤醒的过程要快,那么对列中的第一个线程很少有机会赢得竞争,所以总是会重新阻塞,队列中的后续线程也将会一直阻塞。对于短时持有的同步器来说,当队列中第一个线程时解除封锁时,更多的线程会barging,尤其在多处理器上。如下图所示,净效应是维持一个或多个线程高进展的同时还至少有一定的概率避免饥饿。
当需要更公平的策略时,也不难实现。程序员可以定义严格的公平方法:如果当前线程是不是在队列的头,那么tryAcquire立即返回失败。getFirstQueuedThread可以用来检测当前线程是不是在队列头部,这是框架提供的少数检查方法之一。
更快的但是不那么严格的变种可以这样实现,如果队列(暂时)空,允许tryAcquire成功。在这种情况下,多个线程遇到一个空队列时会发生竞争谁第一个得到机会获取锁,这样至少有一个线程不需要排队。这种公平策略在所有的java.util.concurrent支持公平的同步器中采用。
尽管他们在实践中是有用的,但公平性仍无法保证,因为Java语言规范没有调度方面的保证。例如,即使是严格公平的同步,如果线程之间不需要阻塞等待对方,JVM可以决定运行一组线程纯粹顺序。在实践中,在单处理器上,一些线程可能在上下文切换前运行了一段时间。如果这样一个线程持有一个排它锁,它很快就会被切换回来,仅仅释放该锁并阻塞,因为它知道其它线程需要锁,这进一步增加了锁空闲的时间(锁可用但是没有被获取)。在多处理器上,公平性设置的影响更大,因为交错执行的可能性更大,一个线程发现其他线程需要锁的机会也更大。
尽管在高竞争环境下保护短暂持有的代码变现不加,公平锁仍可以有效工作。例如,当它们保护的代码相对较长或者锁间的间隔相对较长时,barging提供的性能优势很小,风险很大——无限期推迟。框架可以由用户自己决定用哪种方式。
当然,用户也可以自定义他们自己的同步器。
略
略
转载请注明出处: http://blog.csdn.net/zdq0394/article/details/9207633