本节目标:学会把「运行时才炸」的依赖冲突,还原成一条可读的依赖树路径;掌握
mvn dependency:tree的标记含义、Maven 的两条版本调解规则,以及<exclusions>、dependencyManagement、dependency:analyze三种修复手段各自的适用边界。
适用版本:Spring Boot 4.1.x(Java 21)
2.1 依赖冲突排查
入门卷讲过 pom.xml 的骨架和 starter 怎么读,本节解决的是它留下的那个坑:依赖声明对了、编译过了、启动也没报错,但一调某个接口就抛 NoSuchMethodError。这类问题不来自你的代码,而来自 Maven 在背后替你「选」了一个你没声明的版本。
本节统一用书中的示例工程「图书借阅管理服务」(下称 book-loan),它是一个五模块的 Maven 工程,领域模型只有三个实体:Book(图书)、Member(读者)、Loan(借阅记录)。后面两节(2.2 版本对齐、2.3 构建加速)都围绕同一套 POM 展开。
先看症状:三种运行时异常都指向依赖树
依赖冲突的报错信息几乎不会提「依赖」两个字,它只告诉你某个类出问题了。先把症状归成三类,再看它们各自指向什么。
| 运行时异常 | 字面含义 | 首要怀疑 |
|---|---|---|
NoSuchMethodError | 编译期存在的类/方法,运行期那份 jar 里没有 | 版本被「调解」成了更低的版本 |
ClassNotFoundException / NoClassDefFoundError | 类整个不在 classpath 上 | 被 <exclusions> 误排、或 scope 不对 |
AbstractMethodError / IncompatibleClassChangeError | 接口与实现来自不同版本 | 框架升级后新旧版本混在一条 classpath 上 |
一段真实的失败长这样:
java.lang.NoSuchMethodError: 'java.util.stream.Collector com.google.common.collect.ImmutableList.toImmutableList()'
at com.birdor.bookloan.application.LoanQueryService.byMember(LoanQueryService.java:41)
at com.birdor.bookloan.web.LoanController.listByMember(LoanController.java:58)
LoanQueryService 里写了 stream.collect(ImmutableList.toImmutableList()),IDE 里能跳转、编译也通过,说明编译期那份 Guava 有这个静态方法;运行时却没有。toImmutableList() 是 Guava 21.0 才引入的 API,所以运行时用的必然是更老的版本。问题不在代码,在依赖树。
book-loan 的模块结构
先明确工程长什么样,因为「冲突」是多个模块的依赖在运行期合并后才出现的。
book-loan/
├── pom.xml book-loan-parent(packaging=pom,只管版本与插件)
├── book-loan-domain/ Book / Member / Loan 实体与领域接口
├── book-loan-application/ LoanQueryService 等用例编排
├── book-loan-infrastructure/ JPA 仓储实现
├── book-loan-web/ REST 控制器
└── book-loan-boot/ 启动类 + spring-boot-maven-plugin 打包
关键认知:每个模块单独看都是「版本正确」的,冲突发生在合并后的运行期 classpath 上。所以定位冲突的第一步不是改 POM,而是问「这个坐标是被哪个模块、哪条路径拉进来的」。book-loan-application 依赖了 book-loan-domain,同时又引了两个内部 SDK:com.birdor:isbn-metadata-sdk(ISBN 元数据)和 com.birdor:loan-report-sdk(借阅报表)。冲突就藏在后两者里。
读懂 mvn dependency:tree
先只打印出问题那个模块的依赖树:
mvn -pl book-loan-application dependency:tree
-pl(--projects)把输出限定在指定模块,避免五棵树的噪声。默认输出如下(示例输出,版本号随你实际依赖变化):
[INFO] --- maven-dependency-plugin:3.8.1:tree (default-cli) @ book-loan-application ---
[INFO] com.birdor.bookloan:book-loan-application:jar:1.0.0-SNAPSHOT
[INFO] +- org.springframework.boot:spring-boot-starter-webmvc:jar:4.1.1:compile
[INFO] | +- org.springframework.boot:spring-boot-starter:jar:4.1.1:compile
[INFO] | \- org.springframework:spring-webmvc:jar:7.0.9:compile
[INFO] +- com.birdor:isbn-metadata-sdk:jar:2.3.0:compile
[INFO] | \- com.google.guava:guava:jar:19.0:compile
[INFO] +- com.birdor:loan-report-sdk:jar:1.4.0:compile
[INFO] \- org.springframework.boot:spring-boot-starter-test:jar:4.1.1:test
每行的格式是 groupId:artifactId:packaging:version:scope,前面的 +- / \- 和缩进表示层级(深度)。
注意 com.birdor:loan-report-sdk 这一行下面什么都没有。它其实也传递依赖了 Guava,但默认输出把它藏起来了——这正是最危险的地方:你照着默认输出排查,会得出「只有一个 Guava,版本是 19.0」的结论,而看不到冲突本身。
加 -Dverbose 才会把被剪掉的分支打出来:
mvn -pl book-loan-application dependency:tree -Dverbose
[INFO] +- com.birdor:isbn-metadata-sdk:jar:2.3.0:compile
[INFO] | \- com.google.guava:guava:jar:19.0:compile
[INFO] +- com.birdor:loan-report-sdk:jar:1.4.0:compile
[INFO] | \- (com.google.guava:guava:jar:32.0.1-jre:compile - omitted for conflict with 19.0)
结论:排查冲突时一律加 -Dverbose,默认输出会掩盖问题。 常用标记的含义如下:
| 标记 | 含义 |
|---|---|
- omitted for duplicate | 同一坐标已在本模块更早位置出现过,Maven 剪掉这一份 |
- omitted for conflict with X | 与已选定的版本 X 冲突,被调解剪枝(只有 -Dverbose 显示) |
(version managed from A by B) | 版本被 <dependencyManagement> 从 A 改成了 B |
(optional) | 该依赖是 optional,不会向下传递 |
两条调解规则
Maven 遇到同一个坐标有多个版本时不会报错,它会静默「选一个」。规则只有两条,按顺序判断,理解这两条就能预判结果。
规则一:最短路径优先(nearest definition wins)
「路径长度」按从当前模块数起的节点数算。
book-loan-application
+- com.google.guava:guava:32.0.1-jre <- 直接声明,深度 1
\- com.birdor:isbn-metadata-sdk:2.3.0
\- com.google.guava:guava:19.0 <- 传递依赖,深度 2
深度 1 战胜深度 2,最终选 32.0.1-jre。这条规则解释了为什么「直接声明一个依赖」往往能压住冲突——它把版本提到了最短路径上。
规则二:同深度先声明优先(first declaration wins)
两条路径深度相同,比的是在 POM 里谁写在前面:
book-loan-application
+- com.birdor:isbn-metadata-sdk:2.3.0 <- 先声明
| \- com.google.guava:guava:19.0 <- 深度 2
\- com.birdor:loan-report-sdk:1.4.0 <- 后声明
\- com.google.guava:guava:32.0.1-jre <- 深度 2,被剪枝
两个候选都在深度 2,isbn-metadata-sdk 写在前面,于是 19.0 胜出,loan-report-sdk 里的 32.0.1-jre 被剪掉。运行期调用 ImmutableList.toImmutableList() 就抛出了本节开头那个 NoSuchMethodError。
这条规则有个反直觉的推论:POM 里两行依赖的先后顺序会改变运行时行为。它也解释了那个经典困惑——「我只加了一行看起来无关的依赖,服务就崩了」:新依赖插在了前面,悄悄改变了同深度竞争的胜负。所以多模块项目里依赖顺序应当被当成代码一样审查。
定位:从异常里的类名反查
有了规则,定位就是三步机械操作:
- 从异常消息里取出类的全限定名(这里是
com.google.common.collect.ImmutableList),定位到它的坐标com.google.guava:guava; - 用
-Dincludes只打印与该坐标相关的路径,避免在几百行树里翻找; - 看被调解选中的版本,与编译期使用的版本比对。
mvn -pl book-loan-application dependency:tree -Dverbose -Dincludes=com.google.guava:guava
[INFO] +- com.birdor:isbn-metadata-sdk:jar:2.3.0:compile
[INFO] | \- com.google.guava:guava:jar:19.0:compile
[INFO] \- com.birdor:loan-report-sdk:jar:1.4.0:compile
[INFO] \- (com.google.guava:guava:jar:32.0.1-jre:compile - omitted for conflict with 19.0)
-Dincludes 的值格式是 groupId:artifactId,也支持通配(如 *:guava)。它只是过滤显示,不改变调解结果——别指望用它「修」冲突。
修复一:用 摘掉错误来源
既然 19.0 是从 isbn-metadata-sdk 来的,最直接的修法是把这条路径上的 Guava 摘掉:
<dependency>
<groupId>com.birdor</groupId>
<artifactId>isbn-metadata-sdk</artifactId>
<version>2.3.0</version>
<exclusions>
<exclusion>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
</exclusion>
</exclusions>
</dependency>
三个必须记住的要点:
exclusion只写groupId和artifactId,不写版本——它排除的是该坐标的所有版本,无法只排 19.0 而保留 32.0.1。- 它只作用于当前这条
<dependency>的下游,不会影响其它路径引入的同名依赖。 - 排除之后必须确认还有别的路径能提供这个类。如果
loan-report-sdk那条路径也不存在,结果就是从「版本错」升级成「类不存在」,你会看到ClassNotFoundException——问题没解决,只是换了个报错。
isbn-metadata-sdk 内部其实只用了 Guava 的 Preconditions.checkArgument,而这个 API 从 Guava 1.0 就有,所以在这里排除是安全的;如果它用了新版才有的 API,就不能靠排除解决,得推动上游 SDK 升级。
修复二:用 dependencyManagement 锁版本
更稳的做法不是删掉某条路径,而是统一规定这个坐标用哪个版本:
<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>32.0.1-jre</version>
</dependency>
</dependencies>
</dependencyManagement>
放进 dependencyManagement 后,无论 Guava 从哪条路径进来,版本都会被强制改成 32.0.1-jre。它和 <exclusions> 的本质区别在于是否改变依赖的存在性:
| 手段 | 作用 | 是否改变存在性 | 影响范围 |
|---|---|---|---|
<exclusions> | 从某条路径上摘掉一个坐标 | 是(移除) | 只影响该 <dependency> 的下游 |
<dependencyManagement> | 强制统一版本 | 否(只改版本) | 该模块内所有路径 |
直接 <dependency> 带 <version> | 声明为直接依赖 | 是(引入) | 提升到深度 1,压过所有传递路径 |
实践中的优先级:能用 dependencyManagement 统一,就不要用 <exclusions> 逐个打补丁。排除是外科手术,每加一个都要单独解释为什么;版本统一是一条规则,能覆盖整个模块。上例里最干净的解法其实是把 Guava 写进父 POM 的 dependencyManagement(下一节 2.2 会讲怎么把它放进自建 BOM),子模块从此不写版本。
找无用依赖:dependency:analyze
冲突的另一面是「声明了却没用」和「用了却没声明」,两者都是隐患。Maven 提供了专门的检查:
mvn -pl book-loan-application dependency:analyze
[INFO] --- maven-dependency-plugin:3.8.1:analyze (default-cli) @ book-loan-application ---
[WARNING] Used undeclared dependencies found:
[WARNING] org.springframework:spring-core:jar:7.0.9:compile
[WARNING] Unused declared dependencies found:
[WARNING] org.apache.commons:commons-lang3:jar:3.20.0:compile
两个警告区的含义完全不同:
- Used undeclared:字节码里直接引用了某个类,但 POM 里没声明它,纯靠传递依赖「蹭」来的。这是最隐蔽的一类问题——上游哪天不再传递这个库,你的代码就编译不过了。修法是把它显式声明出来(版本交给
dependencyManagement管)。 - Unused declared:声明了但字节码没直接引用。不要盲删:JDBC 驱动、
spring-boot-starter-actuator、注解处理器这类依赖是运行时才起作用或只在配置里出现,analyze看不到引用,删了会出问题。
想让它成为 CI 的硬门槛,可以绑定到 verify 阶段并开启失败即中断:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-dependency-plugin</artifactId>
<executions>
<execution>
<id>analyze</id>
<goals>
<goal>analyze-only</goal>
</goals>
<configuration>
<failOnWarning>true</failOnWarning>
<ignoredUnusedDeclaredDependencies>
<ignoredUnusedDeclaredDependency>org.postgresql:postgresql</ignoredUnusedDeclaredDependency>
</ignoredUnusedDeclaredDependencies>
</configuration>
</execution>
</executions>
</plugin>
ignoredUnusedDeclaredDependencies 就是给上面那类「运行时才用」的依赖开的豁免口。先让它以警告模式跑一段时间,把误报清理干净,再打开 failOnWarning。
常见坑
| 坑 | 表现 | 处理 |
|---|---|---|
只看默认 dependency:tree | 冲突分支被隐藏,误判「没有冲突」 | 排查时一律加 -Dverbose |
用 <exclusions> 一劳永逸 | 排掉后变成 ClassNotFoundException | 排除前确认仍有其它路径提供该类 |
| 直接声明版本「压住」冲突 | 版本对了,但引入了不兼容的组合 | 优先用 dependencyManagement 统一,改动面更小 |
| 各模块各写版本 | 运行期合并后互相覆盖 | 版本只在父 POM / BOM 里写一次 |
靠传递依赖蹭 spring-core | 上游升级后类消失 | analyze 的 Used undeclared 区就是它 |
排除用 *:* 通配 | 整棵子树被排掉,后续难以排查 | 只排具体坐标,逐个写明原因 |
小结
- 依赖冲突的症状是运行时异常(
NoSuchMethodError/ClassNotFoundException/AbstractMethodError),根因在合并后的运行期 classpath,不在代码。 mvn dependency:tree -Dverbose是排查的主力工具;默认输出会隐藏被剪掉的分支,-Dincludes=groupId:artifactId用来聚焦单个坐标。- 两条调解规则决定胜负:最短路径优先、同深度先声明优先。后者意味着 POM 里依赖的书写顺序会影响运行时行为。
<exclusions>改变依赖的存在性,<dependencyManagement>只改版本;能用后者统一就不要用前者打补丁。dependency:analyze分「Used undeclared」与「Unused declared」两区,前者必须修,后者要人工判断,可配failOnWarning纳入 CI。
本节解决的是「冲突已经发生怎么修」。更根本的做法是让冲突不发生——把所有版本收拢到一处统一管理,这正是下一节 2.2 版本对齐与 BOM 的主题。
阅读导航:上一节:1.3 分层与包结构约定 · 下一节:2.2 版本对齐与 BOM 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。