从一次「照书读源码」的失败说起
读 JUC 源码的人,大多经历过同一个场面:翻开一本讲并发的好书,翻到「AQS 的实现」那一章,作者说锁的排队靠 addWaiter 把节点挂到队尾,靠 acquireQueued 在循环里反复尝试,靠 unparkSuccessor 唤醒后继。你打开 IDE,Ctrl+点击 进 AbstractQueuedSynchronizer,然后开始找 addWaiter——找不到。
不是 IDE 出问题,也不是书买错了。那本书写的是 JDK 8 的实现,而你打开的是 JDK 25 的源码。两者之间隔着一次完整的重写。
这件事可以印证,只要一条命令:
export JAVA_HOME=~/.jdks/jdk-25.0.5+8/Contents/Home
S="$JAVA_HOME/lib/src.zip"
for m in addWaiter acquireQueued unparkSuccessor shouldParkAfterFailedAcquire \
setHeadAndPropagate doReleaseShared initialTryLock hasQueuedPredecessors; do
n=$(unzip -p "$S" 'java.base/java/util/concurrent/locks/*.java' | grep -c "$m")
echo "$m: $n 处"
done
本机输出:
addWaiter: 0 处
acquireQueued: 0 处
unparkSuccessor: 0 处
shouldParkAfterFailedAcquire: 0 处
setHeadAndPropagate: 0 处
doReleaseShared: 0 处
initialTryLock: 8 处
hasQueuedPredecessors: 8 处
前六个各 0 处,后两个各 8 处。也就是说,不是「JDK 25 把 AQS 换掉了」,而是它保留接口、重写了内部:公平与非公平的区分还在,只是入口方法从 lock() 里的插队改成了 Sync.initialTryLock(),锁队列从 addWaiter + acquireQueued 改成单循环。
顺带一个容易被旧书误导的点:很多教材讲非公平锁时会引用 nonfairTryAcquire。这个名字在 JDK 25 的 ReentrantLock 里已经不存在,只剩 Semaphore 里有一个 nonfairTryAcquireShared。如果你在 ReentrantLock.java 里搜它,结果是 0。
这就是本书卷二存在的理由。卷一带你把并发代码写对,它的结论不依赖某个 JDK 版本——happens-before 的规则、volatile 的语义、监视器锁的行为,从 JDK 5 到 JDK 25 没有变。卷二要带你看实现为什么长这样,而实现恰恰是版本最敏感的部分。如果不把版本钉死、不逐条核实,写出来的就是一本「看起来很深、实际在讲十几年前代码」的书。
所以这一卷的每一段源码摘录,都是从本机这台机器的 JDK 25 里解压出来、逐字对照过的;每一个「JDK 25 里是这样」的断言,都有对应的命令可以复现。
读源码的三层收益
「读源码」这三个字被说得太轻,以至于很多人读完 ConcurrentHashMap 的 put 流程,除了记住几个字段名,什么也没剩下。问题不在读的人,在目标不清。读 JUC 源码至少有三种完全不同的收益,需要的手段也不一样。
| 层次 | 收益 | 怎么读 | 典型的收获 |
|---|---|---|---|
| 一 | 选型时知道代价在哪 | 只看结构:字段、常量、类之间的分工 | 知道 LongAdder 为什么在高竞争下比 AtomicLong 快,代价是什么 |
| 二 | 排查时能把现象对回代码 | 看路径:一次调用经过哪些方法、在哪里阻塞 | 看到线程 dump 里 park 在 AQS 上,知道它在等哪把锁 |
| 三 | 改造时知道边界在哪 | 看不变式:哪些假设被反复维护、动哪里会破坏它 | 知道能不能在 Condition 上套一层自己的等待,什么时候会死锁 |
第一层最容易拿到,也最容易过期——字段名一改,笔记就废了。第二层的保质期长一些,因为阻塞点通常跟着 API 走。第三层最难,但最耐用:LongAdder 用空间换竞争的思路、ConcurrentHashMap 用「不阻塞读」换复杂度的取舍、StampedLock 用「不可重入」换读性能的决定,这些设计判断跨版本成立。
本卷的主要目标在第二层和第三层。第一层的字段名会讲,但讲完会立刻解释「它为什么必须长这样」,而不是让你背下来。
第一层的陷阱:把实现细节当结论
举个具体的例子。很多人记住「ConcurrentHashMap 的 size() 不精确」。这句话在 JDK 8 之后是对的,但原因已经不是老书说的那样了。老实现用分段锁,size() 需要逐段加锁统计;新实现用 baseCount 加一组 CounterCell,size() 是把它们累加,累加过程中别的线程还在改,所以不精确——不精确的来源从「统计时加锁」变成了「统计时不加锁」。
如果你只知道结论,遇到「为什么我的监控面板上 size() 比实际少了几百」时,就找不到该看哪段代码。如果你知道机制,就会想到去看 sumCount(),然后明白这不是 bug,是设计。
第二层的用处:线程 dump 会说话
卷三会详细讲线程 dump 与 JFR,这里先说一句:线程 dump 里最有信息量的不是线程名,是栈顶几帧。一个线程卡在 LockSupport.park 上,可能是 AQS 的排队、可能是 Condition.await、可能是 SynchronousQueue 的配对等待、也可能是 CompletableFuture.join。同样是 park,恢复条件完全不同。
能区分这些的,只有对路径的熟悉。这一卷的第 1 到第 4 章讲锁的排队,第 7 章讲队列的配对,第 13 章讲 CompletableFuture 的等待,正好覆盖了 park 的四种主要来源。
第三层的用处:改之前先知道能不能改
第三层最典型的场景是做技术选型或者二次封装。比如你想给 ReentrantLock 加一个「等待超过 N 毫秒就打印告警」的能力。读完源码你会知道:Condition.await 最终落到 LockSupport.park,而 park 不接受回调,所以你只能在调用侧包一层计时;想在 AQS 内部挂钩,只能继承 AbstractQueuedSynchronizer 自己写,而那样就享受不到 ReentrantLock 的公平性实现了。
这类判断没法从 API 文档推出来,只能从源码推。
版本差异是这一卷的头号风险
写一本讲实现的书,最大的风险不是写错,是写的是对的,但对的不是现在。JUC 从 JDK 5 诞生到现在,主体接口极其稳定,内部实现改过好几轮。本机核实的差异至少有这些:
| 位置 | 旧版(JDK 8 时代教材) | JDK 25 实际 |
|---|---|---|
| AQS 节点状态字段 | volatile int waitStatus | volatile int status |
| AQS 状态常量 | SIGNAL、CANCELLED、CONDITION、PROPAGATE | WAITING = 1、CANCELLED = 0x80000000、COND = 2 |
| AQS 入队 | addWaiter(Node mode) | 在 acquire(...) 主循环里 casTail 直接挂 |
| AQS 出队与唤醒 | setHead + unparkSuccessor | 循环内改 head + signalNext(head) |
| 共享模式传播 | setHeadAndPropagate + PROPAGATE 状态 | signalNextIfShared(head),判断 instanceof SharedNode |
| 条件队列转移 | transferForSignal | enqueue(ConditionNode) + reacquire(...) |
ReentrantLock 非公平入口 | lock() 里两次 compareAndSetState | Sync.initialTryLock(),先 CAS 再判重入 |
ReentrantReadWriteLock 基类 | AbstractQueuedSynchronizer(int state,高低 16 位) | AbstractQueuedLongSynchronizer(long state,高低 32 位) |
最后一行值得单独说。读写锁把 state 劈成两半这个设计很有名,很多书会告诉你「高 16 位是读锁计数,低 16 位是写锁重入次数,所以写锁最多重入 65535 次」。JDK 25 里这个数字是 Integer.MAX_VALUE——因为它换成了 long 型的 state,读写各占 32 位。如果你照着旧书写「写锁最多重入 65535 次」,读者在自己机器上一验证就发现不对。
这就是为什么这一卷有一条硬规矩:
凡是要写「源码里是这样」的句子,先在 $JAVA_HOME/lib/src.zip 里找到那段源码。
具体做法在第 3 节「前言」里给出,包括怎么解压、怎么用 javap -p 看编译后的字段、怎么用 grep 确认一个方法名到底存不存在。
那为什么还要读旧书
因为设计判断比实现细节寿命长。Doug Lea 在 LongAdder 里用「多个 Cell 分摊竞争」的思路,在 StampedLock 里用「版本戳 + 乐观读」的思路,在 SynchronousQueue 里用「不存储元素、直接配对」的思路,这些判断十几年没变,也很难自己想出来。
正确的读法是:用旧书学思路,用源码校事实。看到一个设计判断,问一句「现在还是这样吗」,然后去源码里找证据。这一卷就是按这个方式写的——每一节先给当前版本的源码,再解释它为什么这么设计,最后和旧版对比一下,让你知道哪些结论可以带走、哪些只属于那个版本。
五部分与十五章
本卷 50 篇:序、前言、目录 3 篇,正文 15 章 45 节,附录 A/B 2 篇。正文分五个部分。
| 部分 | 章 | 主题 | 读完能回答 |
|---|---|---|---|
| 一 | 1–4 | 队列同步器与显式锁 | 锁排队到底怎么排、条件变量怎么等、读写锁怎么分 |
| 二 | 5–7 | 并发容器与阻塞队列 | ConcurrentHashMap 为什么读不加锁、队列选哪个 |
| 三 | 8–10 | 原子类与无锁结构 | LongAdder 怎么分片、VarHandle 怎么替代 Unsafe |
| 四 | 11–13 | 执行器与异步编排 | 线程池的 ctl 怎么编码、ForkJoin 怎么偷、CompletableFuture 的链怎么走 |
| 五 | 14–15 | 组合模式与误用清单 | 超时重试怎么组合、哪些写法看着对其实错 |
十五个章的名字分别是:AQS 基础、ReentrantLock 与 Condition、读写锁与 StampedLock、同步器、ConcurrentHashMap、并发集合、阻塞队列、原子类与 Striped64、VarHandle 与内存序、无锁结构与 ABA、线程池源码、ForkJoin 源码、CompletableFuture 源码、组合模式、陷阱与复查清单。
顺序不是随便排的。第 1 章讲 AQS,是因为后面四章里有三章直接建在它上面;第 8 章的 Striped64 和第 9 章的 VarHandle 要连着读,因为前者是后者的第一个大规模应用;第 11、12 章放在一起,因为 ForkJoinPool 和 ThreadPoolExecutor 共享了很多关于「任务从哪来」的判断。
主线:libraryd-conc 在本卷怎么变
卷一用 libraryd-conc 这个图书借阅服务演示了同一个业务需求的六种并发实现——从故意有竞态的 UnsafeLoanService 一路改到虚拟线程的 VirtualLoanService。本卷不再加新实现,而是把其中两个实现拆开看:
LockedLoanService:卷一只告诉你它用ReentrantLock,本卷要讲清楚这把锁在 AQS 队列里怎么排队、公平与非公平时队列长什么样。PooledLoanService:卷一只告诉你它把请求分片给线程池,本卷要讲清楚ThreadPoolExecutor的ctl怎么同时编码状态和线程数、Worker为什么继承 AQS。
另外会新增一个只在本卷出现的实验类 MapBackedStockCache,用来演示 ConcurrentHashMap 的弱一致迭代:它缓存 ISBN 到库存的映射,后台线程定期刷新,读线程直接 get。这个类的价值不在业务,在于它能稳定复现「迭代时看到一半旧一半新」的场景。
三个模块的分工不变:
| 模块 | 包 | 本卷的用途 |
|---|---|---|
conc-core | com.plume.conc.core | 拆 LockedLoanService 与 PooledLoanService,新增 MapBackedStockCache |
conc-lab | com.plume.conc.lab | JMH 对照实验:公平/非公平、三种队列、三种缓存 |
conc-ops | com.plume.conc.ops | 本卷不用,留给卷三 |
三条阅读路径
完整的学习路径在「目录与学习路径」一篇里,这里只给结论。
路径一:顺序通读。 从第 1 章到第 15 章,适合想系统补齐 JUC 的人。前面几章建立的队列模型,后面每一章都会用到;跳着读会在第 5 章开始吃力。
路径二:按问题跳读。 带着具体问题来的人,可以先看目录篇里的「症状 → 章节」对照表。比如「我的线程池任务堆积了但线程数不涨」,直接去 11.1;「ConcurrentHashMap 里 compute 会不会重复执行」,直接去 5.3。
路径三:按类对照读。 手头正在改某个 JUC 类的用法,想确认自己的理解对不对,可以只读对应的那一节,配合附录 A 的类图。这条路径要求你已经读过卷一,知道基本的 happens-before 规则。
三卷本的分工:这一卷不做什么
三卷本是按能力切的,不是按知识点切的。同一件事在三卷里可能都出现,但角度完全不同。为了让你在读的时候不产生「这讲过了怎么又讲」的困惑,这里把边界划清楚。
| 主题 | 卷一怎么讲 | 卷二怎么讲 | 卷三怎么讲 |
|---|---|---|---|
ReentrantLock | 怎么用、tryLock 怎么写不丢锁 | 排队怎么排、公平性从哪来 | 锁竞争导致的 RT 抖动怎么定位 |
ConcurrentHashMap | 什么时候该用它、compute 怎么用 | 桶首节点怎么 CAS、size 为什么不精确 | 扩容期的内存尖刺怎么看 |
| 线程池 | 七参数怎么配、拒绝策略怎么选 | ctl 怎么编码、Worker 怎么活 | 队列积压怎么排查、怎么扩缩容 |
CompletableFuture | 怎么串流程、异常怎么传 | 依赖栈怎么压、执行器怎么选 | 回调地狱导致的线程泄漏 |
| 虚拟线程 | 怎么创建、什么时候别用 | 不涉及(VirtualThread 不在 java.util.concurrent) | pinning 怎么抓 |
所以本卷有明确的四不做:
- 不重讲 JMM 推导。 happens-before 的八条规则、
volatile的可见性与重排序约束,卷一第 3 章已经推过。本卷只在需要时用一句话回指,例如「state是volatile,它的可见性由卷一 3.1 的规则保证」,然后直接进设计。 - 不做压测与事故复盘。 本卷会出现 JMH 数字,但只用来说明设计取舍——比如公平锁比非公平锁慢一个量级,是因为每次唤醒都要跨线程交接。真实压测的方法论、指标口径、容量规划,卷三第 2 到第 5 章讲。
- 不做线上诊断。 线程 dump、JFR、
jcmd的用法在卷三第 6 到第 9 章讲。本卷提到「这个现象可以去卷三第 N 章看」时,不会再展开。 - 不引第三方并发库。 Disruptor、Reactor、Akka、Netty 的
EventLoop都有值得学的并发设计,但它们不在 JDK 源码范围内,混进来会把「读 JDK」变成「读框架合集」。卷三讲 Reactor 时只作对照,不写代码依赖。
有一处边界比较模糊:java.lang.invoke.VarHandle。它不在 java.util.concurrent 包里,但它是 JDK 9 之后 JUC 内部原子操作的底座,不讲它就讲不清 AtomicInteger 和 LongAdder 的实现。所以第 9 章专门讲它,口径是「作为 JUC 的实现基础来讲」,不是「作为反射替代品来讲」。
书中源码摘录的标注方式
这一卷会出现大量源码。如果每一段都原样贴,篇幅会失控,而且大部分行和本节要讲的结论无关。所以每一段摘录都做三件事。
第一,标注来源与删减。 所有摘录的第一行都是注释:
// JDK 25 源码摘录,已删减
意思是:这段代码来自 Temurin 25.0.5+8 的 lib/src.zip,但删掉了与本段结论无关的行。删掉的通常是参数校验、日志、注释、@ReservedStackAccess 之类的注解,以及只在 OOM 路径上才走的分支。
第二,不写行号。 老书喜欢写「见 AbstractQueuedSynchronizer.java 第 1234 行」。行号在不同 JDK 小版本之间会漂移,一个补丁就可能让你的笔记全部失效。本卷只写类名加方法名,例如「见 AbstractQueuedSynchronizer.signalNext」。用 IDE 的 Symbol 搜索或者 grep -n 都能定位,而且跨版本稳定。
第三,解释必须落在摘录里。 如果正文说「signalNext 会先检查后继节点的状态」,那么摘录里必须出现 s.status != 0 这一行。这条规矩是为了防止一种常见的坏写法:摘录贴 A 方法,解释讲 B 方法,读者对照不上,只能选择相信作者。本卷不接受这种写法。
想把摘录补全,用这条命令把整个文件解出来:
export JAVA_HOME=~/.jdks/jdk-25.0.5+8/Contents/Home
mkdir -p /tmp/juc-src && cd /tmp/juc-src
unzip -oq "$JAVA_HOME/lib/src.zip" 'java.base/java/util/concurrent/*.java' \
'java.base/java/util/concurrent/locks/*.java' \
'java.base/java/util/concurrent/atomic/*.java' \
'java.base/java/lang/invoke/*.java'
解出来的目录结构和包名一致:java.base/java/util/concurrent/locks/AbstractQueuedSynchronizer.java。src.zip 里每个模块一个顶级目录,java.base 是基础模块,JUC 全部在它下面。
为什么用 javap 而不是只读源码
源码能告诉你「写了什么」,javap 能告诉你「编译后剩下什么」。两者有时不一致,最典型的是常量折叠和泛型擦除。比如你想确认 ConcurrentHashMap 的某个常量到底是不是 static final、值是多少,读源码要自己算 1 << 16 这样的表达式,javap -p -constants 直接给你字面量:
javap -p -constants java.util.concurrent.ConcurrentHashMap | grep TREEIFY
本机输出:
static final int TREEIFY_THRESHOLD = 8;
static final int UNTREEIFY_THRESHOLD = 6;
static final int MIN_TREEIFY_CAPACITY = 64;
另一个必须用 javap 的场景是确认字段真实存在。有些字段只在源码里出现于注释,或者被 @Stable 之类的注解包着,读快了容易当成真字段。javap -p 列出来的才是编译产物里有的东西。
本卷有一条自检规矩:凡是正文里写「源码有字段 X」,作者必须用 javap -p 核过一次。你在读的时候也可以随时复核:
javap -p java.util.concurrent.locks.AbstractQueuedSynchronizer | head -20
版本号写死的地方
本卷所有「本机输出」都来自同一个环境:
Apple M1 Pro,10 核,32 GB,macOS 26.3 (25D125)
Temurin 25.0.5+8(build 25.0.5+8-LTS)
Maven 3.9.12
JMH 1.37
JCStress 0.16
JUnit Jupiter 5.14.4
如果你的 JDK 是 8 或 11,大部分章节仍然能读,但你会看到两种源码:书里讲的是新实现,你机器上是旧实现。分界在 JDK 13 与 14 之间,JDK 17 及以后的 LTS 都用新实现。判断方法很简单:打开 AbstractQueuedSynchronizer,搜有没有 addWaiter。有就是旧实现,没有就是新实现。
本书不承诺什么
- 不承诺覆盖全部 JUC。
Flow、SubmissionPublisher、ForkJoinTask的完整状态机、CompletableFuture的全部 40 多个方法都只讲到够用的程度。ThreadLocalRandom只在讲Striped64探针哈希时顺带提到。 - 不承诺性能数字可以横向引用。 本卷的 JMH 数据都来自一台 M1 Pro 笔记本,负载还会波动。数字只用来说明「A 比 B 快一个数量级」这种结构性结论,不用于选型报告。
- 不承诺源码逐行正确。 每一段摘录都标了「JDK 25 源码摘录,已删减」,删掉的是日志、断言、边界分支。被删掉的部分可能有你要的细节,请回原文件看。
- 不承诺 API 永远不变。 JDK 25 是 LTS,但 AQS 的内部实现已经被整体重写过一次,下一个 LTS 再改也不奇怪。所有依赖内部实现的结论,都请用自己的 JDK 复核。
怎么开始
如果你已经读过卷一,直接进「前言」,把源码获取和工具链配好,然后从 1.1 开始。
如果你是从这一卷开始的,建议先补卷一的三节:3.3(final 字段与安全发布)、8.1(CAS 与 ABA)、9.1(ReentrantLock 的基本用法)。这三节是理解本卷全部内容的最小前置。
如果你只想确认这本书值不值得读,翻到 1.1,看它讲 AQS 队列的那张图和那段源码摘录。如果那段摘录和你在自己机器上看到的一致,这本书的其余部分也值得信任;如果不一致,说明你的 JDK 版本和本书不同,请以你的为准,并把差异记下来——这正是读源码该有的姿势。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。