欢迎来到啾啾的博客🐱。
记录学习点滴。分享工作思考和实用技巧,偶尔也分享一些杂谈💬。
有很多很多不足的地方,欢迎评论交流,感谢您的阅读和评论😄。
目录
1 引言
阅读本篇可并发认知+3。
在之前的《线程》系列中,我们有提及AQS,但叙述基本照搬文档与《Java高并发核心编程》。
本篇将从问题出发,重新讲解一下AQS,讲解并发编程的基石。
Q:抛开所有JUC的实现,想象一下,在多线程环境下,我们有一个共享资源,一次只允许一个线程访问(这就是“锁”的本质需求)。当一个线程抢到锁后,其他没抢到的线程该怎么办?
A:AQS(AbstractQueuedSynchronizer)被设计出来处理了“一切”。
2 “自旋”还是“挂起”?
Q:当一个线程抢到锁后,其他竞争的线程是应该不断尝试(自旋)还是休息等待通知呢?又或者有其他的合适做法?
A:没有其他更合适的做法,自旋快但耗CPU,挂起省CPU但可能慢。
AQS采取的做法是“挂起”。
AQS认为,对于获取不到锁的场景,长时间的自旋是不可接受的浪费。所以,AQS建立了一个核心机制,让拿不到锁的线程进入一个“等候区”,并调用LockSupport.park()方法将线程挂起,放弃CPU,进入休眠状态。
2.1 synchronized的混合策略
现代的锁(如synchronized自JDK 1.6优化后)其实采用了混合策略:先进行短暂的、次数有限的“自适应自旋”,如果还拿不到锁,再像AQS一样,进入最终的挂起等待。
但AQS本身,专注于管理和调度那些已经被迫放弃CPU的线程,这是它的核心职责。
3 “挂起”的线程要怎么组织?
Q:如果有大量的线程在等待,我们应该如何组织它们?是随机叫一个起来,还是应该有个先来后到的队列?为什么?
A: 使用队列组织等待的线程,借助队列"先进先出"的特性,但也要考虑“插队”的可能。
AQS可以“先进先出”的公平排队,也设计有“插队”机制。
插队有两种,一种是插入原有队列,一种是快速通道。AQS的实现是“插入原有队列”。
3.1 CLH队列
AQS内部维护了一个CLH队列(一个双向链表的变体)。所有等待的线程都会被封装成一个Node节点,并加入到这个队列的尾部。这天然地实现了公平性(Fairness),即先来后到。
AQS通过暴露给子类(如ReentrantLock)的接口,实现了公平模式和非公平模式。
- 公平模式 (Fair): 严格按照队列顺序唤醒线程,绝对的先来后到。
- 非公平模式 (Non-fair): 当一个线程释放锁时,会唤醒队头的等待者。但如果此时正好有一个新的线程要来获取锁,它会先尝试“插队”(Barging),如果成功了,就直接拿走锁。这种模式下,系统吞吐量通常更高,因为减少了线程挂起和唤醒的开销。
4 “挂起”的线程要怎么唤醒?
Q:锁被释放后,“挂机”等待的线程是随机唤醒还是全部唤醒?
A: 唤醒全部开销大,唤醒下一个开销小。一般是“精准唤醒”。
当一个线程释放锁时,AQS只会去唤醒它在CLH队列中的直接后继者(successor)。它绝不会去惊动所有等待的线程,从而避免了“惊群效应”(Thundering Herd),即大量线程被唤醒后再次激烈竞争锁,导致大量无用的CPU消耗和上下文切换。这种精准制导,是AQS性能远超早期synchronized和Object.wait/notifyAll的关键。
5 AQS源码
我们从ReentrantLock.lock()
开始追踪:
acquire(1) -> tryAcquire() -> addWaiter() -> acquireQueued()
一起来看AQS源码是如何解决设计上述机制的。
5.1 挂起->打包成node
5.1.1 获取锁
使用ReetrantLock默认是非公平锁。
- 非公平锁获取锁
如果锁没有被占,则使用CAS操作获取锁。否则如果是重入,则更新计数状态(+1);
@ReservedStackAccess
final boolean nonfairTryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
if (compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
else if (current == getExclusiveOwnerThread()) {
int nextc = c + acquires;
if (nextc < 0) // overflow
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
return false;
}
- 公平锁获取锁
如果锁没有被占,且没有比当前线程等待获取锁时间更长的线程,则使用CAS操作获取锁。
@ReservedStackAccess
protected final boolean tryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
if (!hasQueuedPredecessors() &&
compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
else if (current == getExclusiveOwnerThread()) {
int nextc = c + acquires;
if (nextc < 0)
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
return false;
}
// hasQueuedPredecessors 方法等效于
// getFirstQueuedThread() != Thread.currentThread() && hasQueuedThreads()
public final boolean hasQueuedPredecessors() {
Node h, s;
if ((h = head) != null) {
if ((s = h.next) == null || s.waitStatus > 0) {
s = null; // traverse in case of concurrent cancellation
for (Node p = tail; p != h && p != null; p = p.prev) {
if (p.waitStatus <= 0)
s = p;
}
}
if (s != null && s.thread != Thread.currentThread())
return true;
}
return false;
}
5.1.2 核心字段state
其中 getState()获取的是AQS的核心字段state
。
- private volatile int state
一个整型变量,代表同步状态。对于ReentrantLock来说,state=0表示锁未被占用,state>0表示锁已被占用,其值代表“重入”的次数。
5.2 挂起
获取锁失败后,调用acquireQueued(addWaiter(Node.EXCLUSIVE), arg)
public final void acquire(int arg) {
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
- addWaiter(Node.EXCLUSIVE)
方法的核心逻辑是为当前线程创建一个新节点,并通过循环和原子操作将其安全地加入到等待队列的尾部。如果队列还未初始化,会先调用 initializeSyncQueue 方法进行初始化。
private Node addWaiter(Node mode) {
Node node = new Node(mode);
for (;;) {
Node oldTail = tail;
if (oldTail != null) {
node.setPrevRelaxed(oldTail);
if (compareAndSetTail(oldTail, node)) {
oldTail.next = node;
return node;
}
} else {
initializeSyncQueue();
}
}
}
- acquireQueued(addWaiter(Node.EXCLUSIVE), arg)
方法核心逻辑为让已经在等待队列中的线程不断尝试获取锁。
如果当前线程是队列中第一个等待的线程,会尝试获取锁;如果获取失败,会根据情况阻塞线程。
在整个过程中,会记录线程是否被中断,并在出现异常时取消获取操作。
final boolean acquireQueued(final Node node, int arg) {
boolean interrupted = false;
try {
for (;;) {
final Node p = node.predecessor();
if (p == head && tryAcquire(arg)) {
setHead(node);
p.next = null; // help GC
return interrupted;
}
if (shouldParkAfterFailedAcquire(p, node))
interrupted |= parkAndCheckInterrupt();
}
} catch (Throwable t) {
cancelAcquire(node);
if (interrupted)
selfInterrupt();
throw t;
}
}
5.3 等待唤醒
在释放锁时,AQS会对等待线程进行精确唤醒。
unlock()->release()
public final boolean release(int arg) {
if (tryRelease(arg)) {
Node h = head;
if (h != null && h.waitStatus != 0)
unparkSuccessor(h);
return true;
}
return false;
}
以ReentrantLock实现为例
@ReservedStackAccess
protected final boolean tryRelease(int releases) {
int c = getState() - releases;
if (Thread.currentThread() != getExclusiveOwnerThread())
throw new IllegalMonitorStateException();
boolean free = false;
if (c == 0) {
free = true;
setExclusiveOwnerThread(null);
}
setState(c);
return free;
}
releases默认为1,这里和lock时state +1 对应,释放锁时-1。
唤醒的核心操作为unparkSuccessor(h),传入的node为头节点:
private void unparkSuccessor(Node node) {
/*
* If status is negative (i.e., possibly needing signal) try
* to clear in anticipation of signalling. It is OK if this
* fails or if status is changed by waiting thread.
*/
int ws = node.waitStatus;
if (ws < 0)
node.compareAndSetWaitStatus(ws, 0);
/*
* Thread to unpark is held in successor, which is normally
* just the next node. But if cancelled or apparently null,
* traverse backwards from tail to find the actual
* non-cancelled successor.
*/
Node s = node.next;
if (s == null || s.waitStatus > 0) {
s = null;
for (Node p = tail; p != node && p != null; p = p.prev)
if (p.waitStatus <= 0)
s = p;
}
if (s != null)
LockSupport.unpark(s.thread);
}
unparkSuccessor核心作用为“唤醒指定节点的后继节点”。
方法的核心逻辑是先尝试清除当前节点的等待状态,然后找到合适的后继节点,最后唤醒该节点对应的线程。
在查找后继节点时,若直接后继节点不可用(s == null || s.waitStatus > 0),会从队列尾部向前遍历,确保找到一个非取消状态的节点进行唤醒。
5.3.1 核心字段waitStatus
waitStatus是整个AQS线程状态流转的“灵魂”,是节点与节点之间的“通信协议”。
AQS的等待队列本质就是一个排队区,waitStatus就是每个排队的Node的“状态说明”。
各值说明:
-
SIGNAL (-1)
这是最重要、最常见的状态。如果一个节点(前一个节点p)的waitStatus是SIGNAL,就意味着它向它的后继节点(当前节点node)做出了一个承诺:“我释放锁的时候,一定会负责把你唤醒(unpark)”。 -
CANCELLED (1)
这个状态表示节点对应的线程已经放弃了等待,比如因为等待超时或者被中断。 -
0 (初始状态)
每个新节点入队时,waitStatus的默认值就是0。这是一个中性的、临时的状态。 -
CONDITION (-2)
这个状态与我们当前的锁队列无关。当线程调用Condition.await()时,它会进入Condition的等待队列,此时节点状态就是CONDITION。(在AQS篇暂不探究) -
PROPAGATE (-3)
这是用于共享锁(如Semaphore, CountDownLatch)的状态,表示释放操作需要向后传播,唤醒更多的等待者。在ReentrantLock的排他锁模式下,我们遇不到它。
6 Debug AQS
上述章节以提问+源码的形式探究了AQS原理,认知+1。
我们继续Debug看看具体流程,让我们对AQS的认知再+1。
import java.util.concurrent.locks.ReentrantLock;
public class AQSDebug {
public static void main(String[] args) {
ReentrantLock lock = new ReentrantLock();
// 线程A先获取锁
new Thread(() -> {
lock.lock();
System.out.println("Thread A locked");
try {
Thread.sleep(10000); // 占有锁10秒
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock();
System.out.println("Thread A unlocked");
}
}, "Thread A").start();
// 保证A先启动
try { Thread.sleep(100); } catch (InterruptedException e) {}
// 线程B来竞争
new Thread(() -> {
System.out.println("Thread B trying to lock");
lock.lock(); // 这里会进入AQS的等待队列
System.out.println("Thread B locked");
lock.unlock();
}, "Thread B").start();
}
}
在ReentrantLock.lock()方法内部打上断点,单步调试。
debug记得挂起选项勾选线程,以控制两个线程进度。
debug参考:
ThreadA获取锁在sleep期间,我们debug Thread B,可以发现Thread B获取锁失败,调用addWaiter尾插自身到队列,因为是第一个队列元素,所以调用initializeSyncQueue()初始化队列,并在下一个循环中,将自身插入到队尾。
注意,initializeSyncQueue有初始化一个头节点head,且tail = head。
插入队尾后,debug回到acquireQueued()方法,这个方法入参就是当前获取锁失败的Thread B封装的node,还有加锁的默认参数 arg(= 1)。
因为队列中只有Thread B,所以将调用shouldParkAfterFailedAcquire(p,node)方法,其中p = null。
在shouldParkAfterFailedAcquire方法中,核心字段volatile int waitStatus
值被设置为SIGNAL (-1)。
然后进入acquireQueued()方法循环第二轮,还是进入到shouldParkAfterFailedAcquire,因为ws == Node.SIGNAL,所以这里返回为true。
if (shouldParkAfterFailedAcquire(p, node))
interrupted |= parkAndCheckInterrupt();
调用线程parkAndCheckInterrupt()使得Thread B中断(Thread.interrupted())。
此时我们回到Thread A,debug让Thread A释放锁,释放锁时在release中获取了当前的head node,此时head.next = Thread B,将head node传入unparkSuccessor,里面会调用LockSupport.unpark(s.thread)
唤醒Thread B。
此时回到Thread B,其已被唤醒,醒来后的线程会继续执行代码,也就是acquireQueued()方法。
acquireQueued()继续执行“判断是当前节点的predecessor否是head然后获取锁”的循环,此时只有Thread B,所以其可以获取到锁了。
这应该讲清楚了吧,还是有不清晰的地方可以留言。
7 图解
使用图解加深影响,认知再+1。
核心问题简单图解⬇️
8 其他
8.1 关于state字段的设计
Q:AQS的核心只有一个private volatile int state。这是一个32位的整型。如何利用这一个int变量,同时表示“共享模式的读锁”和“排他模式的写锁”的状态?
A:AQS作者Doug Lea将32位的int state作为位图(Bitmask)来使用,通过位运算来管理不同的状态。
Doug Lea将int类型的32位,劈成了两半:
高16位 (High 16 bits): 用于读锁的计数。
低16位 (Low 16 bits): 用于写锁的计数。
- 为什么不采取双标识的设计呢?
因为原子性考虑。获取锁,对于state的操作必须是具备原子性的。
将读锁和写锁的状态都放在同一个volatile int里,使得AQS可以用一次CAS操作,就同时判断和修改整个锁的状态,保证了操作的原子性,避免了复杂的锁竞争。
state的计数规则非常清晰:
-
获取写锁 (Exclusive Lock):
- 当一个线程想获取写锁时,它必须检查整个state是否等于0。如果不为0,意味着要么有读锁,要么有写锁,它就必须等待(除非是自己重入)。
- 获取成功后,它会给低16位加1。
- 写锁重入,就是继续给低16位累加。
-
获取读锁 (Shared Lock):
- 当一个线程想获取读锁时,它必须检查低16位(写锁部分)是否为0。如果不为0,意味着别的线程占了写锁,它必须等待。
- 如果写锁部分为0,它就可以安全地给高16位加1,表示自己持有了读锁。
- 其他线程再来获取读锁,会发现写锁部分依然是0,于是它们也给高16位继续累加。
- 读锁重入,也是给高16位累加。
在ReentrantReadWriteLock.Sync类里,你可以看到这些常量和方法,它们就是操作这32位state的工具:
// 16位,用来做移位和掩码
static final int SHARED_SHIFT = 16;
// 读锁的单位,值为 1 << 16 = 65536
static final int SHARED_UNIT = (1 << SHARED_SHIFT);
// 读锁和写锁的最大重入次数 (2^16 - 1) = 65535
static final int MAX_COUNT = (1 << SHARED_SHIFT) - 1;
// 写锁的掩码,低16位全是1,高16位全是0
static final int EXCLUSIVE_MASK = (1 << SHARED_SHIFT) - 1;
// 获取读锁数量 (右移16位)
static int sharedCount(int c) { return c >>> SHARED_SHIFT; }
// 获取写锁数量 (和低16位的掩码做与运算)
static int exclusiveCount(int c) { return c & EXCLUSIVE_MASK; }
8.2 CAS问题
基于CAS自旋实现的轻量级锁有两个大的问题:
(1)CAS恶性空自旋会浪费大量的CPU资源。
(2)在SMP架构的CPU上会导致“总线风暴”。
解决CAS恶性空自旋的有效方式之一是以空间换时间,较为常见的方案有两种:分散操作热点和使用队列削峰。JUC并发包使用的是队列削峰的方案解决CAS的性能问题,并提供了一个基于双向队列的削峰基类——抽象基础类AbstractQueuedSynchronizer(抽象同步器类,简称为AQS)。