编译、单测、打包全绿,上线第一次调用就抛 NoSuchMethodError,堆栈指向的 API 在源码里找不到调用点——根因通常在依赖树。
Maven 的解析是"先建图、再剪枝":同一个 groupId:artifactId 在图中出现多次只保留一个版本,其余丢弃。决定保留谁的是最短路径优先与先声明优先,后者意味着 pom 里两行 <dependency> 的顺序能改变生产环境的运行结果。
1. 两条调解规则
| 规则 | 触发条件 | 判定 | 后果 |
|---|---|---|---|
| 最短路径优先 | 候选版本深度不同 | 深度小的胜 | 直接依赖 > 二级传递 > 三级传递 |
| 先声明优先 | 深度相同 | 依赖树构建顺序中先出现的胜 | 交换 pom 中两段声明即可换版本 |
版本锁定(dependencyManagement) |
任意深度 | 管理版本强制生效 | 优先级高于前两条,但不覆盖已显式声明版本的直接依赖 |
先把 c3p0 声明在前、还是把 legacy-dao 声明在前,解析结果不同:
<!-- A:c3p0 先声明,胜出 0.9.5.5 -->
<dependency>
<groupId>com.mchange</groupId><artifactId>c3p0</artifactId><version>0.9.5.5</version>
</dependency>
<dependency>
<groupId>com.example</groupId><artifactId>legacy-dao</artifactId><version>2.1.0</version>
<!-- 内部依赖 c3p0:0.9.1.2 -->
</dependency>
把这两段声明对调,legacy-dao 先入图,它内部要求的 c3p0:0.9.1.2 先占位,低版本胜出。代码一行未改,classpath 已换——这是不可维护的隐式契约,IDE 排序就能改回去。正确做法:用 dependencyManagement 或 exclusions 显式钉住。
2. dependency:tree 的读法
$ mvn -q dependency:tree -Dverbose -Dincludes=org.springframework
[INFO] com.example:clarity-web:jar:1.0.0
[INFO] +- org.springframework.boot:spring-boot-starter-web:jar:3.3.4:compile
[INFO] | \- org.springframework:spring-webmvc:jar:6.1.13:compile
[INFO] | \- org.springframework:spring-web:jar:6.1.13:compile
[INFO] +- com.example:legacy-client:jar:2.1.0:compile
[INFO] | \- (org.springframework:spring-core:jar:5.3.39:compile - omitted for conflict with 6.1.13)
| 记号 | 含义 | 读法 |
|---|---|---|
+- |
后面还有同级兄弟 | 该层展开未结束 |
\- |
父节点的最后一个孩子 | 分支到此为止 |
omitted for conflict with 6.1.13 |
本版本被 6.1.13 取代 | 查 6.1.13 从哪条路径进来 |
omitted for duplicate |
同一 GAV 已出现过 | 版本一致,通常无害 |
(version managed from 5.3.39) |
版本被管理段改写 | 源头在 dependencyManagement |
:compile / :runtime / :test |
传递后的实际 scope | 决定是否进最终 lib/ |
三个要点:加 -Dverbose,否则 conflict/duplicate 标记不打印;加 -Dincludes,全量树几百节点没法读;树是解析后的视图,被丢弃的版本只有 verbose 才看得见;要留证再加 -DoutputFile=/tmp/tree.txt。
3. dependencyManagement 的边界
它是"版本声明池",不是"依赖清单"。生效范围:
| 场景 | 是否生效 | 原因 |
|---|---|---|
| 传递依赖的版本 | 生效 | 锁定在剪枝之前应用,优先于两条调解规则 |
直接依赖未写 <version> |
生效,补上版本 | 主要用途 |
直接依赖已写 <version> |
不生效 | 显式版本优先 |
| 同 GA 在管理段出现两次 | 取第一次 | 与依赖顺序规则一致 |
| 管理段 GA 拼写不符 | 静默不生效 | 无匹配项,无任何警告 |
"写了却没生效"绝大多数是最后一行:管理段写 spring-boot-starter-web,树里却是 spring-web,两个 artifact,碰不到。
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>3.3.4</version>
<type>pom</type><scope>import</scope>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.17.2</version>
</dependency>
</dependencies>
</dependencyManagement>
同一 GA 取第一次出现,所以单条锁定必须排在 BOM 之后。更稳的做法是改 BOM 暴露的属性(如 jackson-bom.version)——BOM 内部引用属性而非字面量,改一处对所有构件生效。
4. exclusions 与排除过度
exclusions 是唯一能从图上删除节点的手段(管理段只改版本),代价是报错推迟到运行期。
<dependency>
<groupId>com.example</groupId><artifactId>legacy-client</artifactId><version>2.1.0</version>
<exclusions>
<exclusion>
<groupId>org.springframework</groupId><artifactId>spring-core</artifactId>
</exclusions>
</dependency>
| 做法 | 效果 | 风险 |
|---|---|---|
exclusions 排除冲突版本 |
图变小、版本唯一 | 过度排除 → ClassNotFoundException |
改为 provided |
编译可见、运行期由容器提供 | 容器无同名 jar 时 NoClassDefFoundError |
| 升级上游依赖 | 从根上换掉传递版本 | 需上游兼容,改动面大 |
| 管理段改版本 | 保留节点、换版本 | 二进制不兼容时 NoSuchMethodError |
排除过度的典型症状:启动报 NoClassDefFoundError: org/apache/commons/logging/LogFactory,而 dependency:tree 干干净净——树只反映声明的图,不反映运行期需要的类。先确认该类是否由 JDK 或其他依赖提供(jcl-over-slf4j 就是顶替 commons-logging 的桥接包)。
5. scope 与 optional 改变传递性
| 直接依赖 scope | 传递为 compile | 传递为 runtime | 传递为 test |
|---|---|---|---|
compile |
是 | 是 | 否 |
provided |
否 | 否 | 否 |
runtime |
否 | 是 | 否 |
test |
否 | 否 | 否 |
optional=true |
否 | 否 | 否 |
两个高频误判:provided 切断整条传递链,依赖 A 是 provided,A 依赖的 B 也不进 classpath,哪怕你的代码直接用了 B;optional 是单向下行,对声明方存在、对使用方不存在,适合"部分场景才需要"的扩展,反着想强制别人带上就是错的。
6. BOM 统一版本与 shade 重定位
| 方式 | 接管范围 | 适用 |
|---|---|---|
spring-boot-starter-parent 作 parent |
依赖版本 + 插件配置 + 资源过滤 | 单体应用 |
spring-boot-dependencies 作 BOM |
只接管依赖版本 | 多模块、已有 parent |
自建 BOM 的最小结构:packaging=pom + dependencyManagement 列全 GA 与版本,其他工程 import 它。
版本冲突已无法靠换版本解决时(两个库硬依赖同名 API 的不兼容版本),最后一招是 shade 重定位:把其中一个库的包名整体改掉,两份共存。
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.6.0</version>
<executions><execution><phase>package</phase><goals><goal>shade</goal></goals>
<configuration>
<relocations><relocation>
<pattern>com.google.common</pattern>
<shadedPattern>com.example.shaded.guava</shadedPattern>
</relocation></relocations>
<filters><filter><artifact>*:*</artifact><excludes>
<exclude>META-INF/*.SF</exclude>
</excludes></filter></filters>
</configuration>
</execution></executions>
</plugin>
边界很硬:重定位改的是字节码常量池引用,反射按字符串加载的类、ServiceLoader 配置文件、JNI 库路径都不会被改写,需配合 ServicesResourceTransformer 逐项处理。
7. 冲突的三张面孔与定位手段
| 报错 | 语义 | 定位手段 |
|---|---|---|
NoSuchMethodError |
类在、方法签名不存在 | dependency:tree -Dverbose -Dincludes=GA 找胜出版本;javap 看调用点签名 |
NoClassDefFoundError |
编译期有、运行期缺 | 查 lib/ 是否有该 jar;-verbose:class 看加载来源 |
LinkageError / IncompatibleClassChangeError |
同类被两个加载器加载,或形态不匹配 | -verbose:class 搜同一类名出现两次 |
# 打印每个类的来源,确认到底加载了哪个 jar 里的版本
$ java -verbose:class -jar clarity-web.jar 2>&1 | grep -i "spring-core"
[0.312s][info][class,load] org.springframework.core.SpringVersion
source: jar:file:/app/lib/spring-core-6.1.13.jar!/org/springframework/core/SpringVersion.class
读法是搜类名、看 source: 后缀。同一类在两行出现且指向不同 jar,问题不在调解而在打包(fat jar 混入 provided 的 jar,或容器自带 jar 与 lib/ 重复)。
8. 排错
| 现象 | 根因 | 处理 |
|---|---|---|
| 调顺序压住冲突,重构后又复现 | 先声明优先是隐式契约 | 用 dependencyManagement 钉版本 |
| BOM 导入后版本没变 | 单条锁定排在 BOM 之前 | 调到 BOM 之后,或改 BOM 属性 |
| 启动缺类但树里没有 | 排除过度 / provided 断链 |
恢复节点或显式声明 |
| 同一个类出现两份 | 打包混入重复 jar | 查 shade artifactSet 与 scope |
dependency:analyze 报 unused |
只被反射/SPI 使用 | 加 ignoredUnusedDeclaredDependencies 白名单 |
SNAPSHOT 的解析是"每天一次或 -U 时刷新元数据",同一坐标不同时间可能给出不同内容。release 依赖 SNAPSHOT 是构建不可重现的头号来源——树稳定,行为不稳定。
9. 边界
| 场景 | 更合适的做法 | 理由 |
|---|---|---|
| 两库硬依赖不兼容的同名 API | 类加载器隔离或 shade 重定位 | 版本调解无法让两份不兼容实现共存 |
javax.* 与 jakarta.* 混用 |
整批迁移到 jakarta 命名空间 |
包名不同,不是版本问题 |
只有运行期才暴露的 NoSuchMethodError |
集成测试 + -verbose:class 断言 |
依赖分析证明图,证明不了行为 |
Maven 能保证"图里只有一个版本",保证不了"这个版本行为兼容":前者靠 -Dverbose 与 dependencyManagement,后者靠测试和 -verbose:class。
10. 自测
- 同一 artifact 出现在深度 2 和深度 4,保留哪个?都在深度 3 呢?
omitted for conflict with 6.1.13与omitted for duplicate的区别?哪个需要处理?- 管理段写了版本、直接依赖也写了
<version>,最终用哪个? - 某依赖改成
provided后,它的传递依赖还进 classpath 吗? - 报
NoSuchMethodError,哪两条命令分别确认"树上保留谁"和"运行时加载谁"? - 为什么靠调整声明顺序解决冲突不可维护?替代方案是什么?