JDK 8 · 17 · 21 · ABSTRACTQUEUEDSYNCHRONIZER · STATE · CLH · PARK · CONDITION · REENTRANTLOCK · SEMAPHORE · COUNTDOWNLATCH

一个 int,一条排队的链

AQS 只管两样东西:一个叫 state 的 int,和一条先进先出的等待队列。“现在能不能拿到”由子类回答:tryAcquire / tryRelease 用 CAS 改 state。“拿不到怎么办”全由 AQS 包办:排队、park 睡下、谁来叫醒、超时和中断怎么撤。ReentrantLock、Semaphore、CountDownLatch、读写锁、线程池的 Worker、Tomcat 的连接数闸门,都是这样搭出来的。

假设场景面试被问“讲讲 AQS”,又在线上 jstack 里看到一大片 parking to wait for … ReentrantLock$NonfairSync,不知道是哪段代码在等。所以本页偏源码和排查,⑥ 末尾有面试题。版本按生产上最常见的 JDK 8、17、21 三个来讲:AQS 在 JDK 14 被重写过,所以 8 是一套写法,17 和 21 是另一套,面试资料里的 waitStatus、SIGNAL、acquireQueued 都是 JDK 8 的。标“实测”的在本机三个版本上都跑过:Temurin 8u512、17.0.21,Ubuntu OpenJDK 21.0.12,2 核 ARM 虚拟机。

①为什么需要:一次请求,路上就要过好几道 AQS

你可能从没直接写过 AQS,但一个普通的 Spring Boot 请求,从进 Tomcat 到写完日志,经过的这些地方全是它。下图里每一处都用 javap 和字节码扫描核过(版本见图下)。

Tomcat Acceptor连接数超过 maxConnections就停在 LimitLatch 上LimitLatch.Sync extends AQS Tomcat 线程池TaskQueue 继承 LinkedBlockingQueue两把 ReentrantLock + ConditionWorker extends AQS Spring MVC 找处理方法MappingRegistry 每个请求都拿一次读锁ReentrantReadWriteLock 你的业务代码ReentrantLock、SemaphoreCountDownLatch、线程池全是 AQS 子类 logback 写日志OutputStreamAppender每写一条都 lock / unlockReentrantLock JDK 的 Socket、TLSJDK 8:synchronizedJDK 17 / 21:NioSocketImpl、SSLSocketImpl 用 ReentrantLock System.out.printlnJDK 8 / 17:synchronizedJDK 21:InternalLock,里面是一把 ReentrantLock Kafka 生产者缓冲区满了,send()停在 Condition.await 上BufferPool 反过来:HikariCP 借连接默认不走 AQS。 它的 ConcurrentBag 靠 CAS 加 SynchronousQueue;只有开了 allowPoolSuspension,才多一个 Semaphore 做的“暂停闸门”。
框架版本:JDK 8 那一套按 Spring Boot 2.7.18(Tomcat 9.0.83、Spring 5.3.31、logback 1.2.12、HikariCP 4.0.3、Kafka 3.1.2),JDK 17 / 21 那一套按 Spring Boot 3.5.16(Tomcat 10.1.55、Spring 6.2.19、logback 1.5.34、HikariCP 6.3.3、Kafka 3.9.2)。两代里上面这些用法都在。完整清单见 ⑥ 日常开发里的 AQS。
和已学页面的关系ThreadPoolExecutor.execute 源码里的 Worker 就是一个 AQS 子类;SynchronousQueue 和 ConcurrentBag 是不用 AQS 的另一条路,③ 会拿它们对比。

②没有它会怎样:自己写一把锁

最直接的写法是 CAS 自旋:抢不到就一直重试。另一个现成的选择是 synchronized。拿 8 个线程抢一把锁,锁里 sleep 1 ms(模拟拿着锁做 IO),三个 JDK 各跑一次:

8 个线程抢一把锁,跑 2 秒每秒完成2 秒里用掉的 CPU锁里只做一次自增时,每秒完成
CAS 自旋锁321~330约 4.0 秒(两个核全满)490 万~680 万
synchronized934~9350.05~0.06 秒650 万~1160 万
ReentrantLock(AQS,默认非公平)932~9340.06~0.10 秒2340 万~3990 万
ReentrantLock(true)(公平)922~9290.07~0.09 秒20 万~32 万

范围是 JDK 8、17、21 三次的最小值到最大值,三个版本的结论一致。只有 2 核,同一时刻真正在跑的只有 2 个线程,所以自旋锁的“慢”主要是它抢走了持锁线程的 CPU:持锁的线程 sleep 醒来,要等别人转完时间片才轮到它。

痛 1自旋:等的人在烧 CPU

拿不到就重试,等多久就烧多久。锁里一旦有 IO,两个核全花在“等”上,干活的反而被挤慢。要省 CPU,就得让等的线程睡下,还得有人在锁放开时叫醒它。这就需要一条记着“谁在等”的队列。

痛 2synchronized:够用,但缺功能

不能“等 1 秒拿不到就算了”,等的时候不能被中断,一把锁只有一个等待集合(wait / notify),不能选公平,不分读写。JDK 21 的虚拟线程在 synchronized 里阻塞还会钉住载体线程(见 ⑥ 坑 5)。

精华

AQS 的精华:把“排队、睡下、叫醒、超时、中断”只写一遍,各种同步工具只需要回答一个问题:给定 state,现在能不能拿到、放掉以后要不要叫人。

不懂这一点的典型误用:以为 ReentrantLock 是“先来先得”的(默认是非公平的,刚放锁的线程几乎总能抢回来),或者以为 new ReentrantLock(true) 以后 tryLock() 也公平(不是),见 ⑥ 坑 3。

③相邻方案:等的线程放在哪

方案等的线程放在哪能做的例子
synchronizedJVM 里的对象监视器(ObjectMonitor)互斥、可重入、一个 wait 集合;自动释放Hashtable、JDK 8 / 17 的 PrintStream
AQS 一族AQS 的等待队列,节点上挂着线程独占和共享两种模式、超时、中断、多个 Condition、公平和非公平ReentrantLock、Semaphore、CountDownLatch、读写锁
StampedLock自己的一条等待队列,不是 AQS乐观读:读的时候不加锁,读完检查版本号读多写极少、读的代码很短的地方
不用锁没有等待;要等时用别的交接点CAS、原子类、无锁队列ConcurrentHashMap 的大部分路径、LongAdder、HikariCP 的 ConcurrentBag

选择规则:只要互斥、没有特殊需求,synchronized 就行(JDK 21 虚拟线程里阻塞例外);要超时、可中断、多个条件、读写分离、限流计数,就用 AQS 一族;读多写极少才考虑 StampedLock。本页学 AQS。

④原理:state 说能不能拿,队列管拿不到怎么办

比方:银行一个窗口(state)加一排叫号椅(等待队列)。窗口空着就直接办;有人在办就取号坐下打盹;办完的人起身时拍一下下一位的肩膀。比方对不上的地方:默认情况下,刚走进来的人可以趁被拍的人还没站起来,直接冲到窗口前(非公平),见 为什么这样设计。

先知道这些

CAS

compareAndSetState(0, 1):是 0 才改成 1,一条指令完成,同时只有一个线程成功。之前的页面用过很多次。

park / unpark

LockSupport.park() 让线程睡下,unpark(t) 叫醒它。先 unpark 后 park 也行,会留一张“许可”。jstack 里的 WAITING (parking) 就是它。

CLH 队列

一种排队锁:每个等待者是链表上的一个节点,只盯着自己前面那个。AQS 用的是它的变体:节点双向链接,等待者 park 而不是一直转。

模板方法

父类写好流程,留几个方法给子类填。AQS 留的是 tryAcquire、tryRelease、tryAcquireShared、tryReleaseShared、isHeldExclusively。

机制 1一个 state,一条队;节点上的状态,8 和 17 / 21 记法不同

state = 2owner = main(重入了两次) head空节点,不挂线程(或刚拿到锁的) T1waiter = T1prev / next T2waiter = T2 T3waiter = T3 ← tail 节点上的状态(⑤ 实测打印) JDK 8 -1 SIGNAL -1 SIGNAL -1 SIGNAL 0 意思是“我后面那个睡了,放锁时叫它”:记在前驱上,所以最后一个是 0 JDK 17 / 21 0 1 WAITING 1 WAITING 1 WAITING 意思是“我睡了,记得叫我”:记在自己身上,head 是 0。JDK 14 重写 AQS 时改的
AQS 本身只有三个关键字段:state、head、tail。队列是懒建的:没人抢过,head 就是 null;第一次有人要排队时才建一个空的 head。拿到锁的线程,它的节点就成了新 head。两代实现的节点类也不同:JDK 8 是一个 Node 类,用 nextWaiter 区分独占和共享;JDK 17 / 21 拆成了 ExclusiveNode、SharedNode、ConditionNode。

机制 2AQS 管排队,子类管 state 是什么意思

子类state 的意思能不能拿(tryAcquire 系列)模式
ReentrantLock.Sync重入了几次,0 是没人拿0 → CAS 成 1;已经是自己的就 +1独占
ReentrantReadWriteLock.Sync高 16 位:读锁被拿了几次;低 16 位:写锁重入次数写:整个是 0 才行;读:没有别人的写锁就 +1<<16写独占、读共享
Semaphore.Sync剩几个许可剩的 ≥ 要的,就 CAS 减掉共享
CountDownLatch.Sync还要等几次 countDown只看是不是 0,不改 state共享
ThreadPoolExecutor.Worker−1 还没启动,0 空闲,1 正在跑任务CAS 0 → 1,不可重入独占
Tomcat LimitLatch.Sync不用 state,另用一个 AtomicLong 计数计数 +1 后不超过上限才行,超了就减回去共享

每一行都只写了“能不能拿”和“放掉后要不要叫人”。排队、park、被叫醒后重试、超时撤销,这些子类一行都不用写。

机制 3一次 lock / unlock 的全过程 A 拿着锁,B 来排队,A 放锁

第 0 / 6 步

开始。

机制 4共享模式:一个叫醒一个,一路传下去

countDown()计数 1 → 0 叫醒 1 个 A 醒来通过变成 head 叫醒下一个 B 醒来通过变成 head 叫醒下一个 C 醒来通过后面没人了 JDK 8:setHeadAndPropagate + doReleaseShared,还有一个 PROPAGATE(-3)状态专门防止“两次释放挤在一起时漏叫”。 JDK 17 / 21:通过的人调 signalNextIfShared,下一个是共享节点就叫醒它,PROPAGATE 这个状态没有了。
释放的人只叫醒排在最前面的一个,被叫醒的人通过之后,负责叫醒后面的。这样不会出现几十个线程同时醒来抢同一样东西。Semaphore 释放许可时也一样:许可不够,后面的人醒来再试一次,又睡回去。

机制 5Condition:另一条队,await 和 signal 在两条队之间搬节点

条件队列(每个 Condition 一条,单向)await() 的线程排在这里,锁已经整个放掉了 同步队列(AQS 那一条)等着抢锁的线程 signal() ① A await:建 ConditionNode 进条件队列,记下 state,把锁整个放掉(重入几层都放),park。 ② B signal:把条件队列的第一个节点挪到同步队列队尾。A 仍然睡着。 ③ B unlock:叫醒同步队列里的 A;A 重新抢到锁、还原 state,await 才返回。 所以 await 醒来时条件可能已经又不成立了(别人先抢到锁改了),一定要写在 while 里。 一把锁可以有多个 Condition,ArrayBlockingQueue 就有 notEmpty、notFull 两个。

为什么这样设计:三个取舍,都有实测

被放弃的:拿不到就自旋

  • 等多久烧多久,锁里有 IO 时两个核全满(② 实测约 4 秒 CPU / 2 秒)
  • 还抢走了持锁线程的 CPU,干活的反而慢(每秒 321~330 次,对 930 次)

AQS:排队,然后 park

  • 等的线程不占 CPU(0.06~0.10 秒)
  • 代价:睡下、叫醒都是线程切换,微秒级。所以 JDK 17 / 21 里,排在最前面的线程被叫醒后又没抢到时,会先短暂自旋重试几次再睡

被放弃的:一个等待集合,notifyAll 全叫醒

  • 50 个消费者等着,放一个元素叫醒一群,只有一个抢得到,其余白醒(实测平均每个元素叫醒 1.8~5.9 次)

AQS:按队列只叫醒一个

  • release 只叫醒 head 后面那一个;Condition.signal 只挪一个(实测每个元素正好 1.0 次)
  • 代价:被叫醒的人自己负责传给下一个(共享模式),逻辑更绕

被放弃的默认值:公平

  • 每次交接都必须等一个睡着的线程醒来,锁里只做自增时每秒 20 万~32 万次,每次交接约 1 次线程切换

AQS 的默认:非公平

  • 谁正在 CPU 上谁先拿,每秒 2340 万~3990 万次,约是公平锁的 100 倍
  • 代价:排队的人会被插队,尾延迟变长(⑤ 实测 1000 次里 953~995 次被插队)

决定胜负的约束:线程切换是微秒级的,CAS 是纳秒级的。AQS 每个取舍都在躲线程切换:拿不到才睡、只叫醒一个、默认让正在跑的线程直接拿。和 ConcurrentBag、SynchronousQueue 是同一个账。

最少的 API,外加一个 8 行的自定义同步器

API天天用的那几个
lock.lock();
try { … } finally { lock.unlock(); }      // 一定放 finally
if (lock.tryLock(1, SECONDS)) { … }       // 等 1 秒拿不到就算了
Condition notFull = lock.newCondition();
while (q.isFull()) notFull.await();       // 一定写 while
notFull.signal();
Semaphore sem = new Semaphore(10);        // 最多 10 个并发
sem.acquire(); try { … } finally { sem.release(); }
CountDownLatch done = new CountDownLatch(3);
done.countDown();  done.await(5, SECONDS);
自定义一次性开关AQS javadoc 里的 BooleanLatch,JDK 8 / 17 / 21 都能用
class BooleanLatch {
  private static class Sync extends AbstractQueuedSynchronizer {
    protected int tryAcquireShared(int ignore) {
      return getState() != 0 ? 1 : -1;      // 开了就放行
    }
    protected boolean tryReleaseShared(int ignore) {
      setState(1); return true;            // 打开,叫醒等的人
    }
  }
  private final Sync sync = new Sync();
  public void signal() { sync.releaseShared(1); }
  public void await() throws InterruptedException {
    sync.acquireSharedInterruptibly(1);
  }
}

读写锁的锁降级、lockInterruptibly、getQueueLength 这类监控方法、各版本差在哪,放到 第⑥步。

⑤亲眼看到:用反射把队列打出来

一个 Java 8 语法的小程序,javac --release 8 编一次,同一份 class 在 JDK 8、17、21 上跑:让 main 重入两次,三个线程来排队,再用反射读出 state、head 和每个节点的状态。最后测一下插队。

1AqsPeek.javaJDK 8 直接 java AqsPeek;JDK 17 / 21 要加两个 --add-opens(见注释)
import java.lang.reflect.Field;
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.locks.*;

// 直接看 AQS 里面:state、排队的节点、节点状态
// 运行:JDK 8 直接 java AqsPeek;JDK 9+ 要加 --add-opens java.base/java.util.concurrent.locks=ALL-UNNAMED --add-opens java.base/java.util.concurrent=ALL-UNNAMED
public class AqsPeek {
  static Object field(Object o, String... names) throws Exception {
    for (Class<?> c = o.getClass(); c != null; c = c.getSuperclass())
      for (String n : names) try { Field f = c.getDeclaredField(n); f.setAccessible(true); return f.get(o); } catch (NoSuchFieldException e) { }
    return null;
  }
  static AbstractQueuedSynchronizer sync(Object owner) throws Exception { return (AbstractQueuedSynchronizer) field(owner, "sync"); }
  static String queue(AbstractQueuedSynchronizer s) throws Exception {
    StringBuilder b = new StringBuilder("state=" + field(s, "state") + "  队列:");
    Object n = field(s, "head");
    if (n == null) return b.append("(还没建)").toString();
    for (int i = 0; n != null && i < 10; i++, n = field(n, "next")) {
      Thread t = (Thread) field(n, "waiter", "thread");                 // JDK 14+ 叫 waiter,之前叫 thread
      Object st = field(n, "status", "waitStatus");                     // JDK 14+ 叫 status,之前叫 waitStatus
      b.append(i == 0 ? "head" : " → ").append(i == 0 ? "" : (t == null ? "?" : t.getName())).append("[").append(n.getClass().getSimpleName()).append(" ").append(st).append("]");
    }
    return b.toString();
  }
  static void waitParked(Thread t) throws InterruptedException { while (t.getState() != Thread.State.WAITING && t.getState() != Thread.State.TIMED_WAITING) Thread.sleep(1); }

  public static void main(String[] a) throws Exception {
    System.out.println("JDK " + System.getProperty("java.version"));
    final ReentrantLock lock = new ReentrantLock();
    lock.lock(); lock.lock();                                           // main 重入两次
    System.out.println("① main 重入 2 次:" + queue(sync(lock)) + "," + lock);
    List<Thread> ws = new ArrayList<>();
    for (String name : new String[] {"T1", "T2", "T3"}) {
      Thread t = new Thread(() -> { lock.lock(); lock.unlock(); }, name); t.start(); waitParked(t); ws.add(t);
    }
    System.out.println("② 三个线程排队:" + queue(sync(lock)));
    lock.unlock();
    System.out.println("③ unlock 一次:" + queue(sync(lock)) + "(还剩 1 层,没人被叫醒)");
    lock.unlock(); for (Thread t : ws) t.join();
    System.out.println("④ 全部走完:  " + queue(sync(lock)));

    Semaphore sem = new Semaphore(3); sem.acquire(2);
    CountDownLatch latch = new CountDownLatch(2); latch.countDown();
    ReentrantReadWriteLock rw = new ReentrantReadWriteLock(); rw.readLock().lock(); rw.readLock().lock(); rw.writeLock();
    System.out.println("⑤ Semaphore(3) 拿走 2 个:state=" + field(sync(sem), "state") + ";CountDownLatch(2) countDown 一次:state=" + field(sync(latch), "state")
        + ";读锁拿两次:state=" + field(sync(rw), "state") + "(高 16 位是读锁数," + ((int) field(sync(rw), "state") >>> 16) + ")");

    // ⑥ W 已经在排队,持锁的线程 unlock 后马上又抢:tryLock() 和 lock() 分别谁拿到?
    for (boolean fair : new boolean[] {false, true}) {
      int tryWins = 0, lockWins = 0;
      for (int i = 0; i < 1000; i++) {
        final ReentrantLock l = new ReentrantLock(fair); l.lock();
        Thread w = new Thread(() -> { l.lock(); l.unlock(); }); w.start(); waitParked(w);
        l.unlock();
        if (l.tryLock()) { tryWins++; l.unlock(); }
        w.join();
        final ReentrantLock l2 = new ReentrantLock(fair); l2.lock();
        final java.util.concurrent.atomic.AtomicBoolean wGot = new java.util.concurrent.atomic.AtomicBoolean();
        Thread w2 = new Thread(() -> { l2.lock(); wGot.set(true); l2.unlock(); }); w2.start(); waitParked(w2);
        l2.unlock();
        l2.lock(); if (!wGot.get()) lockWins++; l2.unlock();
        w2.join();
      }
      System.out.println("⑥ " + (fair ? "公平  " : "非公平") + ":放下马上再抢,1000 次里 tryLock() 抢到 " + tryWins + " 次,lock() 抢在 W 前面 " + lockWins + " 次");
    }
  }
}
2三个 JDK 的输出只摘出不一样的行,①、③、④、⑤ 三个版本相同
$ java AqsPeek            # JDK 1.8.0_512
② 三个线程排队:state=2  队列:head[Node -1] → T1[Node -1] → T2[Node -1] → T3[Node 0]
⑥ 非公平:放下马上再抢,1000 次里 tryLock() 抢到 999 次,lock() 抢在 W 前面 995 次
⑥ 公平  :放下马上再抢,1000 次里 tryLock() 抢到 999 次,lock() 抢在 W 前面 0 次

$ java --add-opens … AqsPeek    # JDK 17.0.21
② 三个线程排队:state=2  队列:head[ExclusiveNode 0] → T1[ExclusiveNode 1] → T2[ExclusiveNode 1] → T3[ExclusiveNode 1]
⑥ 非公平:放下马上再抢,1000 次里 tryLock() 抢到 1000 次,lock() 抢在 W 前面 953 次
⑥ 公平  :放下马上再抢,1000 次里 tryLock() 抢到 1000 次,lock() 抢在 W 前面 0 次

$ java --add-opens … AqsPeek    # JDK 21.0.12
② 三个线程排队:state=2  队列:head[ExclusiveNode 0] → T1[ExclusiveNode 1] → T2[ExclusiveNode 1] → T3[ExclusiveNode 1]
⑥ 非公平:放下马上再抢,1000 次里 tryLock() 抢到 1000 次,lock() 抢在 W 前面 960 次
⑥ 公平  :放下马上再抢,1000 次里 tryLock() 抢到 1000 次,lock() 抢在 W 前面 0 次

# 三个版本都一样的几行
① main 重入 2 次:state=2  队列:(还没建),java.util.concurrent.locks.ReentrantLock@…[Locked by thread main]
③ unlock 一次:state=1  队列:(同上,没人被叫醒)
④ 全部走完:  state=0  队列:head[…]
⑤ Semaphore(3) 拿走 2 个:state=1;CountDownLatch(2) countDown 一次:state=1;读锁拿两次:state=131072(高 16 位是读锁数,2)
看到了什么对应 ④
没人抢时队列根本不存在;排队后 head 是一个不挂线程的空节点机制 1
JDK 8 的 -1 记在前驱上(T3 自己是 0),JDK 17 / 21 的 1 记在自己身上(head 是 0)机制 1
重入两次只 unlock 一次,state 还是 1,排队的人一个都没被叫醒机制 2
同一个 state,Semaphore 存许可数,CountDownLatch 存剩余计数,读写锁的读锁记在高 16 位(2 × 65536 = 131072)机制 2
非公平锁刚放下就再抢,1000 次里 953~995 次抢在排队的人前面;公平锁的 lock() 一次也没有,可 tryLock() 照样插队为什么这样设计
到这里已经能交差了:能画出 state + 等待队列,说清子类只写 tryAcquire / tryRelease,讲得出 JDK 8 和 17 / 21 节点状态记法的区别,也知道默认非公平意味着什么。面试里“讲讲 AQS”这一问已经能答完整。
已知还没处理的细节
  • 忘了 unlock、重入次数不对、tryLock 插队、await 不写 while:⑥ 坑 1~4
  • JDK 21 虚拟线程为什么要换掉 synchronized:⑥ 坑 5
  • jstack 里怎么认出是谁拿着锁:⑥ 坑 6
  • JDK 8 和 17 / 21 的源码逐行对照、日常开发里用到它的完整清单:⑥ 第三圈

⑥细节:坑、两代源码、日常开发里的完整清单

每条坑都在 JDK 8、17、21 上跑过;源码一段 JDK 8、一段 JDK 17 / 21,对照着读。

第一圈

注意事项

坑 1lock 之后没在 finally 里 unlock:锁永远不放← ④ state 只有持有者能改回 0
现象
某个分支 return 或抛异常没走到 unlock,之后所有线程都停在 parking to wait for … NonfairSync。如果持锁的线程已经因为异常死掉了,jstack 里连持有者都找不到。
根源
synchronized 的释放由 JVM 保证(字节码里有异常表),AQS 的 state 只是一个 int,没人调 tryRelease 它就永远不是 0。owner 字段还指着那个线程。
做法
lock() 写在 try 外面紧挨着 try,unlock() 写在 finally 第一行。
坑 2重入次数和 unlock 次数对不上← ④ state 是重入次数
现象
⑤ 实测:重入两次只 unlock 一次,state 还是 1,排队的三个线程一个都没被叫醒。没拿锁就 unlock,三个版本都抛 IllegalMonitorStateException。实测
根源
tryRelease 只把 state 减 1,减到 0 才算释放、才叫人。
做法
一次 lock 对应一个 finally 里的 unlock;排查时看 getHoldCount()。
坑 3默认非公平,而且 tryLock() 在公平锁上也插队← ④ 非公平是故意的
现象
非公平锁刚放下马上又抢,1000 次里 953~995 次抢在排队的人前面;new ReentrantLock(true) 的 lock() 一次都没插队,可 tryLock() 1000 次里抢到 999~1000 次。实测
根源
非公平的 tryAcquire 不看队列,state 是 0 就 CAS;公平的先看 hasQueuedPredecessors()。而 tryLock() 调的是 Sync 里那个不看队列的版本,javadoc 写明了它会“打破公平”。
做法
要严格先来先得:公平锁 + lock() 或 tryLock(0, TimeUnit.SECONDS)(带超时的版本遵守公平)。多数场景用默认非公平,接受尾延迟。
坑 4await 用 if 判断条件← ④ Condition 的两条队
现象
队列空时消费者 await,被 signal 醒来直接 poll,偶尔拿到 null 或越界。
根源
signal 只是把节点挪到同步队列,醒来还要重新抢锁;抢到之前,别的线程可能已经把东西拿走了。另外还有虚假唤醒。
做法
while (条件不满足) cond.await();,ArrayBlockingQueue 的源码就是这么写的。
坑 5JDK 21 虚拟线程:在 synchronized 里阻塞会钉住载体线程← ④ park 和监视器不同
现象
100 个虚拟线程各拿各的锁、锁里 sleep 50 ms:用 synchronized 要 2517 ms(2 个载体线程一个个轮),换成 ReentrantLock 只要 66 ms。加 -Djdk.tracePinnedThreads=short 会打印 <== monitors:1。实测 JDK 21
根源
AQS 的等待是 LockSupport.park,虚拟线程 park 时会从载体线程上卸下来;JDK 21 的 synchronized 做不到,阻塞时连载体线程一起占住。JDK 24(JEP 491)修了,但 21 没有。JDK 8、17 没有虚拟线程,不涉及。
做法
JDK 21 用虚拟线程时,包着 IO 的锁换成 ReentrantLock。JDK 21 自己也这么做了:java.io 的 PrintStream、BufferedOutputStream 等改用了内部的 ReentrantLock。
坑 6jstack 里找不到“谁拿着锁”排查方法
现象
synchronized 的持有者在 jstack 里显示 - locked <0x…>;ReentrantLock 的持有者默认什么也不显示。
根源
AQS 的锁不是 JVM 监视器,只有加 -l,线程下面才会多出 Locked ownable synchronizers: - <0x…> (a …ReentrantLock$NonfairSync)。
做法
jstack -l <pid>,拿等待者的 parking to wait for <0x…> 地址去搜,同一地址出现在 ownable synchronizers 下的线程就是持有者。三个版本实测都这样;JDK 8 等待者的栈帧是 acquireQueued,17 / 21 是 acquire。
坑 7拿着读锁再要写锁:自己等自己← ④ 读写锁的 state
现象
先 readLock().lock(),再 writeLock().tryLock(1 秒):返回 false;换成 lock() 就永远卡住。反过来,拿着写锁再拿读锁(锁降级)没问题。实测
根源
写锁要求 state 的高 16 位(读锁数)为 0,可这里面就有自己那一份读锁。
做法
要写就先放掉读锁再拿写锁,拿到后重新检查数据;或者干脆一开始就拿写锁。
坑 817 / 21 新实现修过的几个 bug历史原因 · 看小版本
现象
① JDK 17.0.11 之前:Condition 上等的线程很多时,CPU 偏高(JDK-8317960,只有 17u 有这个问题)。② JDK 17.0.13、21.0.5 之前:被取消的 ConditionNode 能熬过 Minor GC,多占内存(JDK-8325754)。③ 大量线程用很短的超时去 tryAcquire、又拿不到时,cleanQueue 可能活锁(JDK-8386085)。
根源
都是 JDK 14 重写后新代码里的细节:① 醒来判断“自己进同步队列没有”时从队尾遍历;② 取消的节点没及时断链;③ 清理队列时多做了一次 CAS。
做法
17 至少 17.0.13,21 至少 21.0.5。③ 主线 2026-06 修复,我查看时只回移到了 25u-dev,8、17、21 还没有;JDK 8 的老实现没有这三个问题。
第二圈

使用规范

lock 紧挨 try,unlock 在 finally

Semaphore 的 acquire / release、读写锁同理。

锁永远不放(坑 1)

涉及 IO 的等待一律带超时

tryLock(timeout)、await(timeout)、CountDownLatch.await(timeout),并处理 false。

下游卡住时整条链路的线程全挂死

await 写在 while 里,条件分开建

生产者等 notFull、消费者等 notEmpty,各 signal 各的。

拿到不该拿的、叫醒不该醒的(坑 4)

Semaphore 只 release 自己 acquire 到的

实测 Semaphore(2) 拿 1 次放 2 次后,许可变成 3:它不检查谁放的。

限流失效,并发越放越多

JDK 21 虚拟线程:阻塞的锁用 ReentrantLock

JDK 8 / 17 照常用 synchronized 即可。

载体线程被钉住,吞吐塌掉(坑 5)

排查锁问题用 jstack -l

同时看 parking to wait for 和 Locked ownable synchronizers。

找不到持有者(坑 6)

第三圈

读源码:同一件事,两代写法

做的事JDK 8(也是 9~13)JDK 17 / 21(JDK 14 重写)
节点一个 Node:waitStatus、prev、next、thread、nextWaiterExclusiveNode / SharedNode / ConditionNode:status、prev、next、waiter
状态值SIGNAL = -1(记在前驱上)、CANCELLED = 1、CONDITION = -2、PROPAGATE = -3WAITING = 1(记在自己身上)、CANCELLED 为负数、COND = 2,没有 PROPAGATE
入队addWaiter 快速 CAS tail,失败走 enq 自旋;第一次入队时建空 head都在 acquire(node, arg, shared, interruptible, timed, time) 这一个方法的循环里
排队等acquireQueued:前驱是 head 就 tryAcquire;否则 shouldParkAfterFailedAcquire 把前驱改成 SIGNAL,再 park同一个 acquire 循环:是第一个就 tryAcquire;先把自己的 status 设成 WAITING 再查一次,然后 park;被叫醒后先自旋几次
叫醒release → unparkSuccessor(head),后继为空或已取消时从 tail 往前找release → signalNext(head),只看 head.next;取消的节点由 cleanQueue 摘掉
共享模式setHeadAndPropagate + doReleaseShared + PROPAGATEsignalNextIfShared
jstack 栈帧AbstractQueuedSynchronizer.acquireQueued(实测)AbstractQueuedSynchronizer.acquire(实测)

JDK 8:acquire → addWaiter → acquireQueued

JDK 8acquire / addWaiter / enqjdk8u AbstractQueuedSynchronizer.java
1197    public final void acquire(int arg) {1198        if (!tryAcquire(arg) &&❶1199            acquireQueued(addWaiter(Node.EXCLUSIVE), arg))❷1200            selfInterrupt();1201    }⋮605    private Node addWaiter(Node mode) {606        Node node = new Node(Thread.currentThread(), mode);607        // Try the fast path of enq; backup to full enq on failure608        Node pred = tail;609        if (pred != null) {❸610            node.prev = pred;611            if (compareAndSetTail(pred, node)) {❹612                pred.next = node;613                return node;614            }615        }616        enq(node);❺617        return node;618    }⋮583    private Node enq(final Node node) {584        for (;;) {585            Node t = tail;586            if (t == null) { // Must initialize❻587                if (compareAndSetHead(new Node()))❼588                    tail = head;589            } else {590                node.prev = t;591                if (compareAndSetTail(t, node)) {592                    t.next = node;593                    return t;594                }595            }596        }597    }
JDK 8acquireQueued / shouldParkAfterFailedAcquire
857    final boolean acquireQueued(final Node node, int arg) {858        boolean failed = true;859        try {860            boolean interrupted = false;861            for (;;) {862                final Node p = node.predecessor();863                if (p == head && tryAcquire(arg)) {❽864                    setHead(node);❾865                    p.next = null; // help GC866                    failed = false;867                    return interrupted;868                }869                if (shouldParkAfterFailedAcquire(p, node) &&❿870                    parkAndCheckInterrupt())871                    interrupted = true;872            }873        } finally {874            if (failed)875                cancelAcquire(node);876        }877    }⋮795    private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {796        int ws = pred.waitStatus;797        if (ws == Node.SIGNAL)undefined798            /*799             * This node has already set status asking a release800             * to signal it, so it can safely park.801             */802            return true;803        if (ws > 0) {undefined804            /*805             * Predecessor was cancelled. Skip over predecessors and806             * indicate retry.807             */808            do {809                node.prev = pred = pred.prev;810            } while (pred.waitStatus > 0);811            pred.next = node;812        } else {813            /*814             * waitStatus must be 0 or PROPAGATE.  Indicate that we815             * need a signal, but don't park yet.  Caller will need to816             * retry to make sure it cannot acquire before parking.817             */818            compareAndSetWaitStatus(pred, ws, Node.SIGNAL);undefined819        }820        return false;821    }
  • 1先试一次 tryAcquire(子类写的),成了就走,根本不碰队列。
  • 2没成:addWaiter 入队,acquireQueued 排队等;等的过程中被中断过,最后补一次 selfInterrupt()(lock() 不响应中断,只是记下来)。
  • 3快速路径:队列已经有了,直接把新节点 CAS 成 tail。
  • 4注意顺序:先设 node.prev,CAS tail 成功后才设 pred.next。所以从前往后的 next 可能暂时是断的,从后往前的 prev 一定是通的。这就是 unparkSuccessor 要从 tail 往前找的原因。
  • 5快速路径失败(队列还没建,或者 CAS 被别人抢了)走 enq 自旋。
  • 6队列为空:先放一个空节点当 head。
  • 7head 和 tail 都指向这个空节点,下一圈循环再把自己挂上去。
  • 8只有前驱是 head(自己排第一)才有资格 tryAcquire。
  • 9拿到了:自己成为新 head,清掉 thread 字段,原来的 head 断开等 GC。
  • 10没拿到:问 shouldParkAfterFailedAcquire 能不能睡,能就 park,醒来接着循环。
  • 11前驱已经是 SIGNAL:它放锁时会叫我,放心睡。
  • 12前驱被取消了(> 0):往前跳过所有取消的节点,重新挂好。
  • 13否则把前驱 CAS 成 SIGNAL,这次先不睡,回去再试一次 tryAcquire。所以一个线程至少试两次才会睡。

JDK 17 / 21:一个 acquire 方法做完全部

JDK 21AbstractQueuedSynchronizer.acquire(Node, …)jdk21u,JDK 17 同一结构(OpenJDK 里按 GPLv2 分发;Doug Lea 写的最初版本在公有领域)
704    final int acquire(Node node, int arg, boolean shared,705                      boolean interruptible, boolean timed, long time) {706        Thread current = Thread.currentThread();707        byte spins = 0, postSpins = 0;   // retries upon unpark of first thread708        boolean interrupted = false, first = false;709        Node pred = null;               // predecessor of node when enqueued710711        /*712         * Repeatedly:713         *  Check if node now first714         *    if so, ensure head stable, else ensure valid predecessor715         *  if node is first or not yet enqueued, try acquiring716         *  else if queue is not initialized, do so by attaching new header node717         *     resort to spinwait on OOME trying to create node718         *  else if node not yet created, create it719         *     resort to spinwait on OOME trying to create node720         *  else if not yet enqueued, try once to enqueue721         *  else if woken from park, retry (up to postSpins times)722         *  else if WAITING status not set, set and retry723         *  else park and clear WAITING status, and check cancellation724         */725726        for (;;) {727            if (!first && (pred = (node == null) ? null : node.prev) != null &&❶728                !(first = (head == pred))) {729                if (pred.status < 0) {❷730                    cleanQueue();           // predecessor cancelled731                    continue;732                } else if (pred.prev == null) {733                    Thread.onSpinWait();    // ensure serialization734                    continue;735                }736            }737            if (first || pred == null) {❸738                boolean acquired;739                try {740                    if (shared)741                        acquired = (tryAcquireShared(arg) >= 0);742                    else743                        acquired = tryAcquire(arg);744                } catch (Throwable ex) {745                    cancelAcquire(node, interrupted, false);746                    throw ex;747                }748                if (acquired) {❹749                    if (first) {❺750                        node.prev = null;751                        head = node;752                        pred.next = null;753                        node.waiter = null;754                        if (shared)755                            signalNextIfShared(node);756                        if (interrupted)757                            current.interrupt();758                    }759                    return 1;760                }761            }762            Node t;763            if ((t = tail) == null) {           // initialize queue❻764                if (tryInitializeHead() == null)765                    return acquireOnOOME(shared, arg);766            } else if (node == null) {          // allocate; retry before enqueue767                try {768                    node = (shared) ? new SharedNode() : new ExclusiveNode();769                } catch (OutOfMemoryError oome) {770                    return acquireOnOOME(shared, arg);771                }772            } else if (pred == null) {          // try to enqueue❼773                node.waiter = current;774                node.setPrevRelaxed(t);         // avoid unnecessary fence775                if (!casTail(t, node))❽776                    node.setPrevRelaxed(null);  // back out777                else778                    t.next = node;779            } else if (first && spins != 0) {❾780                --spins;                        // reduce unfairness on rewaits781                Thread.onSpinWait();782            } else if (node.status == 0) {❿783                node.status = WAITING;          // enable signal and recheck784            } else {785                long nanos;786                spins = postSpins = (byte)((postSpins << 1) | 1);undefined787                if (!timed)788                    LockSupport.park(this);undefined789                else if ((nanos = time - System.nanoTime()) > 0L)790                    LockSupport.parkNanos(this, nanos);791                else792                    break;793                node.clearStatus();794                if ((interrupted |= Thread.interrupted()) && interruptible)undefined795                    break;796            }797        }798        return cancelAcquire(node, interrupted, interruptible);undefined799    }
  • 1先看自己是不是排第一(前驱是 head)。first 一旦为 true 就不再重算。
  • 2前驱被取消了:调 cleanQueue 把取消的节点摘掉,再来。
  • 3排第一,或者还没入队:都可以试 tryAcquire / tryAcquireShared。还没入队就先试一次,和 JDK 8 的“先试再排”一样。
  • 4拿到了。
  • 5如果是排队拿到的:自己成为 head,断开旧 head;共享模式顺手叫醒下一个共享节点。
  • 6队列还没建:建一个空 head。
  • 7节点还没建:现在才 new。先试、后建节点,没竞争时一个对象都不分配。
  • 8CAS tail 入队;和 JDK 8 一样先设 prev 再设 next。
  • 9被叫醒后又没抢到:先 onSpinWait 自旋几次,减少“刚醒就被插队、又得睡”的次数。
  • 10第一次走到这里:把自己的 status 设成 WAITING,不睡,回去再查一次。对应 JDK 8 把前驱设成 SIGNAL。
  • 11真要睡了:自旋次数按 1、3、7… 翻倍,下次醒来没抢到时多转一会儿。
  • 12park;限时版本用 parkNanos,时间到了就跳出循环。
  • 13醒来清掉自己的 status;可中断的版本发现被中断就退出。
  • 14超时或中断:cancelAcquire 把节点标成 CANCELLED 并清理队列。
叫醒release + signalNextJDK 21
1092    public final boolean release(int arg) {1093        if (tryRelease(arg)) {1094            signalNext(head);❶1095            return true;1096        }1097        return false;1098    }⋮641    private static void signalNext(Node h) {642        Node s;643        if (h != null && (s = h.next) != null && s.status != 0) {❷644            s.getAndUnsetStatus(WAITING);645            LockSupport.unpark(s.waiter);❸646        }647    }
叫醒unparkSuccessorJDK 8
638    private void unparkSuccessor(Node node) {639        /*640         * If status is negative (i.e., possibly needing signal) try641         * to clear in anticipation of signalling.  It is OK if this642         * fails or if status is changed by waiting thread.643         */644        int ws = node.waitStatus;645        if (ws < 0)❹646            compareAndSetWaitStatus(node, ws, 0);647648        /*649         * Thread to unpark is held in successor, which is normally650         * just the next node.  But if cancelled or apparently null,651         * traverse backwards from tail to find the actual652         * non-cancelled successor.653         */654        Node s = node.next;655        if (s == null || s.waitStatus > 0) {❺656            s = null;657            for (Node t = tail; t != null && t != node; t = t.prev)❻658                if (t.waitStatus <= 0)659                    s = t;660        }661        if (s != null)662            LockSupport.unpark(s.thread);663    }
  • 1tryRelease 返回 true(state 真的放干净了)才叫人。
  • 2只看 head 后面那一个,而且它的 status 不是 0(真在睡)才叫。
  • 3先清掉 WAITING 再 unpark,避免重复叫。
  • 4JDK 8:先把 head 的 SIGNAL 清成 0。
  • 5后继为空或已取消:
  • 6从 tail 往前找最靠前的、没被取消的节点。原因见上面 addWaiter 第 4 条:prev 链一定是完整的。
非公平 vs 公平ReentrantLockJDK 8
205        final void lock() {206            if (compareAndSetState(0, 1))❶207                setExclusiveOwnerThread(Thread.currentThread());208            else209                acquire(1);210        }⋮231        protected final boolean tryAcquire(int acquires) {232            final Thread current = Thread.currentThread();233            int c = getState();234            if (c == 0) {235                if (!hasQueuedPredecessors() &&❷236                    compareAndSetState(0, acquires)) {237                    setExclusiveOwnerThread(current);238                    return true;239                }240            }
非公平 vs 公平ReentrantLockJDK 21(17 结构相同)
223        final boolean initialTryLock() {224            Thread current = Thread.currentThread();225            if (compareAndSetState(0, 1)) { // first attempt is unguarded❶226                setExclusiveOwnerThread(current);227                return true;⋮279        protected final boolean tryAcquire(int acquires) {280            if (getState() == 0 && !hasQueuedPredecessors() &&❷281                compareAndSetState(0, acquires)) {282                setExclusiveOwnerThread(Thread.currentThread());283                return true;
  • 1非公平:lock() 一上来就 CAS,不管有没有人排队。插队就发生在这一行。
  • 2公平:tryAcquire 先问 hasQueuedPredecessors(),有人排在前面就不抢。两代写法不同,规则一样。

实验台:自己点一遍排队、插队、条件队列 先猜,再一步步点

一次点击是一个线程做完一个动作(lock、unlock、醒来重试、await、signal、countDown、超时)。规则照 JDK 21 的源码写,CAS 在这里总是一次完成。右上角可以切换节点状态的显示方式:JDK 17 / 21 记在自己身上,JDK 8 记在前驱上。

先在上面选一个猜测,再开始点。

同一套模拟逻辑在 node 里随机测过:独占锁(公平、非公平)、Semaphore、CountDownLatch 各 2 万组、共约 165 万步随机操作,没有一次出现两个持有者,也没有一次“资源空着、却有人睡着没人叫”。

日常开发里的 AQS:完整清单

方法:写了一个读 class 文件常量池的小程序,扫 JDK 的 java.base(JDK 8 是 rt.jar)和框架 jar,找出直接继承 AQS 的类、以及引用了 ReentrantLock 等同步器的类,再用 javap 看关键方法里是不是真的在调。

JDK 8u512JDK 17.0.21JDK 21.0.12
直接继承 AQS 的类三个版本都是同样 5 个:ReentrantLock.Sync、ReentrantReadWriteLock.Sync、Semaphore.Sync、CountDownLatch.Sync、ThreadPoolExecutor.Worker
引用 ReentrantLock 的类28 个(整个 rt.jar),几乎都在 java.util.concurrent58 个(java.base)66 个(java.base)
java.util.concurrent一直是大头:ArrayBlockingQueue、LinkedBlockingQueue(两把锁)、LinkedBlockingDeque、PriorityBlockingQueue、DelayQueue、CyclicBarrier、ScheduledThreadPoolExecutor 的延迟队列。线程池的队列全在这里
网络、TLSsynchronizedNioSocketImpl(普通 Socket 的读写)、SocketChannelImpl、SSLSocketImpl 等 20 个 TLS 类、HttpURLConnection、KeepAliveCache
java.iosynchronized(jstack 里是 waiting to lock … (a java.io.PrintStream),实测)PrintStream、BufferedInputStream、BufferedOutputStream、BufferedReader、BufferedWriter、InputStreamReader、OutputStreamWriter 等 11 个类改用 InternalLock(里面是 ReentrantLock,JDK 19 为虚拟线程加的;只在类正好是 JDK 自己的、不是子类时用;-Djdk.io.useMonitors=true 可退回)。jstack 里两个线程抢 println 变成 parking to wait for … ReentrantLock$NonfairSync(实测)
框架Spring Boot 2.7.18(JDK 8 能用的最后一代)Spring Boot 3.5.16(JDK 17 / 21)
Tomcat9.0.83 和 10.1.55 一样:LimitLatch.Sync 继承 AQS,Acceptor 收连接前 countUpOrAwait(),超过 maxConnections(默认 8192)就停在这里;自家线程池的 Worker 也继承 AQS;TaskQueue 继承 LinkedBlockingQueue;SemaphoreValve 用 Semaphore 限并发
Spring MVC5.3.31 和 6.2.19 一样:AbstractHandlerMethodMapping.getHandlerInternal 每个请求都 acquireReadLock()(ReentrantReadWriteLock 的读锁),注册映射时拿写锁;ConcurrentReferenceHashMap.Segment 直接继承 ReentrantLock
Spring 容器5.3:创建单例 bean 用 synchronized6.2:DefaultSingletonBeanRegistry 改成 singletonLock(Lock)+ lenientCreationLock 和一个 Condition
logback1.2.12 和 1.5.34 一样:OutputStreamAppender.writeBytes 每写一条日志都 lock() / unlock(),并发打日志时线程排在这里
HikariCP4.0.3 和 6.3.3 一样:借还连接不走 AQS;只有 allowPoolSuspension=true 时才建一个 Semaphore(10000, true) 做暂停闸门,否则是空实现 FAUX_LOCK
Kafka 客户端3.1.2 和 3.9.2:生产者 BufferPool 用 ReentrantLock + Condition,缓冲区满时 send() 停在 await(max.block.ms);ProduceRequestResult 用 CountDownLatch,future.get() 就等在它上面

版本差异:JDK 8 / 17 / 21

JDK 8JDK 17JDK 21
AQS 实现老实现(waitStatus、acquireQueued)JDK 14 重写后的新实现(JDK-8229442):status、单个 acquire、signalNext、cleanQueue
被叫醒后自旋没有,没抢到就再睡排第一的线程被叫醒又没抢到时,自旋 1、3、7… 次再睡
Condition 等待普通 parkConditionNode 实现 ForkJoinPool.ManagedBlocker:在 ForkJoinPool 里 await 时,池子能补一个线程
该升到的小版本无相关问题至少 17.0.13(8317960 在 17.0.11 修,8325754 在 17.0.13 修)至少 21.0.5(8325754)
虚拟线程没有有;AQS 的 park 会卸下虚拟线程,synchronized 会钉住(到 JDK 24 才修)
JDK 自己用 AQS 的地方基本只有 java.util.concurrent多了网络、TLS、HTTP再多了 java.io(PrintStream 等)、InetAddress 缓存、ReferenceQueue
实测:锁里只自增,8 线程非公平 3990 万/秒,公平 32 万/秒3620 万 / 20 万2340 万 / 23 万

8317960 带着 [17u] 标记,只修在 17u 上;21u 的源码里 canReacquire 已经是修复后的写法(先看 p.next == node,看不出来才遍历)。各修复属于哪个小版本,是拿 GitHub 上 jdk17u、jdk21u 的提交和 jdk-17.0.x-ga、jdk-21.0.x-ga 标签比出来的。吞吐数字只在这台 2 核机器上有意义,看相对大小。

再往深一点

为什么要有一个空的 head

  • “排第一”就简单定义成“前驱是 head”,不用特殊处理空队列。
  • 拿到锁的线程,它的节点直接变成 head,清掉线程引用:head 永远代表“当前持有者或者刚放手的那个位置”。
  • 队列懒建:从来没人竞争的锁,连这个空节点都不会分配(⑤ 里第 ① 行“还没建”)。

JDK 8 为什么把 SIGNAL 记在前驱上

  • 放锁的线程只看 head 一个节点的 waitStatus 就知道“要不要叫人”:是 0 说明后面没人睡,连 unparkSuccessor 都不用进。
  • 代价是理解起来绕:一个节点的状态描述的是它后面那个。JDK 14 改成节点只描述自己,再用“head.next 的 status 不为 0 才叫”达到同样效果,面试时两种都要说得出。

读写锁的 state 怎么切

  • 高 16 位是所有线程读锁的总次数,低 16 位是写锁重入次数,各自最多 65535。
  • 每个线程自己重入了几次读锁,另外记在 ThreadLocal 的 HoldCounter 里,还缓存了第一个读线程和最近一个读线程,省掉多数 ThreadLocal 查找。
  • 写锁要求高低 16 位都为 0(除了自己的写锁重入),所以拿着读锁升级会自己等自己(坑 7)。

Worker、LimitLatch:什么时候值得自己继承 AQS

  • 线程池的 Worker 要一把不可重入的锁来区分“空闲”和“在跑任务”,见 ThreadPoolExecutor 那页。
  • Tomcat 的 LimitLatch 要“计数到上限就排队”,而且上限可以运行中用 setLimit 改;它把计数放在一个 AtomicLong 里,只借用 AQS 的共享模式来排队和叫醒。
  • 共同点:现成的锁、信号量、门闩语义不对,又需要排队和超时,才值得自己写。业务代码里几乎用不到。

面试官会怎么问

讲讲 AQS 的原理。

一个 volatile int state 加一条 FIFO 的双向等待队列(CLH 的变体)。子类实现 tryAcquire / tryRelease(或共享版本)来定义 state 的含义和能不能拿到;拿不到时 AQS 负责把线程包成节点入队、park,释放时叫醒队首的后继,被叫醒的线程重新 tryAcquire。还支持超时、中断、Condition。JDK 8 里用 waitStatus 的 SIGNAL 记在前驱上表示“要叫醒后继”,JDK 14 起改成节点自己的 status。

公平锁和非公平锁的区别?为什么默认非公平?

区别只在 tryAcquire:公平锁先看 hasQueuedPredecessors,有人排队就不抢;非公平锁 state 为 0 就直接 CAS。默认非公平是因为交接给一个睡着的线程要等它醒来,这段时间锁是空的,让正在运行的线程直接拿走,吞吐高得多(本机实测约 100 倍)。代价是排队的线程可能被反复插队。另外 tryLock() 在公平锁上也不公平。

JDK 8 的 unparkSuccessor 为什么要从 tail 往前找?

入队时先设 node.prev,CAS tail 成功后才设 pred.next,next 指针有一段时间是断的;节点取消时 next 也可能被改。prev 链总是完整的,所以后继为空或被取消时,从 tail 往前找最靠前的有效节点。

CountDownLatch 和 CyclicBarrier、Semaphore 怎么实现的?

CountDownLatch 和 Semaphore 都是 AQS 共享模式:Latch 的 state 是剩余计数,归零时 releaseShared,等待者一个叫醒一个;Semaphore 的 state 是许可数,acquire 减、release 加。CyclicBarrier 不直接继承 AQS,它用一把 ReentrantLock 加一个 Condition(trip)实现,最后一个到达的线程 signalAll。

Condition 和 Object.wait/notify 有什么区别?

Condition 依附于 AQS 的锁,一把锁可以建多个 Condition,各自一条等待队列,可以精确地只叫醒等某个条件的线程;支持超时和不可中断的等待。await 时节点进条件队列并完全释放锁,signal 把节点转到同步队列,等锁释放后才真正醒来重新抢锁。

下一圈

  • 已做:AQS 下一圈:读写锁、StampedLock、Semaphore 与 JFR 找锁竞争;synchronized 的实现见 synchronized 与锁升级、CAS 与原子类
  • synchronized 的实现:对象头、轻量级锁、ObjectMonitor 的 EntryList 和 cxq,JDK 15 起默认关闭偏向锁
  • StampedLock:乐观读的版本号,以及它自己的那条等待队列
  • ReentrantReadWriteLock 的写锁饥饿和 readerShouldBlock
  • FutureTask:JDK 6 用 AQS,JDK 7 起换成自己的等待栈,为什么
  • JEP 491:JDK 24 怎么让 synchronized 不再钉住虚拟线程
  • 用 JFR 的 jdk.JavaMonitorEnter 和 jdk.ThreadPark 事件找锁竞争

↺回到第⑤步:改 AqsPeek

每个练习都写了做对时会看到什么。

练习 1 · 把公平锁的插队堵上

在 AqsPeek 第 ⑥ 段公平锁那次,给 W 加一个“我拿到了”的 AtomicBoolean,只数 main 抢在 W 前面的次数;再把 l.tryLock() 换成 l.tryLock(0, TimeUnit.SECONDS) 对比。

做对了会看到:本机三个版本,tryLock() 抢在 W 前面 891~991 次,tryLock(0, SECONDS) 0 次:带超时的版本走 tryAcquireNanos → 公平的 tryAcquire,会先看有没有人排在前面。不加标志的话会多数进去“W 已经拿完走了”的那几次。

用到:坑 3

练习 2 · 自己写一个 AQS 子类

照 ④ 的 BooleanLatch,写一个最多 N 个线程同时进入的“闸门”(共享模式,state 存剩余名额,tryAcquireShared 里 CAS 减),用 10 个线程测。

做对了会看到:任意时刻最多 N 个线程在闸门里;用 ⑤ 的反射打印队列,JDK 17 / 21 里等着的是 SharedNode,JDK 8 里是 Node,waitStatus 也是 -1。

用到:④ 机制 2、机制 4

练习 3 · 用 jstack 找持有者

让一个线程 lock 后 sleep 60 秒,另一个线程去 lock,然后分别执行 jstack 和 jstack -l。

做对了会看到:不加 -l 只看得到等待者的 parking to wait for <0x…>;加 -l 后,持有者下面出现同一个地址的 Locked ownable synchronizers。JDK 8 等待者栈帧是 acquireQueued,17 / 21 是 acquire。

用到:坑 6

1. 用一句话说出 AQS 的精华。

把排队、睡下、叫醒、超时、中断写一遍,子类只用 tryAcquire / tryRelease 说清 state 怎么算“拿到”和“放掉”。

← ② 精华

2. 自己用 CAS 自旋写锁,痛在哪?

等的线程一直在烧 CPU,锁里有 IO 时两个核全满,还抢走了持锁线程的时间片;想不烧 CPU 就得有队列记住谁在等、park 和 unpark,这正是 AQS 做的。

← ② 实测

3. “ReentrantLock 锁住了一直不放”,最常见的根源是 ④ 里哪一条?

state 只是一个 int,只有持有者调 tryRelease 才会变回 0;没在 finally 里 unlock,或者重入次数和 unlock 次数不对,state 就永远不是 0。

← ④ 机制 2

4. 预测:Semaphore(1) 当互斥锁用,A 拿着,B 在等,A 的代码里 release 了两次。之后 B 和新来的 C 会怎样?

许可变成 2(Semaphore 不检查谁放的),B 被叫醒拿走 1 个,C 也能直接拿走另 1 个,两个线程同时进了“互斥区”。

← ④ 机制 2 + 规范第 4 条

5. 预测:一个线程 lock() 等锁时被 interrupt() 了,它会怎样?换成 lockInterruptibly() 呢?

lock():不理会,继续排队,拿到锁后把中断标志补回去(JDK 8 的 selfInterrupt,17 / 21 在 acquire 里重设)。lockInterruptibly():醒来发现被中断,取消节点,抛 InterruptedException。

← ⑥ 源码 JDK 8 第 2 条、JDK 21 第 13~14 条

6. 预测:CountDownLatch(1) 上有 100 个线程在 await,countDown 之后多久全部醒来?会不会一下子 100 个线程同时抢 CPU?

不会同时。countDown 只叫醒第一个,第一个通过后叫醒第二个,依次传下去,100 次叫醒是串起来的。每一棒都要一次线程切换,所以全部醒来有一个微秒到毫秒级的“传递时间”,和线程数成正比。

← ④ 机制 4

7. 预测:JDK 21 上用虚拟线程跑 Spring MVC,日志框架用 logback,打日志会不会钉住载体线程?

不会因为 logback 钉住:它的 OutputStreamAppender 用的是 ReentrantLock(AQS),等锁时虚拟线程会被卸下。真正要查的是自己代码里、或老驱动里包着 IO 的 synchronized。

← ⑥ 坑 5 + 日常开发清单