← 返回博客
2026-09-08 08:00:02

AQS 与 ReentrantLock学习笔记(1/2):AQS 内核——state、队列与阻塞唤醒

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 有两条并行的方法族:

两者的差别不只是方法名。共享模式下,一个线程释放后如果还有余量,要把唤醒传播下去,让后面排队的线程也起来抢,这就是 AQS 里 PROPAGATE 状态和 setHeadAndPropagate 存在的原因。独占模式释放后只唤醒一个后继就够了。

队首是个哑节点,不代表持锁线程

先纠正一个高频误解:队列的 head 节点不代表当前持有锁的线程。持锁的线程根本不在队列里排队,它在外面跑。head 是个哑节点(dummy node),只是队列的锚点。

JDK 8 setHead 的源码只有三行,看得很清楚:

private void setHead(Node node) {
    head = node;      // 让当前节点当新的队首
    node.thread = null;   // 抹掉线程引用
    node.prev = null;
}

一个线程抢到锁之后,把自己的节点设为 head,顺手把 threadprev 清空——它已经不需要排队了,留着引用只会拖住 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. 补上中断标记
}

四个动作逐个说:

  1. tryAcquire:抽象方法,子类实现。非公平锁读 state,是 0 就 CAS 抢;如果当前线程正是持有者,state 加 1,这就是可重入;都不是就返回 false。
  2. addWaiter:把当前线程包装成 Node 挂到队尾。这里有个"快路径"设计——先乐观地读一次 tail,直接 CAS 把新节点挂上去;失败(说明有别的线程同时在入队)才走完整的 enq,而 enq 里用的是死循环,顺便完成队列的懒初始化。
  3. acquireQueued:真正排队等待的地方。
  4. 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 被记住、最后返回给 acquireacquire 再调 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 里有四个取值:

方法返回 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;
}

tryReleasestate 减 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 含义,下半是从「抢不到」到「被唤醒后成为新哑节点」的六步。

AQS 同步队列的结构与节点状态流转:head 是哑节点、持锁线程不在队列里;后继先给前驱 CAS 上 SIGNAL 承诺才 park 挂起;释放时 unparkSuccessor 唤醒 head 的后继,被唤醒线程 setHead 成为新的哑节点

对照图再确认一件事:队列里从头到尾没有持锁线程的位置。图中 head 是哑节点,线程 B/C/D 都在排队,正在跑临界区的那个线程根本不在这条链上。这就是为什么 setHead 要把新队首的 threadprev 清空——它接替的是"锚点"这个角色,不是"持锁者"。

JDK 版本:这套源码在 JDK 14 起被整体重写了

上面读的是 JDK 8 的 AQS。如果你照着这些方法名在自己的 JDK 里搜,大概率一个都找不到——Doug Lea 在 JDK 14 把 AQS 整个重写了一遍。拉 JDK 各版本的源码比对,分界非常干净:

JDK 8 / 11 / 13JDK 14 起(含 JDK 26)
节点状态字段volatile int waitStatusSIGNAL=-1 / CANCELLED=1 / CONDITION=-2 / PROPAGATE=-3volatile int status,改成位标志WAITING=1CANCELLED=0x80000000COND=2
节点类型一个 Node 类,用 Node.EXCLUSIVE / Node.SHARED 两个标记对象区分模式拆成 ExclusiveNode / SharedNode / ConditionNode 三个子类,用类型代替标记
线程字段threadwaiter
入队addWaiter + enqtryInitializeHead
排队等待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.lockReentrantLock$Sync.lockAQS.acquireLockSupport.parkUnsafe.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,81068,233,882184 倍0.970.84
第 2 轮392,81477,135,595196 倍1.000.86
第 3 轮371,50171,083,078191 倍0.960.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 会把新队首的 threadprev 清成 null,就是为了让它纯粹当占位符、顺便帮 GC。

核心收获与下一步

AQS 的本质是状态 + 队列 + 阻塞原语三件套。state 是留给子类自由定义的整数,队列是一条以哑节点起步的双向链表,阻塞和唤醒交给 LockSupport 的许可机制。acquirerelease 是严格对称的:进来先试抢、不行就入队挂起,出去减状态、减到零就唤醒后继。

另外记住两条版本相关的:这套源码是 JDK 8~13 的,JDK 14 起被整体重写(位标志状态 + 三个 Node 子类 + initialTryLock 模板方法),方法名对不上不代表你记错了。

下一步:打开 IDEA,在 acquireQueuedfor 循环和 unparkSuccessor 里打断点,用本文的 LockOrderDemo 单步跑一个三线程抢锁的场景,亲眼看着节点入队、waitStatus 被设成 SIGNAL、线程 park、释放时被 unpark 唤醒。断点走一遍,比读十遍源码都管用。

本文关键词:AQS、state、CLH队列、LockSupport、公平锁、非公平锁