ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

两个同名类引发线上 ClassCastException:双亲委派被打破的三个地方,我踩过其中一个

两个同名类引发线上 ClassCastException:双亲委派被打破的三个地方,我踩过其中一个 title: 两个同名类引发线上 ClassCastException双亲委派被打破的三个地方我踩过其中一个date: 2026-10-01tags: [JVM, 类加载器, 双亲委派, Tomcat, SPI, 源码解析, Java]2024 年我们做老系统容器化把一个 WAR 包往内嵌 Tomcat 迁移。迁移后接口一调就抛ClassCastException: com.example.model.User cannot be cast to com.example.model.User——同一个全限定名 cast 自己都报错。整个团队盯着这行日志沉默了半分钟最后是我在对象前面打印getClass().getClassLoader()才真相大白两个User类一个是AppClassLoader加载的一个是Fat Jar 里LaunchedURLClassLoader加载的JVM 眼里这是两个不同的类cast 当然失败。这个事故让我把类加载器和双亲委派的源码认真啃了一遍。这篇文章把委派模型怎么工作、哪些地方在刻意打破它、以及打破之后要付出什么代价讲清楚。一、先看源码ClassLoader.loadClass 的三段式逻辑双亲委派的核心就在ClassLoader#loadClass去掉无关细节后主干是三段protected Class? loadClass(String name, boolean resolve) throws ClassNotFoundException { synchronized (getClassLoadingLock(name)) { // 第 1 段检查自己以及缓存是否已经加载过 Class? c findLoadedClass(name); if (c null) { try { // 第 2 段有父加载器就先委派给父亲 if (parent ! null) { c parent.loadClass(name, false); } else { // 没有父加载器交给启动类加载器Bootstrap c findBootstrapClassOrNull(name); } } catch (ClassNotFoundException e) { // 父加载器找不到什么都不做往下走 } if (c null) { // 第 3 段父亲加载不了才自己动手 findClass c findClass(name); } } return c; } }逐行解释- 第 1 段保证类的幂等性同一个加载器加载过的类直接命中缓存不会重复加载。- 第 2 段是委派儿子先问父亲父亲再问爷爷一路问到 Bootstrap。父亲能加载的类儿子永远没有机会重复加载。- 第 3 段是回传父亲一路抛 ClassNotFoundException才轮到最初的请求者自己findClass。这个模型带来的核心保证是核心类库的唯一性。你写一个java.lang.String放进自己的 jar运行时用的仍然是 Bootstrap 加载的 JDK String因为你的 AppClassLoader 一开口就把请求委派上去了。没有这个机制伪造核心类的攻击和核心类重复加载的混乱会同时发生。二、事故复盘同名类的 ClassCastException 是怎么来的回到开头的事故。Spring Boot 的 Fat Jar 用LaunchedURLClassLoader加载BOOT-INF/classes和BOOT-INF/lib里的类它的父加载器是 AppClassLoader。当时我们的工程结构里有个尴尬的历史包袱一部分公共类被打进了 Fat Jar 的根目录旧打包脚本残留另一份相同代码在BOOT-INF/classes里。两个不同 URL 搜索路径上的同名类被两个不同的类加载器各自加载了一次。JVM 中判定两个类是否相同用的是全限定名 类加载器二元组所以它们是完全独立的两个类型赋值和 cast 都会失败。排查动作其实只有两行System.out.println(obj.getClass().getClassLoader()); System.out.println(User.class.getClassLoader()); // 输出 // org.springframework.boot.loader.LaunchedURLClassLoader1a2b3c // org.springframework.boot.loader.LaunchedURLClassLoader4d5e6f - 实例不同两个类加载器实例不同搜索路径不同但打印的类名一样基本就可以定性。修复是清理打包脚本保证同名类在全工程只有一份物理来源。这个教训我写进了团队的发布检查单升级打包方式后必须扫一遍产物里有没有重复的全限定类名。三、双亲委派被打破的三个地方委派模型不是铁板一块JDK 生态里有三个著名的破坏者各自动机不同。第一个SPI 与线程上下文类加载器。JDBC 是经典案例。java.sql.DriverManager由 Bootstrap 加载但它要加载的厂商驱动比如 mysql-connector 的com.mysql.cj.jdbc.Driver在应用 classpath 上Bootstrap 根本看不见。如果死守委派模型DriverManager 永远加载不到驱动。解法是在ServiceLoader.load里取当前线程的上下文类加载器让父加载器的代码反向使用子加载器加载类// ServiceLoader.load 的实现简化 public static S ServiceLoaderS load(ClassS service) { // 取线程上下文类加载器通常是 AppClassLoader ClassLoader cl Thread.currentThread().getContextClassLoader(); return ServiceLoader.load(service, cl); }这一行就是父委派反向给子的桥。代价是委派模型的唯一性保证在这个通道上失效了这也是为什么同一个驱动类偶尔会被加载两次的根源。第二个Tomcat 的 WebappClassLoader。一个 Tomcat 部署多个应用每个 Webapp 一个类加载器。它没有完全遵循先委派策略是先自己找找不到再委派给父加载器。动机是应用隔离两个应用各自依赖不同版本的同一个 jar 互不干扰。这也正是我在事故里看到双类加载器的底层原因。第三个OSGi / 模块化的网状委派。委派关系不再是一棵树而是按包名和模块导入导出关系构成的图。动机是支持模块热替换。复杂度也相应爆炸这也是我对网状类加载方案一直持保留态度的原因收益主要给了中间件厂商复杂度却要业务开发者消化。四、自定义类加载器findClass 的最小骨架虽然我不建议业务代码乱写类加载器但掌握findClass的最小骨架对理解整个体系非常有用——你看懂了这个骨架Tomcat 和 OSGi 的委派变体都只是在它之上改写第 2 段和第 3 段的顺序public class DirClassLoader extends ClassLoader { private final String baseDir; public DirClassLoader(String baseDir, ClassLoader parent) { super(parent); // 显式指定父加载器委派链的起点 this.baseDir baseDir; } Override protected Class? findClass(String name) throws ClassNotFoundException { // 全限定名转文件路径 String path baseDir name.replace(., /) .class; byte[] bytes; try (InputStream in new FileInputStream(path); ByteArrayOutputStream bos new ByteArrayOutputStream()) { byte[] buf new byte[4096]; int len; while ((len in.read(buf)) 0) { bos.write(buf, 0, len); } bytes bos.toByteArray(); } catch (IOException e) { // 父加载器和本地目录都没有向调用方明确报错 throw new ClassNotFoundException(name, e); } // defineClass 完成字节码到 Class 对象的转换并自动登记进缓存 return defineClass(name, bytes, 0, bytes.length); } }逐行解释- 构造器里传 parent决定这个加载器挂在委派链的哪个位置。-findClass只负责一件事从自己的搜索路径找到字节码。委派逻辑不要在这里写loadClass已经处理好了。-defineClass是 JVM 的桥把字节数组转成方法区的 Class 对象同时把加载器 类名登记进内部缓存下次findLoadedClass就能命中。- 找不到必须抛 ClassNotFoundException 而不是返回 null否则委派链的报错语义会被破坏。测试代码里一个值得玩味的细节ClassLoader loader1 new DirClassLoader(/data/app/, ClassLoader.getSystemClassLoader()); Class? c1 loader1.loadClass(com.example.model.User); ClassLoader loader2 new DirClassLoader(/data/app/, ClassLoader.getSystemClassLoader()); Class? c2 loader2.loadClass(com.example.model.User); System.out.println(c1 c2); // false System.out.println(c1.isInstance(c2.newInstance())); // false两个独立的 DirClassLoader 实例加载同一个 class 文件得到两个互相不兼容的 Class。这就是开头 ClassCastException 的最小复现——同一个文件两个加载器两个世界。五、Tomcat 的委派变体先自己找找不到再委派WebappClassLoader对标准委派做了一处关键改写简化后的 loadClass// WebappClassLoader.loadClass简化示意 public Class? loadClass(String name, boolean resolve) throws ClassNotFoundException { // 1. 检查本地缓存本 webapp 已加载的类 Class? clazz findLoadedClass0(name); if (clazz null) { // 2. 先在自己的仓库里找WEB-INF/classes 与 WEB-INF/lib clazz findClass(name); if (clazz null) { // 3. 自己没有才委派给父加载器 clazz super.loadClass(name, false); } } return clazz; }这个顺序调换的效果是应用自带的依赖优先于容器公共目录的同名类两个 webapp 各带各的 Spring 版本互不干扰。但也不是无脑先自己找——java.开头的类和被Loader delegatetrue标记的包仍会强制先委派否则有人放一个恶意的java.lang.String进 WEB-INF 就能劫持核心类。Tomcat 的取舍方案很值得读隔离性和安全性之间画了一条清晰的线而不是简单二选一。六、我的取舍判断业务代码永远不要手写自定义 ClassLoader 去加载 classpath 上的普通类能不碰就不碰——唯一性保证一旦破坏问题会在运行期以最诡异的形式出现。遇到cannot be cast to 同名类第一反应打印两边的 classLoader五分钟定性比翻依赖树快得多。需要隔离多版本依赖时优先考虑拆服务或 shaded jar重命名包而不是引入自研类加载器框架。排查依赖冲突用mvn dependency:tree配合 Arthas 的sc -d看类实际加载来源眼见为实。七、复盘真实数字事故场景WAR 迁内嵌 Tomcat接口 100% 报 ClassCastException定位耗时打印 classLoader 后 5 分钟定性清理打包脚本 1 小时根因产物Fat Jar 根目录与 BOOT-INF/classes 存在同名类双份物理来源沉淀发布检查单新增产物重复类扫描此后一年零复发八、思考题在你的环境里跑一下Thread.currentThread().getContextClassLoader()打印看看它是什么加载器。再想想如果有人在子线程里 new 了线程池并把自定义类加载器忘了传递会发生什么欢迎在评论区写下你的推测。
返回列表