NoSuchMethodError 上线才炸:依赖冲突的定位与收敛
预发正常,生产某个冷门接口报 NoSuchMethodError。dependencyManagement 把版本锁回了旧版,dependency:tree 里标 managed 的那条就是真凶。
版本上线的第二天,一个很少被访问的导出接口开始报 500。堆栈只有一行核心信息:
java.lang.NoSuchMethodError:
com.acme.common.json.JsonWriter.writeString(Ljava/lang/String;Z)V
NoSuchMethodError 的特点是「编译期不报,运行期才炸」。本地跑得好好的,预发环境跑得也好好的,一上生产就出问题。更麻烦的是这个接口不在主链路,灰度的时候没人发现,全量之后才被运营发现。
我拿到这个报错的第一件事,是去确认它到底是「代码写错了」还是「jar 里没有这个方法」。编译期通过意味着写代码时引用的那个类里确实有这个方法,那么运行期加载的类,就不是编译期用的那个。
一、先确认类到底从哪个 jar 来
猜是没用的,让 JVM 自己说。报错时把加载来源打出来,是最省时间的一步:
public static void main(String[] args) throws Exception {
Class<?> c = Class.forName("com.acme.common.json.JsonWriter");
System.out.println("class : " + c.getName());
System.out.println("from : " + c.getProtectionDomain().getCodeSource().getLocation());
System.out.println("loader : " + c.getClassLoader());
for (Method m : c.getDeclaredMethods()) {
if (m.getName().equals("writeString")) {
System.out.println("method : " + m);
}
}
}
生产上不方便加代码,可以用 -verbose:class 或者在启动参数里加一个 agent。更轻的办法是 jcmd 直接看已加载的类来自哪个 jar:
# 找到 pid
jps -l
# 看目标类从哪个 jar 加载
jcmd 12345 VM.class_hierarchy com.acme.common.json.JsonWriter -s
# 或者直接看 classpath 里这个类出现在哪几个 jar
javap -classpath $(cat cp.txt) -p com.acme.common.json.JsonWriter | grep writeString
我这边跑出来两个来源:
# 把运行期真实 classpath 打出来,逐个 jar 找这个类
for j in $(tr ':' '\n' < cp.txt); do
if unzip -l "$j" 2>/dev/null | grep -q "com/acme/common/json/JsonWriter.class"; then
echo "== $j"
javap -classpath "$j" com.acme.common.json.JsonWriter | grep "writeString"
fi
done
结果是 acme-common-2.4.1.jar 里有 writeString(String, boolean),而 acme-common-2.1.0.jar 里只有 writeString(String)。生产加载的是 2.1.0。
二、为什么编译期用的是新版本,运行期加载了旧版本
Maven 的依赖调解规则是「最短路径优先,同深度先声明优先」。当两个依赖分别传递引入了 acme-common 的 2.4.1 和 2.1.0,且深度相同时,谁在 pom.xml 里声明得靠前,谁生效。
编译时用的是 dependency:build-classpath 算出来的顺序,运行时用的是打包产物的 BOOT-INF/lib 顺序。两者理论上一致,但只要中间有任何一步改过依赖树——比如某个 starter 在 CI 里被替换了版本,或者有人手工往镜像里塞了一个 jar——就会出现「编译用 A、运行用 B」。
我们这次的原因更隐蔽:acme-common 是通过一个内部 starter 引入的,那个 starter 在 1.7.0 版本把 acme-common 从直接依赖改成了 optional。结果是,主工程里显式声明的 2.4.1 依然在,但另一个业务依赖传递进来的 2.1.0 因为「深度更浅」抢到了位置。
<!-- 冲突示意:两条路径都引了 acme-common,深度不同 -->
<dependency>
<groupId>com.acme</groupId>
<artifactId>acme-common</artifactId>
<version>2.4.1</version> <!-- 主工程显式声明,深度 1 -->
</dependency>
<dependency>
<groupId>com.acme</groupId>
<artifactId>biz-report-sdk</artifactId>
<version>3.2.0</version> <!-- 深度 1,内部又依赖 acme-common 2.1.0,深度 2 -->
</dependency>
这时 acme-common 的深度是 1(主工程直接声明),2.1.0 的深度是 2,按理说 2.4.1 应该赢。问题出在 biz-report-sdk 的 pom 里把 acme-common 写成了 compile scope 的直接依赖,而它在我们的 dependencyManagement 里被另一个 BOM 锁到了 2.1.0——dependencyManagement 的优先级高于传递依赖调解,直接把版本改掉了。
三、用 dependency:tree 把路径打出来
# 1. 找所有引入 acme-common 的路径
mvn -q dependency:tree -Dincludes=com.acme:acme-common -Dverbose
# 输出
# [INFO] +- com.acme:acme-common:jar:2.4.1:compile
# [INFO] \- com.acme:biz-report-sdk:jar:3.2.0:compile
# +- (com.acme:acme-common:jar:2.1.0:compile - omitted for conflict with 2.4.1)
# [INFO] \- com.acme:acme-platform-bom:pom:1.9.0:import
# \- (com.acme:acme-common:jar:2.1.0:compile - managed)
# 2. 看最终生效的版本
mvn -q dependency:list -DincludeArtifactIds=acme-common
最后一行 managed 是关键证据。dependencyManagement 里锁的 2.1.0 直接覆盖了显式声明的 2.4.1,而且 mvn dependency:tree 不会把它标成「冲突」,因为它不是调解出来的,是被管理掉的。
四、收敛:四种手段从软到硬
| 手段 | 写法 | 适用场景 | 风险 |
|---|---|---|---|
| 显式声明版本 | 主工程直接写 <version> |
单模块项目 | 多模块时容易漏 |
dependencyManagement 锁版本 |
在父 pom 里统一锁 | 多模块、多团队 | 需要有人维护 |
exclusions 排除 |
排除传递依赖 | 明确知道不需要 | 排错了运行期才炸 |
| enforcer 强制拦截 | bannedDependencies / dependencyConvergence |
长期防复发 | 首次接入要清一批历史债 |
我选的是「父 pom 锁版本 + enforcer 拦截」的组合。先在最顶层的 dependencyManagement 里把 acme-common 锁到 2.4.1,并且明确注释为什么是这个版本:
<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.acme</groupId>
<artifactId>acme-common</artifactId>
<version>2.4.1</version>
<!-- 2.4.1 起 JsonWriter 增加 writeString(String, boolean),
与 acme-platform-bom 1.9.0 冲突,BOM 后续版本再对齐 -->
</dependency>
</dependencies>
</dependencyManagement>
然后加 enforcer,让「同一个 groupId:artifactId 出现多个版本」在构建阶段直接失败,而不是等到生产:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-enforcer-plugin</artifactId>
<version>3.4.1</version>
<executions>
<execution>
<id>enforce-convergence</id>
<goals><goal>enforce</goal></goals>
<configuration>
<rules>
<dependencyConvergence/>
<bannedDependencies>
<excludes>
<exclude>com.acme:acme-common:[,2.3.9]</exclude>
</excludes>
<message>acme-common 必须 >= 2.4.1,低版本缺 JsonWriter 新方法</message>
</bannedDependencies>
</rules>
<fail>true</fail>
</configuration>
</execution>
</executions>
</plugin>
dependencyConvergence 一开,团队里先炸出 30 多个模块的历史冲突,这个是必然的代价。我们的处理方式是「先只对生产服务开启,历史模块进白名单」,用 failFast 控制,逐步清理。
五、验证不能只看编译通过
改完依赖,最怕的是「编译过了、启动过了、某个冷门分支还是炸」。验证要落在字节码层面:
# 打出来的包里,acme-common 应该只有一个版本
unzip -l target/app.jar | grep -E "acme-common"
# 反编译确认方法签名存在
javap -classpath target/app.jar -p 'BOOT-INF/lib/acme-common-2.4.1.jar' 2>/dev/null
javap -cp BOOT-INF/lib/acme-common-2.4.1.jar com.acme.common.json.JsonWriter | grep writeString
另外加了一个启动自检:应用启动时反射校验关键类的方法签名,缺失就打 ERROR 并退出。这个检查只对少数几个跨团队共享的工具类做,成本很低:
@Component
public class DependencySanityCheck implements ApplicationRunner {
private static final String[][] REQUIRED = {
{"com.acme.common.json.JsonWriter", "writeString", "java.lang.String,boolean"},
};
@Override
public void run(ApplicationArguments args) throws Exception {
for (String[] r : REQUIRED) {
Class<?> c = Class.forName(r[0]);
String[] types = r[2].split(",");
Class<?>[] params = new Class<?>[types.length];
for (int i = 0; i < types.length; i++) params[i] = Class.forName(types[i]);
try {
c.getMethod(r[1], params);
} catch (NoSuchMethodException e) {
throw new IllegalStateException("依赖版本不匹配: " + r[0] + "#" + r[1]
+ " 来自 " + c.getProtectionDomain().getCodeSource().getLocation());
}
}
}
}
上线后这个检查在预发环境立刻拦住了一次同类问题——另一个服务的 biz-report-sdk 升级时又把版本带回了 2.1.0。
六、同类问题的三个变种
NoSuchMethodError 只是最直白的一种,同一类问题还有几种表现,排查思路相通:
NoClassDefFoundError:类在编译期存在,运行期整个类都找不到,多半是providedscope 打错了,或者被exclusions排掉了。AbstractMethodError:接口加了方法,实现类还是旧版本。典型场景是 SDK 新版本给接口加方法,而某个实现类由另一个 jar 提供。ClassCastException: com.acme.Foo cannot be cast to com.acme.Foo:同一个类被两个 ClassLoader 各加载了一次,常见于插件化、热部署、shade之后没有 relocate 的场景。
第三种最容易被误判成业务代码写错。判断方法很简单:把两边的 getClass().getClassLoader() 打出来,不是同一个 loader 就是类加载隔离问题,跟强转逻辑无关。
小结
NoSuchMethodError意味着编译期与运行期加载的不是同一份类,先定位类来自哪个 jar,别改业务代码。dependencyManagement的优先级高于传递依赖调解,dependency:tree里标managed的那条就是真凶。- 排查用
mvn dependency:tree -Dincludes=g:a -Dverbose,验证用javap看方法签名,两步就能闭环。 - 收敛靠父 pom 锁版本 +
maven-enforcer-plugin,dependencyConvergence首次接入必然炸出历史债,要分批清。 - 冷门接口最容易漏测,关键共享类加一个启动期签名自检,比任何流程都可靠。
- 同一个类被两个 ClassLoader 加载时表现为
ClassCastException,先比 loader,再谈类型转换。
正在加载评论…