大厂面试:看你那么牛, 请手写一个AQS 看看!
本文 的 原文 地址
原始的内容,请参考 本文 的 原文 地址
手写 AQS 的高薪问题
在40岁老架构师 尼恩的读者交流群(50+)中,最近有小伙伴拿到了一线互联网企业如阿里、滴滴、极兔、有赞、希音、百度、网易、美团的面试资格,遇到很多很重要的面试题。
前几天,一个自考文凭 小伙, 凭着45岁老架构师尼恩给他 定制的 那个 牛逼轰轰的 绝世简历, 拿到了 一个 30K*14薪的高薪机会。
面试过程中,遇到了三个基础面试题:
- 手写AQS
- 手写LRU
- 手写 ratelimit
另外两个题目,后面的文章给大家复盘, 具体请参加 尼恩的 博客文章:
手写LRU
手写 ratelimit
基础知识:锁与队列的关系
本小节的内容,来自 尼恩的卷2 书《Java高并发核心编程 卷2》。具体的内容 请参阅 卷2 书。
锁与队列的关系紧密。
无论是单体服务应用内部的锁,还是分布式环境下多体服务应用所使用的分布式锁,为了减少由于无效争夺导致的资源浪费和性能恶化,一般都基于队列进行排队与削峰。
1. CLH锁的内部队列
《Java高并发核心编程 卷2》 在第5章, 介绍的CLH自旋锁使用的CLH(Craig, Landin, and Hagersten Lock Queue)是一个单向队列,也是一个FIFO队列。
在独占锁中,竞争资源在一个时间点只能被一个线程锁访问;队列的队首节点(队列的头部)表示占有锁的节点,新加入的抢锁线程则需要等待,会插入到队列的尾部。
CLH锁的内部结构如图6-1所示。
2. 分布式锁的内部队列
在分布式锁的实现中,比较常见的也是基于队列的方式进行不同节点中“等锁线程”的统一调度和管理。
以基于ZooKeeper的分布式锁为例,其等待队列的结构大致如图6-2所示。
图6-1 CLH锁的内部结构
图6-2 ZooKeeper分布式锁的等待队列的结构
ZooKeeper分布式锁的原理和实战知识,请参阅另一本书《Java高并发核心编程 卷1(加强版):NIO、Netty、Redis、ZooKeeper》。
3. AQS的内部队列
AQS是JUC提供的一个用于构建锁和同步容器的基础类。AQS解决了在实现同步容器时设计的大量细节问题。
JUC包内的许多类都是基于AQS构建,例如ReentrantLock、Semaphore、CountDownLatch、ReentrantReadWriteLock、FutureTask等。
AQS是CLH队列的一个变种,主要原理和CLH队列差不多,这也是前面对CLH队列进行长篇大论介绍的原因。
AQS队列内部维护的是一个FIFO的双向链表,这种结构的特点是每个数据结构都有两个指针,分别指向直接的前驱节点和直接的后驱节点。
AQS的内部结构如图6-3所示。
图6-3 AQS锁的内部结构
所以双向链表可以从任意一个节点开始很方便地访问前驱节点和后驱节点。
每个节点其实是由线程封装的,当线程争抢锁失败后会封装成Node加入到AQS队列中去;当获取锁的线程释放锁以后,会从队列中唤醒一个阻塞的节点(线程)。
4、AQS 的设计模式、核心成员
AQS出于“分离变与不变”的原则,基于模板模式实现。
AQS为锁获取、锁释放的排队和出队过程提供了一系列的模板方法。
由于JUC的显式锁种类丰富,因此AQS将不同锁的具体操作抽取为钩子方法,供各种锁的子类(或者其内部类)去实现。
AQS中维持了一个单一的volatile修饰的状态信息state,AQS使用int类型的state标示锁的状态,可以理解为锁的同步状态。
以ReentrantLock为例,state初始化为1,表示 锁定状态。state初始化为0,表示未锁定状态。
//同步状态,使用volatile保证线程可见
private volatile int state;
state因为使用volatile保证了操作的可见性,所以任何线程通过getState()获得状态都是可以得到最新值。
AQS提供了getState()、setState()来获取和设置同步状态,具体的代码如下:
// 获取同步的状态
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);
}
由于setState()无法保证原子性,因此AQS给我们提供了compareAndSetState()方法利用底层UnSafe的CAS机制来实现原子性。compareAndSetState()方法实际上调用的是unsafe成员的compareAndSwapInt()方法。
以ReentrantLock为例,state初始化为1,表示 锁定状态。state初始化为0,表示未锁定状态。A线程执行该锁的lock()操作时,会调用tryAcquire()独占该锁并将state加1。
此后,其他线程再tryAcquire()时就会失败,直到A线程unlock()到state=0(即释放锁)为止,其他线程才有机会获取该锁。当然,释放锁之前,A线程自己是可以重复获取此锁的(state会累加),这就是可重入的概念。但要注意,获取多少次就要释放多么次,这样才能保证state是能回到零态。
面试真题,首先一个简化版本的 AQS
实现一个简化版本的 AbstractQueuedSynchronizer
(AQS)是一个非常有挑战性的任务,因为 AQS 本身是一个非常复杂的组件,涉及到线程的排队、阻塞、唤醒等操作。
简单版本的 AQS 几个核心功能
要实现一个简单版本的 AQS ,首先关注以下几个核心功能:
1. 线程管理:设计一个 Node 对象,封装需要参与排队的 thread 线程 引用。
2. 状态管理:使用一个整数 state
来表示同步状态。
3. 队列管理:使用一个双向链表来管理等待的线程。
4. 线程阻塞和唤醒:使用 LockSupport
来阻塞和唤醒线程。
简单版本的 AQS 属性和方法设计
1、状态管理:
-
设计一个整数
state
来表示同步状态。参考 经典的 ReentrantLock ,state 为1,表示 锁定状态。state初始化为0,表示未锁定状态。A线程执行该锁的lock()操作时,会调用tryAcquire()独占该锁并将state加1。
-
设计
getState
和setState
方法来获取和设置状态。
2. 队列管理:
- 设计一个双向链表来, 管理等待的线程,每个链表节点node 包含一个线程引用。
- 设计一个
addWaiter
方法, 将当前线程封装成一个node 节点, 并加入队列。 - 设计一个
acquireQueued
方法在队列中阻塞当前线程,直到获取同步状态。
3. 线程阻塞和唤醒:
- 使用
LockSupport.park
方法阻塞线程。 - 使用
LockSupport.unpark
方法唤醒线程。
4. 核心方法:
- 设计一个
acquire
方法尝试获取同步状态,如果失败则将当前线程加入队列并阻塞。 - 设计一个
release
方法尝试释放同步状态,如果成功则唤醒后继节点中的线程。 - 设计一个
acquireQueued
实现 等待获取锁
简化的 AQS 代码 实现:
import java.util.concurrent.locks.LockSupport;
public abstract class SimpleAQS {
// 同步状态
private volatile int state;
// 节点类,用于表示等待队列中的节点
private static class Node {
final Thread thread;
Node next;
Node prev;
Node(Thread thread) {
this.thread = thread;
}
}
// 队列的头节点
private transient volatile Node head;
// 队列的尾节点
private transient volatile Node tail;
// 获取当前同步状态
protected final int getState() {
return state;
}
// 设置同步状态
protected final void setState(int newState) {
state = newState;
}
// 尝试获取同步状态,需要子类实现
protected abstract boolean tryAcquire(int arg);
// 尝试释放同步状态,需要子类实现
protected abstract boolean tryRelease(int arg);
// 将当前线程加入等待队列并阻塞
public final void acquire(int arg) {
if (!tryAcquire(arg)) {
Node node = addWaiter();
// 进入自旋等待状态(acquireQueued)
acquireQueued(node, arg);
}
}
// 将当前线程加入等待队列
private Node addWaiter() {
Node node = new Node(Thread.currentThread());
for (;;) {
Node t = tail;
if (t == null) {
// 初始化队列:创建虚拟头节点
Node dummy = new Node(null); // 创建虚拟头节点
dummy.next = node;
node.prev = dummy;
head = dummy; // head 指向虚拟头节点
tail = node; // tail 指向真实节点
return node;
} else {
// 正常入队操作
node.prev = t;
if (compareAndSetTail(t, node)) { // 使用 CAS 确保线程安全
t.next = node;
return node;
}
}
}
}
private void acquireQueued(Node node, int arg) {
boolean interrupted = false; // 中断标志
try {
for (;;) { // 无限循环直到成功获取锁
Node p = node.prev; // 获取前驱节点
// 1. 检查前驱是否为头节点且尝试获取锁
if (p == head && tryAcquire(arg)) {
setHead(node); // 2. 获取成功设为新头节点
return; // 3. 成功获取锁,退出
}
// 4. 检查是否需要阻塞及中断状态
if (shouldParkAfterFailedAcquire()) {
LockSupport.park(this); // 5. 阻塞当前线程
if (Thread.interrupted()) { // 6. 检查是否中断唤醒
interrupted = true; // 7. 标记中断状态
}
}
}
} finally {
// 8. 最终处理中断(恢复中断状态)
if (interrupted)
selfInterrupt();
}
}
// 将节点移动到队列头部
private void setHead(Node node) {
head = node;
node.next = null;
node.prev = null;
}
// 检查是否需要阻塞线程
private final boolean shouldParkAfterFailedAcquire() {
Node p = head;
return p != null && p != tail;
}
// 自中断
private static void selfInterrupt() {
Thread.currentThread().interrupt();
}
// 释放同步状态
public final void release(int arg) {
if (tryRelease(arg)) {
Node h = head;
if (h != null && h != tail) {
unparkSuccessor(h);
}
}
}
// 唤醒后继节点中的线程
private void unparkSuccessor(Node node) {
Node next = node.next;
if (next != null) {
LockSupport.unpark(next.thread);
}
}
}
核心子类:node 类封装&设计
节点node 包含一个等待thread 线程引用
// 节点类,用于表示等待队列中的节点
private static class Node {
final Thread thread;
Node next;
Node prev;
Node(Thread thread) {
this.thread = thread;
}
}
咱们得简单 aqs版本,这里省略了 waitStatus 。
在AQS中,Node的状态(waitStatus)主要有以下几种 :
1、CANCELLED (1):表示节点已取消(超时或中断)
2、SIGNAL (-1):表示当前节点的后继节点需要唤醒(即当前节点释放锁后需要唤醒后继节点)
3、CONDITION (-2):表示节点在条件队列中等待(用于Condition )
4、PROPAGATE (-3):在共享模式下,表示下一次acquireShared应该被传播(共享模式下使用,简化实现通常不处理)
5、0:初始状态(新节点加入队列时的状态)
尼恩提示: 为了简单,咱们这里直接省略 waitStatus 了。因为咱们没有 条件队列, 没有 共享模式, 省略waitStatus 了, 但是还是要告诉面试官。
设计一个 acquire 方法 获取同步状态(获取锁):
acquire 方法 获取同步状态 流程:
-
尝试获取锁(tryAcquire)
-
如果成功,则流程结束。
-
如果失败,则将当前线程包装成节点加入队列(addWaiter)。
-
然后在队列中自旋等待(acquireQueued):不断检查前驱节点是否为头节点,如果是,则再次尝试获取锁。
-
如果获取成功,则将自己设为头节点,并跳出。
-
如果不是头节点,或者获取失败,则阻塞线程(park)。
下面acquire方法的流程图 :
再细致的介绍 一下acquire 方法 核心流程设计 ,包括以下几个步骤:
1、尝试获取锁(tryAcquire)
2、如果成功则直接返回,流程结束
3、如果失败,将当前线程包装成节点,并加入队列尾部(addWaiter)
4、进入自旋等待状态(acquireQueued):
-
a、 检查当前节点的前驱节点是否为 虚头节点(head)
-
b、 如果是虚头节点,则再次尝试获取锁(tryAcquire)
-
c、如果获取成功,将当前节点设置为新的 虚头节点,并跳出循环
-
d、 如果不是虚头节点,或者获取失败,则判断是否需要阻塞(阻塞条件 简单点, 检查 虚头节点 不为空, 实际上是要检测 虚头节点waitstatus 的, 我们这里简化了)
-
e、 如果需要阻塞,则调用LockSupport.park阻塞当前线程
5、当被唤醒时(unpark),继续从步骤4a开始循环
acquire 的代码如下:
// 将当前线程加入等待队列并阻塞
public final void acquire(int arg) {
if (!tryAcquire(arg)) {
//如果失败,将当前线程包装成节点,并加入队列尾部(addWaiter)
Node node = addWaiter();
// 进入自旋等待状态(acquireQueued)
acquireQueued(node, arg);
}
}
addWaiter 将当前线程加入等待队列
如果抢锁 失败,调用 addWaiter 将当前线程包装成节点, 加入队列尾部
// 将当前线程加入等待队列
private Node addWaiter() {
Node node = new Node(Thread.currentThread());
for (;;) {
Node t = tail;
if (t == null) {
// 初始化队列:创建虚拟头节点
Node dummy = new Node(null); // 创建虚拟头节点
dummy.next = node;
node.prev = dummy;
head = dummy; // head 指向虚拟头节点
tail = node; // tail 指向真实节点
return node;
} else {
// 正常入队操作
node.prev = t;
if (compareAndSetTail(t, node)) { // 使用 CAS 确保线程安全
t.next = node;
return node;
}
}
}
原始 AQS 的addWaiter 方法 有 一个 自旋尝试加入队尾 的过程。咱们省略了自旋尝试加入队尾 的代码。
原始 AQS 的addWaiter 方法 中, 如果 CAS 执行失败,则通过 enq(node) 进行自旋尝试加入队尾。
原始 AQS 的addWaiter 方法 代码如下:
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;
//CAS 操作,将尾结点设置为当前节点
if (compareAndSetTail(pred, node)) {
//设置之前尾结点下一节点,指向当前尾结点, 返回尾结点
pred.next = node;
return node;
}
}
//如果尾结点是null, 或者 CAS 操作失败,进行自旋 enq() 加入尾结点
enq(node);
return node;
}
private Node enq(final Node node) {
//自旋
for (;;) {
Node t = tail;
if (t == null) { // Must initialize
//尾节点是null,则 CAS 初始化队列的头节点 head,头结点是空节点, tail = head
if (compareAndSetHead(new Node()))
tail = head;
} else {
// 设置当前节点前一节点为 tail
node.prev = t;
// CAS 操作设置当前节点为 尾结点
if (compareAndSetTail(t, node)) {
// 设置之前尾结点指向当前节点
t.next = node;
return t;
}
}
}
}
AQS 的虚拟头节点
在 AQS(AbstractQueuedSynchronizer)的源码中,存在一个 虚拟头节点(dummy head node),这是其核心设计之一。
在 AbstractQueuedSynchronizer
类中(以 JDK 17 为例):
// 队列初始化方法
private Node enq(final Node node) {
for (;;) {
Node t = tail;
if (t == null) { // 队列为空
// 创建并设置虚拟头节点
if (compareAndSetHead(new Node()))
tail = head; // 初始时头尾指向同一个虚拟节点
} else {
// 正常入队逻辑...
}
}
}
AQS 中虚 节点(dummy head node)的创建原因: 为什么AQS 要创建一个虚拟节点呢?
什么是 虚 节点(dummy head node)?
就是 thread 线程为空的 头节点。
1. 虚头节点背景: 前置节点唤醒 后面的节点
核心问题是:谁来唤醒等待的节点。
最佳答案是: 一般来说, 队列中的线程是一个一个执行的, 最好是 前一个节点 干完后, 唤醒 后一个等待的节点。
所以:一个 AQS node thread 挂起自己之前,需要将 pre前置节点的 ws 状态设置成 SIGNAL,告诉他:你释放锁的时候记得唤醒我。
Node
类的 waitStatus
变量 的核心作用:
- 每个节点都有一个
waitStatus
(简称ws
)变量,用于表示节点的状态。 - 初始状态为 0。
- 如果节点被取消,
ws
会被设置为 1,此时节点会被 AQS 清理。 - 一个重要的状态是
SIGNAL
(值为 -1),表示当前节点释放锁时,需要唤醒下一个节点。
2. 为什么需要 waitStatus
?
- 防止重复操作:假设一个节点已经被释放,而另一个线程不知道,再次尝试释放,这会导致错误。
- 保证数据一致性:通过
waitStatus
状态,确保节点的状态管理是线程安全的,修改waitStatus
时必须使用 CAS 操作。
3. 节点的唤醒机制
- 每个节点在休眠前,必须将前置节点的
ws
设置为SIGNAL
,否则自己无法被唤醒。 - 边界问题:第一个节点没有前置节点,怎么办? 用虚头节点 来解决。
4. 虚拟节点的作用
- 为了解决第一个节点没有前置节点的问题,AQS 创建了一个虚拟节点(
head
)。 - 这个虚拟节点作为队列的头部,初始时指向一个空节点,其
ws
状态为 0。 - 第一个实际等待的节点,会将虚拟节点的
ws
设置为SIGNAL
,从而确保第一个节点也能被正确唤醒。
5. 总结
- 核心原因:每个节点都需要设置前置节点的
ws
状态,而第一个节点没有前置节点,因此需要创建一个虚拟节点。 - 作用:虚拟节点(
head
)作为队列的头部,确保第一个实际等待的节点也能正确设置前置节点的ws
状态,从而保证整个队列的唤醒机制正常工作。
虚拟头节点(dummy head node) 的作用
1.统一空队列处理
- 避免空指针异常
- 使头尾指针永远不会为 null
2.简化锁获取逻辑
- 获取锁条件统一为:(node.prev == head)
- 不用单独处理"无前驱"的特殊情况
3.方便队列维护
- 队列操作无需边界检查
- 解决 谁来唤醒等待节点 的问题
虚拟头节点(dummy head node) 线程为null, 简单理解的话,可以对应的 正在执行的线程 running 线程,running 执行完成之后,唤醒 后继节点, 从而 解决 谁来唤醒等待节点 的问题
虚拟头节点(dummy head node)的生命周期
1.创建时机:当第一个真实线程节点入队时
- 调用
enq()
方法创建
2. 更新机制:每次锁获取成功时更新
// 队列初始化方法
private Node enq(final Node node) {
......
// 在 enq() 中
if (p == head && tryAcquire(arg)) {
setHead(node); // 旧节点变新虚拟头
}
......
}
// 设置头节点方法
private void setHead(Node node) {
head = node;
node.thread = null; // 清除线程引用,使其变为虚拟节点
node.prev = null;
}
3 销毁条件:队列为空时自动解除引用
- 等待垃圾回收
设置 虚头节点步骤:
1、 断开与原头节点链接。
2、 当前节点设为新头节点。
3、 清除新头节点 thread , 使其变为 虚 节点 ,可以理解为这个线程 开始执行了, 不用 排队等待了。
4、 断开原头节点链接。
// 设置头节点方法
private void setHead(Node node) {
head = node;
node.thread = null; // 清除线程引用,使其变为 虚 节点
node.prev = null;
}
设计一个acquireQueued
实现 等待获取锁
由于平台 篇幅限制, 此处省略 1000字+
原始的内容,请参考 本文 的 原文 地址