JDK1.5之后java并发包基本上是基于AQS(AbstractQueuedSynchronizer)这个框架搭建的,AQS框架主要提供了对同步状态,阻塞和未阻塞线程/队列的原子性管理。本文主翻译 Doug Lea 大神的 AQS 框架论文原文,包括这个框架的逻辑依据,设计,实现,使用以及性能。
最近想起java.util.concurrent并发包虽然平时开发一直会用到,但其实源码还没捋一遍,然后这个包的核心就是AQS,所以细读了一下源码作者Doug Lea的《The java.util.concurrent Synchronizer Framework》一文,然后将论文按自己的理解翻译了一下。
1.简介
Java 发布 JDK1.5 引入了 java.util.concurrent并发包,它是通过JCP的JSR166提出的一组中间级并发支持类构成的集合。包组件是由一个同步器集合构成的。这些同步器其实就是抽象数据类(abstract data type),它们可以维护同步器本身内部的同步状态(比如,是否被上锁),对同步状态进行更新和查看,至少有一个方法在适当的时候(比如被上锁的时候)导致调用它的线程阻塞,当其他线程更新同步状态时(比如释放锁的时候)阻塞线程也可以被允许恢复。可以举出很多例子比如:互斥锁,读写锁,信号量,栅栏,future,事件指示器(event indicator)和切换队列。
任何同步器都可以被用来实现另一个同步器。比如,重入锁可以用来构建信号量,反过来也可以。但是这么做在复杂度,开销和灵活性方面的表现都不会更好,所以这在工程角度充其量也就是个第二选择,而且在概念上完全让人提不起精神没有吸引力。如果某个组件从本质上来讲并不比另一个更加贴近原生层面,那么开发者就不应该武断地选择它作为基本单元来构建其他组件。而JSR166发起的AQS框架就不一样了,它提供的通用机制在并发包的大部分同步器中都使用了,我们自己也可以利用AQS来自定义符合自己特定需求的一些类。
2. 必要条件
2.1 功能性
同步器具有两种方法:
- 至少一个acquire操作,它可以阻塞调用线程,除非/直到同步状态允许线程继续执行;
- 至少一个release操作,它会改变同步状态以至于可能这种改变会允许一个或多个阻塞线程解除阻塞状态。
java.util.concurrent 包并没有为同步器提供单个统一的API。其中一些通过普通接口定义(比如 Lock),而其他的同步器都有自己相应的 API 版本。所以这里说的 acquire 和 release 操作在这么多同步器中对应了一系列不同的名字和形式。比如 Lock.lock,Semaphore.acquire,CountDownLatch.await,FutureTask.get 这些方法都对应了这个 AQS 框架中的acquire操作。不过,为了支持一系列常用的使用选择,并发包在类之间维持延续了一致的约定。在有意义的情况下,每个同步器都支持一下条目:
- 阻塞和非阻塞(比如 tryLock )同步;
- 可选的超时配置,这样调用的应用可以选择放弃等待;
- 通过中断可以取消操作,通常分为两个版本,可取消的 acquire 操作,和不可取消的 acquire 操作。
同步器的种类根据是否管理其排他状态而分为两类。对于排他状态同步器,其每次只能有一个线程通过其阻塞点;对于共享状态同步器,可以一次允许多个线程运行通过。一般的锁当然只支持排他状态,但是对于如计数信号量这样的同步器,只要其计数限制允许,它就可以允许多个线程对它进行成功的 acquire 操作。为了 AQS 能更广泛地使用,框架对这两种模式都支持。
java.util.concurrent 包还定义了一个 Condition 接口,用于支持监视器风格的 await/signal 操作,这些操作与排他锁 Lock 类型的类有关,而且 Condition 的具体实现类在本质上就与跟他相关的具体 Lock 类具有剪不断的关系。
2.2 性能目标
Java 内置锁(使用 synchronized 方法和代码块)性能方面的问题已经被长时间关注,也已经有大量论文还阐述其结构(比如参考文献[1] [3])。不过,这些工作的主要关注点在于如何降低空间开销(因为任意 java 对象都可以被当做一把锁)以及如何在单核处理器的大部分单线程上下文环境下降低时间开销。对于同步器本身来说,这两个貌似都不是特别需要关注的:程序员只会在他们需要同步器的时候创建同步器,所以同步器的实例跟其他实力数量比起来肯定是微乎其微的,所以自然不用花大量精力为了这么点可能被浪费的空间去想办法对它进行压缩,此外同步器大部分都是在多线程环境下(特别是多核处理器)使用的,而在这种环境下,偶发的资源竞争本来就是在预料了之内的。所以常规 JVM 优化锁的策略主要是针对零竞争情况的,而没考虑对于其他情况而言有可能会是高不可预测性的“慢路径”[12],对于严重依赖 java.util.concurrent 并发包的典型多线程服务端应用,这种策略显然是不对的。
与常规策略不同,AQS 框架的主要目标不是降低空间时间开销,而是可伸缩性:甚至或者尤其是在同步器在被竞争的时候可预测地保证其效率。理想状态下,无论多少线程试图通过同步点,其开销都应该是一个常量。框架的主要目标之一是在某个线程被允许通过同步点而其他线程还没有通过的情况下,降低总的时间开销。但是同时也必须考虑到要平衡各种资源消耗,包括总的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;
对这些操作的支持需要以下三个基本组件:
- 同步状态的原子性管理;
- 线程的阻塞和解除阻塞;
- 队列的维护。
虽然创建一个允许这三个组件独立实现的框架是可行的。但是,这样做既不会高效也不会提高可用性。比如,队列节点存储的信息必须与需要解除阻塞状态的线程,和依赖于同步状态特性的方法签名相吻合。
这个同步器框架最核心的决定是分别为这三个组件选择一个具体实现,同时又允许在它们在使用上有大量选项可以配置。这种做法有意地限制了其适用范围,但又提供了足够有效的支持,使得实际上我们并没有理由对于某些明明就很适用这个框架的案例也不去用它而却要从头开始构建其他同步器。
3.1 同步状态
AQS类仅用一个 32bit 的 int 数据来维护同步状态,并且暴露了 getState,setState 和 compareAndSetState 三个操作来访问和更新这个同步状态。这些方法反过来依赖于 java.util.concurrent.atomic 包的支持,这个包提供了JSR133(Java 内存模型)中兼容 volatile 关键字在读写上的语义,且通过对本地 compare-and-swap 或者 load-linked / store-conditional 指令的访问来实现 compareAndSetState,使得只有在得到期望得到的值时,同步状态才会被原子性地设置为一个新的值,也就是说这个 compareAndSetState 利用了 Java 内存模型中一些指令,在 set 新值得时候,还要比较原来的旧值有没有变化,如果有变化旧不能设置新值,因为说明状态可能被其他线程修改了,需要更新旧值,然后再次调用 compareAndSetState。
严格地将同步状态限制为一个 32bit 的 int 变量是一个非常务实的决定。JSR166 也提供了对于 64bit long 类型变量的原子操作,但这些操作在满足条件的平台上其实还是必须使用内部锁机制来模拟,而且用它来做同步器性能也不会好。将来,可能会加入一个专门用 64bit 表示同步状态(带有 long 类型的控制实参)的类。不过现在就将这个类引入这个包中的理由好像不够充分。java.util.concurrent 包中只有一个同步器也就是 CyclicBarrier 可能会需要更多比特数来维护同步状态,所以这个类使用了锁(就像这个包中更高等级的工具类也是这样)。
基于 AQS 的具体类必须根据暴露的与状态相关的方法实现 tryAcquire 和 tryRelease 方法,以便控制 acquire 和 release 操作。当同步状态满足条件被获取时,tryAcquire 方法必须返回 true;当新的同步状态允许后续的 acquire 操作时,tryRelease 方法也必须返回 true。这两个方法都支持接受一个 int 参数用来传递想要设置的状态;比如对于可重入锁,为了在条件等待return之后重新获取锁,需要重新建立循环技术。很多同步器并不需要这么一个参数,所以对它直接忽略即可。
3.2 阻塞
在JSR166之前,用来阻塞和解除阻塞状态线程都是基于 Java 内置的监视器机制,并没有可用的 Java API 类创建的同步器来达到相同的目的。Thread.susped 和 Thread.resume 时唯一的可选项,但由于他们会遇到解决不了的竞态问题,所以不可用:如果一个没有阻塞的线程在另一个阻塞线程执行 suspend 方法之前执行了 resume 方法,那么这个 resume 方法不会有任何效果。
java.util.concurrent.locks 包中的 LockSupport 类提供了解决这个问题的方法。调用 LockSupport.park 方法会阻塞当前线程,除非/直到 LockSupport.unpark 方法被调用。(伪唤醒也被允许,意思是先调用 unpark 也是可以的,没有说先调用 unpark 然后调用 park,unpark 就不会有作用。)对 unpark 的调用没有记录调用次数,所以在 park 之前不管调用多少次 unpark ,都只会对一个 park操作进行解除阻塞。此外,这个原则对每个线程而言的,不是对每个同步器。一个对某个新的同步器调用 park 操作的线程有可能在调用之后立即 return 而感觉不到它被阻塞过,因为有可能程序中有之前遗留的未被消费的 unpark 操作。但是在缺少 unpark 的情况下,它的下一次park 调用就会阻塞了。举个例子假如先对某线程调用两次unpark,然后再调用两次 park,那么第一次 park 会不会阻塞,线程立即返回并将 unpark 次数清零,第二次 park 会阻塞线程。虽然可以显示的清楚这种阻塞状态,但不值得我们这么做。在需要的时候多次调用 park 更有效率。
这种简单的机制在某种层面上与 Solaris-9 的线程库,WIN32 中的“可消费事件”,以及 Linux 中的 NPTL 线程库相似。这也对应在这些最常见的运行 Java 的平台上有各自的有效的实现。(不过,目前在 Solaris 和 Linux 上的 Sun Hotspot JVM 引用实现实际上是用 pthread condvar 来适配已经存在的运行时设计的。) park 方法还支持可选的相对和绝对超时配置,且被集成到了 JVM 的 Thread.interrupt 支持中—— unpark 操作可以实现对线程的中断。
3.3 队列
此框架的核心是对所有阻塞线程队列的维护管理,这些队列被严格限定为必须是先进先出的队列。如此,框架也就不支持基于优先级的同步了。
近来,对于同步队列的最佳选择是使用自身没有用底层锁构造的非阻塞数据结构这一点,少有争议。基于此,目前有两个候选项:Mellor-Crummey,Scott 锁(MCS锁)[9] 的变体和 Craig,Landin,and Hagersten (CLH) 锁[5][8][10]的变体。在之前,CLH锁仅使用于自旋锁。不过,对于本同步器框架,CLH 锁似乎比 MCS 更适合,因为 CLH 锁更适合于处理取消(cancellation)和超时,所以将其选为框架的基础。最终的设计结果与原版的CLH结构已经大相径庭,所以还需后续解释说明。
CLH结构的队列其实不太像个队列,因为它的出队入队都与它所为一把锁这个用途紧密相关。它是个链表队列,通过两个自动可更新域 head 和 tail 访问,head 和 tail 初始化时都指向一个空节点。

新节点node,通过一个原子操作入队:
do{
pred = tail;
} while(!tail.compareAndSet(pred, node));
每个节点的“释放”(release)状态都被保存在它的前驱节点中。因此,自旋锁所谓的“自旋”过程如下:
while(pred.status != RELEASED); // spin
自旋后的出队操作只需将head 指向刚刚获取锁的节点:
head = node ;
CLH 的优势在于快速的入队和出队,无锁操作,无障碍(甚至在竞争状态下,也会有一个线程赢得一次插入竞争而不被阻挡);检测是否有线程正在等待也很快(只要检测 head 与 tail 是否相同);而且释放状态与它表示的节点不在一个结构中,这样会避免某些内存竞争。
在原始版本的 CLH 锁中,节点之间是没有连接在一起的。在自旋锁中,pred 变量可以作为本地变量保存。Scott 和Scherer[10]的论文表明通过显式维护前驱节点域,CLH 锁可以处理超时等其他形式的取消:如果一个节点的前驱节点被取消了,这个节点可以向上滑动使用前面一个节点的状态域。
将CLH队列用于阻塞同步器需要的主要的调整是提供一种定位某个节点的后继节点的有效方法。在自旋锁中,一个节点只需要修改它自己的状态,就会在下一次自旋过程中被其后继节点注意到,所以不需要连接。但是在阻塞同步器中,一个节点需要显示唤醒(unpark)其后继结点。
AQS队列节点包括一个指向其后继节点的next连接。但因为不存在 compareAndSet 这样可以对双向链表进行无锁原子插入的技术,所以作为插入操作的一部分,这个连接的设置不是原子的;它只是在插入操作完成后被简单赋值:
pred.next = node;
next连接只是被当做一种优化思路。如果通过 next 域发现节点的后继者不存在(或者被取消了),那么可以从 tail 开始反向查找链表,使用 pred 域来检测后继者是否真的存在。
第二个调整是每个节点中保存的状态域用于阻塞控制而不是自旋。在同步器框架中,只有调用具体子类中的 tryAcquire 获得通过,队列中的线程才能返回;只有一个 “released” 的标志位是不够的。但是还需要一些控制来保证活跃线程处于队列头的时候只被允许调用 tryAcquire 操作;同等条件下 acquire 可能会失败,然后继续阻塞。这不需要获取节点的状态标识因为通过检测节点的前驱者是否是 head 就能决定权限。而且与自旋锁不同,在读取 head 以保证复制时不会有很多内存竞争。不过,取消状态仍然必须在显示在状态域中。
队列节点的 status 字段也被用来避免不必要的 park 和 unpark 调用。这两个方法的运行速度相对来说很快,跟阻塞原语一样快,在 Java 和 JVM 运行时 和/或 OS 的边界之间跨越时还存在可以避免的开销。在调用 park 之前,线程会设置一个唤醒(signal me)位,然后重新检查一次同步和节点状态。一个释放的线程会在释放过程中清除其状态。这使得线程不必频繁尝试阻塞,尤其是在锁相关的类中,这样浪费时间去等待下一个符合条件的线程去获取锁会加剧对其他竞争的影响。除非后继节点设置了唤醒位,否则这也避免了正在释放的线程去决定去后继结点,折返回来消除了一下情形:除非“唤醒”和“取消”同时发生,否则必须遍历多个节点处理next字段显然为 null 的字段。
可能这个同步器框架中使用的 CLH 队列锁的变体与其他语言中使用的队列锁的最主要区别是,垃圾回收依赖于管理节点的内存回收,这避免了复杂性和开销。但即使是对 GC 的依赖也仍然需要在某些节点连接字段确定不会再使用时将其置为 null 。正常情况下,这个操作在出队时完成。不这么做的话,不再使用的节点还会是可达状态,这会导致它们出去没法回收的状态。
另外一些更加深入的次要微调都在 J2SE1.5 的源代码文档中有所描述,包括在 CLH 队列首次竞争时需要懒加载的初始化空节点。
不考虑这些细节,基本 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 的具体实现方式。另外,在不进行“取消”操作的情况下, acquire 和 release 的每个组件的操作都是线程之间分摊的复杂度为 O(1) 的操作,前提是忽略 park 过程中任何的系统线程调度。
取消操作的支持主要是需要在 acquire 循环中每次从 park 操作返回时去检查是否中断或超时。因为超时或者中断导致的取消等待的线程会设置其节点状态,并且对后继节点进行 unpark 操作。在某线程被取消等待的情况下,定位其前驱节点和后继节点以及重置状态可能需要 O(n) 复杂度的遍历(n 表示队列的长度)。因为对于“取消”操作,线程永远不会被再次阻塞,节点的链接和状态字段很快会重新趋稳。
3.4 条件队列
本同步器框架给维护独占同步和遵循 Lock 接口的同步器提供了一个 ConditionObject 。一个锁对象可以关联任意多个条件对象,它提供经典的监视器风格(monitor-style)的 await,signal 和 signalAll 操作,包括具有超时设置的一些检测和监控方法。ConditionObject 类有效的将条件与其他同步操作进行集成,这是通过再一次的修正一些设计决策实现的。这些类只支持 Java 风格的监视器访问规则,在规则中,仅当条件属于当前线程持有的锁时,这些条件操作才是合法的(一些代替方法的讨论[2])。这样,关联到一个 ReentrantLock 的 ConditionObject 就跟内置的监视器(通过 Object.wait)表现得一样了,两者的不同仅仅表现在,方法名不同,附加的功能不同,以及对于同一把锁用户可以声明多个条件。
ConditionObject 采用与同步器一样的内部队列节点,只不过在一个单独的条件队列中来维护它们。 “唤醒” 操作是通过将节点从条件队列转移到锁队列实现的,且无需在被 “唤醒” 线程重新获取锁之前将其唤醒。
基本的 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 字段)来维护条件队列。转移操作无非就是断开条件队列中的第一个节点,然后用 CLH 队列的插入操作将其插入到锁队列。
实现这些操作的主要难点在于如何处理由超时或者 Thread.interrupt 引起的条件等待线程的取消。当“取消”操作和“唤醒”操作几乎在同时发生时就可能会产生竞态问题,而其结果必须遵循内置监视器的规范。在 JSR133 修订之后,规范要求如果中断先于唤醒发生,那么 await 在重新获取锁之后必须抛出 InterruptedException 异常。但如果中断比唤醒发生得晚,那么 await 必须不抛异常返回,然后设置当前线程的中断标志。
为了维护正确的排序,队列节点状态中用了一个比特位用来记录此节点是否已经(或者在正)被转移。“唤醒”和“取消”的运行代码都会试图通过 compareAndSet 来修改节点状态。如果“唤醒”操作在竞争中失败,它就会试图去转移队列中下一个节点,如果存在的话。如果“取消”操作在竞争总失败,就需要终止这次转移,等待再一次获取锁。后面一种情况采用了一种潜在的无限自旋。在节点被成功地插入到所队列之前,被“取消”的等待不能重新获取锁,所以必须自旋等待 CLH 队列的 compareAndSet 插入操作被“唤醒”成功执行。这里需要自旋的情况比较罕见,且使用了一个 Thread.yield 来系统一个线程调度的提示,理想状态下应该运行执行了“唤醒”操作的线程。虽然可以在这里实现一个帮助策略提供给为“取消”操作以插入节点,但这种情况实在太少见以至于没有足够的理由来实现它。在其他所有情况下,这个基本机制都不需要自旋或 yield , 因此在它能够在单核系统上维持合理的性能。
4. 使用
AQS 类整合了文本上述的所有功能,并且作为一种“模板方法模式”基类提供给同步器。子类定义只需要实现那些用于控制 acquire 和 release 的相关状态检查和更新方法即可。不过 AQS 的子类作为抽象数据类型是不可用的,因为这些子类必要地向外暴露了一些用于控制内部 acquire 和 release 策略的方法,但子类的这些方法都不应该对使用者可见。所有 java 并发包内的同步器类都在内部声明了一个私有的 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 采用了 “test-and-test-and set” 的方式在修改它之前对状态值进行校验。
对于像互斥锁这样强调性能的结构也通过这种用委托和虚拟方法结合的方式来定义可能会令人惊讶。但这些正是现代动态编译器长期关注的面向对象设计的结构。编译器很擅长优化这其中的开销,至少对于优化那些频繁调用同步器的代码很擅长。
AQS 类还提供了一些协助同步器类进行策略控制的方法。比如,它包含支持超时和支持中断版本的基本 acquire 操作。虽然到此为止本文讨论的关注点是锁这样排他独占模式的同步器,但 AQS 类在同时也涵盖了另一套方法(如 acquireShared),这两套不同的方法之间的区别是 tryAcquireShared 和 tryReleaseShared 方法可以通知框架(通过它们的返回值)它们还能接受额外的 acquire 请求,最后框架可以通过级联信号来唤醒多个线程。
虽然通常没有什么理由去序列化(持久化存储或者传输)一个同步器,但这些类经常被用于构造其他类,如线程安全集合类,他们通常是可以被序列化的。 AQS 类和 ConditionObject 类提供了用于序列化同步状态的方法,但这不包括潜在的被阻塞的线程或者其他本质上讲是 transient 类型的单纯用于记录的变量。即便如此,大多数同步器在反序列化时也仅仅是将其同步状态置为初始状态而已,这与内置锁在反序列化时将状态置为解锁状态这个隐式策略一致。这相当于是个“无操作”操作,但仍然必须有显式支持使得 final 字段可以被序列化。
4.1 公平性控制
虽然同步器都是基于 FIFO 队列的,但这并不能保证公平性。请注意在基本 acquire 算法(3.3 章节)中,tryAcquire 检测操作在入队操作之前发生。因此一个新加入的 acquire 线程可以从原本“按套路”应该是队列头的第一个线程那里“窃取”对锁的控制权。
这种 barging FIFO 策略提供比其他技术更高的吞吐量。当一个被用于竞争的锁处于空闲状态,但下一个准备获取该说的线程正在解除阻塞的过程中,这就形成了一段空闲时间,使用 barging FIFO 策略可以缩短这段时间。同时,通过只允许(第一个)入队的线程该被唤醒进而尝试 tryAcquire 操作,该策略还避免了过度的、无效的竞争。在期望短时间持有同步器的场景中,开发者之后创建的同步器时可以通过定义 tryAcquire 在控制权交回去之前重试若干次,来强调 barging 效果。Barging FIFO 同步器的公平性只存在于概率意义上。一个锁队列头部的 unparked 线程对于与任何进来 barging 的线程进行的竞争都是无偏向的,如果竞争失败会进行重新阻塞以及重试。如果进来 barging 的线程比 unpared 线程解除阻塞更快到达,那么队列中的第一个线程几乎不可能赢得竞争,所以几乎总要重新阻塞,而它的后继者们也会继续保持阻塞状态。对于被短暂持有的同步器,在队列中第一个线程解除阻塞期间,有多个 barging 和 release 在多核处理器上发生是很常见的。如下文所述,这样做的实际结果是维持了一个或多个线程运行的高概率同时仍然至少避免了一定概率的饥饿。
当出现更高的公平性需求时,反而相对来说更容易处理。需要严格实现公平性时,如果当前线程不在队列头部(这是通过调用 getFirstQueuedThread 实现的,框架中提供的屈指可数的几个自我检测方法之一 ),程序员可以直接定义 tryAcquire 失败(返回 false)。
另一个速度更快,但公平性的严格程度降低的变体是允许 tryAcquire 在队列暂时为空是成功。在此情况下,很多与空队列竞争的线程之中的某一个线程会成为最先成功 acquire 的线程,通常至少其中一个线程是不需要入队的。java.util.concurrent 包中所有支持“公平”模式的同步器都采用了这个策略。
虽然公平性设置在实践中很有用,但是它并不完全具有保证性,因为首先 Java Language Specification 就没有提供调度的保证。比如,即使对于一个严格公平的同步器,如果某组线程永远不需要相互阻塞等待,那么 JVM 可能会纯粹按他们的排序顺序执行。在实践中,在单核处理器上,每个线程在被抢占式上下文切换之前都可能各自运行了一段定长时间。如果这样的线程正持有一个排它锁,它顷刻之间它被切换回来只是为了释放这把锁然后阻塞,现在众所周知另一个线程需要这把锁,这样就增加了同步器可用但空闲没有被线程持有的时间间隔。同步器公平性设置对于多核处理器甚至具有更大的影响,因为在多核环境中程序会产生更多的交叉,因此某个线程发现某把锁被另一线程需要的机会就更大。
尽管公平锁对于保护需要短时间持有锁的代码体的高竞争环境性能较差,但它仍然是有效的,比如,当公平锁保护的是相对比较长的代码块和/或者相对比较长的锁间间隔,在这种情况下, barging 几乎提供不了性能优势,反而会带来更大的无限等待风险。本同步器框架将这类工程型的问题决策留给用户作出。
4.2 各类同步器
以下是使用本框架的 java.util.concurrent 同步器类定义的骨架:
ReentrantLock (可重入锁)类使用同步状态字段 state 来保存某时刻锁被(重复)持有的数量。当某把锁被线程请求时,它会记录下当前线程的标识以便检测重复获取次数,以及当错误的线程视图解锁时检测非法状态异常,那个线程持有锁,就应该由这个线程来解锁,其他线程来解锁的操作是非法的。ReentrantLock 还使用了保中提供的 ConditionObject , 并且导出了其他监视和内省方法。该类通过在内部声明两个不同的 AQS 子类(公平模式的,和禁用 barging 的)来支持可选的“公平性”模式,可以通过调用适当的构造器来设置响应的 ReentrantLock 实例。
ReentrantReadWriteLock (可重入读写锁)类使用同步器状态中的 16 个比特位来存储写锁的线程数,另外 16 比特用来存储读锁的线程数。WriteLock 写锁的构建方式与 ReentrantLock 相同。ReadLock 读锁通过使用 acquireShared 方法支持多个读线程。
Semaphore (计数信号量) 类使用同步状态字段来存储当前的计数。它定义对 acquireShared 的调用会减少 count 的计数,或者如果当 count 不为正数时线程阻塞。对 tryRelease 会增加 count 的计数,可能在恰好计数变为正值时解除等待线程的阻塞。
CountDownLatch (倒数门闩) 类使用同步状态字段表示 count 计数。当 count 为 0 是,所有 acquire 操作通过。
FutureTask 类使用同步状态字段表示一个 future (初始化,运行中,已取消,已完成)的运行状态。设置或者取消 future 任务是会触发 release 操作,通过 acquire 操作来解除线程等待其计算结果值的阻塞。
SynchronousQueue 类是一个 CSP (Communicating Sequential Processes) 风格的类,使用内部的等待节点用于匹配消费者和生产者。使用同步状态来通知当消费者消费某个项目时,生产者被允许继续运行生产,反之亦然。
java.util.concurrent 包的使用者当然也可以为自定义应用场景定义自己的同步器。比如,那些被考虑过带并没有在包中被采用的类,包括 WIN32 事件各种风格语义的锁,二元门闩,集中式管理锁以及基于树的的屏障类。
5. 性能
同步器框架除了支持互斥锁,还支持其他很多风格的同步方式,锁性能是最容易测量和比较的。测量方式有很多种。以下实验主要是被设计用来揭示锁的开销和吞吐量。
在每个测试中,每个线程都使用 nextRandom(int seed) 函数产生的伪随机数进行更新:
int t = (seed % 127773) * 16807 – (seed / 127773) * 2836;
return (t > 0)? t : t + 0x7fffffff;
在每次迭代中,线程以 S 概率在一个互斥锁环境下对共享生成器进行更新,如果 S 概率没达到则更新自己的本地生成器,此操作不需要锁。占有锁的区域时间短暂使得当线程抢占时外部影响最小化。函数的随机性有两个目的:它被用来决定是否进行锁请求;以及使得循环体里的代码不会被优化删除。
这里比较了四种锁: Builtin,使用 synchronized 代码块; Mutex , 使用如第4章里呈现的 Mutex 类; Reentrant , 使用 ReentrantLock 重入锁;Fair ,使用设置了“公平”模式的 ReentrantLock 重入锁。所有测试都在J2SE1.5 JDK build46 版本的服务器模式环境下运行。测试程序在手机测量结果之前运行了20次,以消除预热影响。每个线程运行一千万次迭代,除了 Fair 模式的测试只运行了一百万次迭代。
测试程序再四台基于 x86 的机器和四台基于 UltraSparc 的机器上。所有 x86 机器都是 RedHat NPTL-based 2.4 内核和类库的 Linux 系统。所有 UltraSparc 机器都运行 Solaris-9 系统。所有系统在测试时都出去最轻负载状态。测试的特性不要求系统需要完全空闲。“4P” 反映了双核超线程至强表现得更像一台4路机器而不是两路机器。这里没有进行不同点之间的规范化。如下所示,同步的相对开销与处理器的数量,类型或者速度之间的关系都不简单。
5.1 开销
无竞争的开销测量在单线程下进行,将概率为1的每次迭代时间减去概率为0的每次迭代时间。Table 2 展示了没有添加 synchronized 关键字代码的同步代码块每此锁的开销近似纳秒值。Mutex 类最接近本框架的基本开销。Reentrant 中额外的开销主要来自记录当前拥有锁的线程和错误检测, Fair 锁的额外开销来自第一次对队列是否为空的检测。
Table 2 还展示了与内置锁的“快速路径”相比,tryAcquire 操作的开销。他们的差异反应了锁和机器在使用不同原子指令和内存屏障的不同开销。在多核处理器中,这些指令完全压倒了其他指令。 Builtin 和同步器类的主要区别很显然是由于 Hotspot 锁使用 compareAndSet 用于锁定和解锁,而这些同步器类则是用 compareAndSet 来请求锁用一个 volatile 写(即,多核处理器的内存屏障,和所有类型处理器上的指令重排约束)来释放锁。每种锁的绝对和相对开销因机器的不同而各有不同。
另一种极端情况, Table 3 展示了概率为1同时运行256个线程并产生大量锁竞争环境下每种锁的开销。在完全饱和的情况下, barging FIFO 队列锁的开销比 Builtin 锁少了一个数量级(等价于更高的吞吐量),比 Fair 锁少了两个数量级。这展示了在极端竞争环境下,barging FIFO 策略对于维持多线程运行的有效性。
Table 3 还表明即使内部开销很低,上下文切换时间完全决定了 Fair 锁的性能。表中列出的时间与各个平台上多线程阻塞和解除阻塞的时间比例一致。
另外,后面紧跟的实验(近使用 4P 机器)表明对于这里使用的短时间持有锁,公平性设置对整体晃动的影响很小。多线程终止时间的差异被记录为一个粗粒度变量。4P 机器上 Fair 锁的平均标准差为 0.7% , Reentrant 为 6.0%。 作为对比,为了模拟产时间持有锁的情况,运行了另一个测试,在此测试中,每个线程在获取锁之后,需要计算 16K 次随机数。这里,总的运行时间几乎一样(Fair 运行 9.79秒,Reentrant 运行 9.72秒)。Fair 模式晃动依然很小,平均标准差为 0.1%,而 Reentrant 上升到了平均标准差 29.5%。
5.2 吞吐量
对大多数同步器的使用场景范围很广,从一个极端完全没有竞争到另一个极端饱和竞争。我们可以通过对一组固定的线程修改竞争概率,和/或通过保持一个固定竞争概率不变,增加更多的线程,实验性地在两个维度进行检验。为了展示这些影响,测试在各种不同的经整概率和不同线程数情况下都进行了运行,都是用 Reentrant 锁。附图使用了一个 slowdown 度量公式:
公式中,t 表示被观察的总的运行时间, b 是没有竞争或同步设计的单线程基准时间,n 是线程数, p 是处理器核心数, s 是共享访问的比例,也就是竞争概率。这个计算结果是总的观察运行时间与理想状体下执行时间的比例,理想状态下执行时间采用了对于有序和并行混合任务而言的 Amdahl 法则。理想状态模拟了一个没有同步操作开下,没有因线程之间冲突而导致线程阻塞的执行。即便如此,在低竞争情况下,一小部分测试结果与理想情况相比出现了很小的速度提升,据推测可能是由于基准情况与测试实际情况之间优化,流水线等方面轻微的不同导致的。
图表采用了以2为底的对数值来表示。比如,1.0 表示测量的实际时间是理想情况耗时的两倍,值为 4.0 表示实际情况比理想情况慢16倍。使用对数降低了对随意选择的基准时间(这里,是计算随机数的时间)的依赖,所以对于不同底数的对数计算结果趋势应该是差不多的。这些测试采用的竞争概率从 1/128 (标为 “0.008”) 到 1 ,步长为 2 次幂,线程数从 1 到 1024 ,步长为 2 的幂的一半。
在单核环境(1P 和 1U)下,性能随着竞争的增加而下降,但总体上没有因为线程数的增加而降低。在多核处理器环境下,面对竞争性能下降表现更明显。多核处理器对应的图显示一开始出现了峰值,说明当只有几个线程参与竞争反而产生了最差的相对性能。这反映了一个性能的过去区域,该区域中 barging 线程和被唤醒的线程获取锁的机会差不多,这会频繁地迫使对方阻塞。在大多数情况下,过渡区域之后还会紧跟着一个平滑区域,此时所有锁都不是空闲的,导致结果近似于单核处理器的顺序执行模式;核心数越多,到达平滑区域就越早。注意比如满饱和竞争(标记为 “1.000”)的情况下,显示核心数更少的机器相对来说具有很差的性能下降趋势。
根据这些结果,可以看出进一步对阻塞(park/unpark)支持的调试以使得减少上下文切换和相关开销会给这个框架带来虽然很小但显而易见的进步。此外,对于多核处理器环境下的短时间高竞争锁,框架可能会采用某种自适应自旋方式来代替同步器类,来避免这里出现的一些波动。虽然自适应自旋针对不同的上下文很难有很好的效果,但针对这类使用配置的特定应用,使用这个框架来构建自定义形式的锁时可能的。
结论
在本文编写的时候,java.util.concurrent 包里的同步器类还太新没来得及在实践中对其进行评估。在 J2SE1.5 最终版发布之前不可能广泛使用,切她的设计,API,实现和性能也肯定会存在不可预知的后果。但是,当前,这个框架对于满足为创建新的同步器类提供一个高效的基础架构这个要求还是很成功的。
感谢
感谢各种大神,不翻译了