承接的问题
前两节各拆了一个具体陷阱。这一节做收尾:把前面十四章里反复出现的错误压成一份可以贴在评审模板里的清单,并且尽量让其中一部分能由脚本自动检查。
清单的价值不在于「全」,而在于每一条都能指出后果、给出改法、指回依据。没有依据的条目会在评审里变成争论,有依据的条目才能变成「这条按 5.3,改成 compute」。
这也是它必须放在最后的原因:清单里的每一条都指向某一章的机制。不知道 compute 只锁一个桶,第 8 条就只是一句口号;不知道 synchronizedMap 的方法各自加锁,第 5 条就会被当成误报。先有机制,清单才有分量。
级别由后果决定,不由风格决定
并发问题不分「优雅」和「不优雅」,只分「会错」和「会慢」。所以分级只看后果:
| 级别 | 后果 | 处理规则 | 典型表现 |
|---|---|---|---|
| P0 | 数据错误、死锁 | 阻断合并,必须给复现路径或形式化理由 | 超卖、丢更新、线程永久阻塞 |
| P1 | 活性下降、性能塌方、故障放大 | reviewer 逐条确认,记 issue | 线程池打满、锁持有过久、队列无界 |
| P2 | 可维护性、可观测性 | 记录,排期修 | 时钟用错、异常吞掉、选型不当 |
P0 里最麻烦的是「编译通过、测试通过、线上才炸」的那一类。15.2 的表已经证明:12 次压测里有 1 次超卖数是 0。下面清单里带 ★ 的就是这种——单次测试通过不构成任何证据。
20 条清单
| # | 级别 | 症状 | 正确做法 | 章节 |
|---|---|---|---|---|
| 1 | P0 | DCL 字段缺 volatile,或构造里 this 逃逸 | volatile + 构造内不发布,或静态持有者 | 15.1、卷一 3.3 |
| 2 | P0 | volatile int 上做 count++ | AtomicLong、LongAdder | 8.2、卷一 4.1 |
| 3 | P0 | containsKey 后再 put 做去重 | putIfAbsent | 15.2 |
| 4 ★ | P0 | get 后再 put 扣库存或配额 | compute、replace 重试循环 | 15.2 |
| 5 ★ | P0 | 在 synchronizedMap 上做复合操作 | 同一锁对象包住整段,或换 CHM | 15.2 |
| 6 | P0 | 静态 SimpleDateFormat、静态可变 HashMap | DateTimeFormatter、ConcurrentHashMap | 卷一 2.2 |
| 7 ★ | P0 | 用 ConcurrentHashMap.size() 做业务判断 | 独立原子计数器,或 compute 里判断 | 5.2 |
| 8 ★ | P0 | compute 的 lambda 里做 I/O 或嵌套另一个 map | 映射函数保持纯内存 | 5.3、15.2 |
| 9 ★ | P0 | 多把锁的获取顺序不一致 | 全局锁序、单锁、tryLock 带超时 | 5.3、2.3 |
| 10 | P1 | synchronized 块里调用 HTTP、RPC、数据库 | 临界区内只改状态,出锁再调用 | 15.3 |
| 11 | P1 | Executors.newFixedThreadPool 配无界队列 | 显式 ThreadPoolExecutor + 有界队列 + 拒绝策略 | 11.1 |
| 12 | P1 | Executors.newCachedThreadPool 线程数无上限 | 显式最大线程数与空闲回收 | 11.1 |
| 13 | P1 | 每个请求 new Thread(...) | 线程池,或虚拟线程 | 卷一 10.1 |
| 14 ★ | P1 | Future.get() 不带超时 | get(timeout) 并处理 TimeoutException | 13.3 |
| 15 | P1 | catch (InterruptedException) 后不恢复中断 | Thread.currentThread().interrupt() 或向上抛 | 卷一 6.1 |
| 16 | P2 | 静态 ThreadLocal 用完不 remove | finally 里 remove,或 ScopedValue | 卷一 7.1、7.3 |
| 17 | P2 | 用 currentTimeMillis 算耗时或超时 | System.nanoTime | 14.1 |
| 18 | P2 | 写多读少却用 CopyOnWriteArrayList | 按 6.3 的选型矩阵换容器 | 6.1、6.3 |
| 19 | P2 | 自旋等待共享状态,或把 onSpinWait 当锁 | park、阻塞队列、Condition | 卷一 9.1 |
| 20 | P2 | Thread.stop()、suspend() | 协作式取消,中断 + 标志位 | 卷一 6.2 |
九条 P0 里,1、2、6 是「写的时候就该看出来」的;3、4、5、7 是「每步都线程安全、拼起来不安全」的;8、9 是「把锁带进了不该去的地方」。评审时按这个顺序问,比逐行看有效。
一条 P0 在评审里长什么样
拿第 4 条走一遍。PR 里是这样:
public boolean reserve(String isbn) {
Integer stock = inventory.get(isbn);
if (stock == null || stock <= 0) return false;
inventory.put(isbn, stock - 1);
return true;
}
评审评论要写成三段——哪里错、错多少、怎么改——而不是一句「这里有并发问题」:
[P0] 第 3–5 行:get 与 put 是两次独立调用,中间可以被其他线程插入。
复现:64 线程 × 4000 次,本机 12 次运行里 11 次超卖,最多 3537 本;
库存字段始终落在 0,所以「库存 >= 0」的断言不会失败。
改法:换成 compute;售出计数必须在 lambda 内完成,不能放外面。
对应的修改是:
public boolean reserve(String isbn) {
boolean[] ok = {false};
inventory.compute(isbn, (k, v) -> {
if (v == null || v <= 0) return v;
ok[0] = true;
return v - 1;
});
return ok[0];
}
三段缺一不可。没有第二段,作者只会觉得你在挑刺,因为他本地跑过是好的;没有第三段,修改很容易变成「在方法上加个 synchronized」——正确但把整张表串行化,把一个 P0 换成一个 P1。
P1 也要给数字
「synchronized 里调外部服务」这类问题最容易被评成「建议优化」,然后永远不修。给它一个数字,优先级立刻就清楚了:15.2 的实验里,桶锁内睡 300 ms,同一把锁上的写等了 253 ms。把 300 ms 换成 P99 为 800 ms 的 RPC,这个桶上所有写都要排队 800 ms;等锁的线程占着池里的位置,池被打满,故障就从「一个下游慢」扩散成「整个服务不可用」。
// 改前:锁持有时间 = 状态变更 + 下游 RPC
synchronized (this) {
int left = stock.decrementAndGet();
notifier.send(isbn, left); // 800 ms
}
// 改后:锁只保护状态变更,RPC 在锁外
int left;
synchronized (this) { left = stock.decrementAndGet(); }
notifier.send(isbn, left);
代价也要写进评论:出锁之后再发,发送失败时库存已经扣了,需要补偿或对账。这个取舍 14.1 讲过——「锁内正确」和「锁外可用」经常不能同时满足,选哪个取决于下游失败能不能重放。
把清单变成脚本
清单靠人记不住,能机械判定的部分应该交给脚本。下面这个脚本只做粗筛:把可疑点连同行号摆出来,判断仍然交给人。
#!/usr/bin/env bash
# scan_juc.sh — 粗筛 JUC 误用。启发式,结论要人看。
set -uo pipefail
root="${1:-src}"; out="$(mktemp)"; trap 'rm -f "$out"' EXIT
put() { printf '%s\t%s\t%s:%s\t%s\n' "$1" "$2" "$3" "$4" "$5" >>"$out"; }
rule() { # rule <级别> <规则名> <ERE>
grep -rnE --include='*.java' -- "$3" "$root" 2>/dev/null |
while IFS= read -r hit; do
local f="${hit%%:*}" r="${hit#*:}"
put "$1" "$2" "$f" "${r%%:*}" \
"$(printf '%s' "${r#*:}" | sed 's/^[[:space:]]*//' | cut -c1-58)"
done
}
after() { # after <级别> <规则名> <锚点> <窗口内 ERE>:看锚点后 6 行
grep -rnE --include='*.java' -- "$3" "$root" 2>/dev/null |
while IFS= read -r hit; do
local f="${hit%%:*}" r="${hit#*:}"; local ln="${r%%:*}"
local w; w="$(sed -n "${ln},$((ln + 6))p" "$f")"
local c; c="$(printf '%s\n' "$w" | grep -nE -- "$4" | head -1)"
[ -z "$c" ] || put "$1" "$2" "$f" "$ln" \
"$(printf '%s' "$c" | cut -d: -f2- | sed 's/^[[:space:]]*//' | cut -c1-58)"
done
}
volatile_compound() { # volatile int x 之后出现 x++ / x += n
grep -rnE --include='*.java' 'volatile[[:space:]]+(int|long)[[:space:]]+([A-Za-z_][A-Za-z0-9_]*);' "$root" |
while IFS= read -r hit; do
local f="${hit%%:*}" r="${hit#*:}" n
n="$(printf '%s' "${r#*:}" | sed -E 's/.*[[:space:]]([A-Za-z_][A-Za-z0-9_]*);.*/\1/')"
grep -nE -- "(^|[^A-Za-z0-9_])${n}(\+\+|--|[[:space:]]*[-+*/]?=)" "$f" |
while IFS=: read -r l c; do
put P0 VOLATILE-CMPD "$f" "$l" "$(printf '%s' "$c" | sed 's/^[[:space:]]*//')"
done
done
}
rule P0 RAW-THREAD 'new[[:space:]]+Thread[[:space:]]*\('
rule P0 THREAD-STOP '\.stop\(\)'
rule P0 STATIC-FMT 'static[[:space:]].*SimpleDateFormat'
rule P0 STATIC-MUTABLE 'static[[:space:]]+(final[[:space:]]+)?(HashMap|Map|ArrayList|List|HashSet|Set)<'
rule P1 CHECK-THEN-ACT '\.containsKey\('
rule P1 SYNC-MAP 'Collections\.synchronizedMap'
rule P1 UNBOUNDED-QUEUE 'new[[:space:]]+LinkedBlockingQueue<>\('
rule P1 STATIC-TL 'static[[:space:]]+.*ThreadLocal'
rule P1 INTERRUPT-SWALLOW 'catch[[:space:]]*\(InterruptedException'
rule P2 FIXED-POOL 'Executors\.newFixedThreadPool'
rule P2 CACHED-POOL 'Executors\.newCachedThreadPool'
rule P2 WALL-CLOCK 'System\.currentTimeMillis\(\)'
rule P2 SLEEP 'Thread\.sleep\('
volatile_compound
after P1 SYNC-IO 'synchronized[[:space:]]*\(' '\.(send|post|execute|invoke|publish)\('
sort -t$'\t' -k1,1 -k2,2 -k3,3 "$out" |
awk -F'\t' '{printf "%-3s %-18s %-42s %s\n", $1, $2, $3, $4}'
printf '\n按级别: '; cut -f1 "$out" | sort | uniq -c | tr '\n' ' '; echo
printf '总计 %s 处\n' "$(wc -l <"$out" | tr -d ' ')"
拿它扫一棵故意埋了问题的示例树(6 个类:静态 SimpleDateFormat、静态 HashMap、new Thread、count++、containsKey + put、synchronized 里发消息、newFixedThreadPool、无界队列、吞中断、Thread.stop、currentTimeMillis),本机输出:
P0 RAW-THREAD src/com/plume/conc/core/LoanService.java:17 new Thread(() -> notifySms(isbn)).start();
P0 STATIC-FMT src/com/plume/conc/core/LoanService.java:8 private static final SimpleDateFormat FMT = new SimpleDate
P0 STATIC-MUTABLE src/com/plume/conc/core/Cache.java:7 private static final Map<String, String> LEGACY =
P0 STATIC-MUTABLE src/com/plume/conc/core/LoanService.java:9 private static final Map<String, Integer> STOCK = new Hash
P0 THREAD-STOP src/com/plume/conc/core/Legacy.java:6 t.stop();
P0 VOLATILE-CMPD src/com/plume/conc/core/Counter.java:7 count++;
P1 CHECK-THEN-ACT src/com/plume/conc/core/Cache.java:13 if (!LEGACY.containsKey(k)) {
P1 CHECK-THEN-ACT src/com/plume/conc/core/LoanService.java:21 if (STOCK.containsKey(isbn)) {
P1 INTERRUPT-SWALLOW src/com/plume/conc/core/Cache.java:25 } catch (InterruptedException e) {
P1 INTERRUPT-SWALLOW src/com/plume/conc/core/Timing.java:11 } catch (InterruptedException e) {
P1 STATIC-TL src/com/plume/conc/core/Cache.java:9 private static final ThreadLocal<String> CTX = new ThreadL
P1 SYNC-IO src/com/plume/conc/core/LoanService.java:14 mailClient.send(memberId, "reserved " + isbn);
P1 SYNC-MAP src/com/plume/conc/core/Cache.java:8 Collections.synchronizedMap(new HashMap<>());
P1 UNBOUNDED-QUEUE src/com/plume/conc/core/PoolFactory.java:16 new LinkedBlockingQueue<>());
P2 CACHED-POOL src/com/plume/conc/core/PoolFactory.java:11 return Executors.newCachedThreadPool();
P2 FIXED-POOL src/com/plume/conc/core/PoolFactory.java:7 return Executors.newFixedThreadPool(8);
P2 SLEEP src/com/plume/conc/core/Cache.java:24 Thread.sleep(10);
P2 SLEEP src/com/plume/conc/core/Timing.java:10 Thread.sleep(5);
P2 WALL-CLOCK src/com/plume/conc/core/Timing.java:14 long now = System.currentTimeMillis();
P2 WALL-CLOCK src/com/plume/conc/core/Timing.java:5 private long last = System.currentTimeMillis();
按级别: 6 P0 8 P1 6 P2
总计 20 处
我埋的每一类问题都出现在列表里,20 处命中,没有整类漏掉。但是要看清它做了什么:containsKey 那两条只说明「这里有一次检查」,它无法判断后面跟的是不是 put——真正的复合问题要靠人看那 3 行。SYNC-IO 也只是「synchronized 之后 6 行内出现了 .send(」,一个跨行拆开的调用就漏了。
所以脚本的定位是降低漏看率,不是判定正确性。把它的输出贴进 PR,让 reviewer 从「通读 2000 行」变成「确认 20 个点」。
脚本扫不出来的七件事
- 锁顺序。两把锁在不同方法里以相反顺序获取,任何单文件 grep 都看不出来。
- 复合操作的原子性。
containsKey只是一个信号,是不是 bug 取决于它和后面的写有没有被别的线程插入。 - 安全发布的缺失。没有
volatile的 DCL 字符串和普通的懒加载长得一样。 - 映射函数里的副作用。
compute的 lambda 里调用 RPC,语法上完全合法。 - 池内互相等待。任务 A 提交任务 B 到同一个池并
get(),池满时自锁。 - 超时预算与重试放大。单看一处
orTimeout(3, SECONDS)看不出总预算已经超标。 - 上下文传递。
ThreadLocal在主线程设置、在池线程读取,跨文件才能看出断裂。
这七条只能靠评审问题清单:这把锁还被谁持有?这两步之间谁会插进来?这个回调在哪个线程跑?这个池满了以后谁在等谁?
线上出了事,验证它们的工具也不是 grep,而是线程转储和 JFR:jstack 看锁链和 BLOCKED 线程,jcmd <pid> Thread.print 对比两个时刻的栈。这部分交给卷三第 4 章,这里只需要记住:脚本负责在合并前挡掉能挡的,剩下的靠评审问题,出事之后靠现场证据。
在 libraryd-conc 里落地
把扫描接进 CI,P0 直接阻断合并:
#!/usr/bin/env bash
# ci_gate.sh — 把扫描结果变成合并闸门
set -euo pipefail
out="$(tools/scan_juc.sh conc-core/src/main/java)"
printf '%s\n' "$out"
p0="$(printf '%s\n' "$out" | grep -c '^P0' || true)"
p1="$(printf '%s\n' "$out" | grep -c '^P1' || true)"
printf 'summary: P0=%s P1=%s\n' "$p0" "$p1"
if [ "$p0" -gt 0 ]; then
echo "block: $p0 处 P0,先清干净再合并"
exit 1
fi
if [ "$p1" -gt 3 ]; then
echo "warn: $p1 处 P1,需要 reviewer 逐条确认"
fi
在示例树上跑,输出最后两行是:
summary: P0=6 P1=8
block: 6 处 P0,先清干净再合并
脚本以退出码 1 结束,CI 直接判失败。
P0 一律阻断;P1 允许存在但设阈值,因为「synchronized 里调外部服务」这类问题往往需要重构,不能要求一次提交全清。阈值定在 3 是经验值:超过 3 处说明这个 PR 在并发上改动太大,应该拆。
评审流程配三条规则就够用:P0 必须在 PR 描述里给出复现方式或「为什么这里不会发生」的论证;P1 由 reviewer 打勾确认已读;P2 自动开 issue,不阻断。
阈值不要拍脑袋。先把当前仓库扫一遍,把 P1 的存量记成基线,新 PR 只要求不超过基线,之后每季度降一档。P0 永远要求 0,不留存量豁免——真有正当理由(例如 containsKey 后面跟的是只读日志),就在代码里写明注释,让人在评审里看见,而不是在 CI 里加白名单。
三样东西各管一段,缺一段都撑不住:
| 载体 | 放在哪 | 负责什么 |
|---|---|---|
| 扫描脚本 | 提交前、CI | 把可机械判定的问题变成带行号的报告 |
| 评审样例 | PR 模板 | 让人知道 P0 该怎么描述、改法该怎么给 |
| 20 条清单 | 团队文档 | 每条的依据与对应章节,争论时拿它裁决 |
只有脚本,误报会让人关掉它;只有清单,没人会真的逐条看;只有样例,覆盖不到你没写过的场景。
这份清单的代价
误报会消耗信任。 上面的脚本会把 private static final Map 全部标成 P0,哪怕它初始化之后再也不写。规则粗,就必须在输出里写清「启发式」,否则第一次误报之后,团队就会学会忽略整个报告。
清单会变成仪式。 20 条打勾很容易,理解第 4 条为什么在 12 次里只错 11 次很难。清单只负责让人停下来看一眼,不负责让人想明白。
分级会随系统变化。 在一个内部管理后台里,newFixedThreadPool 的无界队列可能永远堆积不起来,P1 就是 P2;在一个秒杀系统里,它就是 P0。级别要按自己的流量和 SLA 调,不要照抄。
清单不能替代测试。 它只能在代码层面提示「这里可疑」,证明不了任何事。真正的证据还是压测和复现:15.2 用 64 线程、12 次运行才把超卖的概率摸清楚,这个成本比读一遍清单高得多,但它是唯一能写进事故报告的东西。
什么时候不要用
不要在一次性脚本、离线批处理、单线程工具里套这份清单。那里没有共享状态,20 条里 18 条不适用,剩下的只会制造噪音。
不要在还没有并发压力的系统上为了「合规」把池参数、队列容量全部显式配置一遍。默认值在低负载下工作得很好,过早调参只会把问题藏得更深。
最重要的是:不要用清单替代复现。清单告诉你「这里可能错」,只有能跑出错误数字的实验才告诉你「这里确实错、错多少」。15.2 的 12 次运行就是模板。
小结
20 条按后果分三级:P0 是数据错误与死锁,P1 是活性与性能,P2 是可维护性。带 ★ 的六条是「测试通过、线上才炸」的典型,评审时要单独问。脚本能把 20 条里可机械判定的部分变成带行号的报告,本机在示例树上 20 处全中;但它看不出锁顺序、复合原子性和上下文传递,那七件事只能靠人。清单的终点不是打勾,是让每一次「这里为什么安全」都有答案。
阅读导航:上一节 15.2 线程安全类的组合不安全 | 下一节 目录与学习路径
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。