一次超卖事故
libraryd 是这套书稿里反复出现的图书借阅服务。它的库存扣减逻辑很短:
public Reservation reserve(String isbn, String memberId) {
Book book = books.findByIsbn(isbn)
.orElseThrow(() -> new LoanException(ErrorCode.BOOK_NOT_FOUND, isbn));
if (book.stock() <= 0) {
throw new LoanException(ErrorCode.OUT_OF_STOCK, isbn);
}
books.save(new Book(book.isbn(), book.title(), book.stock() - 1));
return new Reservation(UUID.randomUUID().toString(), isbn, memberId, Instant.now().plusSeconds(86400));
}
单线程下它没有毛病:先查库存,再判断,再扣减。放到十个线程里,问题就出来了。某次借阅活动结束后,库存是 -3,而日志里每一条都是「借阅成功」。
这不是一段写错的代码,而是一段只在单线程假设下正确的代码。findByIsbn 和 save 之间隔着一次判断,两个线程可以同时读到 stock = 1,各自判断通过,各自写回 0,于是同一本书被借走了两次。三个线程重复同样的交错,库存就成了负数。
修法有很多种。最直接的是给方法加 synchronized:
public synchronized Reservation reserve(String isbn, String memberId) {
// 同上
}
超卖消失了。但压测数字从 8 000 QPS 掉到 1 200,因为所有借阅请求都排在同一个监视器后面,包括那些查的是不同 ISBN、本来毫无关系的请求。
再换一种:用 AtomicInteger 做库存扣减。
if (stocks.get(isbn).getAndDecrement() <= 0) {
stocks.get(isbn).incrementAndGet();
throw new LoanException(ErrorCode.OUT_OF_STOCK, isbn);
}
QPS 回到 7 000 左右,超卖也没了,但「先扣再还」在扣到 0 的瞬间会让另一个线程读到 -1,前端偶尔显示负库存。要彻底解决,得用 CAS 循环,或者把扣减放进数据库的条件更新里。
这三个版本分别对应三种能力:会写 synchronized、知道它为什么慢、知道什么时候根本不该用它。本书只关心后两种。
三个能力层次
把并发能力拆开,大致是这样三层:
| 层次 | 表现 | 典型问题 | 本书对应 |
|---|---|---|---|
| 会用 | 记得 synchronized、volatile、Executors 的写法 | 代码能跑,压测掉性能 | 卷一第 1–13 章 |
| 懂规则 | 能说清可见性、原子性、有序性各自保证什么 | 知道为什么加 volatile 仍可能错 | 卷一第 3–9 章、卷二 |
| 能判断 | 能决定这里要不要锁、锁多大、用什么代价换 | 在正确与吞吐之间做取舍 | 卷一第 9–15 章、卷三 |
「会用」是最容易的一层。网上有大量示例,IDE 也会提示。难的是后两层,因为它们没有统一答案:
- 同一段
volatile代码,在一个场景里是正确的最小同步,在另一个场景里是漏掉原子性的 bug。 synchronized让代码正确,也可能让系统从 8 000 QPS 掉到 1 200。- 虚拟线程让「线程很贵」这个前提失效,但数据库连接池仍然只有几十个连接。
ConcurrentHashMap保证每个操作线程安全,但「先查再写」的组合仍然不安全。
这些判断没法靠背 API 得到。它们来自对规则的理解,以及在真实代码里反复验证。
为什么并发 bug 难复现
超卖那次事故之后,团队做的第一件事是补单元测试。测试写得很认真:准备一本库存为 1 的书,起 10 个线程同时调用 reserve,断言只有一个成功。跑了一百次,一百次都通过。上线之后,超卖又出现了一次。
这不是测试写得不用心,而是并发 bug 的暴露条件太苛刻:
| 现象 | 根因 | 为什么本地不复现 | 怎么暴露 |
|---|---|---|---|
| 库存超卖 | check-then-act 的窗口只有几条指令 | 10 个线程在 8 核机器上可能刚好串行 | JCStress 放大交错 |
| 循环停不下来 | 读线程看不到写线程的修改 | 开发机上 JIT 可能还没编译 | 加 volatile 前后对比 |
| 线程池堆积 | 队列无界 + 下游变慢 | 本地下游不慢 | 压测 + 队列长度指标 |
ThreadLocal 串值 | 线程池复用线程 | 本地每次新建线程 | 固定线程池跑两轮 |
| 定时任务漂移 | 时钟精度 + GC 停顿 | 本地任务太短 | 长跑 + 记录执行间隔 |
还有几个让问题更难复现的因素:
- JIT 会改变时序。 同一段代码,解释执行和 C2 编译后的指令顺序可能不同。一个只在 C2 编译后出现的可见性 bug,在方法刚启动的前几毫秒是看不到的。
- 日志会掩盖问题。
System.out.println内部有同步,打印本身会把线程交错拉开,让竞态窗口消失。加了日志就不复现、删了日志就复现,是并发排查里的经典陷阱。 - 机器规格不同。 开发机 10 核,容器里可能只有 2 个 CPU 配额。线程数不变,并行度变了,交错方式也变了。
- 测试的断言太粗。 「只有一个线程成功」这种断言,在 10 个线程里可能恰好成立;真正要断言的是「库存不为负」「成功数等于扣减数」「每条借阅都有唯一 loanId」。
所以本书的实验不写成「跑一下单元测试看看」。竞态用 JCStress 放大交错,可见性用 JIT 前后的行为对比,线程池问题用可观测指标和压测,虚拟线程用 JFR 事件。这些工具的作用不是「证明代码对」,而是「让错误有机会出现」。
为什么规则比 API 重要
Java 的并发 API 一直在变,规则变得慢得多。
JDK 25 里,偏向锁已经被移除,-XX:+UseBiasedLocking 会直接报 Unrecognized VM option;Thread.stop() 早已弃用,调用会抛 UnsupportedOperationException;Thread.getId() 被 threadId() 取代;StructuredTaskScope 仍是 preview,而 ScopedValue 已经转正,两者不再是同一个状态,API 与两年前的书上写法已经不一样。
但「监视器进入时建立 happens-before」「volatile 写之前的操作对读之后可见」「final 字段的安全发布」这些规则,从 Java 5 到现在没有变过。学会规则,API 换了也能迁移;只记 API,版本一升级就要重新查。
把最近几个版本的变化列出来,这个判断会更直观:
| 版本 | 并发相关变化 | 对本书的影响 |
|---|---|---|
| JDK 5 | JUC 引入、JMM 按 JSR-133 修订 | 全书的规则基线 |
| JDK 8 | CompletableFuture、LongAdder、@Contended | 第 8、13 章 |
| JDK 15 | 偏向锁默认禁用 | 第 4 章不再讲偏向锁获取 |
| JDK 19 | 虚拟线程进入 preview,Thread.getId() 弃用 | 第 1、14 章 |
| JDK 21 | 虚拟线程正式发布,结构化并发 preview | 第 14、15 章 |
| JDK 24 | JEP 491:synchronized 不再 pin 虚拟线程 | 第 14 章重写 pinning 边界 |
| JDK 25 | ScopedValue 转正;结构化并发仍是 preview | 第 7 章按稳定 API 写,第 15 章标注版本风险 |
这张表里真正影响写法的是最后两行。JDK 24 之前,「虚拟线程里不要用 synchronized」是一条硬规则;JDK 24 之后,这条规则不再成立,剩下会 pin 的是 JNI/FFM 调用和类初始化。照着三年前的文章写,会在已经不存在的问题上做优化。
这也是本卷把 JMM 放在第 3 章、把 volatile 和 synchronized 放在第 4 章的原因。后面的线程池、CompletableFuture、虚拟线程,都是这些规则的应用。规则不清楚,后面的每一章都会变成「记住这个参数」。
三卷本怎么分工
这套书稿有三卷,各写一件事:
| 卷 | 主题 | 回答的问题 | 读完能做什么 |
|---|---|---|---|
| 卷一(本书) | 线程与内存模型 | 怎么写对,为什么这样写对 | 写出正确、可测、不过度同步的并发代码 |
| 卷二 | JUC 源码与容器 | ReentrantLock、AQS、ConcurrentHashMap 内部怎么实现 | 看懂 JUC 源码,能判断一个并发类的代价 |
| 卷三 | 性能诊断与事故 | 怎么压、怎么查、炸了怎么救 | 定位锁竞争、线程池堆积、可见性事故 |
三卷共享一个项目,不共享章节内容。卷一讲 ReentrantLock 的用法和选型,卷二才打开 AQS 的 state 和 CLH 队列;卷一讲虚拟线程怎么用,卷三才做百万线程压测和 pinning 诊断。
已经读过《Java 高级编程》的读者会看到一些熟悉的名词。那本书第 4–6 章用 9 篇给了并发机制的概览,本卷把那 9 篇展开成 45 节,补上实现、实验和取舍。相关章节开头会回指,不重复推导同一段结论。
主线:libraryd-conc
全书只有一个项目:libraryd-conc。它不是另起一个示例工程,而是把前面几卷反复出现的 libraryd 借阅服务做并发改造。
groupId 是 com.plume.conc,三个模块:
| 模块 | 职责 | 出现在 |
|---|---|---|
conc-core | 图书、会员、借阅的领域模型与各种并发实现 | 卷一、卷二 |
conc-lab | JCStress、JMH、竞态与事故复现 | 卷一第 2 章起、卷三 |
conc-ops | 指标、线程诊断、压测脚本 | 卷三 |
领域类型在这里定死,后文不再改:
public record Book(String isbn, String title, int stock) {}
public record Member(String memberId, String name, int quota) {}
public record Loan(String loanId, String isbn, String memberId, Instant dueAt) {}
public record Reservation(String loanId, String isbn, String memberId, Instant dueAt) {}
public enum ErrorCode {
BOOK_NOT_FOUND, OUT_OF_STOCK, QUOTA_EXCEEDED, CONFLICT, INTERRUPTED
}
public interface LoanService {
Reservation reserve(String isbn, String memberId);
boolean returnBook(String loanId);
}
LoanService 会有多个实现,每个实现对应卷一的一类思路:
| 实现 | 章节 | 同步手段 |
|---|---|---|
UnsafeLoanService | 第 2 章 | 无,用来复现竞态 |
SynchronizedLoanService | 第 4–5 章 | 监视器 |
AtomicLoanService | 第 8 章 | CAS |
LockedLoanService | 第 9 章 | ReentrantLock |
PooledLoanService | 第 10 章 | 线程池分片 |
VirtualLoanService | 第 14 章 | 虚拟线程 + 信号量 |
一次 reserve 调用会经过五个步骤,每一步都有各自的并发风险:
| 步骤 | 做什么 | 并发风险 | 对应章节 |
|---|---|---|---|
| 1 | 按 ISBN 查库存 | 读到已被其他线程改过的值 | 第 3 章 |
| 2 | 检查会员借阅配额 | check-then-act,并发下超额 | 第 2、9 章 |
| 3 | 扣减库存 | read-modify-write,丢失更新 | 第 2、8 章 |
| 4 | 保存借阅记录 | 重复保存、对象未安全发布 | 第 3、6 章 |
| 5 | 发送借阅通知 | 阻塞拖慢主流程、异常被吞 | 第 10、13 章 |
这五步不是五个孤立的知识点。第 2 步和第 3 步要么在同一个临界区里,要么用一次 CAS 把「判断 + 扣减」合并;第 4 步的重复保存要靠幂等键,而不是靠锁;第 5 步要做线程池隔离和超时,不能让它把借阅主流程拖死。书中会反复回到这张表,每章解决其中一格或几格。
这些实现不是「一版比一版好」的线性升级。SynchronizedLoanService 在低竞争下可能比 AtomicLoanService 更快,PooledLoanService 在 IO 密集场景才有意义。每一章都会给出适用边界,而不是宣布一个「最佳实践」。
端口沿用前作:libraryd 8080 不变,conc-ops 的指标端口用 8091,避开 linkd-v3 已经占用的 9093–9095。
本书的验证方式
书里的每个结论尽量配一个能自己跑的手段。用到的东西不多,但都会给出实际命令:
| 手段 | 验证什么 | 出现章节 |
|---|---|---|
mvn -q compile | 代码能编译,API 没写错 | 全卷 |
javap -c / javap -v | 字节码里的同步指令、方法标志 | 第 4 章 |
| JCStress | 竞态是否真的能被观察到 | 第 2、8 章 |
| JMH | 原子类、锁、线程池的微基准 | 第 8、9 章 |
| JFR | 虚拟线程 pinning、线程阻塞、Monitor 事件 | 第 14 章、卷三 |
jcmd Thread.print | 线程状态、锁持有者、死锁检测 | 第 1、6 章 |
jstack | 线上线程转储 | 卷三 |
有两个约束需要提前说清楚:
第一,本机输出只写真实执行过的输出。JCStress 的样本数、JMH 的分数、虚拟线程的耗时,都来自一台 Apple M1 Pro、10 核、32 GB 的机器。换一台机器,绝对值会变,但「AtomicLong 在多线程写时比 LongAdder 慢一个量级」这样的相对关系通常还在。
第二,没有实跑的数字会明确标注。容量估算、线程池大小推导、未来版本的性能预期,正文会写「按实现推导」或「示例值」,不会伪装成实测。读技术书最怕的不是数字不准,而是不知道哪个数字是量的、哪个是编的。
代码组织上,本卷不要求每节从零搭一个工程。conc-core 的领域类型在前言给完整定义,后续章节只给与本节相关的类和方法。需要完整上下文的实验放在 conc-lab,每节会说明运行命令。
几个约定
为了避免歧义,书里有几条固定约定:
- 「本机」指 Apple M1 Pro、10 核、32 GB、macOS 26.3、Temurin 25.0.5+8。换机器后绝对值会变,正文会说明哪些结论依赖具体规格。
- 不特别说明时,「锁」指
synchronized的监视器锁;提到ReentrantLock、StampedLock时会写全类名。 - 线程名统一用
conc-前缀,方便在线程转储里过滤。虚拟线程用conc-vt-前缀。 - 代码里的
// ...表示与本节无关的省略,不是「此处省略实现」的占位符。所有关键方法都给完整方法体。 - 性能数字的单位:吞吐用
ops/ms,时延用ms,内存用MB。JMH 的输出原样保留,包括误差。 - 「竞争」指多个线程同时访问同一可变状态;「争用」特指锁或 CAS 上的冲突。两个词不混用。
- 「线程安全」采用《Java Concurrency in Practice》的定义:对象在被多个线程访问时,不需要调用方额外同步,仍表现出正确行为。正确指满足不变式与后置条件,不是「不会崩」。
- 跨卷引用写「卷二第 N 章」「卷三第 N 章」;本节内的编号只写
N.M。 - 涉及 JDK 内部行为的结论以
javap、jcmd、JFR 的实际输出为准;与旧文章冲突时,以本机 JDK 25 的输出为准。
本书不承诺什么
写清楚不承诺什么,比承诺什么更有用:
- 不承诺覆盖所有 JUC 类。 卷二只讲主干:
AQS、显式锁、ConcurrentHashMap、阻塞队列、原子类、线程池、ForkJoin、CompletableFuture。Exchanger、Phaser这类用得少的同步器只在一节里带过。 - 不承诺给出通用的「最佳实践」清单。 锁的粒度、线程池大小、队列长度都依赖具体场景。书里给的是判断方法和边界条件,不是一个可以直接抄的配置。
- 不承诺性能数字可复现。 所有「本机输出」都来自一台 Apple M1 Pro、10 核、32 GB 的机器,换机器绝对值会变。相对关系比绝对值更值得看。
- 不承诺 preview API 稳定。
StructuredTaskScope在 JDK 25 是 preview,写法可能在未来版本变化;书里会标注,并说明替代方案。ScopedValue已转正,不在此列。 - 不承诺读完就能通过面试。 面试题有标准答案,工程判断没有。书里尽量给可验证的实验,而不是结论背诵。
怎么读
三条路径,按需要选:
顺序读。 从第 1 章到第 15 章。适合并发基础不牢、想一次建立完整框架的读者。第 3–4 章是分水岭,读不懂 JMM 和 volatile,后面的章节会变成记忆负担。
按症状跳读。 线上遇到线程池堆积,直接看第 10 章;ThreadLocal 串值,看第 7 章;定时任务漂移,看第 11 章;虚拟线程 pinning,看第 14 章。每节开头都有「承接的问题」,可以先判断是不是你要找的。
对照卷二读。 想深入某个 JUC 类,先读卷一对应章节的用法和边界,再读卷二同一主题的源码。比如先读卷一第 9 章的锁选型,再读卷二第 1–3 章的 AQS、ReentrantLock、读写锁。
不论走哪条路径,都建议把 conc-lab 跑起来。并发问题最难的部分是「看不见」:竞态在单线程测试里不会出现,可见性 bug 在开发机上可能永远不复现。JCStress、JFR、JMH 这些工具不是附录里的装饰,它们是把不可见变成可见的手段。
如果现在只想做一件事,我建议先跑一遍 LostUpdate。在 10 万次以上的样本里,大约四成会观察到「两次自增只生效一次」。这个数字比任何一段解释都更能说明「单线程测试通过」意味着什么。
下一节从线程本身开始。JVM 的线程和操作系统线程是什么关系,创建一个线程要付多少代价,Thread 的六种状态到底在什么时候出现——这些是后面所有讨论的地基。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。