Java 依赖冲突排查实战——classpath hell 的根本原因和系统性解决方案
Java 依赖冲突排查实战——classpath hell 的根本原因和系统性解决方案
适读人群:遇到过 NoSuchMethodError、ClassNotFoundException、LinkageError 等依赖冲突问题的 Java 开发者 | 阅读时长:约 16 分钟 | 核心价值:彻底搞清楚依赖冲突的根本原因,掌握系统性排查和解决方法
如果你做 Java 开发超过两年,应该遇到过这类错误:
java.lang.NoSuchMethodError:
com.google.common.collect.ImmutableList.of(Ljava/lang/Object;...)Lcom/google/common/collect/ImmutableList;或者:
java.lang.ClassNotFoundException: org.apache.http.client.HttpClient然后你花了半天时间,通过排列组合排除依赖版本,终于让它能跑了,但完全不知道为什么。
这就是"classpath hell"。这篇文章把根本原因和系统性解法写清楚。
根本原因:JVM 的类加载机制只加载同名类的一个版本
Java 运行时,同一个全限定类名(包名 + 类名)只能有一个版本在内存里。
如果 classpath 里有两个不同版本的 guava-18.0.jar 和 guava-30.0.jar,JVM 会选一个加载,另一个被忽略。哪个被加载取决于 classpath 的顺序(Maven 依赖有自己的规则)。
如果代码里调用了只在 30.0 才有的方法,但 JVM 加载的是 18.0,就会报 NoSuchMethodError。
Maven 的依赖仲裁规则
Maven 用两条规则决定哪个版本"赢":
规则一:最近依赖路径优先
A -> B:1.0 -> C:2.0
A -> C:1.0C 有两个版本,直接依赖路径长度:B->C 是 2 跳,A->C 是 1 跳。1 跳更近,所以 C:1.0 赢(即使 B 明确需要 2.0)。
规则二:相同路径长度,先声明的优先
A -> B:1.0 (先声明)
A -> C -> B:2.0B 是 2 跳,两条路径一样长,先声明的 B:1.0 赢。
排查工具:mvn dependency:tree
这是最重要的排查工具:
mvn dependency:tree -Dverbose
# 过滤特定依赖
mvn dependency:tree -Dincludes=com.google.guava:guava输出示例:
[INFO] com.example:my-app:jar:1.0.0
[INFO] +- org.springframework.boot:spring-boot-starter-web:jar:2.7.0:compile
[INFO] | +- org.springframework:spring-web:jar:5.3.20:compile
[INFO] | | \- com.fasterxml.jackson.core:jackson-databind:jar:2.13.3:compile
[INFO] +- com.example:my-sdk:jar:2.0.0:compile
[INFO] | \- (com.fasterxml.jackson.core:jackson-databind:jar:2.11.0:compile - omitted for conflict with 2.13.3)最后那行 omitted for conflict 就是被丢弃的版本。这里 jackson-databind 有 2.13.3 和 2.11.0 两个版本,Maven 选了 2.13.3(路径更近),2.11.0 被丢弃了。
踩坑实录一:Guava 版本冲突,NoSuchMethodError 排查过程
我们有一个服务集成了三个内部 SDK,上线后某个功能报错:
java.lang.NoSuchMethodError:
com.google.common.base.Preconditions.checkArgument(ZLjava/lang/String;Ljava/lang/Object;)VcheckArgument(boolean, String, Object) 这个方法在 Guava 20 之前就有,应该不会缺失。
用 dependency:tree 查:
mvn dependency:tree -Dincludes=com.google.guava:guava输出:
[INFO] +- com.example:sdk-a:jar:1.0:compile
[INFO] | \- com.google.guava:guava:jar:16.0:compile
[INFO] +- com.example:sdk-b:jar:2.0:compile
[INFO] | \- (com.google.guava:guava:jar:27.0-jre:compile - omitted for conflict with 16.0)
[INFO] +- com.example:sdk-c:jar:1.5:compile
[INFO] | \- (com.google.guava:guava:jar:29.0-jre:compile - omitted for conflict with 16.0)sdk-a 依赖 guava 16.0,而且在依赖树里是更短的路径(或者先声明),所以 guava 16.0 赢了。但 sdk-b 和 sdk-c 用了 guava 27.0+,调用了 16.0 里没有的方法,运行时崩。
解法:在父 pom 里显式声明 guava 版本,强制统一:
<dependencyManagement>
<dependencies>
<!-- 强制所有依赖都用这个版本的 Guava -->
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>32.1.3-jre</version> <!-- 选所有 SDK 都兼容的最高版本 -->
</dependency>
</dependencies>
</dependencyManagement>完整排查流程:
package com.example.classpath;
/**
* 工具:在运行时检测实际加载的类的版本来源
* 用于确认运行时加载的是哪个 jar 里的类
*/
public class ClasspathDiagnostic {
public static void printClassLocation(Class<?> clazz) {
java.security.CodeSource codeSource =
clazz.getProtectionDomain().getCodeSource();
if (codeSource != null) {
System.out.println(clazz.getName() + " 来自: " + codeSource.getLocation());
} else {
System.out.println(clazz.getName() + " 来自: JDK Bootstrap ClassLoader");
}
}
public static void main(String[] args) {
// 运行时检查某个类实际加载自哪个 jar
printClassLocation(com.google.common.collect.ImmutableList.class);
// 输出类似:com.google.common.collect.ImmutableList 来自: file:/home/app/lib/guava-16.0.jar
}
}踩坑实录二:shade 打包引入的类重定位问题
有些 SDK 为了避免依赖冲突,会用 maven-shade-plugin 把自己的依赖"重定位"(relocate):把 com.google.guava 重命名成 com.example.sdk.shaded.guava,这样两个版本的 Guava 在同一个 classpath 里共存不冲突。
这本来是个好做法,但遇到问题是:有时候 SDK 把你项目也要用的类 shade 进去了,你的代码调用的是原始包名,SDK 内部用的是 shade 后的包名,两者不兼容。
排查手段: 解压 jar 文件,看里面的包结构:
unzip -l sdk-a-1.0.jar | grep "google"
# 如果看到 com/example/sdk/shaded/google/common/...
# 说明这个 jar shade 了 Google 的库踩坑实录三:不同 ClassLoader 加载了同一个类,导致 ClassCastException
这是更隐蔽的问题,通常出现在有多个 ClassLoader 的环境里(比如 Web 容器、OSGi、插件系统)。
java.lang.ClassCastException:
com.example.UserVO cannot be cast to com.example.UserVO同一个类名,cast 失败,看起来很荒谬。原因是:两个 ClassLoader 各自加载了 UserVO 类,JVM 认为它们是两个不同的类(类的唯一性 = ClassLoader + 全限定名)。
解法:确保同一个类只由一个 ClassLoader 加载。 这通常是框架配置问题,不是代码问题,需要根据具体框架(Spring、Tomcat 等)查对应的 ClassLoader 配置。
系统性解决方案:依赖版本统一管理
对于大型项目,建议:
- 单独维护一个 BOM(Bill of Materials)
<!-- my-project-bom/pom.xml -->
<project>
<groupId>com.example</groupId>
<artifactId>my-project-bom</artifactId>
<version>1.0</version>
<packaging>pom</packaging>
<dependencyManagement>
<dependencies>
<!-- 统一管理所有第三方依赖版本 -->
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>32.1.3-jre</version>
</dependency>
<!-- 更多依赖... -->
</dependencies>
</dependencyManagement>
</project>- 在 CI 里加依赖冲突检测
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-enforcer-plugin</artifactId>
<executions>
<execution>
<goals><goal>enforce</goal></goals>
<configuration>
<rules>
<!-- 禁止依赖版本冲突(有被忽略的依赖就构建失败) -->
<dependencyConvergence/>
<!-- 要求使用 BOM 统一版本 -->
<requireUpperBoundDeps/>
</rules>
</configuration>
</execution>
</executions>
</plugin>加了 dependencyConvergence 之后,只要有依赖冲突,Maven 构建就失败,强制你去解决而不是靠运气。
依赖冲突不是"运气问题",是可以系统性预防和解决的工程问题。关键是理解规则,有工具,不靠猜。
