承接的问题
15.1 把能机械判定的规范交给了脚本。脚本之外还有一大片区域:设计意图、失败路径、容量依据、上下文传递。这些东西 grep 不出来,只能靠评审时问对问题。
构造三种常见的评审失效:
- 打勾式。 20 条清单放在 PR 模板里,reviewer 每条都打勾,但没有一条附证据。合并后三天,线程池在 SIGTERM 后不退出——第 8 条明明写着「关闭顺序」,打勾的人没看过那行代码。
- 形容词式。 评论写「这里可能有并发问题,建议优化」。作者本地跑过是好的,于是回复「已验证」,线程继续合入。没有复现路径、没有数字、没有改法,这条评论无法被裁决。
- 级别通胀。 所有问题都标 P0,两次之后大家对 P0 麻木;真正会导致超卖的那条,和「线程名不好看」排在同一个优先级。
这一节要解决的不是「再列一份 API 误用清单」,而是评审流程怎么问、怎么验证、怎么阻断、怎么定级。
与卷二 15.3 的分工
卷二 15.3 已经给过 20 条 JUC 误用清单和 grep 脚本,回答的是「代码里有什么」。这一节回答「评审时怎么把它问出来」。两者不能互相替代:
| 维度 | 卷二 15.3 | 本节 |
|---|---|---|
| 输入 | 源码 | 一个 PR 的改动意图与证据 |
| 输出 | 带行号的疑似误用 | 带级别、证据、改法的评审结论 |
| 执行者 | 脚本 + reviewer | reviewer + 模块 owner |
| 失效模式 | 漏报、误报 | 打勾、形容词、级别通胀 |
| 能否阻断 | CI 退出码 | 合并闸门与豁免流程 |
允许重叠的只有四条:锁顺序、无界队列/池满行为、中断传递、ThreadLocal 清理。这四条在卷二给的是「代码检查项」,本节只给「评审问法」,表里用 ※ 标出,不重复解释机制。
20 条清单:按后果分级
级别只有三档,按后果分,不按风格分:
- 阻断:可能导致数据错误、死锁、不可用,且有明确机制或复现路径。合并前必须给证据或「为什么不会发生」的论证。
- 要求改:活性、容量、可运维性受损,不会立刻错。合并前改完,或拆 issue 带 owner 与截止日期。
- 建议:可维护性、一致性。排期处理,不阻断。
| # | 级别 | 评审问题 | 为什么问 | 怎么验证 |
|---|---|---|---|---|
| 1 | 阻断 | 这次改动新增的共享可变状态是什么?谁写、谁读、在哪个线程? | 找不到写者就无法判断原子性 | 让作者在 PR 描述里列出字段与写点,逐点确认 |
| 2 | 阻断 | 临界区内是否调用了 RPC、数据库、文件或同步日志? | 持锁等下游,把单点延迟放大成全局排队 | 看锁范围;要求给出临界区耗时 p99 |
| 3 ※ | 阻断 | 多把锁的获取顺序是否写进全局层号表?新增的边会不会反向? | 反向边就是死锁 | 对照 4.3 的顺序表;要求补 LockLevel 与反向边测试 |
| 4 | 阻断 | 超时预算是否贯穿连接、读、重试与排队?总和是否小于上游 deadline? | 每段都「合理」,加起来超过调用方预算 | 让作者列预算表求和,与上游超时对比 |
| 5 | 阻断 | 失败路径是否释放锁、许可、连接和临时线程? | 异常路径泄漏会导致永久阻塞 | 查 finally / AutoCloseable;要求一次注入异常的测试 |
| 6 ※ | 阻断 | 队列或池满时的行为是什么?有没有测过? | 拒绝、降级、重试放大都在这里发生 | 要求拒绝率与降级计数;压一档过载看曲线 |
| 7 | 阻断 | 重试或超时重放是否幂等?重复扣减的防护在哪? | 超时后任务可能仍在跑,重试就扣两次 | 找幂等键与去重表;构造重复请求验证 |
| 8 | 阻断 | 关闭顺序是否定义?SIGTERM 后谁先停、等多久、超时怎么办? | 进程不退或任务丢失 | 检查 shutdown → await → 强制 与超时值 |
| 9 | 要求改 | 新增线程数的容量依据是什么? | 线程多不等于吞吐高,可能只是切换多 | 用 Little’s law 估 W = N/λ,再用压测分位校核 |
| 10 ※ | 要求改 | 中断语义是否向上传递? | 取消失效,线程卡在下游 | 检查 catch 后是否 interrupt() 或继续抛 |
| 11 ※ | 要求改 | 上下文在异步边界是否传递、在池线程是否清理? | 串请求身份、丢 trace | 池复用探针;看 set/remove 是否同线程 |
| 12 | 要求改 | 背压是否传导到入口,还是堆在中间层? | 中间堆积把过载变成延迟和内存 | 看队列深度、入口拒绝率与端到端分位 |
| 13 | 要求改 | 集合、缓存、信号量、重试队列是否都有上限? | 无界结构把故障拖到 OOM | 找构造参数与上限;没有就要求补 |
| 14 | 建议 | 时间源是否正确?耗时与租约用的是什么钟? | 墙钟回拨导致超时错乱 | 耗时用 nanoTime;跨节点租约另算时钟漂移 |
| 15 | 要求改 | 新并发结构是否有 active/queue/reject/latency 指标与告警? | 出事时看不见 | 对照 12.1 的口径,缺哪项补哪项 |
| 16 | 要求改 | 并发正确性的证据是什么?单次压测通过不算。 | 竞态是概率问题,一次通过没有信息量 | 要求 JCStress 结果或带原始输出的压测脚本 |
| 17 | 要求改 | 参数或默认值变更能否回滚?新旧版本能否混跑? | 变更放大故障,回滚是最后一道闸 | 找开关与旧值;确认灰度与混跑语义 |
| 18 | 建议 | 是否引入新的并发库或 preview API? | 版本与迁移成本被低估 | 看依赖清单;preview API 必须标注并锁版本 |
| 19 | 要求改 | 异常根因是否保留? | ExecutionException 只打 message 会丢栈 | 检查 unwrap、日志与告警字段 |
| 20 | 建议 | 并发测试是否隔离? | CI 抖动、测试互相污染 | 固定线程数、种子、超时与清理顺序 |
八条阻断项里有五条(2、4、5、7、8)是「设计层面」的,脚本永远查不出来;三条(1、3、6)能和 15.1 的扫描互补。要求改的九条大多需要一个数字或一次实验才能关闭,这正是评审要给作者的压力。
「怎么验证」不能停在「跑一下看看」。不同的并发问题要用不同的证据,混用会得出错误结论:
| 问题类型 | 证据 | 采集方式 | 能证明什么 | 不能证明什么 |
|---|---|---|---|---|
| 锁环 | 等待图、反向边 | 线程转储 + 4.3 的脚本 | 环确实存在 | 环出现的频率 |
| 排队 | p50/p99/p999 + 队列深度 | 阶梯压测原始输出 | 延迟来自排队而非计算 | 下游是否也慢 |
| 泄漏 | 复用后的残留值、线程数 | 最小复现、jcmd Thread.print | 资源没有被释放 | 泄漏的增长速度 |
| 容量 | 吞吐-并发曲线 | 至少三档并发 | 饱和点在哪 | 生产流量是否同分布 |
评审时要求作者给出的证据必须落在其中一格;「本地是好的」「压测过了」不落在任何一格,不能关闭阻断项。
怎么问、怎么阻断、怎么定级
评审话术只需要三段:哪里、错多少、怎么改。缺第二段,作者会觉得你在挑刺;缺第三段,修改很容易变成「方法上加个 synchronized」,把一个数据错误换成一个性能问题。
| 不说 | 改成 |
|---|---|
| 这里线程安全吗? | 这两步之间谁会插进来? |
| 池可能有问题 | 池满以后谁在等谁? |
| 注意超时 | 这个超时加上重试,总预算是多少? |
| 回调要小心 | 这个回调在哪个线程跑? |
阻断不是嗓门大,而是有判据:
- 有机制或复现。 阻断项必须能指出具体的交错、锁环、预算超支或泄漏路径;说不清机制就降到要求改。
- 有验证方式。 每条阻断和要求改都要写「改完怎么证明」。没有验证方式的要求,在闸门里直接判阻断(下面的代码实现了这条)。
- 豁免要留痕。 阻断项可以豁免,但需要两名 reviewer 同意、写明理由与到期时间(建议不超过 30 天),并挂一个监控项。口头同意不算。
- 争议升级。 作者与 reviewer 15 分钟内无法达成一致,升级给模块 owner;owner 不在就默认选更安全的一侧,事后复盘。
三段式评审样例
场景:PR 在池任务里设置请求上下文,任务结束没有清理。评审评论写成三段:
[阻断] BorrowTask.java:37 / R-11
问题:CTX.set(reqId) 在池线程里执行,任务结束没有 remove;worker 复用时,
下一个请求会读到上一个请求的 ID。
证据:最小复现(单线程池、两个任务),本机 Temurin 25.0.5+8 原样输出:
leak task-1 ctx=req-A
leak task-2 ctx=req-A ← 第二个任务读到了第一个请求的值
fixed task-2 ctx=null
改法:set 与 remove 必须在同一个线程,remove 放在任务体的 finally;
若改用 ScopedValue,注意它不跨 Thread.start() 继承。
代价:多一次 remove;跨线程传递仍需显式参数或结构化并发作用域。
证据来自下面这个可运行探针,leak 与 fixed 两次运行只差一个 remove():
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
public final class ContextLeakProbe {
private static final ThreadLocal<String> CTX = new ThreadLocal<>();
public static void main(String[] args) throws Exception {
run("leak");
run("fixed");
}
static void run(String mode) throws Exception {
ThreadPoolExecutor pool = new ThreadPoolExecutor(1, 1, 0, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(4),
r -> Thread.ofPlatform().name("ctx-worker").unstarted(r));
try {
pool.submit(() -> {
CTX.set("req-A");
System.out.println(mode + " task-1 ctx=" + CTX.get());
if (mode.equals("fixed")) {
CTX.remove();
}
}).get();
pool.submit(() -> System.out.println(mode + " task-2 ctx=" + CTX.get())).get();
} finally {
pool.shutdown();
pool.awaitTermination(1, TimeUnit.SECONDS);
}
}
}
leak task-1 ctx=req-A
leak task-2 ctx=req-A
fixed task-1 ctx=req-A
fixed task-2 ctx=null
这条评论为什么能阻断:它有确定的复现(单线程池、两个任务、必现),有原始输出,有改法,也写了代价。作者无法用「本地跑过是好的」反驳,因为这里的「好」本身就不成立。
把分级变成可执行的闸门
清单靠人记,分级靠人吵。把「阻断 / 要求改 / 建议」和「有没有证据、有没有验证方式、有没有豁免」写进一个小程序,评审结论就能复现:
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
/** 评审闸门:把「级别 + 证据 + 验证方式 + 豁免」变成可判定的合并结论。 */
public final class ReviewGate {
public enum Level { BLOCK, REQUIRE, SUGGEST }
public record Finding(String id, Level level, String question,
boolean hasEvidence, boolean hasVerification, boolean waiver) {}
public record Verdict(String decision, List<String> reasons) {}
public static Verdict decide(List<Finding> findings) {
int block = 0;
int require = 0;
int unverifiable = 0;
int waived = 0;
for (Finding f : findings) {
if (f.level() == Level.BLOCK) {
block++;
if (f.waiver()) {
waived++;
}
} else if (f.level() == Level.REQUIRE) {
require++;
}
if (f.level() != Level.SUGGEST && !f.hasVerification()) {
unverifiable++;
}
}
List<String> reasons = new ArrayList<>();
if (block > waived) {
reasons.add("阻断项 " + block + " 条,其中仅 " + waived + " 条有豁免论证");
}
if (unverifiable > 0) {
reasons.add("有 " + unverifiable + " 条要求未给出验证方式,等于无法验收");
}
if (waived > 0) {
reasons.add(waived + " 条阻断项以豁免放行,必须二人复核并登记到期时间");
}
if (require > 2) {
reasons.add("要求改 " + require + " 条,超过 2 条说明改动面过大,建议拆 PR");
} else if (require > 0) {
reasons.add("要求改 " + require + " 条,改完复核");
}
String decision;
if (block > waived || unverifiable > 0) {
decision = "BLOCK";
} else if (waived > 0 || require > 0) {
decision = "REQUIRE";
} else {
decision = "APPROVE";
}
return new Verdict(decision, reasons);
}
public static void main(String[] args) {
record Round(String name, List<Finding> findings) {}
List<Round> rounds = List.of(
new Round("round-1", List.of(
new Finding("R-03", Level.BLOCK, "synchronized 内调用下游 RPC", true, false, false),
new Finding("R-07", Level.BLOCK, "池的关闭顺序未定义", false, false, false),
new Finding("R-11", Level.REQUIRE, "新增 32 个线程的依据", true, true, false),
new Finding("R-15", Level.REQUIRE, "中断语义在异步链路上丢失", true, true, false),
new Finding("R-18", Level.SUGGEST, "线程名缺少业务前缀", true, true, false))),
new Round("round-2", List.of(
new Finding("R-11", Level.REQUIRE, "新增 32 个线程的依据", true, true, false),
new Finding("R-15", Level.REQUIRE, "中断语义在异步链路上丢失", true, true, false),
new Finding("R-18", Level.SUGGEST, "线程名缺少业务前缀", true, true, false))),
new Round("round-3", List.of(
new Finding("R-18", Level.SUGGEST, "线程名缺少业务前缀", true, true, false))));
for (Round round : rounds) {
Verdict verdict = decide(round.findings());
System.out.printf(Locale.ROOT, "%s decision=%s%n", round.name(), verdict.decision());
for (String reason : verdict.reasons()) {
System.out.println(" reason: " + reason);
}
}
}
}
本机输出:
round-1 decision=BLOCK
reason: 阻断项 2 条,其中仅 0 条有豁免论证
reason: 有 2 条要求未给出验证方式,等于无法验收
reason: 要求改 2 条,改完复核
round-2 decision=REQUIRE
reason: 要求改 2 条,改完复核
round-3 decision=APPROVE
第一轮里 R-07 连证据都没有,R-03 有证据但没有验证方式,所以即使作者把两条都改成「已确认」,闸门仍然阻断;第二轮阻断项清掉,只剩两条要求改;第三轮只剩建议,放行。注意「要求改超过 2 条就建议拆 PR」这条阈值是团队约定,不是通用真理——刚接入时存量多,可以放宽到 5 条,稳定后再收紧。
在 libraryd-conc 里落地
- PR 模板只留四栏。 新增/变化的共享状态、失败路径与释放方式、容量依据、验证证据。作者不填,reviewer 不开始看。
- 评审 SLA。 阻断项当天响应;要求改 2 个工作日内关闭;建议项进 backlog 并带 owner。超过 SLA 的 PR 自动打上
review-stale,不占用发布窗口。 - 证据类型约定。 机制类问题给交错或锁环;性能类给分位与原始输出;泄漏类给转储或最小复现。截图不算原始输出,必须要能重跑的命令与结果。
- 评审质量回看。 每月抽 10 个已合并的并发 PR,统计「合并后 30 天内因此出事的条款」。如果某条从未命中也不曾被问过,考虑删掉;如果有命中却没人问,把它升到阻断并加进模板。
常见错误与边界
- 把清单当合规。 20 条全打勾但零证据,比不评审更危险,因为它制造了「已经检查过」的错觉。
- 只在 diff 上看。 并发问题经常跨文件:锁在一个类、调用在另一个类、关闭在启动脚本里。至少要看调用方和被调用方各一眼。
- 把「本地跑过」当验证。 竞态是概率问题;15.1 的扫描和本节的最小复现都比一次绿灯有信息量。
- 豁免没有到期时间。 豁免会变成永久例外;没有到期时间的豁免不是豁免,是删除规则。
- 所有问题都要改。 阻断项必须清零,要求改可以拆 issue,建议项可以不做;分不清三者,团队会把所有级别当成建议。
什么时候不要用这份清单: 一次性脚本、离线批处理、明确单线程的 CLI 工具。那里没有共享状态,20 条里大部分不适用,硬套只会制造噪音。
小结
Code Review 清单的价值不在条目数量,而在每条都能回答三个问题:为什么问、怎么验证、什么级别。本节 20 条里,八条阻断集中在数据错误、死锁、超时预算和关闭顺序;这些恰好是脚本最查不到、事故后果最重的部分。与卷二 15.3 的分工是「代码里有什么」对「评审里怎么问」,只有锁顺序、池满行为、中断、ThreadLocal 四条重叠,且只给问法不给机制。
评审结论要能复现:三段式评论(哪里、错多少、怎么改)加上一个把证据与验证方式纳入判定的闸门。R-07 那种「连证据都没有」的阻断项,不会因为作者回复「已确认」而放行;这正是闸门存在的意义。下一节把同样的严谨带到验收:场景、指标、写死的通过判据,以及一份本机真跑出来的压测报告。
阅读导航:上一节 15.1 编码规范与静态检查 | 下一节 15.3 libraryd 并发验收与压测报告
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。