ARTICLE DETAIL

资讯详情

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

一个 NoSuchMethodError 查了 4 小时:双亲委派‘先问爹’的机制,让新 jar 永远赢不了老 jar

一个 NoSuchMethodError 查了 4 小时:双亲委派‘先问爹’的机制,让新 jar 永远赢不了老 jar

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 没传上去」,有人猜是「编译用了高版本方法」,两个猜测都错。真正的原因在类加载器的双亲委派上:我们自定义的URLClassLoaderAppClassLoader设成了 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,加载StringUtilsURLClassLoader会先委托 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 跑不了」。但NoSuchMethodErrorUnsupportedClassVersionError是两码事,前者是「类能加载、方法找不到」,后者才是版本不对。这条弯路花了约 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,比猜版本快得多。

思考题

把你项目里某个容易版本冲突的三方库(比如fastjsoncommons-lang3)故意放两个版本到不同路径,用「标准双亲委派」和「白名单子优先」分别加载,对比getProtectionDomain().getCodeSource()打印出来的真实来源——你会看清「到底是哪个 jar 在生效」。

返回列表