AQS 与 ReentrantLock学习笔记(1/2):AQS 内核——state、队列与阻塞唤醒
上一单元把 volatile、CAS、ABA 讲完了,那一路解决的是单个变量的可见性和原子性。可你平时写业务,锁一上来就是 synchronized 或者 ReentrantLock,没人会让你手搓 CAS。面试官问 ReentrantLock 凭什么比 synchronized 灵活,答「功能多」;再问 AQS 是什么,答「一个队列」——然后就没有然后了。
这篇把 AQS 的内核拆开讲透:state 到底是什么、队列为什么不能用普通链表、线程怎么被挂起又怎么被唤醒。下一篇再讲 ReentrantLock 的完整 API、Condition 的双队列机制,以及它和 synchronized 的全面对比。
版本说明:本文所有源码引用分两套。面试问的、以及网上教程讲的,是 JDK 8 到 JDK 13 的实现,本节主线用它;而 JDK 14 起 AQS 被整体重写,你现在电脑上打开 JDK 源码看到的完全是另一套类结构,第 4 节专门对照。所有实测输出跑在本机 OpenJDK 26.0.1。
手把手实操:先看公平锁和非公平锁的真实差别
先跑现象,再问为什么。下面这个类同时支持公平锁和非公平锁,参数由命令行给:
import java.util.concurrent.locks.ReentrantLock;
/**
* 公平锁与非公平锁的真实获取顺序对比。
* 参数:fair=true 公平锁,false 非公平锁。
* 3 个线程各拿 2 次锁,记录每次真正拿到锁的线程名。
*/
public class LockOrderDemo {
private static final StringBuilder order = new StringBuilder();
public static void main(String[] args) throws Exception {
boolean fair = Boolean.parseBoolean(args[0]);
ReentrantLock lock = new ReentrantLock(fair);
Runnable task = () -> {
for (int i = 0; i < 2; i++) {
lock.lock();
try {
synchronized (order) {
order.append(Thread.currentThread().getName()).append(' ');
}
Thread.sleep(20);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
lock.unlock();
}
}
};
Thread[] ts = new Thread[3];
for (int i = 1; i <= 3; i++) {
ts[i - 1] = new Thread(task, "线程" + i);
}
for (Thread t : ts) t.start();
for (Thread t : ts) t.join();
System.out.println((fair ? "公平锁 " : "非公平锁") + " 获取顺序: " + order.toString().trim());
}
}
编译运行:
javac LockOrderDemo.java
java LockOrderDemo true # 公平锁
java LockOrderDemo false # 非公平锁
连着跑 8 轮,公平锁的输出是这样的(8 轮里有 6 轮长这样,另外 2 轮是 线程2 线程3 线程1 ... 和 线程1 线程3 线程2 ...):
公平锁 获取顺序: 线程1 线程2 线程3 线程1 线程2 线程3
非公平锁这 8 轮全都是同一个形状:
非公平锁 获取顺序: 线程1 线程1 线程2 线程2 线程3 线程3
拿到这个输出,第一件事是纠正一个网上流传很广的说法。很多文章写「非公平锁下某个线程可能连续拿到两次锁,比如线程3连着拿两次」——听起来像是某个线程特别强势。真实现象是:每个线程拿到锁、执行完、释放的那一刻,立刻又把自己抢了回去,于是每个线程都"连拿两次",整体变成 1 1 2 2 3 3。
为什么会这样?线程执行完临界区调 unlock(),释放锁的同时会去唤醒队列里排队的线程。但被唤醒的线程要重新被操作系统调度上 CPU 才能跑,这中间有延迟;而刚释放锁的那个线程本来就在 CPU 上,循环回到 lock(),一次 CAS 就把 state 从 0 改成 1,插队成功。所以哪怕只有 3 个线程,非公平锁也能吃出这个形状。
这里还有个容易被忽略的点:公平锁那 2 轮"顺序偏了"的输出,不是公平锁失效了。公平锁保证的是进入等待队列之后的先来后到,而谁先进入队列,取决于线程启动和调度的先后——new Thread() 的顺序不等于抢锁的顺序。把「公平」理解成「按启动顺序轮转」就错了。
至于 lock() 和 unlock() 必须配对、unlock() 必须放 finally——一旦业务代码抛异常,锁永远不释放,其他线程全部卡死。这条是面试白给分,别丢。
看源码及解析:AQS 内核(以下源码为 JDK 8)
AQS 全称 AbstractQueuedSynchronizer,在 java.util.concurrent.locks 包下。ReentrantLock 内部有个 Sync 继承它,公平锁和非公平锁是 Sync 的两个子类。
AQS 的全部家当就三样:一个 volatile int state、一条双向链表队列、一个阻塞原语。
state 不是「锁状态」,是「子类随便定义的整数」
这是最常被讲窄的一点。很多资料开篇就说「state 为 0 表示没人持有锁,为 1 表示有人持有」——这只对 ReentrantLock 成立。AQS 是个同步器框架,state 的语义完全由子类定义:
| 同步器 | state 表示什么 | 实测(JDK 26.0.1) |
|---|---|---|
ReentrantLock | 同一线程重入了几次 | 重入 3 次后 getHoldCount() = 3 |
Semaphore | 还剩几个许可 | new Semaphore(5) 取走 2 个,availablePermits() = 3 |
CountDownLatch | 还有几次没倒完 | new CountDownLatch(4) 倒 1 次,getCount() = 3 |
ReentrantReadWriteLock | 高 16 位是读锁数,低 16 位是写锁重入数 | 2 个读锁时 getReadLockCount() = 2,理论 state = 2<<16 = 131072 |
| 自定义同步器 | 想是什么就是什么 | 见下面的 Gate 例子 |
最后一行不是凑数。下面这个同步器用 AQS 实现「最多 3 个线程同时进入」的门禁,state 的含义是剩余名额:
import java.util.concurrent.locks.AbstractQueuedSynchronizer;
public class Gate extends AbstractQueuedSynchronizer {
Gate(int permits) { setState(permits); }
@Override
protected int tryAcquireShared(int arg) {
for (;;) {
int c = getState();
int next = c - arg;
if (next < 0) return -1; // 没名额,去排队
if (compareAndSetState(c, next)) return 1;
}
}
@Override
protected boolean tryReleaseShared(int arg) {
for (;;) {
int c = getState();
if (compareAndSetState(c, c + arg)) return true;
}
}
int state() { return getState(); }
}
实测输出:
自定义 Gate(3) -> 初始 state=3
自定义 Gate(3) -> 放行 2 个后 state=1
看到 state 能取 3、能减到 1,就知道把它记成「0 或 1 的锁标志」是错的。AQS 提供的是"状态 + 队列 + 阻塞"这套骨架,状态具体表示什么,由子类自己定。这个认知一旦立住,Semaphore、CountDownLatch、ReadWriteLock 为什么都基于 AQS 就一眼明白了。
独占模式和共享模式
上面 Gate 里出现的是 tryAcquireShared,和 ReentrantLock 用的 tryAcquire 不是一套。AQS 有两条并行的方法族:
- 独占(Exclusive):同一时刻只允许一个线程拿到。对应
tryAcquire/tryRelease,ReentrantLock用它。 - 共享(Shared):允许多个线程同时拿到。对应
tryAcquireShared/tryReleaseShared,Semaphore、CountDownLatch、ReentrantReadWriteLock的读锁用它。
两者的差别不只是方法名。共享模式下,一个线程释放后如果还有余量,要把唤醒传播下去,让后面排队的线程也起来抢,这就是 AQS 里 PROPAGATE 状态和 setHeadAndPropagate 存在的原因。独占模式释放后只唤醒一个后继就够了。
队首是个哑节点,不代表持锁线程
先纠正一个高频误解:队列的 head 节点不代表当前持有锁的线程。持锁的线程根本不在队列里排队,它在外面跑。head 是个哑节点(dummy node),只是队列的锚点。
JDK 8 setHead 的源码只有三行,看得很清楚:
private void setHead(Node node) {
head = node; // 让当前节点当新的队首
node.thread = null; // 抹掉线程引用
node.prev = null;
}
一个线程抢到锁之后,把自己的节点设为 head,顺手把 thread 和 prev 清空——它已经不需要排队了,留着引用只会拖住 GC。JDK 新版的 AbstractQueuedSynchronizer 注释里写得更直白:CLH 队列需要一个哑节点来起步,只是这个节点不会在构造时创建,真出现竞争了才建。
获取锁:acquire 的完整链路
非公平锁 lock() 的第一步是插队,抢不到才走 AQS 流程。JDK 8 的 NonfairSync.lock():
final void lock() {
if (compareAndSetState(0, 1)) // 先抢一次
setExclusiveOwnerThread(Thread.currentThread());
else
acquire(1); // 抢不到才进 AQS
}
acquire 是 AQS 的模板方法,四个动作串成一条链:
public final void acquire(int arg) {
if (!tryAcquire(arg) && // 1. 再试一次(子类实现)
acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) // 2. 入队 3. 排队等待
selfInterrupt(); // 4. 补上中断标记
}
四个动作逐个说:
tryAcquire:抽象方法,子类实现。非公平锁读state,是 0 就 CAS 抢;如果当前线程正是持有者,state加 1,这就是可重入;都不是就返回 false。addWaiter:把当前线程包装成Node挂到队尾。这里有个"快路径"设计——先乐观地读一次tail,直接 CAS 把新节点挂上去;失败(说明有别的线程同时在入队)才走完整的enq,而enq里用的是死循环,顺便完成队列的懒初始化。acquireQueued:真正排队等待的地方。selfInterrupt:这个最容易被跳过,见下一节。
acquireQueued 是整套机制的核心:
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);
}
}
注意两点。
第一,只有前驱是 head 的节点才会去尝试抢锁。队首的 next 指向的就是"下一个有资格拿到锁的线程",这是 FIFO 的实现方式。其他节点的线程连抢的动作都不做。
第二,interrupted 被记住、最后返回给 acquire,acquire 再调 selfInterrupt() 把中断标记"补回去"。为什么要补?因为 parkAndCheckInterrupt 内部用的是 Thread.interrupted()——这个方法在返回中断状态的同时会清掉中断标记。如果不清不补,上层调用者永远不知道这个线程在等锁期间被打断过。这是面试常考的细节。
挂起与唤醒:不是自旋到死,也不是随便 park
线程不能一直自旋(太耗 CPU),也不能一上来就 park(可能刚准备睡锁就释放了,白白一次上下文切换)。AQS 用一个 waitStatus 状态位做两次自旋、一次挂起:
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
int ws = pred.waitStatus;
if (ws == Node.SIGNAL)
return true; // 前驱已承诺会唤醒我,可以安心挂起
if (ws > 0) { // 前驱是 CANCELLED(已放弃)
do {
node.prev = pred = pred.prev; // 往前跳过所有已取消的节点
} while (pred.waitStatus > 0);
pred.next = node;
} else {
compareAndSetWaitStatus(pred, ws, Node.SIGNAL); // 先给前驱打上"记得叫我"
}
return false; // 返回 false,回去再tryAcquire一次
}
waitStatus 在 JDK 8 里有四个取值:
SIGNAL(-1):后继线程需要被唤醒。这是主要状态,含义是"我承诺释放时会唤醒后面那个"。CANCELLED(1):线程放弃等待(被中断或超时)。大于 0 就是取消,上面那段ws > 0的分支就是在清理这些节点。CONDITION(-2):线程在 Condition 队列里,这个是下一篇的主角。PROPAGATE(-3):共享模式唤醒传播用。- 还有一个
0:初始状态,什么含义都没有。
方法返回 false 时调用方会回到 for 循环再试一次抢锁——这个"再试一次"是刻意的。如果刚设置了前驱的 SIGNAL 锁就释放了,这一轮就能直接抢到,省掉一次 park/unpark。真正返回 true 之后才执行 LockSupport.park()。
LockSupport 的"许可"机制,以及一个反直觉的坑
LockSupport.park() 和 unpark() 不是简单的"睡"和"叫醒",它们之间有一个许可(permit)在传递。JDK 官方注释写得很明确:每个线程关联一个许可,park 时如果有许可就消费掉并立即返回;unpark 会把许可发出去。许可不会累积,最多只有一个。
「许可不累积」意味着 unpark 可以先于 park 发生——这就是 AQS 不会丢唤醒的根本原因:释放锁的线程就算在被唤醒线程真正 park 之前就 unpark 了,许可会存着,等对方 park 时直接消费掉返回,不会睡死。
但许可机制有一个成立前提,官方文档没强调,网上也几乎没人讲。写个探针测一下:
import java.util.concurrent.locks.LockSupport;
public class PermitProbe {
static void log(String s) { System.out.println(s); System.out.flush(); }
public static void main(String[] args) throws Exception {
// A:unpark 在 start() 之前调用
probe("A. start() 之前 unpark", true, 0);
// B:线程已经 park 住了再 unpark
probe("B. 已 park 时 unpark", false, 0);
// C:线程已启动、但还没 park 时 unpark
probe("C. 启动后、park 前 unpark", false, 200);
// E:对照 —— 同样是 start() 之前 unpark,但线程内先睡 200ms 再 park
probe("E. start() 之前 unpark + 睡 200ms 再 park", true, 200);
}
/** earlyUnpark=true 时,在线程 start 之前就 unpark */
static void probe(String title, boolean earlyUnpark, long sleepBeforePark) throws Exception {
final long[] blocked = new long[1];
final boolean[] done = new boolean[1];
Thread t = new Thread(() -> {
try {
if (sleepBeforePark > 0) Thread.sleep(sleepBeforePark);
long t0 = System.currentTimeMillis();
LockSupport.park();
blocked[0] = System.currentTimeMillis() - t0;
done[0] = true;
} catch (Exception e) { log(" 异常: " + e); }
});
t.setDaemon(true);
if (earlyUnpark) {
LockSupport.unpark(t); // 线程还没 start
t.start();
} else {
t.start();
Thread.sleep(100); // 让它先进入 park
LockSupport.unpark(t);
}
t.join(3000);
log(title + " -> " + (done[0] ? ("park 阻塞了 " + blocked[0] + " ms") : "!!! 3 秒内没返回,仍在 park 中"));
}
}
实测输出(连跑 3 次,A / C / E 三组完全一致,B 组 101、101、102 ms):
A. start() 之前 unpark -> !!! 3 秒内没返回,仍在 park 中
B. 已 park 时 unpark -> park 阻塞了 101 ms
C. 启动后、park 前 unpark -> park 阻塞了 0 ms
E. start() 之前 unpark + 睡 200ms 再 park -> !!! 3 秒内没返回,仍在 park 中
在 start() 之前调用 unpark(),许可会直接丢掉。
C 组证明了"许可先于 park 有效"确实成立(0 ms 返回,没睡)。A 组证明这个结论只在线程已经启动的前提下成立。E 组是关键的对照组:同样是 start() 之前 unpark,哪怕让线程先睡 200 毫秒再 park,许可照样不在——所以丢许可的原因是 unpark 发生时线程还没启动,跟 park 的时机无关。
为什么?因为 LockSupport 内部直接调用 Unsafe.park / Unsafe.unpark,底层那个 Parker 对象是挂在 VM 级线程(JavaThread)上的。线程没有 start() 之前,JVM 里根本还没有对应的 JavaThread,许可没有地方存。
这个坑在 AQS 里不会踩到(AQS 永远是线程跑起来之后才 park),但在自己写并发工具时是真实存在的。理解它,比背「park/unpark 不丢唤醒」这句话有价值得多。
释放锁:unparkSuccessor 为什么要从队尾往前找
unlock() 走的是 release:
public final boolean release(int arg) {
if (tryRelease(arg)) { // state 减到 0 才算真释放
Node h = head;
if (h != null && h.waitStatus != 0)
unparkSuccessor(h);
return true;
}
return false;
}
tryRelease 把 state 减 1,减到 0 才把持有线程置空(重入了几次就得 unlock 几次)。然后 unparkSuccessor 负责唤醒:
private void unparkSuccessor(Node node) {
int ws = node.waitStatus;
if (ws < 0)
compareAndSetWaitStatus(node, ws, 0); // 先把 SIGNAL 抹掉
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);
}
两个细节。
先抹掉 SIGNAL:释放线程已经决定要唤醒了,这个信号留着没用,清掉可以避免重复唤醒。
从队尾往前找:正常情况下 node.next 就是那个该唤醒的线程。但从队尾反向遍历是兜底路径——next 指针可能为空(另一个线程正在入队,prev 已经连上、next 还没设好),也可能指向一个已经取消的节点。前驱指针 prev 在入队时就设好了,从后往前找一定能找到最后一个有效节点。所以是「next 快,prev 准」。
把整条链串起来看
到这里 acquire 和 release 两侧都读完了。下面这张图把队列结构和完整流转画在一起:上半是队列上每个节点的 waitStatus 含义,下半是从「抢不到」到「被唤醒后成为新哑节点」的六步。
对照图再确认一件事:队列里从头到尾没有持锁线程的位置。图中 head 是哑节点,线程 B/C/D 都在排队,正在跑临界区的那个线程根本不在这条链上。这就是为什么 setHead 要把新队首的 thread 和 prev 清空——它接替的是"锚点"这个角色,不是"持锁者"。
JDK 版本:这套源码在 JDK 14 起被整体重写了
上面读的是 JDK 8 的 AQS。如果你照着这些方法名在自己的 JDK 里搜,大概率一个都找不到——Doug Lea 在 JDK 14 把 AQS 整个重写了一遍。拉 JDK 各版本的源码比对,分界非常干净:
| JDK 8 / 11 / 13 | JDK 14 起(含 JDK 26) | |
|---|---|---|
| 节点状态字段 | volatile int waitStatus,SIGNAL=-1 / CANCELLED=1 / CONDITION=-2 / PROPAGATE=-3 | volatile int status,改成位标志:WAITING=1、CANCELLED=0x80000000、COND=2 |
| 节点类型 | 一个 Node 类,用 Node.EXCLUSIVE / Node.SHARED 两个标记对象区分模式 | 拆成 ExclusiveNode / SharedNode / ConditionNode 三个子类,用类型代替标记 |
| 线程字段 | thread | waiter |
| 入队 | addWaiter + enq | tryInitializeHead |
| 排队等待 | acquireQueued + shouldParkAfterFailedAcquire | 方法已被移除,逻辑重写 |
| 唤醒后继 | unparkSuccessor(从队尾反向遍历) | 已被移除 |
| 是否原版 CLH | 变体 | 变体,但进一步简化 |
JDK 26 里 Node 的样子是这样的:
// Node status bits, also used as argument and return values
static final int WAITING = 1; // must be 1
static final int CANCELLED = 0x80000000; // must be negative
static final int COND = 2; // in a condition wait
/** CLH Nodes */
abstract static class Node {
volatile Node prev; // initially attached via casTail
volatile Node next; // visibly nonnull when signallable
Thread waiter; // visibly nonnull when enqueued
volatile int status; // written by owner, atomic bit ops by others
}
// Concrete classes tagged by type
static final class ExclusiveNode extends Node { }
static final class SharedNode extends Node {}
static final class ConditionNode extends Node implements ForkJoinPool.ManagedBlocker { }
ReentrantLock 也跟着变了。JDK 8 里非公平锁的 CAS 插队直接写在 NonfairSync.lock() 里;JDK 14 起改成了模板方法 initialTryLock():
// JDK 14 起 ReentrantLock$Sync 的结构
abstract class Sync extends AbstractQueuedSynchronizer {
abstract boolean initialTryLock(); // 子类实现"第一次尝试"
final void lock() { // 由父类统一调度
if (!initialTryLock()) acquire(1);
}
}
// 非公平:先 CAS 抢一次;公平:检查队列里有没有前驱
这段该怎么记。 面试考的是 JDK 8 那套概念——state、CLH 变体、waitStatus 的 SIGNAL、acquireQueued 的自旋加挂起。这套模型在 JDK 14+ 里思想完全没变,只是实现换了骨架:位标志代替枚举状态、子类代替标记对象。所以回答面试时按经典模型讲,同时补一句「JDK 14 起实现被重写、方法名和常量都变了,但设计意图一致」,这一句就能把「背的还是老书」的印象翻过来。
验证方法:三个能自己动手的验证
一、用 jstack 看两种"卡住"的本质区别
下面这个程序在同一进程里放两类被阻塞的线程,主线程持锁不放:
import java.util.concurrent.locks.ReentrantLock;
public class ParkStateDemo {
private static final ReentrantLock LOCK = new ReentrantLock();
private static final Object MONITOR = new Object();
public static void main(String[] args) throws Exception {
LOCK.lock();
synchronized (MONITOR) {
for (int i = 1; i <= 2; i++) {
Thread t = new Thread(() -> LOCK.lock(), "app-lock-waiter-" + i);
t.setDaemon(true);
t.start();
}
Thread m = new Thread(() -> {
synchronized (MONITOR) { /* 永远抢不到 */ }
}, "app-monitor-waiter");
m.setDaemon(true);
m.start();
Thread.sleep(3000); // 留出时间让 jstack 抓
System.out.println("dump-ready");
Thread.sleep(60000);
}
LOCK.unlock();
}
}
看到 dump-ready 之后用 jps -l 找到进程号,再 jstack ,关键几行是这样(内存地址和线程号做了简化):
"app-lock-waiter-1" #27 daemon prio=5 os_prio=0 ... waiting on condition
java.lang.Thread.State: WAITING (parking)
at jdk.internal.misc.Unsafe.park(Native Method)
- parking to wait for <0x...460> (a java.util.concurrent.locks.ReentrantLock$NonfairSync)
at java.util.concurrent.locks.LockSupport.park(LockSupport.java:223)
at java.util.concurrent.locks.AbstractQueuedSynchronizer.acquire(AbstractQueuedSynchronizer.java:790)
at java.util.concurrent.locks.AbstractQueuedSynchronizer.acquire(AbstractQueuedSynchronizer.java:1030)
at java.util.concurrent.locks.ReentrantLock$Sync.lock(ReentrantLock.java:154)
at java.util.concurrent.locks.ReentrantLock.lock(ReentrantLock.java:323)
at ParkStateDemo.lambda$main$0(ParkStateDemo.java:17)
"app-monitor-waiter" #29 daemon prio=5 os_prio=0 ... waiting for monitor entry
java.lang.Thread.State: BLOCKED (on object monitor)
at ParkStateDemo.lambda$main$1(ParkStateDemo.java:22)
- waiting to lock <0x...480> (a java.lang.Object)
三件事一眼可辨:
状态不同。等 ReentrantLock 是 WAITING (parking),等 synchronized 是 BLOCKED (on object monitor)。前者是主动 park 睡下,后者是被 JVM 拦在 monitor 门外——这就是「ReentrantLock 是 Java 层实现、synchronized 是 JVM 内置」最直观的证据。
调用栈把 AQS 的整条链露了出来:ReentrantLock.lock → ReentrantLock$Sync.lock → AQS.acquire → LockSupport.park → Unsafe.park。和上面读的源码对得上。
注意栈里出现了两个 acquire:上面那个(:1030)是公开的 acquire(int),下面那个在 :790 的是它转到内部的 acquire(Node, int, boolean, boolean, boolean, long)——JDK 14 起 park 被收进这个统一内部方法里,而 JDK 8 里 LockSupport.park 是在 acquireQueued 里调的。栈帧自己就把版本差异暴露了。
包名是 JDK 9+ 的。栈里写的是 jdk.internal.misc.Unsafe;JDK 8 是 sun.misc.Unsafe。同一段代码换个 JDK,栈里就换了包名,这也是判断运行环境的线索。
二、用真实压测看公平锁的代价
公平锁「按队列顺序来」听着很美好,代价有多大?4 个线程抢同一把锁,固定跑 2 秒,每个线程拿到锁就把自己的计数加一:
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicIntegerArray;
import java.util.concurrent.locks.ReentrantLock;
public class FairnessDistributionDemo {
private static final int THREADS = 4;
private static final long DURATION_MS = 2000;
public static void main(String[] args) throws Exception {
run(true, 500); // 预热,别把 JIT 编译算进去
run(false, 500);
System.out.println("---- 以下为 2 秒正式测量 ----");
run(true, DURATION_MS);
run(false, DURATION_MS);
}
static void run(boolean fair, long durationMs) throws Exception {
ReentrantLock lock = new ReentrantLock(fair);
AtomicIntegerArray counts = new AtomicIntegerArray(THREADS);
AtomicBoolean stop = new AtomicBoolean(false);
Thread[] ts = new Thread[THREADS];
for (int i = 0; i < THREADS; i++) {
final int idx = i;
ts[i] = new Thread(() -> {
while (!stop.get()) {
lock.lock();
try { counts.incrementAndGet(idx); } finally { lock.unlock(); }
}
}, "线程" + (i + 1));
}
for (Thread t : ts) t.start();
Thread.sleep(durationMs);
stop.set(true);
for (Thread t : ts) t.join();
int min = Integer.MAX_VALUE, max = 0, total = 0;
StringBuilder sb = new StringBuilder();
for (int i = 0; i < THREADS; i++) {
int c = counts.get(i);
sb.append("线程").append(i + 1).append('=').append(c).append(" ");
min = Math.min(min, c); max = Math.max(max, c); total += c;
}
System.out.println((fair ? "公平锁 " : "非公平锁") + " " + sb
+ "| 总计=" + total + " | 最少/最多=" + String.format("%.2f", (double) min / max));
}
}
注:这里用固定时长而不是固定次数。固定次数的话,被饿住的线程可能一次都没抢到就提前退出,分布数据就废了。
3 轮实测:
| 轮次 | 公平锁总计 | 非公平锁总计 | 倍数 | 公平锁 最少/最多 | 非公平锁 最少/最多 |
|---|---|---|---|---|---|
| 第 1 轮 | 371,810 | 68,233,882 | 184 倍 | 0.97 | 0.84 |
| 第 2 轮 | 392,814 | 77,135,595 | 196 倍 | 1.00 | 0.86 |
| 第 3 轮 | 371,501 | 71,083,078 | 191 倍 | 0.96 | 0.86 |
两个结论,一个要打消幻想:
非公平锁的吞吐是公平锁的 180~196 倍,差别大得离谱。原因不神秘:公平锁每次获取都要经历「入队 → park 挂起 → 被 unpark 唤醒 → 抢到锁」,是一次完整的线程上下文切换;而非公平锁在这个空跑场景里,同一个线程可以连续 CAS 成功,压根不涉及挂起。这是极限情况下的差距,真实业务里临界区有实际工作,倍数会小很多,但方向一致:公平性是用吞吐换的。
但别指望公平锁能让分布"完全均匀到秒级可测"。公平锁的最少/最多稳定在 0.96~1.00,已经非常均匀;非公平锁是 0.84~0.86,确实偏斜,但在 4 线程、2 秒这个窗口里没有出现"某个线程几乎抢不到"的饿死现象。真正的线程饥饿需要特定条件——比如少数线程优先级极低、或者非公平锁下长时间只有一个线程在 CPU 上。所以「非公平锁会饿死线程」这句话是对的,但别把它说成"跑几秒就能看到某个线程一次都抢不到",那不符合实测。
面试速答
Q:AQS 的核心是什么?
A:一个 volatile int state、一条双向队列、一个阻塞原语(LockSupport.park/unpark)。state 的语义由子类定义:ReentrantLock 是重入次数、Semaphore 是剩余许可、CountDownLatch 是剩余计数、ReentrantReadWriteLock 里高 16 位是读锁数低 16 位是写锁重入数。获取失败就包装成节点入队,队首的后继才有资格重试抢锁,其余线程 park 挂起;释放时唤醒队首的有效后继。
Q:公平锁和非公平锁差在哪?
A:非公平锁在 lock() 里先做一次 CAS 插队,抢不到才入队;公平锁直接进 acquire,并且在 tryAcquire 里用 hasQueuedPredecessors() 检查队列里有没有排在自己前面的线程。实测:非公平锁下每个线程释放后能立刻把自己再抢回去,输出呈 1 1 2 2 3 3;公平锁是严格 FIFO。非公平锁吞吐高得多(空跑场景约 180 倍),代价是可能饥饿;公平锁的公平从「进入队列」算起,不是从「启动线程」算起。
Q:为什么用 CLH 队列,而不是普通链表?
A:原版 CLH 里每个线程只自旋检查前驱节点的状态,不碰全局变量,把竞争分散到各个节点上。AQS 用的是它的变体,但改了两处:加 next 指针,因为要能 unpark 指定后继,单向的 prev 不够用;把纯自旋改成「自旋两次 → park 挂起」,因为纯自旋在多核以外或竞争激烈时空耗 CPU。
Q:waitStatus 的 SIGNAL 是干嘛的?
A:表示「当前节点的后继需要被唤醒」,由后继在挂起前通过 shouldParkAfterFailedAcquire 给前驱设置,相当于一个承诺:你释放的时候记得叫我。释放线程看到非 0 的 waitStatus 才去 unparkSuccessor。另外 CANCELLED 是 1(大于 0 就是取消),清理逻辑就靠这个特征值。
Q:为什么 acquire 最后要调 selfInterrupt()?
A:parkAndCheckInterrupt 内部用 Thread.interrupted() 检查中断,而它会清除中断标记。如果线程在等锁期间被中断,标记被吃掉,上层就再也感知不到了。所以把中断状态暂存、等真正拿到锁之后再调 selfInterrupt() 补回去,保证中断不丢。
Q:LockSupport 的 park/unpark 会不会丢唤醒?
A:不会,靠的是「许可」。每个线程有一个许可,最多一个、不累积;unpark 把许可发出去,park 时如果有许可就消费掉立即返回。所以 unpark 可以先于 park。但有个前提容易被忽略:线程必须已经 start()。实测在 start() 之前调用 unpark,许可会直接丢掉,之后 park 会永久阻塞。因为底层 Parker 挂在 VM 级线程上,线程没启动就没有存放许可的地方。
Q:头节点(head)代表持有锁的线程吗?
A:不代表。持锁线程不在队列里,head 是哑节点,只是队列锚点。JDK 8 setHead 会把新队首的 thread 和 prev 清成 null,就是为了让它纯粹当占位符、顺便帮 GC。
核心收获与下一步
AQS 的本质是状态 + 队列 + 阻塞原语三件套。state 是留给子类自由定义的整数,队列是一条以哑节点起步的双向链表,阻塞和唤醒交给 LockSupport 的许可机制。acquire 和 release 是严格对称的:进来先试抢、不行就入队挂起,出去减状态、减到零就唤醒后继。
另外记住两条版本相关的:这套源码是 JDK 8~13 的,JDK 14 起被整体重写(位标志状态 + 三个 Node 子类 + initialTryLock 模板方法),方法名对不上不代表你记错了。
下一步:打开 IDEA,在 acquireQueued 的 for 循环和 unparkSuccessor 里打断点,用本文的 LockOrderDemo 单步跑一个三线程抢锁的场景,亲眼看着节点入队、waitStatus 被设成 SIGNAL、线程 park、释放时被 unpark 唤醒。断点走一遍,比读十遍源码都管用。
本文关键词:AQS、state、CLH队列、LockSupport、公平锁、非公平锁