目录
- 同步器简介
- 方法简介
- 同步器实现原理
- 自己动手实现独占锁
- 同步器源码分析
同步器简介
AQS 可以认为是一个模板方法,是 JDK 提供的实现 JVM 锁的模板,AQS 封装了同步机制,通过实现 AQS 可以让开发者比较简单就可以实现 JVM 锁,而不用去考虑底层的同步机制。降低了锁的实现难度及实现代价。
AQS 同步器提供了两套同步方案:独占式、共享式。也就是说要实现独占锁或者共享锁都可以通过继承 AQS 实现。
方法简介
独占式 | 共享式 | 方法介绍 |
---|---|---|
acquire(int arg) | acquireShared(int arg) | 阻塞式加锁 |
acquireInterruptibly(int arg) | acquireSharedInterruptibly(int arg) | 在 acquire 的基础上响应中断 |
tryAcquireNanos(int arg, long nanosTimeout) | tryAcquireSharedNanos(int arg, long nanosTimeout) | 在 acquireInterruptibly 的基础上增加了超时时间 |
release(int arg) | releaseShared(int arg) | 解锁 |
可以看出 AQS 同步器为两套方案分别提供了两套方法,不带 Shared
的方法是独占式的,相应带 Shared
的方法就是共享式的。AQS 同步器提供了三种不同的加锁方式,用于适用不同的业务场景。
实现原理
同步状态
AQS 提供了一个同步状态属性 state
,通过线程同步地设置同步状态实现加锁逻辑,让一个属性实现线程同步很简单,两个步骤:
- 使用 volatile 修饰,保证线程可见性
- 使用 Unsafe 类的 cas 方法修改属性值
volatile + cas 可以保证线程安全,当有多个线程需要修改 state 属性值时,只会有一个线程会操作成功,这就可以在JVM层面实现加锁操作。
public abstract class AbstractQueuedSynchronizer extends AbstractOwnableSynchronizer implements java.io.Serializable {
// 同步状态
private volatile int state;
// 获取同步状态
protected final int getState() {
return state;
}
// 设置同步状态
protected final void setState(int newState) {
state = newState;
}
// cas 设置同步状态
protected final boolean compareAndSetState(int expect, int update) {
return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
}
}
同步状态 state 是个 int 类型的数值,通过对数值的控制可以实现不同类型的锁。比如 ReentrantLock 就是将同步状态从 0 修改为 1 表示加锁来实现独占锁,而 Semaphore 是为同步状态设置一个初始值,同步状态大于 0 就可以加锁成功,然后同步状态减 1,这样实现多个线程同时加锁的共享锁。
多个线程同时加锁,但是只有一个线程会成功,那么加锁失败的线程怎么处理?AQS 提供了一套完整的基于 CLH 队列的解决方案,加锁失败后线程会进入 CLH 队列进行阻塞等待,AQS 的加锁流程如下图所示
CLH 锁
CLH(Craig, Landin, and Hagersten locks): 是一种基于链表的可扩展、高性能、公平的自旋锁,能确保无饥饿性,提供先来先服务的公平性。
通过 CLH 的定义可以捕捉到几个重要的点
- 基于链表
- 自旋
- 先进先服务
AQS 使用的是一个 FIFO 的双向链表来实现 CLH 队列,可以看到 AQS 中定义了一个 Node 类作为 CLH 队列中的节点,Node 类中定义了 prev
指向前一个节点,next
指向后一个节点,AQS 中定义了 head
及 tail
分别表示 CLH 的头节点和尾节点,通过这个结构就可以从头节点或者尾节点遍历整个 CLH 队列。
public abstract class AbstractQueuedSynchronizer extends AbstractOwnableSynchronizer implements java.io.Serializable {
// 头节点
private transient volatile Node head;
// 尾节点
private transient volatile Node tail;
// 节点定义
static final class Node {
static final Node SHARED = new Node();
static final Node EXCLUSIVE = null;
static final int CANCELLED = 1;
static final int SIGNAL = -1;
static final int CONDITION = -2;
static final int PROPAGATE = -3;
// 等待状态
volatile int waitStatus;
// 前驱节点
volatile Node prev;
// 后继节点
volatile Node next;
// 线程
volatile Thread thread;
// 标记用
Node nextWaiter;
}
}
CLH 队列处理流程
源码中提供了一个简易的图形表示,线程加锁失败后,进入 CLH 队列,成为队列的尾节点,优先头节点获取锁,然后按链表顺序依次获取锁。
+------+ prev +-----+ +-----+
head | | <---- | | <---- | | tail
+------+ +-----+ +-----+
按 CLH 定义除头节点外,其它节点应该自旋等待,头节点释放锁后,则其后续节点就可以立刻获取锁。但是自旋是有代价的,AQS 使用 阻塞-唤醒
来替代自旋,其实还是自旋,只是自旋过程中会挂起线程,等待线程被唤醒后继续自旋。
独占锁与共享锁
AQS 提供了独占与共享两种模式,这两种模式的处理方案的主要区别为
- 加锁,独占锁只有一个线程能加锁成功,而共享锁可以多个。
- 释放锁,都需要唤醒 CLH 中的线程重新参与加锁,独占锁只需要唤醒一个节点即可,但共享锁需要唤醒多个。
接下来演示一下独占模式 AQS 实际的处理流程
第一步:CLH 队列原始状态,只有头节点拥有同步状态(标记绿色),其它节点都处于阻塞状态(标记红色)
第二步:当前线程尝试加锁失败,进入 CLH 队列尾部(标记黄色),排队等待获取同步状态
第三步:CLH 队列头节点(N0)释放同步状态,同时唤醒其后继节点(N1),N1 尝试加锁,如果成功,则将自己设置为头节点,N0 被踢出队列
重复第三步,直到当前线程节点获取到同步状态
公平锁与非公平锁
AQS 并没有提供公平锁与非公平锁的实现,但是基于 AQS 提供的方法可以实现公平锁与非公平锁,比如 ReentrantLock 就可以在创建的时候选择公平锁还是非公平锁。
所谓公平与非公平指的是尝试加锁的那个瞬间,当线程释放锁时,会唤醒 CLH 队列中的节点进行加锁,如果此时还有其它线程也需要加锁,那么就会存在竞争关系,公平锁的处理方案就是先来后到,非公平锁处理方案就是竞争,如果竞争不成功就老实排队。
自己动手实现独占锁
通过阅读上文的实现原理,可以知道 AQS 已经实现了 CLH 队列,实现一个锁只需要实现加锁及释放锁的逻辑即可,这个步骤可以通过对同步状态的设置进行实现。
实现思路:同步状态初始值为 0,加锁,将同步状态设置为 1,释放锁,将同步状态重置为 0,如此就可以实现一个最简单的独占锁。
// 实现 JDK 提供的锁接口
public class MutexLock implements Lock {
// 实现 AQS,实现自定义的加锁及释放锁逻辑
private static class Sync extends AbstractQueuedSynchronizer {
// 加锁,cas 设置同步状态为 1
@Override
protected boolean tryAcquire(int arg) {
return compareAndSetState(0, arg);
}
// 解锁,cas 设置同步状态为 0
@Override
protected boolean tryRelease(int arg) {
if (getState() == 0) {
throw new IllegalMonitorStateException();
}
setState(0);
return true;
}
Condition newCondition() {
return new ConditionObject();
}
}
// 锁的实现委托给 AQS 的实现类
private final Sync sync = new Sync();
public void lock() { sync.acquire(1); }
public void lockInterruptibly() throws InterruptedException { sync.acquireInterruptibly(1); }
public boolean tryLock() { return sync.tryAcquire(1); }
public boolean tryLock(long time, TimeUnit unit) throws InterruptedException { return sync.tryAcquireNanos(1, unit.toNanos(time)); }
public void unlock() { sync.release(0); }
public Condition newCondition() { return sync.newCondition(); }
}
源码分析
独占锁源码分析
首先看加锁方法 acquire,代码如下
public final void acquire(int arg) {
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
这部分代码写的十分精简,扩展开来会比较清晰,这里涉及到几个重要的方法:
- tryAcquire:尝试获取独占锁,需要实现类自行实现
- addWaiter:将当前线程封装成 Node 节点,添加至 CLH 等待队列尾部
- acquireQueued:在 CLH 队列中通过自旋的方式进行锁的获取
- selfInterrupt:调用 Thread.currentThread().interrupt() 方法为当前线程设置个中断标记
public final void acquire(int arg) {
// 尝试获取独占锁,如果成功立即返回
boolean flag = tryAcquire(arg);
// 如果加锁不成功
if (!flag) {
// 将当前线程加入等待队列尾部
Node waiter = addWaiter(Node.EXCLUSIVE);
// 通过自旋方式获取独占锁
boolean interrupted = acquireQueued(waiter, arg);
if (interrupted) {
selfInterrupt();
}
}
}
tryAcquire
方法需要实现类自行实现加锁逻辑,通过操作同步状态就可以实现加锁
protected boolean tryAcquire(int arg) {
throw new UnsupportedOperationException();
}
addWaiter
方法完成两件事件:
- 将当前线程封装为 Node 对象
- 将 Node 对象添加至等待队列尾部,为保证线程安全性,替换队列尾节点的操作通过 cas 完成。
private Node addWaiter(Node mode) {
// 将当前线程封装成 Node 对象
Node node = new Node(Thread.currentThread(), mode);
// 快速尝试一次将 Node 对象添加至等待队列尾部,如果尝试失败则调用 enq 方法
Node pred = tail;
if (pred != null) {
node.prev = pred;
if (compareAndSetTail(pred, node)) {
pred.next = node;
return node;
}
}
// 将 Node 对象添加至等待队列尾部
enq(node);
return node;
}
enq 方法完成两件事件:
- 如果 CLH 队列为空,则初始化队列,初始化时会在队列头设置一个空节点,通过这种方式保证唤醒操作,永远都是唤醒第二个节点。
- 如果 CLH 队列不为这,则将当前线程节点添加至等待队尾。为保证线程安全性,替换队列头节点及尾节点的操作都是通过 cas 完成。
addWaiter
方法中快速尝试替换尾节点的操作跟 enq
方法中的代码其实是一模一样的,所以本人没看出来那部分代码有什么用,并不存在效率上的提升,这只是个人愚见,有不同意见可以探讨。
private Node enq(final Node node) {
for (;;) {
Node t = tail;
if (t == null) {
// 如果尾部不存在,初始化等待队列,将队列头及尾设置为空 Node
if (compareAndSetHead(new Node()))
tail = head;
} else {
node.prev = t;
// 通过 cas 线程安全地将当前 Node 替换原来的尾节点
if (compareAndSetTail(t, node)) {
t.next = node;
return t;
}
}
}
}
此至,当前线程的 Node 对象已添加至等待队列尾部,acquireQueued
方法则是在队列中通过自旋的方式获取锁,AQS 中的自旋,并不是无限制地自旋,而是通过 挂起-唤醒
机制消除自旋可能引起的 CPU 空耗。
final boolean acquireQueued(final Node node, int arg) {
boolean failed = true;
try {
boolean interrupted = false;
// 自旋
for (;;) {
// 获取当前节点的前驱节点
final Node p = node.predecessor();
// 如果前驱节点是头节点,则尝试获取锁
if (p == head && tryAcquire(arg)) {
// 加锁成功,则将当前节点设置为头节点
setHead(node);
p.next = null; // help GC
failed = false;
return interrupted;
}
// 加锁失败,根据节点状态决定是否需要挂起线程
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
interrupted = true;
}
} finally {
// 如果出现异常,则取消加锁,并进行出队操作
if (failed)
cancelAcquire(node);
}
}
shouldParkAfterFailedAcquire
方法根据前驱节点的状态判断是否需要挂起当前线程节点
- 如果前驱节点的状态为
SIGNAL
,表示该节点释放锁时会唤醒其后继节点,所以可以安心挂起当前线程 - 如果前驱节点的状态大于 0,即为
CANCELLED
已取消状态,则将当前节点前驱的所有已取消状态的节点都从队列中踢出 - 如果前驱节点的状态小于等于 0,即为
CONDITION
或者PROPAGATE
,分别表示在等待条件或者是共享状态,则将前驱节点的状态通过 cas 设置为SIGNAL
,不需要挂起
可以看出,只有前驱节点为 SIGNAL
状态时,后继节点才可以挂起,因为 SIGNAL
状态的节点在释放锁时会唤醒其后继节点,而前驱节点处于其它状态时,当前线程不会挂起,而是将前驱节点的状态修改为 SIGNAL
,继续自旋,如果再次获取锁失败,此时前驱节点的状态就是 SIGNAL
了,然后就可以挂起了。
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
// 前驱节点的状态
int ws = pred.waitStatus;
// 如果前驱节点的状态为 SIGNAL
if (ws == Node.SIGNAL)
return true;
if (ws > 0) { // 前驱节点处于取消状态,则需要将前驱节点从队列中删除
do {
node.prev = pred = pred.prev;
} while (pred.waitStatus > 0);
pred.next = node;
} else { // 0、`CONDITION` 或者 `PROPAGATE` 状态,设置为 SIGNAL
compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
}
return false;
}
parkAndCheckInterrupt
方法用于阻塞当前线程
private final boolean parkAndCheckInterrupt() {
// 阻塞当前线程,进入等待状态,需要调用 LockSupport.unpark 方法或者 interrupt 中断进行唤醒
LockSupport.park(this);
// 被唤醒之后返回当前线程是否在中断状态,并清除中断记号
return Thread.interrupted();
}
至此,整个加锁过程就已经完成。
最后看一下解锁方法 tryRelease
,代码如下
public final boolean release(int arg) {
// 执行释放锁,需要实现类自行实现逻辑
if (tryRelease(arg)) {
Node h = head;
// 唤醒 CLH 队列的头节点
if (h != null && h.waitStatus != 0)
unparkSuccessor(h);
return true;
}
return false;
}
unparkSuccessor
方法用于唤醒 CLH 头节点的后继节点(前文中一直说唤醒头节点,其实真正唤醒的是头节点的后继节点),传入的 node 就是头节点。如果后继节点状态为已取消,则向后查找最近的一个有效节点进行唤醒。
private void unparkSuccessor(Node node) {
int ws = node.waitStatus;
// 将头节点的状态重置为 0
if (ws < 0)
compareAndSetWaitStatus(node, ws, 0);
// 如果后续节点为已取消,则从队列尾部开始向前查找,找到离当前节点最近的一个有效节点
Node s = node.next;
if (s == null || s.waitStatus > 0) {
s = null;
for (Node t = tail; t != null && t != node; t = t.prev)
if (t.waitStatus <= 0)
s = t;
}
// 唤醒该节点中的线程
if (s != null)
LockSupport.unpark(s.thread);
}
最后理一下独占模式下 CLH 节点的状态变化过程,如下图所示,其中后继节点加入与加锁是同步进行,只要在释放锁之前有后继节点加入,则状态会被修改为 SIGNAL
,这样后继节点才可以挂起线程,等待前驱节点释放锁之后会主动唤醒后继节点。
共享锁源码分析
共享锁与独占锁的实现逻辑大体一致,也是利用 CLH 队列,CLH 队列也是通过 挂机-唤醒
机制自旋式获取锁,但是共享锁与独占锁不同的是,共享锁可以允许多个线程同时成功加锁,所以在加锁的实现逻辑上有差别,另外,线程释放锁时,其它线程就可以进行加锁,这里也不能像独占锁一样,只唤醒后继节点即可,因为可能多个节点都可以成功加锁,只唤醒一个就失去了共享锁的意义。
首先查看一下加锁方法 acquireShared
,该方法首先调用 tryAcquireShared
方法尝试加锁,这里与独占锁的加锁方法 tryAcquire
有所不同,独占锁只有一个线程可以成功,所以返回布尔值,要么成功,要么失败。tryAcquireShared
方法返回一个数值,大于等于 0,表示加锁成功,小于 0,表示加锁失败,走 CLH 队列流程。
public final void acquireShared(int arg) {
if (tryAcquireShared(arg) < 0)
doAcquireShared(arg);
}
tryAcquireShared
方法与 tryAcquire
方法一样需要实现类自行实现
protected int tryAcquireShared(int arg) {
throw new UnsupportedOperationException();
}
doAcquireShared
方法相当于独占锁的 addWaiter
+ acquireQueued
+ selfInterrupt
,独占锁写的比较艺术,而这里就是简单的代码陈列。
private void doAcquireShared(int arg) {
// 设置节点为共享节点,加入 CLH 队列尾部
final Node node = addWaiter(Node.SHARED);
boolean failed = true;
try {
boolean interrupted = false;
for (;;) { // 自旋
final Node p = node.predecessor(); // 获取前驱节点
if (p == head) {
int r = tryAcquireShared(arg); // 尝试加锁
if (r >= 0) { // 加锁成功
setHeadAndPropagate(node, r);// 设置当前节点为头节点
p.next = null; // help GC
if (interrupted)
selfInterrupt(); // 设置中断标记
failed = false;
return;
}
}
// 加锁失败,阻塞线程,这里与独占锁的实现一模一样
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
interrupted = true;
}
} finally {
if (failed)
cancelAcquire(node);
}
}
可以对比一下独占锁的代码实现,可以发现实现逻辑几乎一样。这里只说明一下不一样的地方。
- 创建节点,调用
addWaiter(Node.SHARED)
创建共享节点,而独占锁调用addWaiter(Node.EXCLUSIVE)
创建独占节点。 - 加锁,调用
tryAcquireShared
,而独占锁调用tryAcquire
方法,这两个方法都需要实现类自行实现。 - 加锁成功,调用
setHeadAndPropagate(node, r)
方法设置当前节点为头节点,而独占锁调用setHead(node)
共享锁的加锁与独占锁不一样,虽然都是利用同步状态做文章,共享锁可以理解为资源,每一次加锁,就是向 AQS 申请资源,而释放锁就是向 AQS 归还资源,所以只要 AQS 还有资源就可以允许加锁。所以 tryAcquireShared
方法的返回值,可以理解为加锁后的剩余资源,所以只要大于等于 0,就是加锁成功。
这里重点关注一下 setHeadAndPropagate
这个方法,通过方法名可以猜测,setHeadAndPropagate
包含了 setHead
的逻辑,同时还要实现 propagate
(传递),所谓传递就是指,只要 AQS 还有剩余资源,就继续唤醒后继节点,进行加锁操作。唤醒操作调用 doReleaseShared()
方法实现,这个方法也是释放同步锁时会调用的方法,在下文还会详细介绍,这里只要理解为继续唤醒后继节点即可。
// propagate 就是 tryAcquireShared 加锁后返回的剩余资源
private void setHeadAndPropagate(Node node, int propagate) {
Node h = head;
setHead(node); // 将当前节点设置为头节点
// propagate > 0 表示只要资源还有,则需要继续唤醒后续节点
if (propagate > 0 || h == null || h.waitStatus < 0 ||
(h = head) == null || h.waitStatus < 0) {
Node s = node.next;
// 后继节点应该也是共享节点
if (s == null || s.isShared())
doReleaseShared(); // 释放共享锁时调用的方法
}
}
接着看一下共享锁释放方法 releaseShared
,首先调用 tryReleaseShared
进行资源归还,这个方法需要实现类自行实现,然后调用 doReleaseShared
方法唤醒 CLH 队列中的节点。
public final boolean releaseShared(int arg) {
if (tryReleaseShared(arg)) {
doReleaseShared();
return true;
}
return false;
}
doReleaseShared
这个方法对比独占锁释放时的操作就要复杂很多,因为独占锁只有一个线程可以获取锁,所以释放的时候不存在线程安全的问题,但是这个方法在队列中加锁成功与任意线程释放锁都会调用,就有可能出现判断过程中出现头节点被替换的问题。
private void doReleaseShared() {
for (;;) {
Node h = head;
if (h != null && h != tail) {
int ws = h.waitStatus;
// 如果节点状态为 -1,则将状态设置为 0,唤醒后继节点
if (ws == Node.SIGNAL) {
if (!compareAndSetWaitStatus(h, Node.SIGNAL, 0))
continue; // loop to recheck cases
unparkSuccessor(h);
// 如果节点状态为 0,则将状态设置为 -3
} else if (ws == 0 && !compareAndSetWaitStatus(h, 0, Node.PROPAGATE))
continue; // loop on failed CAS
}
if (h == head) // loop if head changed
break;
}
}
最后理一下共享模式下 CLH 节点的状态变化过程,如下图所示。