承接的问题
2.1 拆出了三种竞态形态:check-then-act、read-modify-write、发布逸出。认出形态之后,下一步是选手段。
常见的反应是「加 synchronized」。这没错,但不是唯一答案,也不总是最好的答案。一个只读的配置对象加锁是浪费;一个每线程独立的格式化器放进共享 map 是自找麻烦;一个按 isbn 分区的库存表用一把全局锁会把并发压成串行。
这一节讲四类策略:
| 策略 | 核心想法 | 典型手段 |
|---|---|---|
| 不可变 | 不共享可变状态,就没有写冲突 | record、final、防御性拷贝 |
| 互斥 | 把复合操作关进同一个临界区 | synchronized、显式锁 |
| 线程封闭 | 状态只属于一个线程,不共享 | ThreadLocal、方法内局部变量 |
| 并发容器 | 把同步责任交给容器 | ConcurrentHashMap、compute |
它们不是四个并列的「最佳实践」。每一种都有适用边界,也都有明确不该用的场景。
策略一:不可变
不可变对象在构造完成后状态不再变化。没有写,就没有 read-modify-write;没有发布后的修改,也就没有 check-then-act 的窗口。Book 已经是 record:
public record Book(String isbn, String title, int stock) {}
record 的字段是 final 的,访问器只读,没有 setter。但这只是浅不可变。如果字段本身是可变对象,外部仍然可以改它的内容:
package com.plume.conc.core;
import java.util.List;
public record BookDetail(String isbn, String title, List<String> tags) {
public BookDetail {
tags = List.copyOf(tags); // 防御性拷贝,切断外部引用
}
}
没有这行 List.copyOf,构造器拿到的 tags 仍然被调用方持有,调用方可以继续 add,对象看起来是 record,实际状态仍在变。
不可变的代价很直接:
- 每次修改都要创建新对象。小对象便宜,大对象图或高频更新就贵。
- 对象身份会变。不能再用
==判断「还是不是同一个」,缓存和IdentityHashMap要重新考虑。 - 不可变只保证对象内部不变,不保证引用替换是原子的。两个线程同时把
bookRef从 A 换成 B,仍然会丢更新。
所以对 UnsafeLoanService 来说,把 Book 写成 record 并没有修好库存问题。竞态不在 Book 内部,而在「读引用、判断库存、替换引用」这组操作上。要让「替换整个 Book」原子,需要 CAS(第 8.2 节的 AtomicLoanService);不可变在这里是必要条件,不是充分条件。
什么时候不要用: 高频更新的大对象、需要稳定身份的对象、每次修改都只改一两个字段却要复制整个聚合根的场景。这时候互斥或分片更合适。
策略二:互斥
互斥是把「检查、计算、写回」放进同一个临界区,让它们对外表现为一个原子步骤。synchronized 是最直接的工具:
package com.plume.conc.core;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.UUID;
public final class SynchronizedLoanService implements LoanService {
private final BookRepository books;
private final LoanRepository loans;
private final Object lock = new Object();
public SynchronizedLoanService(BookRepository books, LoanRepository loans) {
this.books = books;
this.loans = loans;
}
@Override
public Reservation reserve(String isbn, String memberId) {
synchronized (lock) {
Book book = books.findByIsbn(isbn)
.orElseThrow(() -> new LoanException(
ErrorCode.BOOK_NOT_FOUND, "book not found: " + isbn));
if (book.stock() <= 0) {
throw new LoanException(ErrorCode.OUT_OF_STOCK, "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().plus(14, ChronoUnit.DAYS));
}
}
@Override
public boolean returnBook(String loanId) {
synchronized (lock) {
Loan loan = loans.findById(loanId).orElse(null);
if (loan == null) {
return false;
}
Book book = books.findByIsbn(loan.isbn())
.orElseThrow(() -> new LoanException(
ErrorCode.BOOK_NOT_FOUND, "book not found: " + loan.isbn()));
books.save(new Book(book.isbn(), book.title(), book.stock() + 1));
return true;
}
}
}
锁对象是 private final Object lock,不是 this,也不是 Book.class。原因有三点:外部代码可能已经拿 this 当锁;Book.class 是全局共享的,会把无关的类也串起来;锁字段如果是可变的,换一个实例就换了一把锁。第 4.2 节会从字节码和对象头角度展开 monitorenter/monitorexit。
互斥的代价是串行化。上面这把锁把所有书的借阅都串起来了。某本书的热点会拖慢其他书。可以按 isbn 分段加锁(第 9.3 节的思路),但锁越多,死锁和锁顺序问题越难查。
还有三个边界:
- 锁内不要做远程调用、文件 IO、
sleep。临界区越长,排队越严重。 - 单机锁不能跨 JVM。部署两个实例,各自的
synchronized互不知情,库存仍然会超卖。 - 锁的可重入不等于可以随便嵌套。多把锁的获取顺序不一致会死锁。
什么时候不要用: 临界区里有慢操作、需要跨实例一致性、读远多于写且读操作本身很重。前两种要考虑拆分或外部协调,后一种考虑读写锁或不可变快照。
策略三:线程封闭
线程封闭是把状态限制在一个线程内,其他线程根本不访问它。局部变量天然封闭,ThreadLocal 则把封闭范围扩展到「当前线程的多次调用」。
请求上下文是典型场景:memberId、traceId、租户信息在整条调用链上都要用,但不应该作为参数层层透传:
package com.plume.conc.core;
public final class RequestContext {
private static final ThreadLocal<Member> CURRENT = new ThreadLocal<>();
private RequestContext() {
}
public static void bind(Member member) {
CURRENT.set(member);
}
public static Member current() {
Member member = CURRENT.get();
if (member == null) {
throw new IllegalStateException("no member bound to current thread");
}
return member;
}
public static void clear() {
CURRENT.remove();
}
}
在任务里必须用 try/finally 清理:
executor.submit(() -> {
try {
RequestContext.bind(member);
service.reserve(isbn, member.memberId());
} finally {
RequestContext.clear();
}
});
线程池的线程会复用。如果只在任务开始时 set、结束时忘了 remove,下一个任务可能读到上一个任务的 member。这就是「串值」。第 7.1 节会讲 ThreadLocalMap 的弱引用 key 与 value 泄漏,第 7.2 节会讲 InheritableThreadLocal 在线程池里为什么断掉。
线程封闭的边界很硬:它只适合「每个线程一份、彼此不需要看到」的状态。把库存放进 ThreadLocal,每个线程会看到自己的库存,业务不变量直接失去意义:A 线程扣到 0,B 线程仍然看到 5。这不是线程安全,而是把共享状态拆成了多个互不相干的副本。
什么时候不要用: 需要跨线程汇总的结果、需要在线程池任务之间传递的可变状态、生命周期比线程更长的缓存。虚拟线程场景下还要额外考虑每个虚拟线程一份 ThreadLocal 的内存成本(第 14.3 节)。
策略四:并发容器
ConcurrentHashMap 把单次操作的同步做进了容器:get、put、remove、compute、computeIfAbsent、merge 都是线程安全的。对「按键读写」的状态,它通常比一把全局锁伸缩性更好。
库存天然是 isbn -> Book 的映射,可以把「读、判断、写」收进 compute:
package com.plume.conc.core;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.UUID;
import java.util.concurrent.ConcurrentMap;
public final class ConcurrentLoanService implements LoanService {
private final ConcurrentMap<String, Book> books;
private final LoanRepository loans;
public ConcurrentLoanService(ConcurrentMap<String, Book> books, LoanRepository loans) {
this.books = books;
this.loans = loans;
}
@Override
public Reservation reserve(String isbn, String memberId) {
Book updated = books.compute(isbn, (key, book) -> {
if (book == null) {
throw new LoanException(ErrorCode.BOOK_NOT_FOUND, "book not found: " + key);
}
if (book.stock() <= 0) {
throw new LoanException(ErrorCode.OUT_OF_STOCK, "out of stock: " + key);
}
return new Book(book.isbn(), book.title(), book.stock() - 1);
});
return new Reservation(
UUID.randomUUID().toString(),
updated.isbn(),
memberId,
Instant.now().plus(14, ChronoUnit.DAYS));
}
@Override
public boolean returnBook(String loanId) {
Loan loan = loans.findById(loanId).orElse(null);
if (loan == null) {
return false;
}
books.computeIfPresent(loan.isbn(), (key, book) ->
new Book(book.isbn(), book.title(), book.stock() + 1));
return true;
}
}
compute 对同一个 key 是原子的:映射函数执行期间,其他线程对同一 key 的更新会等待。映射函数抛异常时,映射保持不变,异常原样抛出,所以 OUT_OF_STOCK 能正常传出去。
但 ConcurrentHashMap 只保证单次调用的原子性,不保证多次调用组成的复合操作。下面三种写法仍然不安全:
// 1. check-then-act:检查和放入是两次调用
if (!books.containsKey(isbn)) {
books.put(isbn, book);
}
// 2. read-modify-write:get 和 put 之间可能被别人插入
books.put(isbn, new Book(isbn, title, books.get(isbn).stock() - 1));
// 3. 用 size 做容量判断:size 是弱一致快照
if (books.size() < LIMIT) {
books.put(isbn, book);
}
第 1 种要用 putIfAbsent 或 computeIfAbsent;第 2 种要用 compute 或 merge;第 3 种在并发下没有稳定的「当前大小」,需要信号量或专门的计数器。
还有两个容易踩的边界:
compute的映射函数里不要更新其他 key。不同 key 可能落在同一个桶、同一把内部锁上,嵌套更新会带来锁顺序问题,甚至死锁。- 迭代器是弱一致的。遍历时可能看到旧值、新值,也可能看不到遍历开始后的更新;它不会抛
ConcurrentModificationException,但也不是一致性快照。
什么时候不要用: 需要跨多个 key 的原子性(比如一次借三本书,要么全成要么全败)、需要「先读全表再决策」、状态不是按键组织。这些场景要么用互斥包住整个操作,要么交给数据库事务。
选型矩阵
把四个维度放在一起看:共享可变吗、状态多大、读写比如何、一致性边界在哪。
| 共享可变 | 状态大小 | 读写比 | 一致性边界 | 建议 | 主要代价 |
|---|---|---|---|---|---|
| 否 | 小到中 | 读多写少 | 单对象 | 不可变 + 安全发布 | 每次变更复制;引用替换仍需原子 |
| 是 | 小 | 写少、临界区短 | 单 JVM | synchronized | 串行化;不能跨实例 |
| 是 | 大 | 读多写少 | 单对象 | 读写锁 / StampedLock(第 9 章) | 实现复杂;写饥饿 |
| 否(线程内) | 小 | 任意 | 线程内 | ThreadLocal | 池化线程必须 remove |
| 是 | 中到大(按键分区) | 读写都高 | 单 key | ConcurrentHashMap.compute | 跨 key 无原子性 |
| 是 | 中到大(跨 key) | 任意 | 多 key / 多表 | 互斥或事务 | 吞吐下降;锁范围难切 |
| 是 | 小(单值) | 写多读少 | 单值 | AtomicLong / LongAdder(第 8 章) | LongAdder.sum() 非快照 |
这张表的读法是先问三个问题:
- 这个状态真的需要共享吗? 能改成每线程一份,就先考虑线程封闭;能改成不可变快照,就先考虑不可变。
- 共享的范围有多大? 单个对象、单个 key、多个 key、跨实例,对应的手段不同。范围越大,越可能需要事务或外部协调。
- 读写比和临界区长度是多少? 读多写少可以考虑读写分离;临界区里有慢操作,先拆临界区,再谈选锁。
顺序不是教条。一个高频写的计数器,不可变的复制成本可能高于 LongAdder;一个只需要「放入一次」的注册表,putIfAbsent 比 synchronized 更直接。
对 UnsafeLoanService 的四类改法取舍
把四类策略逐一放回 2.1 的服务,结论并不一样:
| 改法 | 具体怎么改 | 能否修好这个服务 | 适用边界 |
|---|---|---|---|
| 不可变 | Book 已是 record;每次扣减生成新 Book | 单独不够,还要原子替换引用 | 书目元数据、返回值、快照 |
| 互斥 | synchronized 包住 reserve 与 returnBook | 单机能修好 | 低竞争、单实例、临界区短 |
| 线程封闭 | 把 memberId/traceId 放进 ThreadLocal | 对库存无效,方向错误 | 请求上下文,不用于共享库存 |
| 并发容器 | ConcurrentMap + compute | 单本书能修好 | 按 key 分区、单 key 原子 |
具体取舍:
- 只部署一个实例、QPS 不高。
SynchronizedLoanService改动最小,审查成本最低。缺点是所有书共享一把锁,热点书会拖慢其他书;但在这个量级下,通常不是瓶颈。 - 单实例、按书分片后仍有热点。
ConcurrentLoanService的compute把同步范围缩小到单个 key 的更新,伸缩性更好。代价是BookRepository接口不暴露compute,要么把ConcurrentMap交给 service,要么给仓库加一个原子的update方法。这是接口设计问题,不是并发问题。 - 多实例部署。 上面两个都不够。两个 JVM 各自的锁和
ConcurrentHashMap互不可见,库存仍然会超卖。需要数据库行锁、乐观锁版本号或集中式协调;第 8 章的 CAS 思路和第 9 章的锁是本地工具,卷三会讲线上事故。 - 不可变。 它适合
Book的元数据(title之类发布后不变的部分),也适合把借阅结果做成不可变快照返回。但库存扣减需要「比较并替换」,这落在第 8.2 节。把Book改成record就宣布线程安全,是这一节最常见的误判。 - 线程封闭。 它对库存是错配。库存必须跨线程共享,放进
ThreadLocal只会让每个线程看到自己的副本。它真正的位置是请求上下文:memberId、traceId、租户 ID。用对地方很省事,用错地方会制造「看起来加了隔离,实际业务错了」的 bug。
如果只能选一个:单机低竞争选互斥,单机按 key 热点选并发容器,多实例选事务/乐观锁。没有一种改法能同时满足「跨实例、跨 key、高并发、低延迟」。
常见错误与边界
- 锁对象选错。 锁
this、锁Book.class、锁字符串字面量、锁一个可变的字段,都会让锁的范围失控或根本锁不住。 ConcurrentHashMap的复合操作。get再put、containsKey再put、用size做判断,仍然是 2.1 的三种形态。record里有可变字段。record只保证字段引用不变,不保证引用对象不变;List、Map、数组都要防御性拷贝。- 线程池里不
remove。 线程复用会把上一个任务的值带给下一个任务。 - 锁内做 IO。 临界区被 IO 拉长,排队和超时会被放大。
- 把
Collections.synchronizedMap当成复合操作安全。 它只同步单次方法调用,get再put仍要客户端加锁。 - 以为不可变可以替代一切。 不可变解决的是「对象不被改」,不解决「引用替换的原子性」。
小结
- 四类策略分别对应四种切断共享的路径:不改变、串行化访问、每线程一份、把同步交给容器。
synchronized修的是复合操作的原子性与可见性;不可变修的是对象状态;ThreadLocal修的是共享范围;ConcurrentHashMap修的是单次按键操作。- 对
UnsafeLoanService,互斥和compute能修好单机库存,不可变需要配合 CAS,线程封闭不适用。 ConcurrentHashMap的compute是原子的,但get再put不是。复合操作永远是复合操作。- 下一节用 JCStress 把「丢失更新」从偶发现象变成可统计的结果。
阅读导航:上一节 2.1 竞态的三种形态 | 下一节 2.3 用 JCStress 复现计数器竞态
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。