6.3 类加载冲突与双亲委派 本节摘要:JVM 里类的唯一标识是"类加载器 + 全限定名",同名类被不同加载器加载后是两个互不相干的 Class。本节从一次诡异的 讲起,讲清类加载五阶段、双亲委派的意图与打破它的正当场景,覆盖 NoClassDefFoundError 与 ClassNotFoundException 的区分、依赖冲突(第 7 章的前菜)与隔离容器的解法。 事故现场:越部署越错的 SPI 插件 数据平台支持插件式数据源,插件 jar 运行时从外部目录加载。某次升级后,新插件在自己机器上好好的,部署到平台就抛: 堆栈在说一件奇怪的事:同一个 ,JVM 里存在两个 Class 对象。
本节摘要:JVM 里类的唯一标识是"类加载器 + 全限定名",同名类被不同加载器加载后是两个互不相干的 Class。本节从一次诡异的
LinkageError讲起,讲清类加载五阶段、双亲委派的意图与打破它的正当场景,覆盖 NoClassDefFoundError 与 ClassNotFoundException 的区分、依赖冲突(第 7 章的前菜)与隔离容器的解法。
数据平台支持插件式数据源,插件 jar 运行时从外部目录加载。某次升级后,新插件在自己机器上好好的,部署到平台就抛:
java.lang.LinkageError: loader constraint violation: when resolving method ... PluginImpl.init(Lcom/data/SpiContext;)V the class LoaderA and LoaderB have different Class objects for com.data.SpiContext
堆栈在说一件奇怪的事:同一个 com.data.SpiContext,JVM 里存在两个 Class 对象。一个由平台主加载器加载(平台自己也在用这个类),一个由插件的独立加载器加载(插件 jar 里也打了一份)。在 JVM 眼里它们是两个毫无关系的类型——init(SpiContextA) 与 init(SpiContextB) 方法签名对不上,链接失败。
这揭示了 JVM 类身份的铁律:Class 的唯一键 = 加载器实例 + 全限定名。同名不同加载器,instanceof 是 false、强转是 CCE、方法解析是 LinkageError。OSGi、Tomcat 多应用隔离、Java 9 模块化,全部建立在这条规则上。
一个类从 class 文件到可用,经过五个阶段:
static int a = 1 的 a 是 0,赋值在下一步)<clinit> 方法,JVM 保证线程安全(这正是"静态内部类单例线程安全"的底层保证,呼应第 5 章)验证/准备/解析合称链接。初始化的触发时机很保守:new、读写静态字段、反射调用、初始化子类时连带父类——"被动引用"(通过子类引用父类静态字段、数组定义、引用编译期常量)不触发,这些细节解释了不少"静态块没执行"的灵异现象。
类加载器收到加载请求,先委托父加载器,父加载器找不到才自己加载(自 JDK 9 起概念演化为"父级优先的模块化查找",意图不变)。三层经典结构:启动类加载器(JDK 核心类)→ 平台/扩展类加载器 → 应用类加载器。
这个设计买来三样东西:核心类不被替换(你写的 java.lang.String 永远不会被加载,安全且语义稳定);同一个类全局只加载一份(父加载器加载过了,子加载器直接复用——第 6.1 节 Metaspace 膨胀的反面);命名空间隔离有序。事故里如果插件类委托给了平台加载器去加载 SpiContext(只把插件自己的实现类留给插件加载器),LinkageError 就不会发生——共享类上收,专有类下沉,是所有插件系统的设计铁律。
打破双亲委派的正当场景也存在:SPI(JDBC 驱动)里核心库要反向加载应用 jar 里的实现,靠线程上下文类加载器完成"父请子";热部署/OSGi 用"子优先"策略换灵活性;Web 容器每应用一个加载器实现隔离。打破是工具不是自由——每次打破都要回答"共享类的身份归谁"。
ClassNotFoundException 与 NoClassDefFoundError 都说"找不到类",成因天差地别,排错方向完全不同:
| 维度 | CNFE(异常) | NCDFE(错误) |
|---|---|---|
| 抛出时机 | 显式加载(Class.forName、loader.loadClass) | 链接/初始化时(new、静态访问) |
| 语义 | "现在就找不到" | "编译时在,运行时没了或初始化失败" |
| 常见根因 | 依赖缺失、类名拼错、加载器不对 | 打包缺 jar、静态初始化块抛过异常、多版本冲突 |
| 排查 | 检查 classpath 与加载器 | 先查依赖打包,再查第一次加载的初始化异常 |
特别提防 NCDFE 的阴间变体:静态块里第一次抛了异常,类标记为初始化失败,之后所有使用都抛 NCDFE——真正的根因在更早的第一条日志里,盯着 NCDFE 本身看一无所获。-verbose:class 打印每个类的加载来源,多版本冲突(同一个类出现在两个 jar)时一眼定位加载了哪个版本(第 7 章依赖冲突的主力工具)。
类也是有生命周期的元数据,卸载条件苛刻:该类所有实例已回收、加载器已回收、Class 对象无引用——三个条件里加载器存活就全盘保留。应用容器重部署几次后 Metaspace 涨而不落,多半是某个全局缓存(第 2 章的老朋友)攥着旧加载器的 Class 或实例。CGLib/Groovy 这类动态生类框架是另一个膨胀源,讲究的用法是给代理类做缓存复用,而不是每次请求都生成新类。
⚠️ 常见坑:插件 jar 里用 maven shade 把共享接口也打进去。接口包一被两个加载器各加载一份,LinkageError 如约而至——共享依赖在插件打包配置里必须是 provided scope。
💡 关键直觉:类的全限定名不是身份,加载器才是姓氏。设计任何动态加载系统,第一张图先画"哪些类由谁加载、共享类归谁",这张图画对,九成类加载事故不会发生。
-verbose:class 与 -Xlog:class+load 是版本冲突的照妖镜<clinit> 线程安全是单例的底第 6 章收束。下一章升到生态层——框架、构建与中间件的踩坑现场。