《Java 并发编程·性能诊断与事故》15.2 Code Review 清单

把 Code Review 从「看一眼」变成可阻断的流程:给出 20 条按后果分级的评审问题,每条包含为什么问、怎么验证、什么级别,讲清与卷二 15.3 检查清单的分工,并附三段式评审样例、ThreadLocal 泄漏的真实取证输出与一个可运行的评审闸门。

承接的问题

15.1 把能机械判定的规范交给了脚本。脚本之外还有一大片区域:设计意图、失败路径、容量依据、上下文传递。这些东西 grep 不出来,只能靠评审时问对问题。

构造三种常见的评审失效:

  • 打勾式。 20 条清单放在 PR 模板里,reviewer 每条都打勾,但没有一条附证据。合并后三天,线程池在 SIGTERM 后不退出——第 8 条明明写着「关闭顺序」,打勾的人没看过那行代码。
  • 形容词式。 评论写「这里可能有并发问题,建议优化」。作者本地跑过是好的,于是回复「已验证」,线程继续合入。没有复现路径、没有数字、没有改法,这条评论无法被裁决。
  • 级别通胀。 所有问题都标 P0,两次之后大家对 P0 麻木;真正会导致超卖的那条,和「线程名不好看」排在同一个优先级。

这一节要解决的不是「再列一份 API 误用清单」,而是评审流程怎么问、怎么验证、怎么阻断、怎么定级。

与卷二 15.3 的分工

卷二 15.3 已经给过 20 条 JUC 误用清单和 grep 脚本,回答的是「代码里有什么」。这一节回答「评审时怎么把它问出来」。两者不能互相替代:

维度卷二 15.3本节
输入源码一个 PR 的改动意图与证据
输出带行号的疑似误用带级别、证据、改法的评审结论
执行者脚本 + reviewerreviewer + 模块 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」,把一个数据错误换成一个性能问题。

不说改成
这里线程安全吗?这两步之间谁会插进来?
池可能有问题池满以后谁在等谁?
注意超时这个超时加上重试,总预算是多少?
回调要小心这个回调在哪个线程跑?

阻断不是嗓门大,而是有判据:

  1. 有机制或复现。 阻断项必须能指出具体的交错、锁环、预算超支或泄漏路径;说不清机制就降到要求改。
  2. 有验证方式。 每条阻断和要求改都要写「改完怎么证明」。没有验证方式的要求,在闸门里直接判阻断(下面的代码实现了这条)。
  3. 豁免要留痕。 阻断项可以豁免,但需要两名 reviewer 同意、写明理由与到期时间(建议不超过 30 天),并挂一个监控项。口头同意不算。
  4. 争议升级。 作者与 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 并发验收与压测报告

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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