1.前言
Java中的大部分同步类(Lock、Semaphore、ReentrantLock等)都是基于AQS实现的。AQS 全称是 AbstractQueuedSynchronizer,顾名思义,是一个用来构建锁和同步器的框架,它底层用了 CAS 技术来保证操作的原子性,同时运用了 CLH 同步队列作同步器,这也是 ReentrantLock、CountDownLatch 等同步工具实现同步的底层实现机制。它能够成为实现大部分同步需求的基础,也是 J.U.C 并发包同步的核心基础组件。
2.原理概览
AQS核心思想是,如果被请求的共享资源空闲,那么就将当前请求资源的线程设置为有效的工作线程,将共享资源设置为锁定状态;如果共享资源被占用,就需要一定的阻塞等待唤醒机制来保证锁分配。这个机制主要用的是CLH队列的变体实现的,将暂时获取不到锁的线程加入到队列中。
CLH:Craig、Landin and Hagersten队列,是单向链表,AQS中的队列是CLH变体的虚拟双向队列(FIFO),AQS是通过将每条请求共享资源的线程封装成一个节点来实现锁的分配。
主要原理图如下:
AQS使用一个Volatile的int类型的成员变量来表示同步状态,通过内置的FIFO队列来完成资源获取的排队工作,通过CAS完成对State值的修改。
2.1 AQS数据结构
先来看下AQS中最基本的数据结构——Node,Node即为上面CLH变体队列中的节点
static final class Node {
// 节点在共享模式下等待的标记
static final Node SHARED = new Node();
// 节点在独占模式下等待的标记
static final Node EXCLUSIVE = null;
// 等待状态值:1 -> 表示线程获取锁的请求已经取消了
static final int CANCELLED = 1;
// 等待状态值:-1 -> 表示当前结点准备好了,就等资源释放了
static final int SIGNAL = -1;
// 等待状态值: -2 -> 表示节点在等待队列中,节点线程等待唤醒
static final int CONDITION = -2;
// 等待状态值:-3 -> (当前线程处在SHARED情况下)表示下一个acquireShared应该无条件传播
static final int PROPAGATE = -3;
/**
* Status field, taking on only the values:
* SIGNAL: 此节点的后续节点被(或将很快被)阻塞(通过park),因此
* 当前节点在释放或取消时必须取消其后续节点。为了避免争
* 用,获取方法必须首先表明它们需要一个信号,然后重试原
* 子获取,如果失败,则阻塞。
* CANCELLED: 由于超时或中断,该节点被取消。节点永远不会离开此状态。
* 特别是,具有取消节点的线程永远不会再次阻塞。
* CONDITION: 该节点当前处于条件队列中。在传输之前,它将不用作同步
* 队列节点,此时状态将设置为0.(此处使用此值与该字段的
* 其他用途无关,但简化了机制.)
* PROPAGATE: 一个releaseShared应该被传播到其他节点。这是在
* doReleaseShared中设置的(仅针对head节点),以确保
* 传播能够继续,即使其他操作已经介入。
* 0: None of the above
*
* 值以数字形式排列以简化使用。非负值意味着节点不需要发出信号。因此,大
* 多数代码不需要检查特定的值,只需检查符号。
* 对于正常的同步节点,字段初始化为0,对于条件节点,字段初始化为
* CONDITION(-2)。可以使用CAS(或者在可能的情况下,使用无条件的
* volatile写)修改它。
*/
volatile int waitStatus;
// 当前节点的前一个节点
volatile Node prev;
// 当前节点的下一个节点
volatile Node next;
// 当前节点所代表的的线程
volatile Thread thread;
// 可以理解为当前是独占模式还是共享模式
Node nextWaiter;
// 如果节点在共享模式下等待,则返回true
final boolean isShared() {
return nextWaiter == SHARED;
}
// 获取前一个节点
final Node predecessor() throws NullPointerException {
Node p = prev;
if (p == null)
throw new NullPointerException();
else
return p;
}
// 构造函数
Node() { // Used to establish initial head or SHARED marker
}
Node(Thread thread, Node mode) { // Used by addWaiter
this.nextWaiter = mode;
this.thread = thread;
}
Node(Thread thread, int waitStatus) { // Used by Condition
this.waitStatus = waitStatus;
this.thread = thread;
}
}
2.2 同步状态State
在了解数据结构后,接下来了解一下AQS的同步状态——State。AQS中维护了一个名为state的字段,意为同步状态,是由Volatile修饰的,用于展示当前临界资源的获锁情况。
// java.util.concurrent.locks.AbstractQueuedSynchronizer
private volatile int state;
下面提供了几个访问这个字段的方法:
方法名 | 描述 |
---|---|
protected final int getState() | 获取State的值 |
protected final void setState(int newState) | 设置State的值 |
protected final boolean compareAndSetState(int expect, int update) | 使用CAS方式更新State |
这几个方法都是Final修饰的,说明子类中无法重写它们。我们可以通过修改State字段表示的同步状态来实现多线程的独占模式和共享模式(加锁过程)。
2.3 CLH的入队和出队
前面我们讲过,CLH(Craig、Landin and Hagersten)队列,是单向链表,AQS中的队列是CLH变体的双向队列(FIFO),AQS是通过将每条请求共享资源的线程封装成一个节点来实现锁的分配。那么下面我们来一起通过源码探究下CLH队列是怎么运作的。
2.3.1 入队(以独占锁为例)
- 如果当前线程通过CAS获取锁失败,AQS会将该线程以及等待状态等信息打包成一个Node节点,并将其加入同步队列的尾部,同时将当前线程挂起。
// java/util/concurrent/locks/AbstractQueuedSynchronizer.java
/**
* 以独占模式获取,忽略中断。通过至少一次调用{@link #tryAcquire}来实现,成功后返回。
* 否则线程将排队,可能会重复阻塞和取消阻塞,调用{@link #tryAcquire}直到成功。
* 此方法可用于实现方法{@link Lock# Lock}。
*
* @param arg the acquire argument. 这个值被传递给{@link #tryAcquire},但是没有被
* 解释,可以代表你喜欢的任何东西。
*/
public final void acquire(int arg) {
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
protected boolean tryAcquire(int arg) {
throw new UnsupportedOperationException();
}
上面代码的执行顺序:
1). 实现父类AbstractQueuedSynchronizer得到子类A,子类想获取锁,调用了父类的acquire();方法;
2). 调用tryAcquire(arg);这里的tryAcquire(arg);是调用AbstractQueuedSynchronizer的子类的方法,其父类AbstractQueuedSynchronizer并没有给出具体的实现方式。
3).如果获取锁成功,后面的acquireQueued(addWaiter(Node.EXCLUSIVE), arg);方法就不会执行;反之,就会执行,入队;
- 下面讲解 addWaiter(Node.EXCLUSIVE);的执行过程;
// java/util/concurrent/locks/AbstractQueuedSynchronizer.java
/**
* 等待队列的头部;其具有延迟初始化和只能通过setHead方法进行修改的特点;
* NOTE:如果head存在,则保证其waitStatus不为CANCELLED;
*/
private transient volatile Node head;
/**
* 等待队列的尾部;其具有lazily initialized和只能通过enq()方法进行添加
* 新的node的特点;
*/
private transient volatile Node tail;
/**
* 为当前线程和给定mode创建并排队节点。
* @param mode Node.EXCLUSIVE for exclusive, Node.SHARED for shared
* @return the new node
*/
① private Node addWaiter(Node mode) {
Node node = new Node(Thread.currentThread(), mode);
// Try the fast path of enq; backup to full enq on failure
Node pred = tail;
if (pred != null) {
// 第一种:有尾节点的模式,说明队列已经初始化了;
node.prev = pred;
// 详情见③
if (compareAndSetTail(pred, node)) {
pred.next = node;
return node;
}
}
/**
* 第二种:如果Pred指针是Null(说明等待队列中没有元素),或者当前Pred指针
* 和Tail指向的位置不同(说明被别的线程已经修改),就需要看一下Enq的方法。
*/
enq(node);
return node;
}
② /**
* 往队列中插入node,如果需要,会初始化队列
*/
private Node enq(final Node node) {
for (;;) {
Node t = tail;
if (t == null) { // Must initialize
if (compareAndSetHead(new Node()))
tail = head;
} else {
node.prev = t;
if (compareAndSetTail(t, node)) {
t.next = node;
return t;
}
}
}
}
/**
* 这个方法主要是对tailOffset和Expect进行比较,如果tailOffset的Node和
* Expect的Node地址是相同的,那么设置Tail的值为Update的值
*/
③ private final boolean compareAndSetTail(Node expect, Node update) {
return unsafe.compareAndSwapObject(this, tailOffset, expect, update);
}
上面②中:如果没有被初始化,需要进行初始化一个头结点出来。但请注意,初始化的头结点并不是当前线程节点,而是调用了无参构造函数的节点。如果经历了初始化或者并发导致队列中有元素,则与之前的方法相同。其实,addWaiter就是一个在双端链表添加尾节点的操作,需要注意的是,双端链表的头结点是一个无参构造函数的头结点。
2.3.2 出队
// java.util.concurrent.locks.AbstractQueuedSynchronizer
/**
* 各种各样的获取方式,在独占/共享和控制模式上各不相同。每一种都大同小异,但
* 令人讨厌的因素各不同,由于异常机制(包括确保我们在tryAcquire抛出异常时取
* 消)和其他控制的交互作用,只有很少的因素是可能的,至少不会对性能造成太大的
* 损害。
*/
/**
* 以独占的不中断模式获取已在队列中的线程。用于条件等待方法以及获取。
* @param node the node
* @param arg the acquire argument
* @return {@code true} if interrupted while waiting
*/
① final boolean acquireQueued(final Node node, int arg) {
// 标记是否成功拿到资源
boolean failed = true;
try {
// 标记等待过程中是否中断过
boolean interrupted = false;
// 开始自旋,要么获取锁,要么中断
for (;;) {
// 获取当前节点的前任节点
final Node p = node.predecessor();
// 如果p是头结点,说明当前节点在真实数据队列的首部,就尝试获取锁(别忘了头结点是虚节点)
if (p == head && tryAcquire(arg)) {
// 获取锁成功,头指针移动到当前node
setHead(node);
p.next = null; // help GC
failed = false;
return interrupted;
}
/**
* 说明p为头节点且当前没有获取到锁(可能是非公平锁被抢占了)或者是p不为头结点,
* 这个时候就要判断当前node是否要被阻塞(被阻塞条件:前驱节点的waitStatus
* 为-1),防止无限循环浪费资源。
*/
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
interrupted = true;
}
} finally {
if (failed)
// 获取资源失败,将正在尝试获取锁Node的状态标记为CANCELLED
cancelAcquire(node);
}
}
// 靠前驱节点判断当前线程是否应该被阻塞
② private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
// 获取头结点的节点状态
int ws = pred.waitStatus;
// 说明头结点处于唤醒状态
if (ws == Node.SIGNAL)
return true;
// 通过枚举值我们知道waitStatus>0是取消状态
if (ws > 0) {
do {
// 循环向前查找取消节点,把取消节点从队列中剔除
node.prev = pred = pred.prev;
} while (pred.waitStatus > 0);
pred.next = node;
} else {
// 设置前任节点等待状态为SIGNAL
compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
}
return false;
}
// 用于挂起当前线程,阻塞调用栈,返回当前线程的中断状态
③ private final boolean parkAndCheckInterrupt() {
LockSupport.park(this);
return Thread.interrupted();
}
// 将正在尝试获取锁Node的状态标记为CANCELLED
④ private void cancelAcquire(Node node) {
// 将无效节点过滤
if (node == null)
return;
// 设置该节点不关联任何线程,也就是虚节点
node.thread = null;
// 通过前驱节点,跳过CANCELLED状态的node
Node pred = node.prev;
while (pred.waitStatus > 0)
node.prev = pred = pred.prev;
// 获取过滤后的前驱节点的后继节点
Node predNext = pred.next;
// 把当前node的状态设置为CANCELLED
node.waitStatus = Node.CANCELLED;
// 如果当前节点是尾节点,将从后往前的第一个非CANCELLED状态的节点设置为尾节点
// 更新失败的话,则进入else,如果更新成功,将tail的后继节点设置为null
if (node == tail && compareAndSetTail(node, pred)) {
compareAndSetNext(pred, predNext, null);
} else {
int ws;
/**
* 如果当前节点不是head的后继节点,执行①:判断当前节点前驱节点的是否为
* SIGNAL,①不成立,则执行②:判断前驱节点是否小于等于0,如果true,则
* 把前驱节点设置为SINGAL看是否成功。
* 如果①和②中有一个为true,再判断③当前节点的线程是否为null。
* 如果上述条件都满足,把当前节点的前驱节点的后继指针指向当前节点的后继节点
*/
if (pred != head &&
① ((ws = pred.waitStatus) == Node.SIGNAL ||
② (ws <= 0 && compareAndSetWaitStatus(pred, ws, Node.SIGNAL))) &&
③ pred.thread != null) {
Node next = node.next;
if (next != null && next.waitStatus <= 0)
compareAndSetNext(pred, predNext, next);
} else {
// 如果当前节点是head的后继节点,或者上述条件不满足,那就唤醒当前节点的后继节点
unparkSuccessor(node);
}
node.next = node; // help GC
}
}
总结线程获取锁的时候,过程大体如下:
通过
tryAcquire()
获取锁,如果成功,直接通过;如果没有线程获取到锁,走acquireQueued(addWaiter(Node.EXCLUSIVE), arg);
这个是两个步骤,addWaiter()
是入队,acquireQueued()
是通过自旋按顺序出队,尝试获取锁;
addWaiter()
中,通过tail尾结点判断是否初始化了队列
tail
不为null,在队列尾部添加当前结点
taill
为null,进入enq()
方法;再次判断链表尾结点是否为空,如果为空,初始化链表;不为空,在队列尾部添加当前结点;需要注意的是,双端链表的头结点是一个无参构造函数的头结点。
acquireQueued(Node,1);
Node为addWaiter()
中添加到尾结点的node;假设为nodeA
,获取nodeA的前驱结点p,通过for循环每个结点尝试获取锁出队:
- 尝试1:如果
p
为head结点且尝试获取锁成功,那么就是设``nodeA`为head结点;清空队列;- 尝试2:执行
shouldParkAfterFailedAcquire(p,nodeA) && parkAndCheckInterrupt();
shouldParkAfterFailedAcquire(p,nodeA)
:遍历nodeA
前面的所有结点,直到找到一个waitStatus
为SIGNAL
或者waitStatus<0
的结点为nodeB
,若找到,设置nodeA
为nodeB
的后继结点,执行parkAndCheckInterrupt()
;反之,继续for循环,下一个结点继续尝试;parkAndCheckInterrupt()
:用于挂起当前线程,阻塞调用栈,返回当前线程的中断状态
3.AQS 底层使用了模板方法模式
同步器的设计是基于模板方法模式的,如果需要自定义同步器一般的方式是这样(模板方法模式很经典的一个应用):
- 使用者继承 AbstractQueuedSynchronizer 并重写指定的方法。(这些重写方法很简单,无非是对于共享资源 state 的获取和释放)
- 将 AQS 组合在自定义同步组件的实现中,并调用其模板方法,而这些模板方法会调用使用者重写的方法。
这和我们以往通过实现接口的方式有很大区别,这是模板方法模式很经典的一个运用,下面简单的给大家介绍一下模板方法模式,模板方法模式是一个很容易理解的设计模式之一。
模板方法模式是基于”继承“的,主要是为了在不改变模板结构的前提下在子类中重新定义模板中的内容以实现复用代码。举个很简单的例子假如我们要去一个地方的步骤是:购票
buyTicket()
->安检securityCheck()
->乘坐某某工具回家ride()
->到达目的地arrive()
。我们可能乘坐不同的交通工具回家比如飞机或者火车,所以除了ride()
方法,其他方法的实现几乎相同。我们可以定义一个包含了这些方法的抽象类,然后用户根据自己的需要继承该抽象类然后修改ride()
方法。
AQS 使用了模板方法模式,自定义同步器时需要重写下面几个 AQS 提供的模板方法:
isHeldExclusively()//该线程是否正在独占资源。只有用到condition才需要去实现它。
tryAcquire(int)//独占方式。尝试获取资源,成功则返回true,失败则返回false。
tryRelease(int)//独占方式。尝试释放资源,成功则返回true,失败则返回false。
tryAcquireShared(int)//共享方式。尝试获取资源。负数表示失败;0表示成功,但没有剩余可用资源;正数表示成功,且有剩余资源。
tryReleaseShared(int)//共享方式。尝试释放资源,成功则返回true,失败则返回false。
默认情况下,每个方法都抛出 UnsupportedOperationException
。 这些方法的实现必须是内部线程安全的,并且通常应该简短而不是阻塞。AQS 类中的其他方法都是 final ,所以无法被其他类使用,只有这几个方法可以被其他类使用。
以 ReentrantLock 为例,state 初始化为 0,表示未锁定状态。A 线程 lock()时,会调用 tryAcquire()独占该锁并将 state+1。此后,其他线程再 tryAcquire()时就会失败,直到 A 线程 unlock()到 state=0(即释放锁)为止,其它线程才有机会获取该锁。当然,释放锁之前,A 线程自己是可以重复获取此锁的(state 会累加),这就是可重入的概念。但要注意,获取多少次就要释放多么次,这样才能保证 state 是能回到零态的。
再以 CountDownLatch 以例,任务分为 N 个子线程去执行,state 也初始化为 N(注意 N 要与线程个数一致)。这 N 个子线程是并行执行的,每个子线程执行完后 countDown()一次,state 会 CAS(Compare and Swap)减 1。等到所有子线程都执行完后(即 state=0),会 unpark()主调用线程,然后主调用线程就会从 await()函数返回,继续后余动作。
一般来说,自定义同步器要么是独占方法,要么是共享方式,他们也只需实现tryAcquire-tryRelease
、tryAcquireShared-tryReleaseShared
中的一种即可。但 AQS 也支持自定义同步器同时实现独占和共享两种方式,如ReentrantReadWriteLock
。
总结一下:
// java.util.concurrent.locks.AbstractQueuedSynchronizer
try {
stateOffset = unsafe.objectFieldOffset
(AbstractQueuedSynchronizer.class.getDeclaredField("state"));
headOffset = unsafe.objectFieldOffset
(AbstractQueuedSynchronizer.class.getDeclaredField("head"));
tailOffset = unsafe.objectFieldOffset
(AbstractQueuedSynchronizer.class.getDeclaredField("tail"));
waitStatusOffset = unsafe.objectFieldOffset
(Node.class.getDeclaredField("waitStatus"));
nextOffset = unsafe.objectFieldOffset
(Node.class.getDeclaredField("next"));
} catch (Exception ex) { throw new Error(ex); }
从上面AQS的静态代码块可以看出,都是获取一个对象的属性相对于该对象在内存当中的偏移量,这样我们就可以根据这个偏移量在对象内存当中找到这个属性。tailOffset指的是tail对应的偏移量,所以这个时候会将new出来的Node置为当前队列的尾节点。同时,由于是双向链表,也需要将前一个节点指向尾节点。
-
cancelAcquire(Node node)流程的图文描述:
- 获取当前节点的前驱节点,如果前驱节点的状态是CANCELLED,那就一直往前遍历,找到第一个waitStatus <= 0的节点,将找到的Pred节点和当前Node关联,将当前Node设置为CANCELLED。
- 根据当前节点的位置,考虑以下三种情况:
(1) 当前节点是尾节点。
(2) 当前节点是Head的后继节点。
(3) 当前节点不是Head的后继节点,也不是尾节点。
根据上述第二条,我们来分析每一种情况的流程。
当前节点是尾节点。
当前节点是Head的后继节点。
当前节点不是Head的后继节点,也不是尾节点。
执行cancelAcquire的时候,当前节点的前置节点可能已经从队列中出去了(已经执行过Try代码块中的shouldParkAfterFailedAcquire方法了),如果此时修改Prev指针,有可能会导致Prev指向另一个已经移除队列的Node,因此这块变化Prev指针不安全。 shouldParkAfterFailedAcquire方法中,会执行下面的代码,其实就是在处理Prev指针。shouldParkAfterFailedAcquire是获取锁失败的情况下才会执行,进入该方法后,说明共享资源已被获取,当前节点之前的节点都不会出现变化,因此这个时候变更Prev指针比较安全。
do { node.prev = pred = pred.prev; } while (pred.waitStatus > 0);
参考:https://tech.meituan.com/2019/12/05/aqs-theory-and-apply.html
https://github.com/Snailclimb/JavaGuide/blob/master/docs/java/Multithread/AQS.md#2-aqs-原理