Java并发-JUC-AQS论文翻译

原文地址
AQS论文

摘要

J2SE1.5中java.util.concurrent包的大多数同步器(locks,barriers等)基于类AbstractQueuedSynchronizer(后文简称AQS)的简单框架,该框架(AQS)提供了原子性管理同步状态、排队的阻塞线程和解除线程。本文描述了基本原理、设计、实施、使用和性能框架。

1.引言

  • Java5版本引入了java.util.concurrent包,改包是通过JAVA社区(JCP)规定的JSR-166规范编写的支持并发操作的类集合. 这些组件包括抽象同步队列,用于维护内部同步状态(例如,表示锁是处于锁定状态还是未锁定状态)更新和检查状态的操作,至少有一个方法会导致调用线程阻塞,当其他线程更改同步状态时允许它恢复. 改包还提供了包括各种形式的互斥锁(ReentrantLock)、读写锁(ReentrantLock)、信号量(Semaphore)、屏障(CyclicBarrier)、Futures、event indicators, and handoff queues

  • 众所周知,几乎任何同步器都可以用来实现其他类型同步器,例如、可以根据可重入锁构建信号量(semaphores),反之亦然。但是,这样会导致程序复杂行高、更大的开销、和缺少灵活性。并不是最佳选项。此外,从概念上讲,它也没有吸引力。如果这些构造在本质上都不比其他构造更原始,那么不应强迫开发人员任意选择其中之一作为构建其他构造的基础。相反,JSR-166提供了以AbstractQueuedSynchronizer为中心的小框架,它提供了大多数人使用的通用机制.以及用户可以自己定义的其他类。

  • 本文的其余部分讨论了该框架的需求、设计和实现背后的主要思想、示例用法以及显示其性能特征的一些度量。

2.需求

2.1 功能

AQS至少提供两种方法acquire和release:

  1. acquire:至少有一个会阻塞调用线程,除非获得操作/直到同步状态允许它继续
  2. release:至少一个版本操作变化同步状态,可能会允许一个或多个阻塞线程执行。

java.util.concurrent包 并没有为同步器定义统一的API,一些是通过通用接口(例如Lock)定义的,而有一些仅属于某些专用版本,因此acquire和release操作在不同的类中采用不同的名称和形式方法Lock.lock,Semaphore.acquire,CountDownLatch.await和FutureTask.get,这些方法都是acquire操作,但是,这个包跨类维护一致的约定,以支持一系列常见的用法,当有意义时,每个同步器都支持:

  • 非阻塞同步尝试(例如,tryLock)阻塞版本。
  • 可选的超时,这样应用程序就可以放弃等待
  • 能被中断的获取锁,同时也会提供不可中断的获取锁

不同功能的同步器之间的差异取决于它们是管理排他状态(即一次只有一个线程可以继续超过可能的阻塞点),还是管理共享状态(至少有时多个线程可以继续执行),常规锁当然只维护排他状态,但是,(例如,计数信号量可以由计数允许的任意数量的线程获得),为了广泛使用,框架必须支持这两种操作模式

AQS支持两种模式

  1. 独占模式
  2. 共享模式

java.util.concurrent包还定义了接口Condition,以支持监视样式的await/signal操作,这些操作可能与互斥锁类相关联,其实现在本质上与互斥锁类关联在一起.

2.2 性能目标

Java内置锁(使用同步方法和同步代码块访问)长期以来一直存在着性能问题,关于它的研究存在着大量文献.

但是,这类研究的主要重点是减少空间开销(因为任何Java对象都可以充当锁)和在单线程单处理器上下文使用时的减少时间开销。

对于同步器来说,这两个问题都不是特别重要:

  1. 程序员只在需要的时候创建同步器,因此不需要压缩空间,否则会浪费.
  2. 同步器几乎专用于多线程设计(越来越多地用于多处理器),偶尔会发生争用这是意料之中的。

因此,通常的JVM策略主要针对零争用情况优化锁,将其他情况留给难以预测的"slow paths",对于严重依赖java.util.concurrent的典型多线程服务器应用程序来说,这不是正确的策略。

相反,这里的主要性能目标是可伸缩性:甚至在同步器竞争时,也可以预测地保持效率.理想情况下,无论有多少线程尝试通过同步点,所需的开销都应保持恒定。主要目标是最小化的减少一个线程通过同步点但是还没有完成的这个过程的时间。
(这里我理解的是让线程尽快确定最终状态,要么快速获取同步状态,要么迅速阻塞,避免循环获取同步状态带来的性能和时间损耗),

然而,这必须与资源考虑相平衡,包括总CPU时间需求、内存流量和线程调度开销。例如,自旋锁通常比阻塞锁提供更短的获取时间,但通常因为空循环并产生内存争用,因此通常并不经常使用。

这些目标涉及两种通用的使用模式,大多数应用程序应最大程度地提高总吞吐量,最大程度地容忍缺乏饥饿的概率保证。但是,在诸如资源控制之类的应用程序中,保持跨线程访问的公平性,容忍较差的聚合吞吐量更为重要,没有任何框架能够代表用户在这些相互冲突的目标之间做出决定;相反,必须适应不同的公平政策。

无论同步器内部设计得多么好,它们都会在某些应用程序中产生性能瓶颈。因此,框架必须能够监视和检查基本操作,以允许用户发现和缓解瓶颈。这至少(也是最有用的)需要提供一种方法来确定有多少线程被阻塞。

3.设计与实现

同步器的基本思想非常简单,获取操作的过程如下:

while (同步状态不允许获取) {
如果当前线程还没有进入队列,则进入队列;
可能阻塞当前线程;
}
如果当前线程已进入队列,则将其退出队列;

释放操作是:

更新同步状态;
if (state允许阻塞线程获取)
解除一个或多个队列线程的阻塞;

对这些操作的支持需要三个基本组件的协调:

  • 原子地管理同步状态
  • 阻塞和解除阻塞线程
  • 维护队列

我们可以创建一个框架,让这三个部分各自独立地变化。但是,这既不是很有效,也不是很有用。例如,保存在队列节点中的信息必须与解除阻塞所需的信息相匹配,导出(提供)方法的签名取决于同步状态的性质。

同步器框架的核心设计决策是选择这三个组件中的每一个的具体实现,同时仍然允许在如何使用它们方面有广泛的选择。这有意地限制了适用的范围,但却提供了足够有效的支持,在确实适用框架的情况下,几乎从来没有理由不使用该框架(而是从头开始构建同步器)。

3.1 同步状态

类AbstractQueuedSynchronizer仅使用一个(32位)int来维护同步状态,并提供getState,setState和compareAndSetState操作来访问和更新此状态.这些方法反过来又依赖于java.util.concurrent.atomic支持,该支持在读取和写入时提供符合JSR-133(Java内存模型)的volatile语义,并通过本地方法 compare-and-swap 或者 loadlinked/store-conditional指令来实现compareAndSetState,只有当state持有给定的期望值时,它才会原子地将state设置为给定的新值。

将同步状态限制为32位int是一个务实的决定,尽管JSR-166还提供了64位长字段的原子操作,但仍然必须在足够多的平台上使用内部锁来模拟这些操作,以致最终的同步器无法正常工作,将来,可能会添加专门用于64位状态(即具有长控制参数)的第二个基类.但是,现在没有令人信服的理由将其包含在JUC中.,目前,大多数应用程序都需要32位,只有一个java.util.concurrent同步器类CyclicBarrier会需要更多位来维护状态,因此可以使用锁(包中的大多数更高级别的实用程序也是如此)。

基于AbstractQueuedSynchronizer的具体类必须根据这些提供的状态方法定义tryAcquire和tryRelease方法,以便控制获取和释放操作.如果获得了同步,tryAcquire方法必须返回true,如果新的同步状态可能允许将来进行获取,则tryRelease方法必须返回true.这些方法接受单个int参数,用于传递所需的状态;例如,在重入锁中,从条件等待返回后重新获取锁时重新建立递归计数。许多同步器不需要这样的参数,所以忽略它即可

3.2 阻塞

在JSR-166之前,还没有可用的Java API来阻塞和解除阻塞线程,以创建不基于内置监视器的同步器. 唯一的候选对象是Thread.suspend和Thread.resume. resume是不可用的,因为它们遇到了无法解决的竞争问题:如果一个解除阻塞的线程在阻塞线程执行suspend之前调用了resume,那么resume操作将没有效果。

java.util.concurrent.locks包括一个LockSupport类,该类具有解决此问题的方法.除非或直到发出LockSupport.unpark,否则方法LockSupport.park会阻止当前线程.(虚假唤醒也可以)调用unpark不会被"计算"在内,所以在park之前有多个unpark只会解阻塞一个park。此外,这适用于每个线程,而不是每个同步器.在新的同步器上调用park的线程可能会立即返回.但是,如果没有unpark,则其下一个调用将被阻塞,尽管可以明确清除此状态,但这样做是不值得的,在碰巧有必要时多次调用park会更有效。

这个简单的机制在某种程度上类似于在Solaris-9线程库、WIN32"consumable events"和Linux NPTL线程库中所使用的机制,因此可以有效地映射到Java运行的最常见平台上的每个线程。(然而,目前Solaris和Linux上的Sun Hotspot JVM参考实现实际上使用了pthread condvar,以适应现有的运行时设计)park方法还支持可选的相对超时和绝对超时,并且集成了JVM 中Thread.interrupt支持 - 线程unparks的时候可以中断它。

3.3 队列

框架的核心是对阻塞线程队列的维护,这在这里仅限于FIFO队列。因此,框架不支持基于优先级的同步。

如今,几乎没有争议的是,同步队列最合适的选择是非阻塞数据结构,这些数据结构本身不需要使用低级锁来构造.其中有两个主要候选对象:Mellor-Crummey和Scott(MCS)锁的变体以及Craig,Landin和Hagersten(CLH)锁的变体.CLH锁仅在自旋锁中使用,但是在同步器框架中使用的锁似乎比MCS更易于使用,因为它们更易于处理取消和超时,因此被选为基础.最终的设计与原始的CLH结构相去甚远,需要进行解释.

CLH队列不是非常类似于队列,因为它的入队和出队操作与它作为锁的使用紧密相关.它是一个通过两个原子可更新字段(head和tail)访问的链接队列,这两个字段最初都指向一个虚拟节点

使用原子操作将新节点node放入队列:

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

每个节点的发布状态保存在其前任节点中,因此,自旋锁的"自旋"看起来像:

while (pred.status != RELEASED) ; // 自旋

这种旋转之后的出队操作仅需要将head字段设置为刚刚获得锁的节点:

head = node;

CLH锁的优点之一是入队和出队是快速、无锁和无阻塞的(即使在竞争情况下,一个线程也总是会赢得插入竞赛,因此会取得进展;检测是否有线程在等待也很快(只需检查head是否与tail相同); 并且释放状态是分散的,避免了一些内存争用。

在CLH锁的原始版本中,甚至没有连接节点的链接.在自旋锁中,pred变量可以作为局部变量保存。然而,Scott和Scherer表明,通过在节点中显式地维护前任字段,CLH锁可以处理超时和其他形式的取消:如果一个节点的前任字段被取消,该节点可以向上滑动使用前一个节点的状态字段。

使用CLH队列阻塞同步器所需的主要额外修改是为一个节点提供一种有效的方法来定位其后续节点.在自旋锁中,一个节点只需要改变它的状态,在下一次自旋时它的后继节点会注意到它的状态,所以链接是不必要的.但在阻塞同步器中,节点需要显式唤醒(unpark)它的后续节点。

AbstractQueuedSynchronizer队列节点包含指向其后继节点的下一个链接.但是,由于使用compareAndSet对双链表节点进行无锁原子插入没有适用的技术,因此该链接不是作为插入的一部分进行原子设置的;它被简单地分配

pred.next = node;

在插入之后.这反映在所有的用法中.下一个链接仅作为优化路径处理。如果节点的后续节点通过它的下一个字段似乎不存在(或似乎被取消),那么总是可以从列表的末尾开始,并使用pred字段向后遍历,以准确地检查是否确实存在一个节点。

第二次修改是使用每个节点中保留的status字段来控制阻塞,而不是旋转。在同步器框架中,队列线程只有在通过了在具体子类中定义的tryAcquire方法后才能从acquire操作返回;单个“released”是不够的。但是仍然需要控制以确保活动线程只允许在队列的头部调用tryAcquire;在这种情况下,它可能无法获取并重新阻塞。这不需要每个节点的状态标志,因为可以通过检查当前节点的前身是否是头部来确定权限。与自旋锁不同的是,没有足够的内存争用读取头来保证复制。但是,状态字段中必须仍显示取消状态

队列节点状态字段还用于避免对park和unpark进行不必要的调用。虽然这些方法相对于阻塞原语来说比较快,但它们在Java和JVM运行时和或操作系统之间的边界交叉时遇到了可以避免的开销。在调用park之前,线程设置一个“signal me”位,然后在调用park之前再次检查同步和节点状态。释放线程清除状态。这样可以避免线程不必要地频繁地阻塞,特别是对于锁类,由于等待下一个合格的线程获得锁而损失的时间会加重其他争用效果。这也避免了需要释放线程来确定它的后继线程,除非后继线程已经设置了信号位,这进而消除了必须遍历多个节点来处理明显为空的NEXT字段的情况,除非信号与取消一起发生。

同步器框架中使用的CLH锁的变体与其他语言中使用的CLH锁之间的主要区别可能在于,垃圾收集依赖于管理节点的存储回收,从而避免了复杂性和开销。然而,依赖GC仍然需要将链接字段设为空,而这些链接字段肯定永远不需要.通常可以在出队时完成.否则,未使用的节点仍将可访问,从而导致无法收集它们。

J2SE1.5发行版的源代码文档中描述了一些更小的调优,包括CLH队列在第一次争用时所需的初始虚拟节点的延迟初始化。

省略这些细节,基本获取操作(独占的、不可中断的、不定时的情况)最终实现的一般形式是:

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
}

当然,acquire循环的迭代次数取决于tryAcquire的性质。否则,在没有取消的情况下,获取和释放的每个组件都是一个常数时间O(1)的操作,在线程间摊销,不考虑在park内发生的任何OS线程调度。

取消支持主要需要在获取循环内每次从停放返回时检查中断或超时。 由于超时或中断而取消的线程将设置其节点状态并释放其后继者,以便它可以重置链接。 通过取消,确定前任和后继以及重置状态可能包括O(n)遍历(其中n是队列的长度)。 由于线程再也不会阻塞取消的操作,因此链接和状态字段往往会迅速恢复稳定。

取消支持主要是在每次从acquire循环中park返回时检查中断或超时。由于超时或中断而被取消的线程会设置它的节点状态并释放它的后继线程,以便它可以重置链接。通过取消,确定前身和后继以及重置状态可能包括O(n)遍历(其中n是队列的长度)。因为线程不会再因为取消的操作而阻塞,所以链接和状态字段会很快恢复稳定。

3.4 Condition Queues (条件队列)

同步器框架提供了一个ConditionObject类,供维护独占同步并符合Lock接口的同步器使用。可以将任意数量的条件对象附加到锁对象,从而提供经典的监视器风格的wait、signal和signalAll操作,包括那些超时的操作,以及一些检查和监视方法。

ConditionObject类通过修复一些设计决策,使得它能够和其他同步操作有效地集成在一起,该类只支持java风格的监视访问规则,在这种规则中,只有当拥有条件的锁由当前线程持有时,条件操作才合法(有关替代方法的讨论,请参阅[4])。因此,附加到ReentrantLock的条件对象与内置监视器的行为方式相同(通过Object.Wait等),不同的只是方法名称、额外功能以及用户可以为每个锁声明多个条件。

ConditionObject使用与同步器相同的内部队列节点,但在单独的条件队列中维护它们。信号操作被实现为从条件队列到锁队列的队列传输,而不必在重新获得锁之前唤醒收到信号的线程。

基本的await操作是:
创建并添加新的节点到条件队列;
释放锁;
阻塞,直到节点处于锁定队列;
重新获得锁;
signal操作为:
将第一节点从条件队列转移到锁定队列;

因为这些操作仅在持有锁时才执行,所以它们可以使用顺序链接队列操作(使用节点中的nextWaiter字段)来维护条件队列.传输操作只是将第一个节点从条件队列断开链接,然后使用CLH插入将其附加到锁队列。

实现这些操作的主要复杂之处是如何取消由于超时或Thread.interrupt引起的条件等待。取消和signal大约同时发生的竞争,其结果符合内置监视器的规范。正如JSR133中修改的那样,这些要求是,如果中断发生在信号之前,那么等待方法必须在重新获取锁之后抛出InterruptedException。但是如果它在一个信号之后被中断,那么该方法必须返回而不抛出异常,但设置了线程中断状态。

为了保持正确的排序,队列节点状态中的一位记录了该节点是否已经(或正在)传输。信号码和取消码都试图比较和设置这个状态。如果一个信号操作输掉了这场竞争,它将转而传输队列上的下一个节点(如果存在)。如果取消失败,它必须中止传输,然后等待重新获取锁。后一种情况引入了一个潜在的无界自旋。取消的等待在节点成功插入锁队列之前不能开始重新获取锁,因此必须旋转,等待信令线程执行的CLH队列插入compareAndSet成功。这里很少需要旋转,它使用Thread.Year来提供调度提示,提示其他线程(理想情况下是执行信号的线程)应该运行。虽然在这里可以实现一个帮助策略来取消插入节点,但这种情况太少了,无法证明这将带来的额外开销是合理的。在所有其他情况下,这里和其他地方的基本机制都没有使用spin或yield,这在单处理器上保持了合理的性能。

4. 使用
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值