JUC同步器框架AbstractQueuedSynchronizer源码图文分析
前提
Doug Lea大神在编写JUC(java.util.concurrent
)包的时候引入了java.util.concurrent.locks.AbstractQueuedSynchronizer
,Abstract Queued Synchronizer,也就是”基于队列实现的抽象同步器”,一般我们称之为AQS。其实Doug Lea大神编写AQS是有严谨的理论基础的,他的个人博客上有一篇论文《The java.util.concurrent Synchronizer Framework》,文章在http://ifeve.com上可以找到相关的译文(《JUC同步器框架》),如果想要深入研究AQS必须要理解一下该论文的内容,然后详细分析一下AQS的源码实现。本文在阅读AQS源码的时候选用的JDK版本是JDK11。
AQS的主要功能
AQS是JUC包中用于构建锁或者其他同步组件(信号量、事件等)的基础框架类。AQS从它的实现上看主要提供了下面的功能:
- 同步状态的原子性管理。
- 线程的阻塞和解除阻塞。
- 提供阻塞线程的存储队列。
基于这三大功能,衍生出下面的附加功能:
- 通过中断实现的任务取消,基于线程中断实现。
- 可选的超时设置,也就是调用者可以选择放弃等待。
- 定义了
Condition接口
,用于支持管程形式的await/signal/signalAll操作,代替了Object
类基于JNI提供的wait/notify/notifyAll。
AQS
还根据同步状态的不同管理方式区分为两种不同的实现:独占状态的同步器和共享状态的同步器。
JUC同步器框架原理
《The java.util.concurrent Synchronizer Framework》一文中其实有提及到同步器框架的伪代码:
// acquire操作如下:
|
翻译一下:
// acquire操作如下:
|
为了实现上述操作,需要下面三个基本组件的相互协作:
- 同步状态的原子性管理。
- 等待队列的管理。
- 线程的阻塞与解除阻塞。
其实基本原理很简单,但是为了应对复杂的并发场景和并发场景下程序执行的正确性,同步器框架在上面的acquire操作和release操作中使用了死循环和CAS等操作,很多时候会让人感觉逻辑过于复杂。
同步状态管理
AQS
内部内部定义了一个32位整型的state变量用于保存同步状态:
/**
|
同步状态state在不同的实现中可以有不同的作用或者表示意义,它可以代表资源数、锁状态等等,遇到具体的场景我们再分析它表示的意义。
CLH队列变体
CLH锁即Craig, Landin, and Hagersten (CLH) locks,因为它底层是基于队列实现,一般也称为CLH队列锁。CLH锁也是一种基于链表的可扩展、高性能、公平的自旋锁,申请线程仅仅在本地变量上自旋,它不断轮询前驱的状态,假设发现前驱释放了锁就结束自旋。从实现上看,CLH锁是一种自旋锁,能确保无饥饿性,提供先来先服务的公平性。先看简单的CLH锁的一个简单实现:
public class CLHLock implements Lock {
|
上面是一个简单的CLH队列锁的实现,内部类QueueNode
只使用了一个简单的布尔值locked属性记录了每个线程的状态,如果该属性为true,则相应的线程要么已经获取到锁,要么正在等待锁,如果该属性为false,则相应的线程已经释放了锁。新来的想要获取锁的线程必须对tail属性调用getAndSet()
方法,使得自身成为队列的尾部,同时得到一个指向前驱节点的引用pred,最后线程所在节点在其前驱节点的locked属性上自旋,值得前驱节点释放锁。上面的实现是无法运行的,因为一旦自旋就会进入死循环导致CPU飙升,可以尝试使用下面将要提到的LockSupport
进行改造。
CLH队列锁本质是使用队列(实际上是单向链表)存放等待获取锁的线程,等待的线程总是在其所在节点的前驱节点的状态上自旋,直到前驱节点释放资源。从实际来看,过度自旋带来的CPU性能损耗比较大,并不是理想的线程等待队列实现。
基于原始的CLH队列锁中提供的等待队列的基本原理,AQS
实现一种了CLH锁队列的变体(variant)。AQS
类的protected修饰的构造函数里面有一大段注释用于说明AQS
实现的等待队列的细节事项,这里列举几点重要的:
AQS
实现的等待队列没有直接使用CLH锁队列,但是参考了其设计思路,等待节点会保存前驱节点中线程的信息,内部也会维护一个控制线程阻塞的状态值。- 每个节点都设计为一个持有单独的等待线程并且”带有具体的通知方式”的监视器,这里所谓通知方式就是自定义唤醒阻塞线程的方式而已。
- 一个线程是等待队列中的第一个等待节点的持有线程会尝试获取锁,但是并不意味着它一定能够获取锁成功(这里的意思是存在公平和非公平的实现),获取失败就要重新等待。
- 等待队列中的节点通过prev属性连接前驱节点,通过next属性连接后继节点,简单来说,就是双向链表的设计。
- CLH队列本应该需要一个虚拟的头节点,但是在
AQS
中没有直接提供虚拟的头节点,而是延迟到第一次竞争出现的时候懒创建虚拟的头节点(其实也会创建尾节点,初始化时头尾节点是同一个节点)。 - Condition(条件)等待队列中的阻塞线程使用的是相同的
Node
结构,但是提供了另一个链表用来存放,Condition等待队列的实现比非Condition等待队列复杂。
线程阻塞与唤醒
线程的阻塞和唤醒在JDK1.5之前,一般只能依赖于Object
类提供的wait()
、notify()
和notifyAll()
方法,它们都是JNI方法,由JVM提供实现,并且它们必须运行在获取监视器锁的代码块内(synchronized
代码块中),这个局限性先不谈性能上的问题,代码的简洁性和灵活性是比较低的。JDK1.5引入了LockSupport
类,底层是基于Unsafe
类的park()
和unpark()
方法,提供了线程阻塞和唤醒的功能,它的机制有点像只有一个允许使用资源的信号量java.util.concurrent.Semaphore
,也就是一个线程只能通过park()
方法阻塞一次,只能调用unpark()
方法解除调用阻塞一次,线程就会唤醒(多次调用unpark()
方法也只会唤醒一次),可以想象是内部维护了一个0-1的计数器。
LockSupport
类如果使用得好,可以提供更灵活的编码方式,这里举个简单的使用例子:
public class LockSupportMain implements Runnable {
|
LockSupport
类park()
方法也有带超时的变体版本方法,有些适合使用阻塞超时的场景不妨可以使用。
独占线程的保存
AbstractOwnableSynchronizer
是AQS
的父类,一个同步器框架有可能在一个时刻被某一个线程独占,AbstractOwnableSynchronizer
就是为所有的同步器实现和锁相关实现提供了基础的保存、获取和设置独占线程的功能,这个类的源码很简单:
public abstract class AbstractOwnableSynchronizer
|
它就提供了一个保存独占线程的变量对应的Setter和Getter方法,方法都是final修饰的,子类只能使用不能覆盖。
CLH队列变体的实现
这里先重点分析一下AQS
中等待队列的节点AQS$Node
的源码:
static final class Node {
|
其中,变量句柄(VarHandle)是JDK9引用的新特性,其实底层依赖的还是Unsafe
的方法,总体和JDK8的实现是基本一致。这里需要关注一下Node
里面的几个属性:
- waitStatus:当前
Node
实例的等待状态,可选值有5个。- 初始值整数0:当前节点如果不指定初始化状态值,默认值就是0,侧面说明节点正在等待队列中处于等待状态。
Node#CANCELLED
整数值1:表示当前节点实例因为超时或者线程中断而被取消,等待中的节点永远不会处于此状态,被取消的节点中的线程实例不会阻塞。Node#SIGNAL
整数值-1:表示当前节点的后继节点是(或即将是)阻塞的(通过park
),当它释放或取消时,当前节点必须unpark
它的后继节点。Node#CONDITION
整数值-2:表示当前节点是条件队列中的一个节点,当它转换为同步队列中的节点的时候,状态会被重新设置为0。Node#PROPAGATE
整数值-3:此状态值通常只设置到调用了doReleaseShared()
方法的头节点,确保releaseShared()
方法的调用可以传播到其他的所有节点,简单理解就是共享模式下节点释放的传递标记。
- prev、next:当前
Node
实例的前驱节点引用和后继节点引用。 - thread:当前
Node
实例持有的线程实例引用。 - nextWaiter:这个值是一个比较容易令人生疑的值,虽然表面上它称为”下一个等待的节点”,但是实际上它有三种取值的情况。
- 值为静态实例
Node.EXCLUSIVE
(也就是null),代表当前的Node
实例是独占模式。 - 值为静态实例
Node.SHARED
,代表当前的Node
实例是共享模式。 - 值为非
Node.EXCLUSIVE
和Node.SHARED
的其他节点实例,代表Condition等待队列中当前节点的下一个等待节点。
- 值为静态实例
Node
类的等待状态waitStatus理解起来是十分费劲的,下面分析其他源码的时候会标识此状态变化的时机。
其实上面的Node
类可以直接拷贝出来当成一个新建的类,然后尝试构建一个双向链表自行调试,这样子就能深刻它的数据结构。例如:
public class AqsNode {
|
实际上,AQS
中一共存在两种等待队列,其中一种是普通的同步等待队列,这里命名为Sync-Queue,另一种是基于Sync-Queue实现的条件等待队列,这里命名为Condition-Queue。
Sync-Queue
前面已经介绍完AQS
的同步等待队列节点类,下面重点分析一下同步等待队列的相关源码,下文的Sync队列、同步队列和同步等待队列是同一个东西。首先,我们通过分析Node
节点得知Sync队列一定是双向链表,AQS
中有两个瞬时成员变量用来存放头节点和尾节点:
// 头节点引用
|
当前线程加入同步等待队列和同步等待队列的初始化是同一个方法,前文提到过:同步等待队列的初始化会延迟到第一次可能出现竞争的情况,这是为了避免无谓的资源浪费,具体方法是addWaiter(Node mode)
:
// 添加等待节点到同步等待队列,实际上初始化队列也是这个方法完成的
|
在首次调用addWaiter()
方法,死循环至少执行两轮再跳出,因为同步队列必须初始化完成后(第一轮循环),然后再把当前线程所在的新节点实例添加到等待队列中再返回(第二轮循环)当前的节点,这里需要注意的是新加入同步等待队列的节点一定是添加到队列的尾部并且会更新AQS
中的tail属性为最新入队的节点实例。
假设我们使用Node.EXCLUSIVE
模式入队列,手上有三个线程分别是thread-1、thread-2和thread-3,线程入队的时候都处于阻塞状态,模拟一下依次调用上面的入队方法的同步队列的整个链表的状态。
先是线程thread-1加入等待队列:
接着是线程thread-2加入等待队列:
最后是线程thread-3加入等待队列:
如果仔细研究会发现,如果所有的入队线程都处于阻塞状态的话,新入队的线程总是添加到队列的tail节点,阻塞的线程总是”争抢”着成为head节点,这一点和CLH队列锁的阻塞线程总是基于前驱节点自旋以获取锁的思路是一致的。下面将会分析的独占模式与共享模式,线程加入等待队列都是通过addWaiter()
方法。
Condition-Queue
前面已经相对详细地介绍过同步等待队列,在AQS
中还存在另外一种相对特殊和复杂的等待队列-条件等待队列。介绍条件等待队列之前,要先介绍java.util.concurrent.locks.Condition
接口。
public interface Condition {
|
Condition
可以理解为Object
中的wait()
、notify()
和notifyAll()
的替代品,因为Object
中的相应方法是JNI(Native)方法,由JVM实现,对使用者而言并不是十分友好(可能需要感知JVM的源码实现),而Condition
是基于数据结构和相应算法实现对应的功能,我们可以从源码上分析其实现。
Condition
的实现类是AQS
的公有内部类ConditionObject
。ConditionObject
提供的入队列方法如下:
public class ConditionObject implements Condition, java.io.Serializable {
|
实际上,Condition
的所有await()
方法变体都调用addConditionWaiter()
添加阻塞线程到条件队列中。我们按照分析同步等待队列的情况,分析一下条件等待队列。正常情况下,假设有2个线程thread-1和thread-2进入条件等待队列,都处于阻塞状态。
然后是thread-2进入条件队列:
条件等待队列看起来也并不复杂,但是它并不是单独存在和使用的,一般依赖于同步等待队列,下面的一节分析Condition的实现的时候再详细分析。
独占模式与共享模式
前文提及到,同步器涉及到独占模型和共享模式。下面就针对这两种模式详细分析一下AQS
的具体实现源码。
独占模式
AQS
同步器如果使用独占(EXCLUSIVE)模式,那么意味着同一个时刻,只有节点所在一个线程获取(acuqire)原子状态status成功,此时该线程可以从阻塞状态解除继续运行,而同步等待队列中的其他节点持有的线程依然处于阻塞状态。独占模式同步器的功能主要由下面的四个方法提供:
acquire(int arg)
;申请获取arg个原子状态status(申请成功可以简单理解为status = status - arg
)。acquireInterruptibly(int arg)
:申请获取arg个原子状态status,响应线程中断。tryAcquireNanos(int arg, long nanosTimeout)
:申请获取arg个原子状态status,带超时的版本。release(int arg)
:释放arg个原子状态status(释放成功可以简单理解为status = status + arg
)。
独占模式下,AQS
同步器实例初始化时候传入的status值,可以简单理解为”允许申请的资源数量的上限值”,下面的acquire
类型的方法暂时称为”获取资源”,而release
方法暂时称为”释放资源”。接着我们分析前面提到的四个方法的源码,先看acquire(int arg)
:
public final void acquire(int arg) {
|
上面的代码虽然看起来能基本理解,但是最好用图推敲一下”空间上的变化
接着分析一下release(int arg)
的实现:
// 释放资源
|
接着用上面的图:
上面图中thread-2晋升为头节点的第一个后继节点,等待下一个release()
释放资源唤醒之就能晋升为头节点,一旦晋升为头节点也就是意味着可以解除阻塞继续运行。接着我们可以看acquire()
的响应中断版本和带超时的版本。先看acquireInterruptibly(int arg)
:
public final void acquireInterruptibly(int arg)
|
doAcquireInterruptibly(int arg)
方法和acquire(int arg)
类似,最大的不同点在于阻塞线程解除阻塞后并不是正常继续运行,而是直接抛出InterruptedException
异常。最后看tryAcquireNanos(int arg, long nanosTimeout)
的实现:
// 独占模式下尝试在指定超时时间内获取资源,响应线程中断
|
tryAcquireNanos(int arg, long nanosTimeout)
其实和doAcquireInterruptibly(int arg)
类似,它们都响应线程中断,不过tryAcquireNanos()
在获取资源的每一轮循环尝试都会计算剩余可用的超时时间,只有同时满足获取失败需要阻塞并且剩余超时时间大于SPIN_FOR_TIMEOUT_THRESHOLD(1000纳秒)
的情况下才会进行阻塞。
独占模式的同步器的一个显著特点就是:头节点的第一个有效(非取消)的后继节点,总是尝试获取资源,一旦获取资源成功就会解除阻塞并且晋升为头节点,原来所在节点会移除出同步等待队列,原来的队列长度就会减少1,然后头结点的第一个有效的后继节点继续开始竞争资源。
使用独占模式同步器的主要类库有:
- 可重入锁
ReentrantLock
。 - 读写锁
ReentrantReadWriteLock
中的写锁WriteLock
。
共享模式
共享(SHARED)模式中的”共享”的含义是:同一个时刻,如果有一个节点所在线程获取(acuqire)原子状态status成功,那么它会解除阻塞被唤醒,并且会把唤醒状态传播到所有的后继节点(换言之就是唤醒整个同步等待队列中的所有节点)。共享模式同步器的功能主要由下面的四个方法提供:
acquireShared(int arg)
;申请获取arg个原子状态status(申请成功可以简单理解为status = status - arg
)。acquireSharedInterruptibly(int arg)
:申请获取arg个原子状态status,响应线程中断。tryAcquireSharedNanos(int arg, long nanosTimeout)
:申请获取arg个原子状态status,带超时的版本。releaseShared(int arg)
:释放arg个原子状态status(释放成功可以简单理解为status = status + arg
)。
先看acquireShared(int arg)
的源码:
// 共享模式下获取资源
|
其实代码的实现和独占模式有很多类似的地方,一个很大的不同点是:共享模式同步器当节点获取资源成功晋升为头节点之后,它会把自身的等待状态通过CAS更新为Node.PROPAGATE
,下一个加入等待队列的新节点会把头节点的等待状态值更新回Node.SIGNAL
,标记后继节点处于可以被唤醒的状态,如果遇上资源释放,那么这个阻塞的节点就能被唤醒解除阻塞。我们还是画图理解一下,先假设tryAcquireShared(int arg)
总是返回小于0的值,入队两个阻塞的线程thread-1和thread-2,然后进行资源释放确保tryAcquireShared(int arg)
总是返回大于0的值:
看起来和独占模式下的同步等待队列差不多,实际上真正不同的地方在于有节点解除阻塞和晋升为头节点的过程。因此我们可以先看releaseShared(int arg)
的源码:
// 共享模式下释放资源
|
releaseShared(int arg)
就是在tryReleaseShared(int arg)
调用返回true的情况下主动调用一次doReleaseShared()
从而基于头节点传播唤醒状态和unpark
头节点的后继节点。接着之前的图:
接着看acquireSharedInterruptibly(int arg)
的源码实现:
// 共享模式下获取资源的方法,响应线程中断
|
最后看tryAcquireSharedNanos(int arg, long nanosTimeout)
的源码实现:
// 共享模式下获取资源的方法,带超时时间版本
|
共享模式的同步器的一个显著特点就是:头节点的第一个有效(非取消)的后继节点,总是尝试获取资源,一旦获取资源成功就会解除阻塞并且晋升为头节点,原来所在节点会移除出同步等待队列,原来的队列长度就会减少1,重新设置头节点的过程会传播唤醒的状态,简单来说就是唤醒一个有效的后继节点,只要一个节点可以晋升为头节点,它的后继节点就能被唤醒。节点的唤醒顺序遵循类似于FIFO的原则,通俗说就是先阻塞或者阻塞时间最长则先被唤醒。
使用共享模式同步器的主要类库有:
- 信号量
Semaphore
。 - 倒数栅栏
CountDownLatch
。
Condition的实现
Condition
实例的建立是在Lock
接口的newCondition()
方法,它是锁条件等待的实现,基于作用或者语义可以见Condition
接口的相关API注释:
Condition是对象监视器锁方法Object#wait()、Object#notify()和Object#notifyAll()的替代实现,对象监视器锁实现锁的时候作用的效果是每个锁对象必须使用多个wait-set(JVM内置的等待队列),通过Object提供的方法和监视器锁结合使用就能达到Lock的实现效果。如果替换synchronized方法和语句并且结合使用Lock和Condition,就能替换并且达到对象监视器锁的效果。
Condition
必须固有地绑定在一个Lock
的实现类上,也就是要通过Lock
的实例建立Condition
实例,而且Condition
的方法调用使用必须在Lock
的”锁定代码块”中,这一点和synchronized
关键字以及Object
的相关JNI方法使用的情况十分相似。
前文介绍过Condition
接口提供的方法以及Condition
队列,也就是条件等待队列,通过PPT画图简单介绍了它的队列节点组成。实际上,条件等待队列需要结合同步等待队列使用,这也刚好对应于前面提到的Condition
的方法调用使用必须在Lock
的锁定代码块中。听起来很懵逼,我们慢慢分析一下ConditionObject
的方法源码就能知道具体的原因。
先看ConditionObject#await()
方法:
// 退出等待后主动进行中断当前线程 |