主题
代码覆盖率采集原理
一句话:覆盖率采集把「哪些行、哪些分支被执行过」变成可合并的布尔位图(bitmap),再用静态分析得到的可执行行做分母;它换来的是可量化的测试盲区,代价是探针侵入字节码、数据要按请求隔离并可靠上报。
适用 JDK 17 / JDK 21。字节码增强的机制见 16-Java Agent与字节码增强,本篇只讲覆盖率这条数据链。
为什么需要
没有覆盖率时,「测试做完了吗」只能靠感觉:页面点过了,但异常分支、边界条件、老代码里的兜底逻辑有没有走到,没人知道。覆盖率把这个问题变成两个可计算的集合:分母是「应该被执行的可执行单元」,分子是「实际执行过的单元」。它不能证明代码正确,只能证明代码被执行过。但在精准测试里它很有用:把 Git diff 的变更行和运行时覆盖求交,就能回答「这次改动有没有被测到」,比整体覆盖率更接近上线风险。
核心机制
探针只负责把「执行过」写进位图,行号/分支映射、压缩和合并都在离线完成;右侧的 Git diff 提供增量分母,两边求交才是增量覆盖率。链路上每个箭头都可能丢数据或错位,下面的机制都围绕这两件事。
口径与分母
| 口径 | 分子 | 分母来源 |
|---|---|---|
| 行覆盖 | 执行过的行 | LineNumberTable 中至少有一条指令的行 |
| 分支覆盖 | 执行过的分支方向 | 条件跳转的两个方向、switch 的每个 case 与 default |
| 方法/类覆盖 | 执行过的方法/类 | 字节码结构,不依赖调试信息 |
| 增量覆盖 | 变更行中被执行的行 | git diff 的新增/修改行 ∩ 可执行行 |
行覆盖的分母不是源码总行数:空行、注释、纯声明、被编译器优化掉的行都不算。一个文件里相当一部分行不可执行是常态,拿总行数当分母会得出偏低的数字。JaCoCo 口径也不把 catch 块算作分支,异常路径要单独统计就得自己加探针。
探针模型
两种设计:
- 探针自带行号/方法 ID:
hit(methodId, line),直观、映射简单,但字节码变大、常量池条目变多。 - 探针只写数组下标:运行时只记「第 N 个探针触发过」,探针到行/分支的映射在报告阶段用同一版 class 文件算出来。字节码和上报数据都小,代价是报告时类版本必须对得上。
一个类一个布尔数组,槽位数等于探针数;ID 分配必须确定性,例如按方法内字节码偏移顺序编号,或用 lineId * K + 分支序号,否则每次增强结果不同,历史数据无法合并。LineNumberTable 决定行号,LocalVariableTable 只影响调试名字;用 -g:none 编译会连行号一起丢,此时只能退到方法/类级覆盖。
分支覆盖
- 每个条件跳转有两条边:fall-through 和 jump target,各放一个探针;
switch编译成tableswitch/lookupswitch,每个 case 目标加 default 各一个,N 个 case 是 N+1 个分支。 - 循环的回边会反复命中同一个探针,布尔写是幂等的;但循环退出那条边要有自己的探针,否则循环条件永远显示「只覆盖一半」。
- 异常表不是条件跳转。不额外在 handler 入口插探针,catch 块就会一直显示未覆盖,这也是不同工具分支口径对不上的常见原因。
finally被 javac 复制到正常和异常两条路径,同一行可能对应多段代码:行覆盖仍算一行,分支数可能翻倍;&&、||也会展开成多个跳转,一行源码出现多个分支很正常。
上下文与线程安全
- 全局位图最简单(执行过就置 true),但无法回答「哪个用例覆盖了它」。
- 请求级上下文:在 HTTP filter、MQ listener、定时任务包装器里创建,探针写当前上下文的位图,退出时合并上报。这样才能按用例、按 trace 归因。
- 跨线程池与异步任务:
InheritableThreadLocal只在创建线程时复制,线程池复用线程后失效,要用可传递的 ThreadLocal 扩展或在提交Runnable/Callable时手动包装;异步任务可能比请求活得久,要么让上下文延迟到任务结束再关闭,要么明确归到「后台流量」,不能让它污染下一个请求。 - 所有线程都写
true,重复写没有副作用,不需要锁;但「读快照并清零」和别的请求并发时会互相覆盖,所以要么每请求独立结构,要么用双缓冲/世代号切换。 - 没有识别到入口的流量(定时任务、健康检查、旁路调用)单独放一个桶,默认不计入测试覆盖,否则数字会虚高。
上报与聚合
本地缓冲 → 定期快照并清空 → 连续区间压缩 → 批量异步上报。队列必须有界,满了丢最旧的批次,绝不阻塞业务线程;覆盖率是单调的,丢一批只会少报,不会让业务出错;要补数据只能靠重跑或保留窗口内的重试缓冲。上报用至少一次语义,服务端按 (traceId, classId) 去重;合并就是按 class id 对布尔数组逐位求或,多实例、多批次都走同一条路径。采样要按「请求」采样,不要按「探针」采样,否则被采中的请求覆盖被放大、没采中的完全丢失,比例无法还原。开关放在入口判断一次,不要每行探针都读配置。
增量覆盖率
bash
git diff --unified=0 <merge-base>...HEAD -- '*.java'解析 @@ -a,b +c,d @@,新文件的 c..c+d-1 就是新增/修改行;删除行在新文件里不存在,不进分母。再与新文件的可执行行求交,得到增量分母。基线必须用 merge-base,不能拿当前分支去比目标分支的 HEAD,否则会把别人的改动算进来。重构会误判:纯移动的代码在 diff 里是「删一段、加一段」,会显示成新增未覆盖行。开启 -M 识别重命名能缓解改名,跨文件搬移仍要靠 blame 或语法树比对。没有变更的可执行行时,增量覆盖率应报 N/A,而不是 100%。
兼容 JaCoCo
.exec 的结构是:魔数 0xC0C0 + 格式版本 + 会话块(0x10)+ 执行数据块(0x11,类的 CRC64 id、类名、布尔探针数组)。它不存行号,报告时用同一批 class 文件反推探针到行/分支的映射。自研平台常兼容它,是因为 Sonar、Jenkins、IDE 都能直接消费,历史数据也能对比。合并多份 exec 就是按 class id 把数组逐位求或;class id 是 class 文件字节的 CRC64,编译参数、调试信息不同都会产生不同 id,不能混算。差异在于:JaCoCo 的探针在方法入口和跳转目标,自研方案常用「行号常量」,后者字节码更大但映射不依赖离线分析。
性能与正确性坑
- 数组过大:探针数按行+分支增长,类多、生成代码多时内存可观;按类懒初始化,不要启动时全量分配。
- cache 争用:相邻布尔槽常落在同一 cache line,多核高频写会互相失效,探针从几条指令放大成显著开销;用线程本地缓冲批量合并可以缓解。
- 多版本类、内部类与 lambda:热部署、同一类名不同字节码会让 class id 对不上,旧数据映射到新行号就是错报;内部类是独立 class 文件,lambda 体是宿主类里的 synthetic 方法,跳过它丢覆盖,增强它又可能和宿主方法重复计行。
VerifyError:新增跳转目标却没有栈映射帧是最常见原因;用EXPAND_FRAMES读原始帧、COMPUTE_FRAMES重算,注意后者在类加载锁里加载类可能死锁。- 重入:增强器自己加载类时又触发 transform,要用 ThreadLocal 标记跳过,否则递归。
最小上手
片段一放进 premain 的 transformer(cr/cw 是 ClassReader/ClassWriter),片段二是运行时;省略 import 与异常处理,打包时 manifest 要写 Premain-Class 和 Can-Retransform-Classes: true。
java
// 1) 在每个行号标签(含方法第一条可执行行)后插 Cover.hit(line)
cr.accept(new ClassVisitor(Opcodes.ASM9, cw) {
public MethodVisitor visitMethod(int a, String n, String d, String s, String[] e) {
MethodVisitor mv = super.visitMethod(a, n, d, s, e);
return new MethodVisitor(Opcodes.ASM9, mv) {
public void visitLineNumber(int line, Label start) {
super.visitLineNumber(line, start);
mv.visitLdcInsn(line);
mv.visitMethodInsn(Opcodes.INVOKESTATIC, "Cover", "hit", "(I)V", false);
}
};
}
}, 0);
// 2) 行号即下标,退出时打印
public class Cover {
static final boolean[] HIT = new boolean[256];
public static void hit(int line) { HIT[line] = true; }
public static void dump() {
for (int i = 0; i < HIT.length; i++) if (HIT[i]) System.out.print(i + " ");
System.out.println();
}
}bash
javac -cp asm-9.7.jar -d out *.java && jar --create --file agent.jar --manifest MANIFEST.MF -C out .
java -javaagent:agent.jar -cp out Calc
# 3 8 9Calc 按注释行号排版:abs 的 if 在第 3 行、return x 在第 4 行,main 在第 8 行调用 abs(-1)、第 9 行调用 Cover.dump(),unused() 不调用;HIT 数组大小要大于最大行号(示例用 256)。启动后输出 3 8 9:第 4 行没走到,unused 完全没进,说明探针按行生效。再把 abs(-1) 注释掉重跑,输出只剩 8 9;javap -c -p -cp out Calc 能看到注入的 invokestatic Cover.hit。
生产动作
- 开关按环境隔离:测试环境默认开,生产默认关;开关在入口读一次,关闭时连上下文都不创建。
- 按 class id 归档:上报数据带 class id 和构建号,报告只与同一批 class 文件匹配,热部署后旧数据自动失效而不是错位。
- 可观测与保留策略:记录入队、上报成功、丢弃三类计数,丢弃持续大于 0 说明消费能力或网络有问题;CI 每次跑完合并 exec,按测试套件维度保留,不要让原始文件无限堆积。
- 区分流量:手工、自动化、定时任务、生产回放各打各的标,报告默认只看目标流量。
| 现象 | 证据 | 判断 |
|---|---|---|
| 某类覆盖全 0 | class id 与报告用的 class 文件不一致 | 编译版本不同或热部署过,重新对齐构建产物 |
| 行覆盖有、分支全空 | 字节码里没有跳转探针 | 只插了行号探针,或过滤跳过了条件指令 |
| 覆盖率异常高 | 入口标识与上下文归属 | 定时任务/生产流量被算进测试口径 |
启动报 VerifyError | 异常中的类与方法 | 新增跳转缺帧,改 COMPUTE_FRAMES 或去掉新目标 |
| 数据整段缺失 | 上报队列丢弃计数 | 队列满、网络失败或消费积压 |
边界
- 执行过不等于正确,高覆盖也不等于高质量:一行被跑到,断言可能没写、可能写错;只调 getter、只走 happy path 也能堆出高行覆盖,分支覆盖和断言质量要一起看。
- 异步与定时任务会污染口径:它们不属于某次测试,却会持续写覆盖;必须按入口标识隔离,否则数字只涨不跌。
- 分母依赖调试信息:
-g:none、混淆、AOP 生成类都可能让行号缺失或错位;这类代码只能给方法/类级结论。 - 动态生成的类可能采不到,覆盖也不能自动归因到用例:反射代理、字节码生成框架产生的类不一定经过你的 transformer;全局位图只能回答「有没有」,要回答「谁覆盖的」必须做请求级上下文,成本更高。
- 多版本数据不能合并:同一个类名不同字节码必须按 class id 分组,混合并会把不同代码的行号叠在一起。
讲清楚
覆盖率采集分两半:静态那一半从字节码的
LineNumberTable和跳转指令算出「有哪些行、哪些分支」,动态那一半靠增强插进去的探针,把执行过的位置写成布尔数组。行覆盖的分母是可执行行,不是源码总行数;分支覆盖的分母是条件跳转的两个方向和 switch 的每个 case。要按测试维度算,就得在请求入口建线程上下文、跨线程池传递、退出时合并上报,增量覆盖率再和 Git diff 求交。它只能证明代码被执行过,不能证明断言正确,异步任务和生产流量还会污染口径。