《Java 并发编程·线程与内存模型》序:从会开线程到知道为什么

《Java 并发编程·线程与内存模型》开篇:从一次库存超卖事故出发,说明「会开线程、懂规则、能判断」三个能力层次,交代 libraryd-conc 的借阅主线、conc-core/conc-lab/conc-ops 三个模块、三卷分工,以及本书不承诺的内容和最小可运行闭环。

一次超卖事故

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 5JUC 引入、JMM 按 JSR-133 修订全书的规则基线
JDK 8CompletableFuture、LongAdder、@Contended第 8、13 章
JDK 15偏向锁默认禁用第 4 章不再讲偏向锁获取
JDK 19虚拟线程进入 preview,Thread.getId() 弃用第 1、14 章
JDK 21虚拟线程正式发布,结构化并发 preview第 14、15 章
JDK 24JEP 491:synchronized 不再 pin 虚拟线程第 14 章重写 pinning 边界
JDK 25ScopedValue 转正;结构化并发仍是 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-labJCStress、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 的六种状态到底在什么时候出现——这些是后面所有讨论的地基。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《Java 消息中间件·事件驱动架构》附录 B 架构决策记录模板
  2. 《Java 消息中间件·事件驱动架构》附录 A 术语表
  3. 《Java 消息中间件·事件驱动架构》15.3 全书验收