同一份 class 文件,为什么得到两种结果

商品目录导入允许后来的记录覆盖同一商品编号的旧值。实现使用 ImmutableMap.Builder.buildKeepingLast,在开发环境编译和测试通过。部署包却携带更早的 Guava,第一次执行导入时出现 NoSuchMethodError。此时重新检查 Java 源文件没有意义:运行进程执行的是已编译的 class 文件,方法是否存在由实际加载的类决定。

本篇用一份探针字节码复现这个边界。编译依赖固定 Guava 33.5.0-jre;运行时分别只提供 33.5.0-jre 和 30.1.1-jre。正常项目的依赖不降级,旧 jar 仅进入独立子进程。两个进程都打印实际 CodeSource,并分别断言成功结果和链接错误。完整测试、复现命令附在文末资源中。

这组方向必须写清楚:新 API 编译出来的程序运行在旧库上失败,并不能推出“把旧库升级成新库必然不兼容”。源兼容、二进制兼容和运行语义是三个问题。前一篇测试方法论见 第 35 篇,本篇把断言移动到真实进程边界。

编译器接受的是哪一份契约

buildKeepingLast 的业务含义是重复键保留最后一个值。固定 33.5.0 源码在方法注释中标记 since 31.1,方法体调用 build(false)。相邻的 buildOrThrow 调用 build(true),两者对重复键的处理不同。官方 31.1 发布记录也把 buildKeepingLast 列为新增 API;这与源码吻合,但两份材料都由同一上游维护,不能计作两次独立实验。31.1 发布记录、固定新版源码。

旧版 v30.1.1 固定提交为 43a53bc0328c7efd1da152f3b56b1fd241c88d4c。其 Builder 提供 build,公开注释约定重复键抛 IllegalArgumentException,没有 buildKeepingLast。旧版 build 的实现最终进入 RegularImmutableMap.fromEntryArray 等分支。因此,删掉方法名后缀换成 build 虽可能消除对新增符号的引用,却把“覆盖旧记录”改成了“拒绝重复记录”。链接问题解决后,业务契约仍然可能错误。固定旧版源码。

新版源码还约定默认按键第一次出现的顺序迭代,而重复键的值取最后一次提供的值。这两条规则可以同时成立。对于 A=1、B=9、A=2,结果顺序仍为 A、B,值分别为 2、9;它不是按最后更新时间排序。上游测试 testBuildKeepingLast_allowsOverwrite 检查覆盖,shortTable 测试以 LinkedHashMap 构建预期并检查顺序。该阅读结果属于 SOURCE,本篇两个进程只使用 A=1、A=2,不能把本地覆盖范围夸大为全部顺序和哈希冲突场景。

链接错误发生在哪里

探针的关键调用完整写成下面这个可独立编译的类;附件将同样主体放进测试的静态嵌套类,以便由测试启动真实子进程。

1
2
3
4
5
6
7
8
9
10
import com.google.common.collect.ImmutableMap;

public final class UpgradeProbe {
public static void main(String[] args) {
System.out.println("loadedFrom=" + ImmutableMap.class
.getProtectionDomain().getCodeSource().getLocation());
System.out.println("value=" + ImmutableMap.<String, Integer>builder()
.put("A", 1).put("A", 2).buildKeepingLast().get("A"));
}
}

javap -c 的输出保留了对 ImmutableMap$Builder.buildKeepingLast 的 invokevirtual 调用。方法描述符包含参数和返回类型;这里没有参数,返回 ImmutableMap。运行时解析这个符号引用时,如果不能找到匹配方法,JVM 抛出 NoSuchMethodError。该错误属于链接错误,不能用捕获普通业务异常并返回空目录的方式隐藏。JVMS 8 方法解析。

本次旧进程已经打印 loadedFrom,说明类并非完全不存在。随后 stderr 指向 buildKeepingLast,符合“类已找到、目标方法不能解析”的观察。它与 ClassNotFoundException 的诊断方向不同;也不同于调用真实存在的方法后由于重复键抛出的 IllegalArgumentException。先根据错误类型定位边界,才能决定检查 classpath、方法描述符还是业务输入。

不能只看 Maven 的依赖树就宣布实际运行加载了某个 jar。依赖树描述构建解析结果,容器共享库、启动脚本、应用打包和自定义类加载器都可能改变运行来源。本篇不模拟这些部署系统,只通过两个最小 classpath 把差异控制到一个变量,并保存实际 CodeSource。运行来源的路径不等于内容身份,因此再对两个 jar 计算 SHA-256。

两个有界子进程的实测

测试从当前 JVM 的 java.home 定位 java 可执行文件,并从测试类 CodeSource 定位编译输出。每个子进程 classpath 只有测试类目录和选定的一个 Guava jar。它不复用 Surefire 的完整依赖列表,不依赖两个版本在同一 classpath 中的排列顺序,也不使用 Mockito 模拟第三方缺失方法。

每个进程最多等待二十秒,stdout、stderr 重定向到不同文件;结束后保存退出码和实际启动参数,未结束的进程强制终止。正常分支必须退出零且输出 value=2,旧版分支必须退出一且 stderr 包含 NoSuchMethodError 与方法名。旧版的非零退出是预期观察,外层 JUnit 断言成立后,整个测试套件仍应成功。

运行环境 编译依赖 子进程运行依赖 二进制观察
JDK 8 33.5.0-jre 33.5.0-jre 退出 0,value=2
JDK 8 33.5.0-jre 30.1.1-jre 退出 1,NoSuchMethodError
JDK 21 33.5.0-jre 33.5.0-jre 退出 0,value=2
JDK 21 33.5.0-jre 30.1.1-jre 退出 1,NoSuchMethodError

两套 JDK 都执行 clean verify,避免把另一套运行留下的测试报告当成本轮结果。每轮两个测试,没有跳过。字节码检查则直接对新旧 jar 分别执行 javap,旧版公开方法列表没有该方法,新版存在;探针反汇编同时证明调用点确实引用新增方法,而非测试代码手工抛出了同名错误。

首轮失败发生在证据断言:macOS 将 /tmp 规范化为 /private/tmp,实际加载正确,但字符串路径比较不相等。修复采用 toRealPath 后重新执行完整两组测试。这个差异说明测试也需要验证自身假设;修复不能删去来源断言,否则“成功加载了哪份库”的关键证据会消失。

本地日志只能证明这些输入、版本、JDK 和 classpath 的行为。它没有覆盖 OSGi、应用服务器、Android、模块路径和所有 Guava API;也不是吞吐量实验。把环境条件写进证据,比从四个进程推导跨平台保证更有价值。

迁移时分开检查身份与语义

第一个可迁移模式是“编译身份与运行身份双记录”。在迁移检查中同时记录编译依赖版本、构建解析树、部署产物内容、关键类 CodeSource 和二进制摘要。出现链接错误时先核对运行身份,再检查新增、删除或改变描述符的符号。若编译使用新版而部署仍装载旧版,修改业务分支不能修正部署事实。

第二个模式是“链接检查与契约检查分层”。链接探针用很少的输入确认调用可达;契约测试再覆盖 null、重复、顺序、异常、快照和资源生命周期。目录导入至少需要保留重复编号覆盖策略,并明确何时拒绝坏记录。单纯让程序启动成功,无法证明被覆盖的商品详情、导入计数和失败回滚仍满足要求。

若确实必须支持旧运行库,可以先在 LinkedHashMap 中完成覆盖,再构建不可变结果;这只是候选实现。采用之前仍需比较 null 拒绝时机、键顺序、输入遍历次数和中间内存。本篇没有把该候选实现作为已验证迁移结果。另一个选择是放弃最后值覆盖,改为重复即失败,但那需要业务契约明确变化,而不能借依赖升级悄悄发生。

发布回滚也要按产物配对。将新应用 jar 与旧依赖混合,可能正好形成实验中的失败组合。记录应用制品摘要及依赖清单,回滚经过验证的一整套产物,比只改版本号更容易复现。对于由容器提供共享依赖的系统,还应把容器版本纳入这套配对关系;本篇未运行容器演练,将其列为后续验收项。

从一个失败样本形成可执行的验收

若排查对象是一份真实部署包,先复制其启动参数与依赖清单,在隔离环境重现错误,再改变一个变量。直接清空本地缓存、同时升级多项依赖、修改业务代码后重新打包,即使错误消失,也很难确认究竟修复了什么。本篇始终复用同一探针字节码,两个分支只替换运行库,因此“方法存在与否”能够得到明确解释。

还需要防止假阴性:如果测试从未执行新增方法,进程正常启动也可能看不见问题。目录导入属于按需调用的路径,健康检查可能只创建服务对象,并未触及该符号。验收用例应明确走到新增调用点,而不是仅检查主进程存活。探针先打印来源再调用目标方法,两个观察点分别回答来源正确与链接可达,缺少任何一个都会削弱诊断。

假阳性则可能来自不相关的启动失败。例如旧进程因找不到主类而退出一,若断言只检查非零退出码,就会误判为成功复现。测试因此同时检查方法名、错误类型及标准输出,并排除 ClassNotFoundException。将标准错误保存为独立文件,也便于发现启动参数错误或未来运行环境新增的诊断。

二进制兼容检查工具可以扩大扫描范围,但扫描结果仍需结合调用方向理解。新增方法通常不要求旧程序立即修改;调用新增方法的新程序却要求运行库包含这个方法。审阅升级报告时应先写明“哪个版本编译的调用者,对哪个版本运行库”,再讨论是否兼容,避免把方向相反的两个问题合并成一个结论。

安全公告是另一条证据线

Guava 官方 32.0.0 发布说明记录了 Files.createTempDir 与 FileBackedOutputStream 的安全修复,涉及 CVE-2020-8908 和 CVE-2023-2976,并提示该版本在 Windows 有后续修复。这些说明需要按实际调用、版本范围和运行平台核验,不能用 ImmutableMap 探针替代安全评估。官方 32.0.0 安全修复说明。

本篇保留旧 jar 只是为了隔离复现方法解析失败。探针只构造内存中的 ImmutableMap,没有调用上述 IO API,也没有证明旧版适合生产使用。安全扫描通过、源码编译成功和二进制链接成功分别回答不同问题;任意一项成功都不能代替另外两项。

迁移检查速查

要回答的问题 最小材料 不足以证明它的材料
编译时是否能调用 固定依赖下实际编译 IDE 自动补全
部署是否加载目标库 实际 CodeSource 与产物摘要 仅 pom.xml
方法是否存在 指定 jar 的 javap、固定源码 方法名相似
新旧语义是否满足业务 真实输入的契约断言 进程退出零
安全修复是否适用 上游公告与实际版本、调用面 本篇集合探针通过

手算题:相同探针依赖新版编译完成后,仅把运行 jar 换成旧版,错误会发生在 javac、加载 ImmutableMap 之前,还是解析 buildKeepingLast 时?根据已打印的 loadedFrom 与 javap 调用点解释答案,不要只背错误名称。

改动练习:把输入扩展为 A=1、B=9、A=2,给正常分支增加顺序与值断言;再设计一个仅能运行旧 API 的替代实现,并添加 null 输入与重复策略的对照。保存新一轮日志,把 SOURCE 推导转成 LAB 证据。不要为了让预期红条消失而把错误进程的退出码改为零。

复现与证据范围

运行说明提供旧 jar 的官方下载命令、摘要、双 JDK 入口和 javap 命令;测试源文件与工程内容一致。仓库 examples/java-libraries/evidence/38 保存每个 fork 的完整参数、标准输出、标准错误和退出码,以及两套 Surefire XML。

DOC 记录上游公开 API、发布说明与 JVM 解析规则;SOURCE 记录两个固定提交与上游测试;LAB 记录两个 JDK 的四个真实进程。候选替代实现、实际部署容器和其他平台属于 NOT_RUN。证据账本保留这些边界,升级决定应以目标应用补齐后的材料为依据。