承接的问题
上一节讲的是「一个对象怎么安全发布」,这一节讲「几个安全的方法拼起来为什么不安全」。
这是并发 bug 里最常见、也最难在评审里看出来的一类。ConcurrentHashMap 是线程安全的,get 是线程安全的,put 也是线程安全的,于是 if (get(k) > 0) put(k, get(k) - 1) 看起来每一步都安全。但它不安全,因为**「检查」和「修改」之间有一个窗口,别的线程可以在这个窗口里做任意事**。
第 5 章拆过 CHM 的桶锁,第 8 章拆过 CAS,这一节不再进源码,只做一件事:把「组合」这件事的边界画出来,并且用能跑的数字说明它有多容易出错。
单个方法原子,不等于组合原子
先看一个不需要计数、只需要「别重复」的场景:同一个会员对同一本书只能有一条预约记录。
ConcurrentHashMap<String, String> loans = new ConcurrentHashMap<>();
String key = isbn + "/" + memberId;
if (!loans.containsKey(key)) {
loans.put(key, "loan-" + Thread.currentThread().threadId());
}
64 个线程同时进入,本机三次运行:
containsKey+put : entries=1, put calls=64, saw existing=0
containsKey+put : entries=1, put calls=54, saw existing=10
containsKey+put : entries=1, put calls=64, saw existing=0
entries=1 会让所有「跑完看一眼 map 大小」的测试通过。可是 put calls=64 说明这条预约记录被写了 64 次,每次覆盖前一个 loanId。在真实系统里,这意味着 63 条已经发给会员的确认短信指向了不存在的借阅号。
换成 putIfAbsent,它把「检查 + 插入」做成一个原子操作,返回旧值表示没插进去:
if (good.putIfAbsent(key, loanId) == null) {
created.incrementAndGet();
} else {
rejected.incrementAndGet();
}
putIfAbsent : entries=1, created=1, rejected=63
三次运行都是 created=1。差别不在「容器是否线程安全」,而在原子性的粒度:containsKey 和 put 各自是原子的,它们之间不是。
复合操作的形态其实只有三类,每一类都有对应的原子化工具:
| 形态 | 典型写法 | 原子化工具 | 工具覆盖不到时 |
|---|---|---|---|
| 检查后动作 | containsKey → put | putIfAbsent、computeIfAbsent | 外部锁 |
| 读改写 | get → 计算 → put | compute、merge、replace(k, old, new) | 外部锁或原子类 |
| 跨键、跨容器 | 扣库存 + 写预约 + 发通知 | 没有 | 补偿、事务、单线程串行 |
前两类是容器自己的职责,第三类不是。评审时看到 if 里包着一次读、if 外面跟着一次写,就该问:这两步能不能换成上表第二列的一次调用。
先查后改:库存超卖的真实数字
把同样的写法用到库存上,后果更直观。64 线程、每线程 4 000 次尝试、库存 100:
ConcurrentHashMap<String, Integer> stock = new ConcurrentHashMap<>();
stock.put(ISBN, 100);
Integer now = stock.get(ISBN);
if (now != null && now > 0) {
stock.put(ISBN, now - 1);
sold.incrementAndGet();
}
本机一次运行:
chm get-then-put : sold=261 final=0 oversell=161
synchronizedMap : sold=509 final=0 oversell=409
chm compute : sold=100 final=0 oversell=0
三行分别是:CHM 上的先查后改、Collections.synchronizedMap 上的同样写法、把修改换成 compute 之后的结果。
注意 final=0。库存字段从来没有变成负数,所有「库存不能小于 0」的断言都能通过,账面上却卖了 261 本。超卖不会体现在被保护的那个字段上,只会体现在「卖出去的记录数」上——这是它难被发现的原因。
超卖数还不稳定。本机连跑 12 次:
| 运行 | CHM 先查后改 | synchronizedMap | compute |
|---|---|---|---|
| 1 | 161 | 409 | 0 |
| 2 | 788 | 33 | 0 |
| 3 | 3537 | 0 | 0 |
| 4 | 49 | 2637 | 0 |
| 5 | 0 | 22 | 0 |
| 6 | 296 | 152 | 0 |
| 7 | 286 | 450 | 0 |
| 8 | 1 | 297 | 0 |
| 9 | 211 | 626 | 0 |
| 10 | 50 | 147 | 0 |
| 11 | 1743 | 1676 | 0 |
| 12 | 12 | 71 | 0 |
CHM 版 12 次里超卖 0–3537,中位数 186,其中第 5 次恰好是 0;synchronizedMap 版第 3 次也是 0。如果只在 CI 里跑一次,这个 bug 有十二分之一的概率直接漏过去,而线上流量是它的几千倍。compute 版 12 次全部是 100,一次没多、一次没少。
Collections.synchronizedMap 并没有更好,原因值得说清:它确实把每个方法都锁住了,但 64 个线程仍然可以先后读到同一个 now=1——get 和 put 之间那把锁已经释放了。锁只保证「不读到半个值」,不保证「读完到写回之间没人动过」。这也解释了为什么波动这么大:超卖数取决于有多少线程恰好在别人写回之前读到了同一个值,而这个数量完全由调度决定。
compute 的写法是这样,判断和修改在同一个桶锁内完成:
stock.compute(ISBN, (k, v) -> {
if (v != null && v > 0) {
sold.incrementAndGet();
return v - 1;
}
return v;
});
merge 能做同样的事,只是语义更窄:键存在时把旧值和新值交给函数,键不存在时直接放新值。库存扣减更适合 compute,因为「库存不足时什么都不做」需要在函数里返回原值。
compute 的原子边界与它的阻塞风险
compute 的原子范围是一个桶内、一个键的「读—算—写」(5.3 拆过源码)。它不覆盖:
| 你以为原子 | 实际 |
|---|---|
| 两个不同键的更新 | 两个桶各锁各的,中间可以插入 |
| 一个键的更新 + 另一个容器的写入 | 完全无关,没有任何顺序 |
| 映射函数里的 I/O | 在桶锁里执行,锁被占满整个 I/O 时长 |
第三条是本节最该记住的。映射函数不是「回调」,它是在锁里执行的用户代码。本机测了一次:一个线程在 compute 的 lambda 里睡 300 ms,另一个线程 50 ms 后对同一个键 put:
put behind compute(300ms lambda): blocked=253 ms, a=99
那个 put 等了 253 ms 才拿到桶锁。如果 lambda 里是一次 2 秒的 HTTP 调用,这个桶上所有键的写操作都要排队 2 秒,包括跟这次调用毫无关系的键。读操作不受影响(CHM 的 get 无锁),但写会全部堵住。
递归还有两个不同的结果,很容易踩:
recursive compute on existing key: no exception, returned=109, a=109
recursive compute on absent key -> java.lang.IllegalStateException: Recursive update
跑之前 a 已经是 99(被前一段的 put(99) 覆盖过),内层 +10 得到 109。键已存在时,桶锁是可重入的监视器,内层 compute 直接改了同一个键,不报错;键不存在时,外层放了 ReservationNode 占位,内层检测到后抛 IllegalStateException。同一个错误写法,一个静默通过、一个抛异常,取决于键在不在——这类不一致比稳定报错更危险。
跨两个 map 嵌套 compute 会死锁:5.3 已经用两个所有键都落同一个桶的 map 跑出过 findDeadlockedThreads 的两个 BLOCKED 线程。15.2 不再重复实验,只补一句结论:在桶锁里再去拿另一把桶锁,就是锁顺序反转,跟它是不是「线程安全的容器」无关。
如果更新逻辑需要重试(比如版本号比较),replace(k, old, new) 是比 compute 更轻的选择:它只在当前值等于 old 时替换,等价于一次针对单个键的 CAS(8.1 的乐观更新):
for (;;) {
Integer cur = stock.get(isbn);
if (cur == null || cur <= 0) return false;
if (stock.replace(isbn, cur, cur - 1)) return true;
}
循环里没有显式锁,失败就重读重试。replace 内部同样要拿桶锁,但锁里只做一次比较替换,不执行用户函数,所以持有时间极短——上面的 sold 计数不能放在循环里,否则重试会重复计数。需要「算新值 + 记录副作用」一起完成时,还是回到 compute。
synchronizedMap 的复合操作仍然要外部加锁
Collections.synchronizedMap 包一层 synchronized 方法,每个方法都原子。上表里它有 12 次中的 11 次超卖,最多 2637 本,和 CHM 一样烂。原因相同:get 和 put 是两次独立的加锁。
要修,必须让两次调用落在同一把锁里:
Map<String, Integer> sync = Collections.synchronizedMap(new HashMap<>());
synchronized (sync) { // 锁对象就是 wrapper 自己
Integer now = sync.get(isbn);
if (now != null && now > 0) sync.put(isbn, now - 1);
}
注意锁的对象:Collections.synchronizedMap 返回的 wrapper 内部用的就是 wrapper 自身的监视器,所以在 wrapper 上加锁才能和它的方法互斥。在 sync 外面套一把自己的 ReentrantLock 是没用的——别的线程直接调 sync.get() 不会拿你的锁。
改完再跑同样的 64 线程压测,三次都是 100 卖、0 超卖:
synchronizedMap + external lock: sold=100 final=0 oversell=0
synchronizedMap + external lock: sold=100 final=0 oversell=0
synchronizedMap + external lock: sold=100 final=0 oversell=0
正确性回来了,代价是整张表退化成一把大锁:所有键的读写串行,和直接用 Hashtable 没有区别。数据量小、写不频繁时这个代价可以接受;一旦成为热点,就该换成 CHM 加 compute,让锁的粒度落到桶上。
顺带一提,size()、isEmpty()、containsValue() 也经常作为「检查」出现在复合操作里。它们同样是单次调用,同样不构成原子判断。if (map.size() < limit) map.put(...) 在并发下可以放进任意多个元素。
JDK 文档对迭代也有同样的要求:遍历 synchronizedMap 的集合视图时,必须手工在 map 上同步。不这么做的后果下一节就给。
迭代期间修改:弱一致与快速失败
两个容器的迭代器语义完全不同,这不是实现细节,是契约。
HashMap(哪怕套了 synchronizedMap)的迭代器是快速失败(fail-fast):它记一个 modCount,发现被改过就抛 ConcurrentModificationException。本机 300 000 个键、另一个线程写 30 000 个键,20 轮:
synchronizedMap iterator: 20/20 rounds threw ConcurrentModificationException
chm iterator: cme=0, size seen 300000..327690 (base=300000)
ConcurrentHashMap 的迭代器是弱一致的:不抛异常,但不保证看到某个时刻的完整快照。20 轮里它数到的条目数在 300 000 到 327 690 之间浮动,而写入线程一共加了 30 000 个键——它可能看到一部分新键,也可能一个都看不到。
这两条语义对应两种用法:
- 需要「遍历时不许改」的强约束:用
synchronizedMap并在同一个锁对象上包住整个遍历。代价是遍历期间所有写都被挡住,长遍历会变成停顿。 - 需要「边写边读、不阻塞」:用 CHM 的弱一致迭代,接受「可能看到一部分更新」。统计、扫描、清理任务适合这种;「必须精确统计」不适合。
最糟的组合是:用 CHM 的弱一致迭代,却在业务上假设它数出来的是准确值。5.2 讲过 size() 是估计值,这里是同一个问题的另一面。
在 libraryd-conc 里落地
把库存扣减改成单次 compute,并把「读库存」和「扣库存」合成一个服务方法:
package com.plume.conc.core;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
public final class StockService {
private final ConcurrentHashMap<String, Integer> stock = new ConcurrentHashMap<>();
public void init(String isbn, int amount) {
stock.put(isbn, amount);
}
public Optional<Integer> peek(String isbn) {
return Optional.ofNullable(stock.get(isbn));
}
public boolean tryReserve(String isbn, int amount) {
boolean[] ok = {false};
stock.compute(isbn, (k, v) -> {
if (v == null) return null; // 书不存在,不创建
if (v < amount) return v; // 库存不足,原样返回
ok[0] = true;
return v - amount;
});
return ok[0];
}
}
布尔结果用单元素数组带出来,是因为 compute 只能通过返回值表达「新值」。这个数组只在该线程内可见(lambda 同步执行),不需要原子类。金额、库存这类需要和扣减一起变的量,不能放在 lambda 外面单独更新,否则又把原子性拆开了——要么放进同一个值对象里用 compute 一起换,要么接受它不是原子的。
预约去重同理,用 putIfAbsent 的返回值判断:
public Reservation reserve(String isbn, String memberId) {
String key = isbn + "/" + memberId;
String loanId = "loan-" + Thread.currentThread().threadId();
String existing = loans.putIfAbsent(key, loanId);
if (existing != null) {
throw new LoanException(ErrorCode.CONFLICT, "already reserved: " + key);
}
return new Reservation(loanId, isbn, memberId, Instant.now().plus(7, ChronoUnit.DAYS));
}
这里有一个必须承认的缺口:putIfAbsent 成功之后,如果后续的 reservations.put(loanId, ...) 或发通知失败,map 里会留下一条没有对应实体的记录。原子性只在这一次调用内,跨容器的两步操作要靠补偿或事务,JUC 给不了。
这个设计的代价
compute 把锁暴露给了业务代码。 lambda 执行多久,桶锁就持有多久;lambda 里抛异常,这次更新不生效但也不会回滚你已经做过的副作用(比如上面已经自增的 sold)。
弱一致是真实的语义,不是「差不多准」。 用 CHM 迭代做对账,就必须接受「这一轮可能漏掉刚写入的记录」;要精确,只能停机或加外部快照。
synchronizedMap 加外部锁会让整张表串行。 修好正确性之后,吞吐会掉到和 Hashtable 差不多。数据量小、竞争低时没问题,别拿它当高并发方案。
什么时候不要用 compute
纯读路径不要用 compute:它至少会拿一次桶锁,比 get 贵得多。用 computeIfAbsent 做缓存也要小心,映射函数里做 I/O 会把整个桶堵住(6.1 的 CopyOnWriteArrayList 和 5.3 的缓存讨论是同一个取舍)。
不要在 compute 里调用外部服务、发消息、写数据库。需要「扣库存 + 发通知」这种跨资源操作,正确的位置是先在锁内完成状态变更,把要发的消息放进队列,出了锁再发——这是 14.2 背压那一节的做法。
不要用「跑一次没出问题」来证明复合操作安全。上面的表已经说明:12 次里有一次完全不超卖。
小结
线程安全类保证的是单个方法的原子性;两次调用之间的窗口没有人管。containsKey + put、get + put 都是同一个错误,putIfAbsent 和 compute 才是把它们合成一步的工具。compute 的原子范围只到一个桶、一个键,映射函数在锁里执行,所以只能做纯内存计算;跨 map 嵌套会死锁,5.3 已经抓到过线程转储。synchronizedMap 的复合操作同样要外部加锁,迭代时还要在同一个锁对象上包住整个遍历;CHM 的弱一致迭代不抛异常,但它给的不是快照。判断标准只有一条:这次操作需要原子的是哪几步,容器有没有把这几步做成一次调用。
阅读导航:上一节 15.1 双重检查锁定的正确写法 | 下一节 15.3 JUC 误用清单与 Code Review
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。