第 07 章(节选) 心智模型与诊断工具箱
本篇来自《Go 程序员的 Java 修炼之路》第 07 章「第 07 章 依赖地狱治理:Jar Hell、ClassLoader 与 Shade(NoSuchMethodError 的三种死法)」。 返回:第 07 章索引
07.1 先建立心智模型:Jar Hell 到底是什么
先给一个工作定义,别去背维基百科:
Jar Hell = classpath 上有多个版本的同一个类,或者缺少运行时需要的类,导致的运行时失败。
注意两个关键词:classpath(不是 pom)和运行时(不是编译期)。你的 pom.xml 写得再干净,只要最终进 classpath 的 jar 集合有问题,它就炸。
根本原因只有一条
翻回第 05 章最后那张因果链,把它压缩成一句话:
JVM 用「全限定名」寻址类,而全限定名里没有版本信息。
com.fasterxml.jackson.databind.ObjectMapper 这个字符串里,没有版本、没有”来自哪个 jar”、没有”由谁引入”。ClassLoader 拿到这个名字,沿着 classpath 顺序找,找到第一个就算数。所以:
- 两个版本的同名类可以在 classpath 里和平共处(物理上都存在)
- 但只有一个会被加载,另一个被屏蔽(shadow)
- 加载哪个,取决于 jar 在 classpath 里的物理顺序
而 Go 里,github.com/gin-gonic/gin v1.9.1 和 v1.8.0 是两条不同的模块路径记录,MVS 选一个,编译期写死,运行时连”找”这个动作都不存在。
| 维度 | Go | Java |
|---|---|---|
| 符号解析时机 | 编译期(链接器写死地址) | 运行时(ClassLoader 按名字查) |
| 寻址依据 | 模块路径 + 版本(唯一) | 全限定名(无版本) |
| 同名多版本能共存吗 | 能(编译期各走各的符号空间) | 不能(classpath 上只有第一个生效) |
| 缺符号的后果 | 编译失败,拿不到产物 | 能编译、能启动、能过测试,运行时才炸 |
| ”哪个版本在跑”怎么查 | go version -m ./binary,10 秒 | 解包 jar / -verbose:class / Arthas |
【思考】如果让你给 JVM 加版本支持,你会加在哪儿?为什么二十多年没加?
先别急着说”在 class 文件里加个版本号”。想三层:① 加在哪(class 文件?ClassLoader API?字节码里的符号引用?)② 加了之后老 class 文件怎么办 ③ 谁应该负责这件事。
参考答案
直接答案:技术上可行,加两处 —— class 文件的元数据里加”模块名 + 版本”,ClassLoader 的寻址键从”全限定名”变成”(模块, 版本, 全限定名)“。但 JVM 没这么做,三个理由每层都比”能不能实现”更硬。
第一层:向后兼容,这是死线。
class 文件格式本来就允许挂自定义属性(老 JVM 会忽略不认识的),所以”加元数据”技术上不难。难的是语义:寻址键从字符串变成三元组后,那些没有这个属性的老 class 文件,版本字段是 null、0 还是 UNVERSIONED?一个带版本的类去引用不带版本的类怎么解析?你立刻要发明一整套”版本缺失时的降级规则” —— 而这套规则本身就是新的 Jar Hell。
第二层:性能。类加载在热路径上。
现在是一次字符串哈希查找(每个 ClassLoader 一个字典 + 一层 SystemDictionary 共享缓存)。变成三元组后多一次比较、多一次哈希。而类加载在冷启动阶段是几十万次的操作(Spring Boot 启动要加载上万各类,JDK 自身几千个)。让所有人的启动变慢 5% 去解决一部分人的冲突问题,这个交易在 JCP 里通不过。
第三层(最本质):社区共识是”这该由构建工具解决”。
这是真正的原因。JVM 规范组二十年来的一贯立场是:运行时只提供机制(ClassLoader 是可插拔的),策略交给上层。需要版本隔离?自己写 ClassLoader(Tomcat 这么干了)。需要版本协商?用构建工具(Maven/Gradle 这么干了)。需要物理隔离?做重定位(Shade 这么干了)。
所以你看到的结果是:JVM 侧基本没动,生态侧长出了四套方案 —— BOM 统一版本、Shade 重定位、ClassLoader 隔离、构建期冲突检测(enforcer)。四套方案各解决一部分,互不统属,这就是 Jar Hell “治理”而不是”解决”的现状。
两条弯路值得知道:OSGi(2000 年代初)真的实现了”一个 JVM 里跑多个版本的同一个 bundle”,靠每个 bundle 一个 ClassLoader + 显式的 Import-Package/Export-Package 版本区间声明。技术上成功了,代价是每个 jar 都要写元数据、调试地狱、ClassNotFoundException 变得更多而不是更少 —— 它活在了 Eclipse 插件体系里,没能在应用开发领域普及。JPMS(Java 9,2017)见 07.3。
代码锚点 —— JVM 里”类的身份”到底是什么:
Class<?> a = loaderA.loadClass("com.example.User");
Class<?> b = loaderB.loadClass("com.example.User");
System.out.println(a == b); // false
System.out.println(a.getName()); // "com.example.User" —— 名字一模一样
System.out.println(a.getClassLoader()); // 这才是区分它们的唯一线索更深一层:这不是”JVM 设计得不好”的证据,是分层设计的必然结果。JVM 选了”运行时保持最小语义、把复杂度外推”,换来极长的生命周期和极强的可扩展性(Kotlin、Scala、Groovy 都能跑在上面,Arthas 能热插桩)。Go 把复杂度收在编译器里,换来确定性和简单。每一层的”没做”,都是在给另一层让出空间。
五种典型症状:什么情况会报哪个
这张表要精确到”什么情况会报哪个”。很多人分不清 NoClassDefFoundError 和 ClassNotFoundException,本质区别只有一句:前者是 JVM 在”用”的时候发现的,后者是你的代码在”找”的时候发现的。
| 异常 / 错误 | 谁抛的 | 精确触发条件 | 第一排查动作 |
|---|---|---|---|
ClassNotFoundException | Class.forName() / ClassLoader.loadClass() | 主动按名字去加载,classpath 里没有这个类。典型的:反射写错类名、provided 依赖没打进包、插件容器里没带这个 jar | Class.forName 的调用点在哪?classpath 里有没有这个 jar?scope 是不是 provided/test? |
NoClassDefFoundError(普通) | JVM 解析符号引用时 | 编译期有、运行时 classpath 里没有。典型:传递依赖被 exclusions 掉、scope 用错、war 包和容器的 classpath 不一样 | 对比”编译 classpath”和”运行时 classpath”的差集 |
NoClassDefFoundError: Could not initialize class X | JVM | 类曾经初始化失败(<clinit> 抛异常被标记 erroneous),后续所有使用直接抛,不会重试 | 翻日志最开头,找第一次的 ExceptionInInitializerError |
NoSuchMethodError / NoSuchFieldError | JVM | 类找到了,但里面没有这个签名 → 编译期和运行期用的不是同一版本 | dependency:tree -Dverbose + javap 验证签名 |
ClassCastException: A cannot be cast to A | JVM 的 checkcast 指令 | 全限定名相同,但来自两个不同的 ClassLoader | 打印两边的 getClassLoader() 和 getProtectionDomain().getCodeSource() |
第二条和第三条同名不同因,值得再说清楚:
// NoClassDefFoundError 的两种成因,看消息区分
java.lang.NoClassDefFoundError: com/example/Foo
// 没有 "Could not initialize" 前缀 → classpath 里没有这个类(或它的依赖链断了)
java.lang.NoClassDefFoundError: Could not initialize class com/example/Foo
// 有前缀 → 类在,但 <clinit> 炸过,被永久标记成 erroneous第三种是最坑的。 JVM 规范规定:一个类初始化失败后会被标记 erroneous,此后任何对它的初始化尝试都直接抛 NoSuchFieldError 之外的 NoClassDefFoundError,不再执行 <clinit>。所以真正的病根 —— 那个在 <clinit> 里抛的 ExceptionInInitializerError —— 只出现一次,而且可能在日志很早很早的地方。
真实场景长这样:
public class SslConfig {
static {
// 读证书文件,文件不存在 → RuntimeException
SSL_CTX = buildContext("/etc/certs/client.p12");
}
}
// 启动后第 1 次调用(比如服务启动时的一个预热任务):
// ExceptionInInitializerError: 静态初始化失败 ← 真正的根因,只报这一次
// 之后 200 个请求:
// NoClassDefFoundError: Could not initialize class SslConfig ← 你看到的满屏这个问题 1: 你在日志里搜 NoClassDefFoundError,搜到几百条,是不是应该先去搜 ExceptionInInitializerError?
是。看到 Could not initialize class,直接跳到日志最开头搜第一次出现的 ExceptionInInitializerError 或者 Caused by,那一行才是根因。Go 里没有这个机制 —— 包级变量初始化失败就是 panic,一次到位,没有”记一笔账后续换个错误”的设计。
07.2 诊断工具箱:三分钟定位一个依赖冲突
给你一套 SOP。按顺序走,不要跳步。
1. 读异常栈 → 锁定"哪个类的哪个方法"
2. mvn dependency:tree -Dverbose -Dincludes=... → 看有没有多版本
3. javap 验证签名(别急着改,先证明)
4. 运行时确认:这个类到底从哪个 jar 加载的
5. 修复 + 防复发(enforcer 卡 CI)
第 1 步:从异常栈读三件事
java.lang.NoSuchMethodError: org.apache.http.client.utils.URLEncodedUtils.parse
at io.github.openfeign.httpclient.ApacheHttpClient.execute(ApacheHttpClient.java:98)
- 异常类型 =
NoSuchMethodError→ 类在,方法签名不对 → 范围立刻缩小到”版本不对”,排除”依赖缺失” - 类名 =
org.apache.http.client.utils.URLEncodedUtils→ 冲突的是httpclient - 调用位置 = 谁在调 → 决定了”谁要新版本”
第 2 步:dependency:tree 的高级技巧
第 05 章讲过基础用法,这里讲三个进阶点。
-Dincludes / -Dexcludes 的通配符。 格式是 groupId:artifactId:type:classifier,每一段都支持 *:
# 精确到一个 artifact
mvn dependency:tree -Dverbose -Dincludes=org.apache.httpcomponents:httpclient
# 整个 groupId(同 group 下多个 artifact 版本错配时非常好用)
mvn dependency:tree -Dverbose -Dincludes=org.apache.httpcomponents
# 排除掉噪音(比如只看非 Spring 的)
mvn dependency:tree -Dverbose -Dexcludes=org.springframework*,com.fasterxml.jackson*
# 多条件逗号分隔
mvn dependency:tree -Dverbose -Dincludes=org.apache.*:http*,com.google.guava:guava识别两种标记(这是核心):
[INFO] +- com.thirdparty:pay-sdk:jar:2.3.1:compile
[INFO] | \- org.apache.httpcomponents:httpclient:jar:4.5.13:compile
[INFO] \- org.springframework.boot:spring-boot-starter-web:jar:2.7.18:compile
[INFO] \- (org.apache.httpcomponents:httpclient:jar:4.3.6:compile - omitted for conflict with 4.5.13)
| 标记 | 谁改的 | 含义 | 严重性 |
|---|---|---|---|
omitted for conflict with X | 仲裁规则 | 这个节点输了,不进 classpath | 高 —— 这就是冲突,要处理 |
version managed from X | dependencyManagement / BOM | 被主动覆盖成新版本 | 低 —— 治理生效了 |
一句话记法:“omitted” 是被动挨打,“managed” 是主动管控。
插件版本要锁。 maven-dependency-plugin 3.x 各版本对 verbose 的处理有差异(有的版本默认输出冲突、有的移除了参数)。发现 -Dverbose 没效果,用全限定形式锁版本:
mvn org.apache.maven.plugins:maven-dependency-plugin:3.6.1:tree -Dverbose第 3 步:dependency:analyze、effective-pom、list-repositories
mvn dependency:analyze[WARNING] Used undeclared dependencies found:
[WARNING] org.apache.commons:commons-lang3:jar:3.12.0:compile
[WARNING] Unused declared dependencies found:
[WARNING] com.google.guava:guava:jar:31.1-jre:compile
| 警告 | 含义 | 处理原则 |
|---|---|---|
| Used undeclared(用了没声明) | 靠传递依赖进来的,你在代码里直接用了 | 必须修 —— 上游哪天去掉这个传递依赖,你的代码就编译不过。这也是 07.7 案例的教训之一 |
| Unused declared(声明了没用) | 字节码里没搜到直接引用 | 谨慎删 —— 三类误报:① 反射加载(只有字符串,没有符号引用)② 注解处理器(Lombok、MapStruct)③ SPI 实现(META-INF/services 里声明的类) |
误报率有多高? 在用了 Spring + MyBatis + Lombok 的项目里,Unused declared 至少有三分之一不能删。判断方法:想删之前先全局搜一遍这个 artifact 的包名前缀,看有没有出现在字符串或者配置文件里。
mvn help:effective-pom # 排查"我的配置被谁覆盖了"的核武器
mvn dependency:list-repositories # 看依赖都从哪个仓库来(排查"为什么拉到奇怪的版本")help:effective-pom 输出的是 Maven 最终看到的 pom:父 pom 继承下来的、BOM 展开后的、profile 激活的、插件默认配置。搞不清”一个没写版本号的依赖最终版本从哪来”时,它就是答案。
第 4 步:从类名反查属于哪个 jar
这是最实用的一招,也是 IDE 帮不了你的地方(IDE 的 Go to Class 只能告诉你它能找到的那个)。
# 在本地仓库里找出所有含某个类的 jar(可能不止一个!)
find ~/.m2/repository -name "*.jar" | while read j; do
unzip -l "$j" 2>/dev/null | grep -q "com/fasterxml/jackson/databind/ObjectMapper.class" && echo "$j"
done
# 在打包产物里找(这才是真正会进 classpath 的集合)
find target/ -name "*.jar" | while read j; do
unzip -l "$j" 2>/dev/null | grep "ObjectMapper.class";
done更好的工具:
| 工具 | 命令 | 特点 |
|---|---|---|
jdeps | jdeps -verbose:class --class-path 'libs/*' app.jar | JDK 自带,能报出找不到的依赖(... not found),适合查”缺了什么” |
jarscan | JBoss 系的小工具,按类名扫整个目录 | 专治”这个类在哪个 jar”,输出带 jar 路径 |
| IDEA | 双击 Shift 勾上 “Include non-project items”,或者对 jar 目录 Find in Files | 最省事,但只能搜你挂载的目录 |
第 5 步:运行时看 classpath 到底有什么
静态分析只能告诉你”应该是什么”,运行时才能告诉你”实际是什么”。这一步不能省。
# JDK 8 风格(老项目、老教程里全是这个)
java -verbose:class -jar app.jar | grep jackson
# JDK 9+ 统一日志(推荐,输出更规整)
java -Xlog:class+load=info -jar app.jar | grep ObjectMapper输出长这样:
# JDK 8
[Loaded com.fasterxml.jackson.databind.ObjectMapper from jar:file:/app/libs/jackson-databind-2.9.0.jar!/]
# JDK 9+
[0.412s][info][class,load] com.fasterxml.jackson.databind.ObjectMapper source: jar:file:/app/libs/jackson-databind-2.9.0.jar!/
这行输出是排 Jar Hell 的终极武器 —— 它直接告诉你”这个类物理上来自哪个文件”,绕过一切推理。
代码里也能拿到同样的信息,适合写在启动检查里:
// 打印一个类的物理来源(排 Jar Hell 最有用的一行代码)
System.out.println(ObjectMapper.class
.getProtectionDomain()
.getCodeSource()
.getLocation());
// file:/app/libs/jackson-databind-2.9.0.jar
// 打印完整 classpath
System.out.println(System.getProperty("java.class.path"));不想改代码、不想重启?用 Arthas:
sc -d com.fasterxml.jackson.databind.ObjectMapper
# class-info com.fasterxml.jackson.databind.ObjectMapper
# code-source /app/libs/jackson-databind-2.9.0.jar
# class-loader +-org.springframework.boot.loader.LaunchedURLClassLoader@1a2b3c
# +-jdk.internal.loader.ClassLoaders$AppClassLoader@2d3e4fsc(search class)加 -d 会同时给出 code-source(来自哪个 jar) 和 class-loader 链(被谁加载的)。这两条信息加起来,Jar Hell 基本上就破案了。Spring Boot 应用还可以看 /actuator/beans(第 00 章提过)辅助判断。
Go ↔ Java 诊断工具对照(第 2 处对照)
| 需求 | Go | Java |
|---|---|---|
| 依赖树 | go mod graph | mvn dependency:tree -Dverbose |
| 为什么依赖它 | go mod why -m <mod> | 靠 -Dincludes 看父节点(没有直接的 “why”) |
| 最终版本清单 | go list -m all | mvn dependency:list |
| 二进制里实际编了什么 | go version -m ./binary(10 秒出结果) | 无内建;要 -verbose:class 或解包 jar |
| 缺什么依赖 | 编译期直接报 | jdeps / dependency:analyze |
| 运行时查来源 | 不需要(编译期定死) | -verbose:class / sc -d / getCodeSource() |
这张表里最扎心的一行是”二进制里实际编了什么”。 线上出事故时,Go 工程师花 10 秒确认版本;Java 工程师要登机器、找 jar、解压、看 MANIFEST —— 而那个 MANIFEST 里还不一定有版本信息。
【思考】
-verbose:class输出[Loaded com.fasterxml.jackson.databind.ObjectMapper from jar:file:/.../jackson-databind-2.9.0.jar!/],你怎么利用这个信息定位是哪个依赖引入了 2.9.0?提示:jar 路径 → 坐标 → 回依赖树找父节点。中间的第二步你打算怎么做?如果那个 jar 不来自本地仓库(比如是运维手工塞进去的)呢?
参考答案
直接答案:三段跳 —— jar 路径 → artifact 坐标 → 回 dependency:tree 找它的父节点。
第一段:路径 → 坐标。 第 05.2 节讲过这个映射,反过来用即可:
~/.m2/repository/com/fasterxml/jackson/core/jackson-databind/2.9.0/jackson-databind-2.9.0.jar
└─ groupId(点→斜杠) ─┘ └artifactId┘ └ver┘
↓ com.fasterxml.jackson.core:jackson-databind:2.9.0
第二段:坐标 → 父节点。
mvn dependency:tree -Dverbose -Dincludes=com.fasterxml.jackson.core:jackson-databind[INFO] +- com.thirdparty:report-sdk:jar:1.4.0:compile
[INFO] | \- com.fasterxml.jackson.core:jackson-databind:jar:2.9.0:compile ← 找到了,父节点是 report-sdk
[INFO] \- org.springframework.boot:spring-boot-starter-json:jar:2.7.18:compile
[INFO] \- (com.fasterxml.jackson.core:jackson-databind:jar:2.15.3:compile - omitted for conflict with 2.9.0)
父节点就是”凶手” —— report-sdk 在深度 2,比 starter-json 的深度 3 短,nearest wins 让它赢。
第三段(最关键的追问):如果 tree 里根本没有 2.9.0 呢?
这才是真正的坑。三种可能:
① jar 不来自 Maven。 运维手工塞的、构建脚本从别处拷的、-cp 里额外加的、容器基础镜像里带的。这时 dependency:tree 干干净净,但 classpath 里就是有 2.9.0。查法:java -Xlog:class+load=info -jar app.jar | grep jackson 看 source: 的完整路径,或用 jcmd <pid> VM.system_properties | grep java.class.path 看运行中进程的真实 classpath。
② 打包阶段的二次加工。 Shade 打平、Docker 分层、运维往 lib/ 里”补”一个 jar。Spring Boot fat jar 的 BOOT-INF/lib/ 由打包插件生成,跟 dependency:tree 并不总是一致。永远以运行时 classpath 为准,不要只信 dependency:tree。
③ scope 不是 compile。 provided/runtime 的 jar 不进编译 classpath,但进运行时 classpath —— 而炸的正是运行时。
代码锚点 —— 一个可复用的诊断片段:
// 启动时打印所有关键类的来源,上线前跑一次留档
for (Class<?> c : new Class<?>[]{
com.fasterxml.jackson.databind.ObjectMapper.class,
org.apache.http.client.utils.URLEncodedUtils.class,
com.google.common.base.Preconditions.class}) {
System.out.printf("%-60s <- %s%n", c.getName(),
c.getProtectionDomain().getCodeSource().getLocation());
}更深一层:这个”jar 路径 → 坐标 → 父节点”的三段跳,本质上是在重建一条被运行时丢失的信息。Go 在 go version -m 里保留了这条信息(模块路径 + 版本 + hash 全在二进制里),Java 在运行时把它丢了(class 文件里只有类名),于是你只能靠”物理文件路径”反推。
这就是”运行时寻址”的隐性成本之一:信息在编译后被丢弃,排查时要用额外的工具和额外的步骤去重建它。