title: "一个 NoSuchMethodError 查了 4 小时:双亲委派‘先问爹’的机制,让新 jar 永远赢不了老 jar"
tags: [类加载器, 双亲委派, Java, 源码分析, 后端]
category: 后端
插件上线后,启动就抛 NoSuchMethodError
我们有一套自研的插件框架,允许运营把外部 jar 丢到plugins/目录,应用启动时用URLClassLoader加载这些 jar 里的策略类。某次一个插件升级了它依赖的commons-lang3到 3.12.0,新增了一个方法,但应用本身的lib/目录里还躺着 3.4 版本。上线后一启动就崩:
java.lang.NoSuchMethodError: org.apache.commons.lang3.StringUtils.isAllBlank(Ljava/lang/CharSequence;)Z at com.acme.plugin.RulePlugin.validate(RulePlugin.java:42)诡异的地方在于:插件 jar 里明明带着 3.12.0,本地用jar tf也确认isAllBlank方法在;可运行时偏偏报「方法不存在」。环境是 JDK 8u202,应用以 fat jar 方式启动,AppClassLoader负责加载lib/下的全部依赖。
当时组里有人猜是「jar 没传上去」,有人猜是「编译用了高版本方法」,两个猜测都错。真正的原因在类加载器的双亲委派上:我们自定义的URLClassLoader把AppClassLoader设成了 parent,而双亲委派规定「加载类先问爹」,于是StringUtils直接从 parent 的老版本里命中了,新 jar 根本没机会出场。
最小复现:一个子类加载器,parent 指向 AppClassLoader
把插件加载逻辑抽出来,复现只有几行:
// 假设 plugins/new-plugin.jar 里有 commons-lang3-3.12.0 URL[] urls = { new File("plugins/new-plugin.jar").toURI().toURL() }; URLClassLoader pluginLoader = new URLClassLoader(urls, ClassLoader.getSystemClassLoader()); // parent = AppClassLoader(含老版 commons-lang3) Class<?> ruleClass = pluginLoader.loadClass("com.acme.plugin.RulePlugin"); Object rule = ruleClass.getDeclaredConstructor().newInstance(); rule.getClass().getMethod("validate").invoke(rule); // 这里抛 NoSuchMethodError逐行拆解:
- 第 2 行把插件 jar 放进
URLClassLoader的搜索路径。 - 第 3 行是关键:
new URLClassLoader(urls, ClassLoader.getSystemClassLoader())把系统类加载器(AppClassLoader)设为 parent。 - 第 5 行
pluginLoader.loadClass("com.acme.plugin.RulePlugin")触发加载,但RulePlugin内部引用了StringUtils,加载StringUtils时URLClassLoader会先委托 parent。 - 第 7 行执行到
RulePlugin.validate,它调用StringUtils.isAllBlank,此时 JVM 链接的是 parent 加载的老版本StringUtils,老版本没有isAllBlank,于是NoSuchMethodError。
把第 3 行的 parent 改成null(即只从插件 jar 自身找),NoSuchMethodError立刻消失——这一脚反向验证:问题不在 jar 内容,而在「谁先被允许去加载」。
双亲委派到底是什么顺序
ClassLoader.loadClass的默认实现把「先问爹」写死在逻辑里:
// ClassLoader.loadClass,JDK 双亲委派的默认实现 protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException { synchronized (getClassLoadingLock(name)) { Class<?> c = findLoadedClass(name); // ① 先看自己有没有加载过 if (c == null) { try { if (parent != null) { c = parent.loadClass(name, false); // ② 自己没有,先委托 parent } else { c = findBootstrapClassOrNull(name);// ③ 没有 parent 就找 Bootstrap } } catch (ClassNotFoundException e) { } if (c == null) { c = findClass(name); // ④ parent 也没找到,才自己加载 } } if (resolve) resolveClass(c); return c; } }逐行拆解这段经典实现:
- 第 3 行
findLoadedClass先查自己命名空间里有没有已加载的类,避免重复加载。 - 第 6 行
parent.loadClass是双亲委派的核心:自己不急着动手,先把请求往上层抛。 - 第 9 行
findBootstrapClassOrNull当没有 parent(即到了ExtClassLoader/启动类边界)时,尝试让最顶层的 Bootstrap 加载。 - 第 12 行
findClass是「兜底」:只有当 parent 一路往上都没找到,才轮到自己定义的findClass(比如URLClassLoader去自己的 jar 里找)。
这个顺序的天经地义之处在于安全与唯一:核心类(如java.lang.String)永远由 Bootstrap 加载,谁都没法用自己的String顶替它。但它有个副作用——parent 里只要能找到同名类,child 里再新也没用。在我们的事故里,parent(AppClassLoader)里的老版commons-lang3先于插件 jar 命中,新方法自然「消失」了。
排查时我们走过的弯路
第一条:怀疑编译环境。RulePlugin是用 JDK 17 打的包,我们一度以为是「高版本编译的 class 在低版本 JVM 跑不了」。但NoSuchMethodError和UnsupportedClassVersionError是两码事,前者是「类能加载、方法找不到」,后者才是版本不对。这条弯路花了约 50 分钟。
第二条:怀疑 fat jar 把老版commons-lang3shade 进了插件 jar 内部。我们用javap -p反编译插件里的StringUtils,确认它确实是 3.12.0、有isAllBlank。方向错了,又浪费了 40 分钟。
最后是用-verbose:class打开类加载日志,看到StringUtils是从jar:file:/app/lib/commons-lang3-3.4.jar加载的——而那不是插件目录,是 parent 的lib/。日志一条就把锅甩给了双亲委派。
打破委派:让插件 jar「优先于 parent」
要绕开「先问爹」,常见做法是重写loadClass,把findClass提前(即「子优先」),这就是 Tomcat、OSGi 这类容器的思路。但全盘倒置容易把java.*核心类也抢着加载,引发SecurityException。稳妥的写法是:只对自己插件的包名优先,其余仍走双亲委派。
public class PluginClassLoader extends URLClassLoader { private final String pluginPackage; // 例如 "com.acme.plugin" @Override protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException { synchronized (getClassLoadingLock(name)) { Class<?> c = findLoadedClass(name); if (c == null && name.startsWith(pluginPackage)) { // 自己插件的类:先在自己 jar 找,找不到再问 parent try { c = findClass(name); } catch (ClassNotFoundException e) { c = super.loadClass(name, false); } } else { // 其它类(含 JDK、三方依赖):仍走标准双亲委派 c = super.loadClass(name, resolve); } if (resolve) resolveClass(c); return c; } } }逐行拆解:
- 第 6 行先查已加载,避免重复。
- 第 7 行判断类名是否属于本插件的包:
com.acme.plugin开头的类才走「子优先」。 - 第 9 行
findClass(name)先到自己的 jar 里找,于是插件自带的RulePlugin及其直接依赖优先命中新版本。 - 第 11 行若自己 jar 里没有,再
super.loadClass退回标准委派,保证 JDK 类和公共三方库仍由 parent 提供。 - 第 13 行对不属于本插件包的类(比如
java.*、org.apache.commons.*之外的),老老实实走super.loadClass,不破坏整体隔离。
注意这里我们没有让org.apache.commons.lang3也走子优先——否则不同插件带了不同版本的commons-lang3会互相污染。真正需要隔离的只有插件自身的业务类,三方依赖应当共享。我们后来的做法是把插件依赖的「冲突包」单独列白名单,只有白名单里的才子优先。
四种隔离策略怎么选
| 策略 | 隔离强度 | 实现成本 | 适用场景 |
|---|---|---|---|
| 标准双亲委派(parent 优先) | 无隔离,同名类取 parent | 最低 | 插件不依赖特定版本时 |
| 子优先(全盘倒置 loadClass) | 强,但易踩核心类红线 | 中 | 极少数容器级场景 |
| 包名白名单子优先(本文方案) | 中,只隔离插件自身 | 中 | 多插件共用三方库 |
| 线程上下文类加载器 TCCL | 借调用方加载器打破委派 | 低 | SPI 场景(JDBC 驱动等) |
我们最终选了「包名白名单子优先」。它在「插件代码隔离」和「三方库共享」之间取了平衡,没把commons-lang3这类公共库也隔离掉,避免了更隐蔽的ClassCastException。
复盘数字
- 这次定位从报错到改完,约 4 小时,其中约 90 分钟耗在「怀疑编译版本」「怀疑 shade」两条错误方向上。
- 上线后插件目录里共有 6 个插件,静态梳理发现其中 3 个带了和
lib/冲突的三方库版本,全部纳入白名单管理。 - 后来我们给启动脚本加了
-verbose:class > classload.log的常态化采集,类似问题的平均定位时间从小时级降到 10 分钟以内。
我的取舍
我不建议无脑把loadClass全盘倒置成「子优先」。那套写法在 Tomcat、OSGi 里能跑,是因为它们配套了一整套「哪些类允许被接管、哪些必须交给父」的名单,自己手写很容易漏掉java.前缀导致启动即崩。我的判断是:隔离粒度越细越好,只对你真正需要版本独立的包做子优先,其余一律回归标准双亲委派。另外,NoSuchMethodError/NoSuchFieldError/ClassNotFoundException这类「方法或字段找不到」的错,第一反应就应该是「同名类被别的加载器先加载了」,直接上-verbose:class,比猜版本快得多。
思考题
把你项目里某个容易版本冲突的三方库(比如fastjson、commons-lang3)故意放两个版本到不同路径,用「标准双亲委派」和「白名单子优先」分别加载,对比getProtectionDomain().getCodeSource()打印出来的真实来源——你会看清「到底是哪个 jar 在生效」。