第 07 章(节选) 核心结论与深度思考题

来自第 07 章「第 07 章 依赖地狱治理:Jar Hell、ClassLoader 与 Shade(NoSuchMethodError 的三种死法)」。 返回:第 07 章索引

07.8 本章核心结论

  1. Jar Hell 的根本原因只有一条:JVM 用全限定名寻址类,而全限定名里没有版本信息。 其余全是这条的推论。

  2. ClassNotFoundException 是”主动去找没找到”,NoClassDefFoundError 是”用的时候发现不在/不可用”。 看到 Could not initialize class X,去日志最开头找第一次的 ExceptionInInitializerError —— 真正的根因只报一次。

  3. 诊断 SOP 五步:读栈锁定类 → dependency:tree -Dverbose 找冲突 → javap 验证签名 → 运行时确认加载来源 → 修复 + enforcer 防复发。 omitted for conflict with 是冲突的铁证,version managed from 是治理生效。

  4. 类的唯一性由「加载器 + 全限定名」决定,这是所有隔离技术的基础。 ClassCastException: A cannot be cast to A 的唯二成因就是这个(另一个是 provided 打进了 war)。

  5. JPMS 解决封装,不解决版本冲突 —— 模块路径上一个模块名只能出现一次。Go 用”路径里带 /v2”这个命名层的土办法,反而更实用。

  6. Shade 是语法层的重命名,不是语义层的隔离。 它能改常量池里的结构化符号引用,改不了字符串字面量 —— 所以反射、Class.forName、配置文件里的类名、序列化、SPI 文件全是它的盲区。

  7. Spring Boot 选”嵌套 jar + 自定义 ClassLoader”而不是 shade,因为保留结构 = 保留信息:反射可用、SPI 不覆盖、能定位版本、能做 Docker 分层构建。

  8. 治理的核心动作是把冲突从运行时提前到构建期。 dependencyConvergence + banDuplicateClasses 卡 CI,比任何线上救火都便宜。

  9. 显式声明你直接用到的所有依赖(直接使用原则),别指望传递依赖永远在。

  10. Java 的强向后兼容承诺降低了单次升级成本,却取消了升级的紧迫性,让技术债以”版本落后”的形式累积。 这是”依赖升级痛苦”最反直觉的一条成因。


07.9 深度思考题

题 1:为什么 NoClassDefFoundError 比 ClassNotFoundException 更难排查?给一套完整 SOP。

参考答案

直接答案:三个原因 —— 错误信息里没有”谁在找”、抛出点和根因点分离、以及”曾经成功过”带来的错误预期。

原因一:ClassNotFoundException 的栈里有”凶手”,NoClassDefFoundError 的栈里只有”受害者”。

java.lang.ClassNotFoundException: com.mysql.cj.jdbc.Driver
    at java.net.URLClassLoader.findClass(URLClassLoader.java:382)
    at java.lang.ClassLoader.loadClass(ClassLoader.java:424)
    at java.lang.Class.forName0(Native Method)
    at java.lang.Class.forName(Class.forName.java:348)
    at com.example.MyDataSource.init(MyDataSource.java:23)   ← 谁在找,清清楚楚
java.lang.NoClassDefFoundError: Could not initialize class com.example.SslConfig
    at com.example.PayClient.sign(PayClient.java:88)          ← 这只是"用到了它的人"
    at com.example.PayService.pay(PayService.java:62)

后者没有告诉你 SslConfig 的 <clinit> 里到底发生了什么。栈顶是第一个受害者,不是凶手。

原因二:抛出点和根因点可能相隔几千行日志、甚至几小时。

ExceptionInInitializerError(真凶)只在第一次初始化时抛一次。之后所有使用都是 NoClassDefFoundError。如果你的服务在启动时就预热了这个类,那真凶在启动日志的第 200 行,而满屏的错误在三小时后的业务日志里。

原因三(心理层面):它叫 “NoClassDefFound”,你会去找”类是不是缺失”,而真相可能是”类在但初始化失败”。 名字本身有误导性。

SOP(按顺序,不要跳):

# Step 1:确认是哪种 NoClassDefFoundError —— 看消息里有没有 "Could not initialize"
#   有   → 跳到 Step 2(找初始化失败的根因)
#   没有 → 跳到 Step 4(classpath 缺失或依赖链断裂)
 
# Step 2:翻日志最开头,找第一次的 ExceptionInInitializerError 或 Caused by
grep -n "ExceptionInInitializerError" app.log | head -5
grep -n -A 30 "ExceptionInInitializerError" app.log | head -60   # 看它的 Caused by
 
# Step 3:如果日志被滚动删了,在本地复现(去掉 try-catch,让异常直接打出来)
#   或者加 -XX:+TraceClassInitialization(JDK 8)/ -Xlog:class+init=info(JDK 9+)
 
# Step 4:确认类是否真的不在 classpath
find target/ -name "*.jar" | while read j; do unzip -l "$j" | grep "com/example/SslConfig.class"; done
java -Xlog:class+load=info -jar app.jar 2>&1 | grep SslConfig
 
# Step 5:检查 scope —— provided / test 的依赖不打进产物
mvn dependency:tree -Dincludes=com.example:ssl-config
#   输出 ssl-config:jar:1.0.0:provided  ← 找到了
 
# Step 6:检查依赖链断裂 —— 类在,但它继承/引用的另一个类不在
#   javap 看它的父类和接口,逐个确认
javap -c -p target/classes/com/example/SslConfig.class | grep -E "class|interface"

代码锚点 —— 把”初始化失败”变成可诊断的:

// 错误:异常被吞掉,只留下一个永久 erroneous 的类
public class SslConfig {
    static {
        try {
            SSL_CTX = buildContext("/etc/certs/client.p12");
        } catch (Exception e) {
            log.warn("init failed", e);   // ← 只打日志,类仍被标记 erroneous
        }
    }
}
 
// 正确:初始化失败就让它失败(快速失败),或者延迟到首次使用时按需初始化
public class SslConfig {
    private static volatile SSLContext ctx;   // 懒加载,失败可重试
    public static SSLContext get() {
        if (ctx == null) {
            synchronized (SslConfig.class) {
                if (ctx == null) ctx = buildContext("/etc/certs/client.p12");
            }
        }
        return ctx;
    }
}

更深一层:NoClassDefFoundError 的难,本质上是**“状态”带来的难**。ClassNotFoundException 是一次性事件(去找,没找到);NoClassDefFoundError: Could not initialize 是JVM 记住了一个历史状态(这个类坏过),然后用同一个错误信息回应所有后续查询。

JVM 这么设计是有道理的 —— 初始化失败的类状态不确定,重试可能导致更诡异的行为,不如一次失败永久拒绝。但代价是诊断信息在时间和空间上被分离了。这是”fail fast”和”diagnosable”之间的一次取舍:JVM 选了最安全的 fail fast,把诊断的麻烦留给了你。

Go 里没有这个问题,因为包级变量初始化失败就是 panic,一次到位,栈就在那里。Go 没有”类的初始化状态机”这个概念 —— 又一次,状态越少,诊断越简单。


题 2:一个类被两个 ClassLoader 加载,instanceof 会怎样?强转呢?静态字段呢?

参考答案

直接答案:instanceof 返回 false,强转抛 ClassCastException,静态字段是两份独立的副本。三者的原因都是同一个:JVM 眼里这不是一个类,是两个。

代码锚点:

// 两个加载器各加载一份 com.example.User
ClassLoader l1 = new MyLoader("path/to/v1");
ClassLoader l2 = new MyLoader("path/to/v2");
 
Class<?> c1 = l1.loadClass("com.example.User");
Class<?> c2 = l2.loadClass("com.example.User");
 
Object u1 = c1.getDeclaredConstructor().newInstance();   // v1 的实例
Object u2 = c2.getDeclaredConstructor().newInstance();   // v2 的实例
 
// 1. instanceof
System.out.println(c1.isInstance(u2));        // false
System.out.println(c1.isAssignableFrom(c2));  // false
System.out.println(c1 == c2);                 // false
 
// 2. 强转(用反射模拟,编译期写不出这个转换)
try {
    c1.cast(u2);
} catch (ClassCastException e) {
    System.out.println(e.getMessage());
    // com.example.User cannot be cast to com.example.User   ← 名字一模一样!
}
 
// 3. 静态字段
Field f1 = c1.getDeclaredField("COUNT");
Field f2 = c2.getDeclaredField("COUNT");
f1.setAccessible(true);
f1.setInt(null, 100);
System.out.println(f2.getInt(null));   // 0 —— 完全独立,互不影响

这三条的底层原理:

行为判断依据为什么跨加载器就不成立
instanceof / isInstance检查对象的 Klass 指针是否是目标类的同一个 Klass,或者其子类两个 Klass 是元空间里两个独立对象,没有继承关系
checkcast(强转)同上,失败抛 ClassCastException同上
静态字段JVM 规范:静态字段存储在**类对应的镜像类(java.lang.Class 实例)**里,每个被加载的类有一份两个 Class 对象,两片存储
单例(static final INSTANCE)静态字段两个”单例” —— 这是最常踩的坑

这条知识最有价值的实践场景:Tomcat 热部署后的内存泄漏。

WebAppClassLoader@1a2b3c(旧)→ 加载了 3847 个类 + 若干单例对象
WebAppClassLoader@4d5e6f(新)→ 又加载 3847 个类 + 若干新单例

旧的那个加载器如果没有任何外部引用,应该被 GC 回收,它加载的类才会被卸载。但只要有一处引用漏在外面(最常见三种):

  1. JDBC 驱动注册进了 DriverManager(DriverManager 在 Bootstrap,生命周期跟 JVM 一样)
  2. ThreadLocal 里存了 WebApp 的类(线程来自 Tomcat 的线程池,永生)
  3. JMX / 日志框架的静态注册表持有 WebApp 类的引用

……整个旧加载器和它的 3847 个类就全部无法卸载。反复部署十次,元空间满,OutOfMemoryError: Metaspace。

排查方法(第 04 章讲过,这里串起来):

jcmd <pid> VM.classloader_stats      # 看各加载器加载了多少类,有没有孤儿加载器
jmap -histo:live <pid> | grep WebAppClassLoader   # 有几个 WebAppClassLoader 实例?
# 如果部署了 3 次却看到 3 个 WebAppClassLoader 还活着 → 泄漏确认

更深一层:ClassCastException: A cannot be cast to A 是”类 = 加载器 + 全限定名”这条规则最刺眼的显形。

它刺眼是因为错误信息自我矛盾 —— 类型名完全相同却转不过去。但正是这个矛盾,逼你去理解 JVM 的类型模型不是”按名字”的,是”按身份”的。

对照 Go:Go 里类型就是编译期的符号,全局唯一,没有”身份”和”名字”的区分。 所以 Go 里不存在 cannot be cast to itself,也不存在”两个单例”。代价是 Go 没有热部署、没有插件化、Tomcat 那种”一个 JVM 跑十个应用”的模式在 Go 里从第一天起就不可能。

又一次同一个取舍:Java 用”身份复杂性”换了”运行时灵活性”。


题 3:Shade 能解决所有冲突吗?它在什么情况下会失效?给一个完整清单。

参考答案

直接答案:不能。Shade 只改写字节码里”编译器能静态确定”的符号引用,凡是”运行时才决定类名”的机制,它都管不着。

失效清单(按踩坑频率排序):

#场景为什么失效症状
1反射加载 Class.forName("com.foo.Bar")字符串字面量在 CONSTANT_String 里,Shade 无法判断它是不是类名ClassNotFoundException,只在走到那条分支时
2配置文件里的类名字符串在 jar 外部,Shade 根本看不到启动或按需加载时失败
3SPI 文件 META-INF/services/*资源文件内容默认不重定位No implementations found,07.7 案例二
4Java 序列化类名被写进字节流,还有 serialVersionUIDClassNotFoundException / InvalidClassException,跨进程调用时炸
5JNI FindClass("com/foo/Bar")native 代码里的字符串,Shade 不会碰UnsatisfiedLinkError 或 native 层崩溃
6注解里的 Class<?> 值注解属性是 CONSTANT_Class,理论上能改;但注解处理器生成的元数据 / .kotlin_module / Lombok 生成物可能不一致框架读不到配置,静默失效
7跨进程 RPC 的类名约定对端不知道你改了名Dubbo/gRPC 的反序列化失败,Xxx cannot be cast to Xxx
8动态代理生成的类名$Proxy0、$$EnhancerBySpringCGLIB$$ 生成时用的是运行时的类名名字变长变乱,且可能超出文件系统/常量池限制
9同名资源覆盖打平时只有一个文件能活07.7 案例二:spring.factories
10签名 jar类内容变了 → 签名失效SecurityException: invalid signature file digest
11<minimizeJar>静态可达性分析删掉反射用到的类运行时 ClassNotFoundException,且难以复现

代码锚点 —— 验证一个库能不能安全 shade:

# 1. 扫库里的反射调用(Class.forName 的字符串参数)
javap -c -p -constants some-lib.jar \
  | grep -B2 "String com\.\|String org\." | head -40
 
# 2. 扫 SPI 文件
unzip -l some-lib.jar | grep "META-INF/services"
 
# 3. 扫配置文件里硬编码的类名
unzip -p some-lib.jar "*.properties" "*.xml" 2>/dev/null | grep -o "com\.[a-z.]*[A-Z][A-Za-z]*"
 
# 4. 扫 JNI
unzip -l some-lib.jar | grep -E "\.so$|\.dll$|\.dylib$"
 
# 5. shade 之后复查:jar 里还有没有老包路径
jar tf target/app.jar | grep "com/google/common"   # 应该为空

决策清单(要不要对一个依赖做 shade):

依赖里有没有 Class.forName / ServiceLoader / 序列化 / JNI / 动态代理?
├─ 有 → 不要 shade。改用 ClassLoader 隔离,或者统一版本
└─ 没有 → 继续问:
   它是纯计算型库(Guava、commons-lang3、Netty buffer)吗?
   ├─ 是 → 可以 shade,风险低
   └─ 不是(框架类:Spring、Hibernate、Jackson databind)→ 谨慎,先做全量回归

更深一层:这份失效清单的共同点是什么?

它们全都是”运行时才知道名字”的场景。 反射是运行时才知道,配置文件是运行时才读,序列化是运行时才解析,JNI 是运行时才查找。

而 Shade 是一个编译期/构建期的工具。 它只能处理构建时可见的信息。

所以问题的本质不是”Shade 实现得不够好”,而是”用一个构建期的工具去解决一个运行期的问题,必然存在能力边界”。 这个边界可以缩小(比如改进字符串分析),但不可能消除 —— 因为停机问题决定了你无法在编译期知道一个运行时字符串的值。

对照 Go:Go 的静态链接没有这个边界,因为它不做重命名 —— 两个版本的模块路径本来就不同(/v2),编译期和运行时看到的是同一套名字,不存在”改了名导致对不上”的问题。

这提示了一个更普适的判断:任何”事后重命名”的方案,都会撞上”运行时动态性”这堵墙。 Shade 如此,代码混淆(ProGuard)如此,Go 的 -ldflags -X 改变量值也如此。能在源头就把名字定对的方案(Go 的 /v2、语义化导入版本),永远比事后打补丁的方案干净。


题 4:你们的团队要不要上 JPMS 模块系统?给决策建议。

参考答案

先给结论(我倾向明确):大多数业务团队不要上。除非你符合下面清单里的前两条之一。

该上的三种情况:

  1. 你在写”JDK 级别的库” —— 会被不认识的人依赖、需要长期维护、需要强封装防止内部 API 被滥用。这是 JPMS 的设计目标场景
  2. 你要用 jlink 裁剪运行时 —— 做 CLI 工具、桌面应用、或者容器镜像体积是硬约束。这时 JPMS 的收益是实打实的(一个裁剪后的 JRE 能小到 30~40MB,而完整 JRE 是 180MB+)
  3. 新项目、零历史包袱、且依赖生态全部已模块化 —— 这种情况非常少见,得先 jdeps --module-path 扫一遍你所有的依赖

不该上的四种情况(覆盖 95% 的业务团队):

情况为什么别上
用 Spring Boot / Hibernate / MyBatis这些框架强依赖反射和字节码增强,跟强封装(--illegal-access=deny,JDK 16+ 默认)反复打架。Spring Boot 官方长期没有把应用模块化的推荐路径
依赖树里有大量没有 module-info 的库会退化成”自动模块”,模块名从 jar 文件名推导(jackson-databind-2.15.3.jar → jackson.databind),而文件名一改模块名就变 —— 脆到不能接受
有历史代码要维护迁移成本随代码量线性增长,收益却跟代码量无关(封装收益是架构层面的)
团队没人为此负责模块化的决策(哪些包导出、哪些不导出)需要持续维护。没人负责就会退化成 exports 全部包,等于白做

如果你决定不上,能拿到 JPMS 的部分收益吗?能,三条:

# 1. jlink 裁剪运行时(不需要你的代码模块化,JDK 自身已是模块)
jlink --add-modules java.base,java.sql,java.logging \
      --output /opt/jre-minimal --strip-debug --no-man-pages
# 产物通常 40MB 左右,Dockerfile 里用它代替完整 JRE
 
# 2. 用 jdeps 做依赖分析(不需要模块化就能用)
jdeps --class-path 'libs/*' -summary app.jar
 
# 3. 如果你的依赖有问题,用 --add-opens 精确放行(比全开放好)
java --add-opens java.base/java.lang=ALL-UNNAMED -jar app.jar

代码锚点 —— 一个最小 module-info(万一你要写库):

// src/main/java/module-info.java
module com.example.order.api {
    exports com.example.order.api.dto;        // 只有 DTO 对外可见
    exports com.example.order.api.service;
    // com.example.order.api.internal 不导出 → 外部无法访问,包括反射
    requires transitive java.sql;             // transitive:依赖我的模块也能读 java.sql
}

注意 exports 只控制”编译期可见 + 运行期非反射访问”。 JDK 16 起(JEP 396)默认 --illegal-access=deny,所以未导出的包连反射都进不去 —— 这是封装变强了,也是大量框架炸了的原因。

更深一层:JPMS 是一个”解决错问题的好方案”。

它解决的问题是:JDK 自身太大(rt.jar 有 60MB,几万个类,内部 API 被滥用)、以及大型库需要强封装边界。 这两个问题它解决得很好 —— JDK 9 之后 JDK 自己被拆成 70 多个模块,jlink 能裁剪,内部 API 被封住。

但它被公众期待解决的问题是:Jar Hell / 依赖版本冲突。 这个它没解决,也没打算解决(07.3 论证过:模块路径上一个模块名只能有一份)。

期待和能力的错位,是它被骂”没用”的真正原因。 它从来不是为 Jar Hell 设计的。

对照 Go:Go 从一开始就没打算做”模块系统”,因为它不需要 —— 大小写导出解决了封装(语言级),MVS 解决了版本协商(工具级),/v2 解决了版本共存(命名级)。三个问题在三个不同的层各用最小的机制解决了。

Go 的经验告诉我们:封装、版本协商、版本共存是三个不同的问题,应该用三个不同的、各自最小的机制去解决。 JPMS 试图用一个统一的运行时机制同时解决前两个,结果第一个解决得不错,第二个根本没碰,还引入了迁移成本。


题 5(开放题,无标准答案):如果让你设计一个”下一代 Java 依赖系统”,你会从 Go modules 抄哪些,保留 Java 的哪些?

【思考】

几个方向,每条都值得写下来:

  • 抄什么:go.sum 的内容校验(Maven 现在默认不校验,这是个洞);MVS 的”顺序无关 + 单调”性质;版本化模块路径(/v2)
  • 保留什么:BOM 的版本收敛能力(Go 完全没有这层,导致公司内部无法统一基线);仓库/私服体系(代理 + 托管 + 治理三合一,Go 的 GOPROXY 只做代理)
  • 改什么:nearest wins → 换成什么?如果换成 MVS,“显式声明优先”这个 valuable 的性质会丢掉吗?
  • 敢不敢砍:六种 scope 能砍成几种?provided 在容器时代还有必要吗?system 是不是该直接删掉?
  • 最激进的一条:如果允许 classpath 上同名类共存(每个依赖有自己的命名空间,像 Node 的 node_modules),JVM 要改什么?为什么 JVM 没这么做?(提示:回到 07.1 那个思考题)

一个参考坐标:Go 的答案是”把复杂度付给作者和编译器”,Java 的答案是”把复杂度付给工具和使用者”。你设计的新系统,打算把复杂度付给谁?想清楚这一条,前面所有问题的答案会自己浮出来。


下一章预告

现在你能三分钟定位一个 NoSuchMethodError,能说清 Tomcat 为什么要破坏双亲委派,知道 Shade 什么时候会反咬你一口,也能在 CI 里用 enforcer 把冲突挡在上线之前。

但你迟早会遇到这样一类问题:依赖是对的,代码是对的,服务还是不对。

接口偶发超时、线程池莫名打满、某个 Bean 的行为跟代码不一致、异常被吞掉后线索全断 —— 这些问题的共同点是:它们不在依赖层,在运行时行为层。你需要的是一套可复用的排查方法论,而不是某个具体工具。

第 08 章《排错方法论:异常体系、日志与 Arthas》,我们建这套体系:Java 异常体系该怎么用(什么时候该抛、什么时候该包装、什么时候该吞)、日志框架那堆桥接包(slf4j-api + logback + jul-to-slf4j + log4j-over-slf4j)为什么要这么接、MDC 怎么让日志带上 traceId,以及 Arthas 的 watch / trace / tt / thread 怎么把”改代码加日志重新部署”这个循环压缩成一条命令。

本章攒下的工具会直接派上用场 —— sc -d 已经在 07.2 露过脸,第 08 章它会成为你的日常。