1. 为什么我劝你把依赖树当成日常排查工具
如果你只在本地跑过 mvn clean install,项目出问题时第一反应是翻源码找冲突,那你大概率还没真正用上 Maven 最值钱的命令行能力之一。mvn dependency:tree这条命令看起来平平无奇,实际是我这几年排查构建问题、定位依赖冲突、给团队统一版本、给新同事解释项目结构时用得最顺手的一把刀。它能把项目里全部直接依赖和传递依赖,以树状结构一次性打印出来,配合-Dincludes、-Dverbose、-DoutputFile这几个参数,几乎可以覆盖日常所有依赖相关的问题定位场景。
先说清楚它解决的问题。Maven 默认的依赖解析机制遵循“最近优先”和“先声明优先”两条规则:同一个坐标如果在不同层级、不同分支被引入,离你项目最近的那个生效;距离相同则看你 pom 里谁写在前面。这个规则本身没问题,但它是隐式的,你从 pom 文件里根本看不出最终到底用了哪个版本。一旦出现NoSuchMethodError、ClassNotFoundException、NoClassDefFoundError、序列化报错这类运行时问题,八成是某个传递依赖的版本被悄悄替换掉了。这时候打开依赖树,谁覆盖了谁、哪条路径引进来的,一眼就能看清。
它适合谁用?我给出的判断很简单:只要你在用 Maven 建项目,并且项目依赖超过二十个,就应该把它纳入日常工具箱。新手用它理解“为什么我只写了一个坐标,最后却下下来十几个 jar”;老手用它做版本收敛、做依赖白名单、排查构建体积膨胀。尤其是在多模块项目、Spring Boot 这类依赖传递层级特别深的框架下,它几乎是定位问题的第一手段。
需要提前说明的是,本文涉及的命令行操作与实操步骤,是在原文所提供的方向和常见工程实践基础上进行的合理补充与整理,并结合我实际使用中的经验展开。你可以直接照着敲,也可以按自己的项目情况调整参数。
2. 命令整体设计与参数选型的思路
2.1 为什么是 dependency:tree 而不是其他手段
Maven 里和依赖相关的命令其实不少:dependency:list会打印一个平铺的依赖列表,dependency:analyze帮你找“声明了但没用”和“用了但没声明”的依赖,dependency:sources负责拉源码包。那为什么我一直优先用dependency:tree?
原因在于依赖冲突的关键信息是“路径”,而不是“集合”。dependency:list给你的是一堆坐标,你看到有两个不同版本的同一个 artifact,却不知道它们分别从哪条链路进来,也不知道为什么最后生效的是这个版本。依赖树把父子关系保留下来,缩进层级就是传递深度,+-和\-代表不同分支,末尾的(version managed from xxx)、(omitted for duplicate)、(omitted for conflict with xxx)这些标注,直接把 Maven 的裁剪决策摊开给你看。这是根因级的信息,平铺列表给不了。
另一个理由是它无侵入。你不需要改 pom,不需要装插件,不需要额外配置,只要在项目根目录敲一条命令就能拿到结果。对于线上环境复现、临时排查、给同事截图说明问题来说,这个特性太重要了。改 pom 去加插件再跑一遍构建,本身就是一种污染,尤其在多人协作的分支上,你很难解释为什么排查个问题还动了构建配置。
2.2 常用参数的作用与选择逻辑
dependency:tree的参数不算多,但每一个都值得单独讲清楚,因为选错参数会让你看到的信息量差好几倍。我把最常用的几个整理成下面这张表,后面会逐个展开说明。
| 参数 | 作用 | 典型使用场景 |
|---|---|---|
-Dverbose | 显示被省略、被冲突裁剪的依赖节点 | 定位“明明引了却没用上”的依赖 |
-Dincludes=groupId:artifactId | 只显示匹配的依赖路径 | 聚焦某个可疑坐标 |
-Dexcludes=... | 反向排除 | 过滤掉噪声大的框架依赖 |
-DoutputFile=name.txt | 结果写入文件 | 依赖过多、终端刷屏时 |
-DoutputType=graphml | 输出图形化结构 | 导入可视化工具分析 |
-pl 模块名 -am | 只分析指定模块及其上游 | 多模块项目定点排查 |
最容易被忽略的是-Dverbose。不加它,默认输出只展示“生效”的依赖,所有被冲突干掉、被重复省略的节点全部隐藏。你看到一个版本,其实是 Maven 帮你做完决策之后的最终结果,中间发生了什么你完全不知道。加它之后,被裁掉的节点会带着(omitted for conflict with 2.3.1)这类提示出现,你才能判断某个报错是不是因为版本被换掉了。
-Dincludes的写法有两种:groupId:artifactId或groupId:artifactId:version,也支持通配符*。我的习惯是排查具体报错时一定加上它,因为一个中型项目的完整依赖树动辄上千行,人的注意力是有限的,先把可疑坐标的引入路径全部拉出来,再决定下一步。
2.3 它在大项目里的定位
多模块项目里,依赖树有一个容易被忽视的用法:配合-pl和-am做定向分析。比如你有一个web模块、一个service模块、一个common模块,web依赖service,service依赖common。你在web下直接跑dependency:tree,会把它自己和所有下游的依赖都打出来,信息会很杂。用mvn -pl web -am dependency:tree,Maven 会先构建web的上游模块,再只对web做依赖树分析,输出范围更聚焦。
更进一步,如果你只是想确认某个坐标是从哪个模块传进web的,可以配合-Dincludes一起用。这两个参数组合起来,基本可以做到“指哪打哪”,不会被无关模块的输出淹没。
3. 依赖树核心细节与实操要点拆解
3.1 输出符号与关键标记的读法
刚接触依赖树的人,看到一屏+-、\-、|加竖线缩进会觉得有点乱,其实这套符号非常规整。+-表示一个还有后续兄弟节点的分支,\-表示最后一个分支,|是垂直的连接线,用来对齐层级。缩进和竖线的数量直接对应依赖深度,越深的行代表传递层级越多。
真正需要盯住的是行尾的括号标记,我列几个最常见的:
(version managed from 1.2.3):说明这个依赖的版本被dependencyManagement里的声明强制改写过,实际用的版本不是你看到的这个。(omitted for duplicate):同一坐标在同一路径下重复出现,后面的被省略,取第一个。(omitted for conflict with 5.5.5):这条路径引进来的版本与最终生效版本冲突,被裁掉,真正生效的是括号里那个。(scope managed from test):作用域被dependencyManagement改过,这种情况常在测试依赖泄漏到主构建时出现。
有一次我遇到某工具类在本地跑得好、打到测试环境就报ClassNotFoundException,就是因为dependencyManagement里统一把某个日志实现的版本压低了,树里显示version managed from,只看 pom 完全发现不了。这个标记是排查“本地正常、环境异常”类问题的关键线索。
3.2 dependencyManagement 对树的改写作用
dependencyManagement是父 pom 控制全项目版本的核心手段,但它对依赖树的影响是隐式的。它不主动引入依赖,只负责“如果你用到了这个坐标,版本按我说的来”。这就导致一个现象:子模块里写的版本号,可能在最终树里根本不是那个值。
举个我真实遇到的例子。子模块 A 写了fastjson 1.2.60,父 pom 的dependencyManagement里统一声明fastjson 1.2.83。树上会显示fastjson:1.2.83 (version managed from 1.2.60),实际打进包的就是 1.2.83。有人看子模块 pom 以为用的是 1.2.60,排查安全问题时判断错了版本,这就是没读依赖树的代价。
注意:
dependencyManagement的版本改写会覆盖所有子模块,包括从传递依赖引进来的坐标。如果你的项目里有多个父 pom 层级,就近的那一层生效。排查时不要在子模块里盲目改版本,先确认父 pom 有没有统一管理。
实际经验里,很多团队会用dependencyManagement做“版本收敛”,把所有传递依赖的版本钉死在一个可控集合内。这本身是好事,但它会让子模块 pom 和实际依赖产生偏差。所以每次接手一个新项目,我的第一件事就是在根目录跑一次mvn dependency:tree -Dverbose,把实际生效的依赖快照存下来,作为后续排错和评审的基线。
3.3 scope 对输出范围的影响
默认情况下,dependency:tree会把compile、runtime、provided、test全都展示出来,只是会在行尾标注作用域。很多人排查线上依赖时,把test作用域的依赖也当成打进包的一部分,这是典型的误判。
作用域的含义这里快速过一遍:compile是编译和运行都参与;provided编译期参与、运行期由容器提供,比如 servlet-api;runtime编译期不参与、运行期参与,比如 JDBC 驱动;test只在测试编译和运行中参与,不会打进最终产物。
如果你只想看运行时会打进去的依赖,可以加-Dscope=runtime。注意这个参数不是“过滤显示”,它会改变依赖解析的范围。排查生产问题、评估发布包体积时,用这个参数更贴近真实情况。而排查测试依赖泄漏、单测找不到类这类问题时,反而不该加,保持全量输出才能看到test节点的引入路径。
3.4 一个完整输出片段的解读示例
为了让大家有具体感受,我拿一段真实输出片段做解读(坐标做过脱敏处理):
[INFO] +- org.springframework.boot:spring-boot-starter-web:jar:2.7.18:compile [INFO] | +- org.springframework.boot:spring-boot-starter:jar:2.7.18:compile [INFO] | | \- org.springframework:spring-core:jar:5.3.31:compile [INFO] | \- org.springframework:spring-webmvc:jar:5.3.31:compile [INFO] | \- org.springframework:spring-context:jar:5.3.31:compile [INFO] +- com.example:common-utils:jar:1.0.0:compile [INFO] | \- com.alibaba:fastjson:jar:1.2.60:compile (version managed from 1.2.83)看这段:spring-core是从spring-boot-starter传进来的,深度为 3;spring-webmvc是从spring-boot-starter-web直接传进来,深度为 2。因为深度更浅,spring-webmvc及其子树优先级更高。而fastjson那一行的version managed from告诉你,虽然树里显示 1.2.60,但最终打进包的其实是被父 pom 改写后的 1.2.83,实际生效版本以括号里为准。
这就是为什么我一直强调:读依赖树不能只看坐标和版本号,行尾括号里的信息才是决策结果。
4. 从零开始跑通依赖树实操全过程
4.1 前置确认:环境与项目状态
敲命令之前,有两个地方必须先确认,否则很容易拿到误导性结果。
第一是 Maven 版本。dependency:tree由maven-dependency-plugin提供,默认版本随 Maven 发行版绑定。Maven 3.6 及以上版本对应插件行为比较稳定,输出格式也一致。如果你用的是很老的 3.3 以下版本,有些标注可能不显示。用mvn -v看一眼当前版本,心里有数就行。顺带一提,如果项目里显式声明了插件版本,那就以项目声明的为准,可以用mvn dependency:tree -Dplugin.version=3.6.1这类方式指定。
第二是项目状态。依赖树分析的是 pom 的声明与解析结果,不需要先编译。也就是说,你可以在没下载代码依赖的情况下直接跑,Maven 会自动解析并拉取元数据。但如果本地仓库里缺少某个坐标的元数据、且当前网络不通,会报解析失败。这种情况加-o走离线模式之前,先确认本地仓库里确实有这个包,否则报错信息会很绕。
提示:依赖树分析建议在项目根目录执行,多模块项目尤其如此。在子模块目录执行只会输出该模块自身的视角,父 pom 的
dependencyManagement是否生效、聚合模块的依赖如何传递,都会看不全。
4.2 基础命令与最小可运行示例
最基础的形式就是一行:
mvn dependency:tree在单模块项目里,这一条命令就够了。执行过程分三步:Maven 读取当前目录的 pom,解析项目依赖,然后按树状结构打印。输出分三段:前半段是[INFO]的构建过程日志,中间是依赖树正文,结尾是BUILD SUCCESS和构建耗时。依赖树正文从[INFO] --- maven-dependency-plugin:...:tree这一行之后开始。
如果你在 Windows 的 cmd 里执行,命令是一样的,只是路径分隔符和终端滚屏行为略有不同。建议把输出重定向到文件,否则依赖一多,最后几屏会把前面刷没:
mvn dependency:tree -DoutputFile=tree.txt这条命令会把依赖树写进项目根目录下的tree.txt,终端只显示构建日志。这样你既保留了完整结果,又不影响阅读,还可以把这个文件丢进代码评审、贴到工单里。我通常会在文件名里带上日期和分支名,比如tree-20240612-feature-login.txt,回溯起来方便。
4.3 过滤目标坐标:includes 与 excludes 的实战写法
当依赖树超过几百行,全量输出就没有意义了。这时候必须用过滤,把关注范围收窄。
排查某个具体报错时,用-Dincludes指定坐标。注意它的匹配单位是“路径”,不是简单匹配坐标本身:
mvn dependency:tree -Dincludes=com.alibaba:fastjson这条命令会保留所有包含fastjson的路径,包括它自己的子树和所有能到达它的父节点。输出会变成若干条从根到该坐标的路径,远比全量树好读。
如果只想看某个 groupId 下的全部依赖:
mvn dependency:tree -Dincludes=com.fasterxml.jackson.core:*反过来,如果你觉得日志框架、测试框架这类依赖刷屏太烦,可以用-Dexcludes排除:
mvn dependency:tree -Dexcludes=org.slf4j:*,ch.qos.logback:*includes和excludes可以同时使用,逻辑是先按 excludes 排除,再按 includes 保留交集。我一般不会两个一起用,容易绕晕,更常见的组合是-Dincludes加-Dverbose。
4.4 用 verbose 把被隐藏的冲突节点挖出来
这是我个人认为最有价值的一个参数。前面说过,默认输出只展示生效节点,被裁掉的看不到。加-Dverbose之后,整个决策过程会完整暴露:
mvn dependency:tree -Dverbose -Dincludes=com.google.guava:guava输出里会出现类似这样的行:
[INFO] +- com.google.guava:guava:jar:20.0:compile (omitted for conflict with 32.1.3-jre) [INFO] \- com.google.guava:guava:jar:32.1.3-jre:compile这一行直接告诉你:某个传递依赖引进了 guava 20.0,但最终生效的是 32.1.3-jre,20.0 被裁掉了。如果某个三方库是按 guava 20.0 的 API 编译的,运行在 32.1.3 上出现NoSuchMethodError,你就找到了根因。
排查这类问题时我有个固定套路:先用-Dverbose -Dincludes=可疑坐标拿到全部冲突路径,再用mvn dependency:tree -Dverbose -DoutputFile=full-tree.txt存一份全量快照,把两个文件对照着看。冲突路径告诉你“谁引进来、谁胜出”,全量快照告诉你“有没有别的路径也在引这个坐标”。两者结合,基本不会有遗漏。
4.5 多模块项目下的定点分析
多模块项目里,直接跑dependency:tree会有两个问题:一是每个模块都输出一棵树,量大;二是聚合顺序和你想看的模块不一定一致。推荐用-pl指定模块、-am带上它的上游:
mvn -pl web-app -am dependency:tree -Dincludes=org.apache.commons:commons-lang3-pl指定目标模块,-am表示同时构建该模块依赖的上游模块。注意这个组合下,Maven 会先构建上游模块的依赖树信息,再对目标模块做分析,所以你会先看到上游模块的输出,然后是目标模块的。如果你只想要目标模块的结果,可以配合-DoutputFile把整体写文件,再定位目标模块那一段。
有个坑要提醒:-pl只在聚合 pom(packaging 为 pom 的父工程)的目录下有效。如果你在某个子模块目录里执行,Maven 找不到-pl指定的模块路径,会直接报错。所以多模块排查一定回到根目录。
5. 依赖冲突与常见报错的排查实录
5.1 从 NoSuchMethodError 到版本冲突的定位链路
这是我最常处理的一类问题。现象是:编译通过、本地单测通过,一上环境就抛NoSuchMethodError: com.xxx.SomeClass.someMethod。这种错误的本质是——你编译时用的是一个版本,运行时加载的是另一个版本,而另一个版本里没有这个方法。
定位链路我总结成四步。第一步,拿到报错类全限定名,比如com.fasterxml.jackson.databind.ObjectMapper。第二步,用依赖树反查这个类来自哪个 artifact,通常直接用 groupId 加 artifact 名过滤:
mvn dependency:tree -Dverbose -Dincludes=com.fasterxml.jackson.core:jackson-databind第三步,看输出里有没有omitted for conflict标记,找到所有被裁剪的版本号和它们的引入路径。第四步,判断引入路径的源头:是某个三方库传进来的老版本,还是你自己声明的?如果是三方库,在dependencyManagement里锁定新版本即可;如果是自己在不同模块声明了不同版本,统一收敛。
实测下来,NoSuchMethodError有七八成是这条链路能定位到的,剩下的是三方库内部做了不兼容的重构,那就需要单独评估升级成本。
5.2 常见问题速查表
下面这张表是我这些年积累的排查速查表,遇到问题可以先对照查,能省不少时间。
| 现象 | 可能原因 | 排查动作 |
|---|---|---|
NoSuchMethodError | 运行时版本与方法签名不匹配 | -Dverbose -Dincludes=坐标找冲突节点 |
ClassNotFoundException | 依赖未引入或作用域错误 | 查依赖树中该坐标是否存在、scope 是否为 provided/test |
| 本地正常、环境报错 | dependencyManagement改写了版本 | 查version managed from标记 |
| 打完包体积异常大 | 引进了带完整依赖链的 starter | 全量树对比,检查重复坐标 |
| 同一坐标出现多个版本 | 传递依赖路径不同、未收敛 | -Dverbose看全部冲突,统一dependencyManagement |
| 测试类编译不过 | test 依赖未声明或 scope 错误 | 查依赖树中 test 作用域节点 |
| 构建时间突然变长 | 某个依赖引入大量传递依赖 | 全量树与历史快照对比 |
注意:速查表只能帮你缩小范围,最终定位依然要靠真实现象加依赖树对照。不要看到
NoSuchMethodError就直接改版本号,先确认冲突路径,否则容易越改越乱。
5.3 用依赖树做版本收敛的实操
版本收敛是依赖树的高阶用法。所谓收敛,就是把项目里同一个坐标的多个版本统一成一个。做法是:先用全量-Dverbose树找出所有存在多版本的坐标,再在父 pom 的dependencyManagement里逐个锁定。
具体操作上,我一般会跑两次树。第一次是收敛前,存成before.txt;改完dependencyManagement后跑第二次,存成after.txt。然后把两个文件丢给 diff 工具对比,确认目标坐标的冲突标记消失、没有引入新的冲突。这一步很重要,因为dependencyManagement锁定版本时,如果选了一个和某条传递路径不兼容的版本,可能引入新的运行时报错。diff 能帮你提前发现这类风险。
收敛的原则是“就近取新,兼顾兼容”。意思是优先选择那个被最多路径依赖的版本,同时确认它和主要框架兼容。不要盲目追新版本,尤其涉及序列化、字节码增强、反射相关的库,版本跨度大时要走完整回归。
5.4 一个真实的排查案例复盘
讲一个我印象最深的案例。某次线上接口偶发序列化失败,报错是com.fasterxml.jackson.databind.exc.InvalidDefinitionException,提示某个类没有序列化器。本地完全复现不了。
按流程走:先过滤jackson-databind,输出显示最终生效版本是 2.13.5,但某个内部工具库传进来一个 2.9.10,被标记omitted for conflict。看起来没问题,冲突已经处理了。继续看jackson-databind的子树,发现它依赖的jackson-core和jackson-annotations版本没被统一管理,树里同时存在 2.13.5 和 2.9.10 两个版本,而且jackson-annotations最终生效的是 2.9.10。
问题就在这:jackson-databind 2.13.5和jackson-annotations 2.9.10混用,某些注解在 2.9 里还不支持,导致序列化器解析失败。修复方式是在dependencyManagement里把 jackson 这一族三个坐标统一锁定为 2.13.5,重新构建后问题消失。
这个案例的教训是:不要只盯被标记冲突的那一行,同族坐标要一起看。Jackson、Spring、Netty 这类库的多个 artifact 之间有严格的版本对应关系,单锁一个往往不够。
6. 把依赖树用出花:进阶技巧与工程化落地
6.1 输出到文件并纳入构建记录
依赖树在团队协作里的价值,很大程度取决于它能不能被沉淀下来。我通常会在 CI 流水线里加一步,把依赖树输出成文件并作为构建产物归档:
mvn dependency:tree -Dverbose -DoutputFile=target/dependency-tree.txt这样每次构建都会生成一份依赖快照,随构建记录一起保留。好处有两个:一是版本变化有据可查,某次发布后出现兼容问题,可以对比上一次的树确认哪个依赖被换了;二是给安全审计用,某个组件爆出漏洞时,能快速定位哪些构建产物包含它。
文件名我建议带上构建号和提交哈希,比如dependency-tree-${BUILD_NUMBER}-${GIT_COMMIT}.txt。有些团队会把这一步做成独立 job,只在依赖相关文件变更时触发,避免每次构建都跑,节省流水线时间。
6.2 用 graphml 输出做可视化分析
当依赖数量很大时,纯文本树读起来还是累。dependency:tree支持输出成 graphml 格式,可以导入图分析软件查看:
mvn dependency:tree -DoutputType=graphml -DoutputFile=dependencies.graphmlgraphml 是一种通用的图结构描述格式,很多可视化工具都支持读取。导入后你能看到节点和边的分布,哪些坐标被大量节点依赖、哪些路径特别长,一目了然。这个用法我觉得特别适合两类场景:一是做架构评审,直观展示项目依赖的复杂度;二是给人解释“为什么引入一个小库会带进来几十个坐标”。
需要提醒的是,graphml 输出的是原始图结构,不包含冲突裁剪信息,梳理冲突还是要回到文本树配合-Dverbose。
6.3 依赖分析与安全审查的联动
dependency:tree本身不负责安全审查,但它为安全审查提供了最基础的数据源。现在很多依赖扫描工具都支持直接读取 Maven 依赖树的结果,或者通过dependency:list的输出做匹配。我的习惯是先把树导出,再把结果喂给扫描工具,这样扫描范围和报告能对得上。
另外,dependency:analyze和dependency:tree是互补的。前者告诉你哪些依赖声明了但没用到、哪些用到了但没声明,后者告诉你依赖从哪来。两者结合,可以做依赖清单的清理:把未使用的直接依赖删掉,把隐式依赖显式声明出来,项目会干净很多。
6.4 几个我踩过的坑
第一个坑是-Dincludes和-Dexcludes的匹配范围。它们匹配的是坐标字符串,不是模糊语义。如果你的 artifactId 里带连字符,用通配符时要注意写法,*utils*比utils更稳。我一开始用-Dincludes=guava死活匹配不到,后来才意识到必须写完整的groupId:artifactId或加通配符。
第二个坑是-Dverbose在部分 Maven 版本上和-DoutputFile组合时,写入的内容和终端显示偶有不一致。我的应对方式是不依赖文件,关键排查时以终端输出为准,文件作为留档。这个问题不常见,但踩到一次就会怀疑人生。
第三个坑是离线模式。本地仓库没缓存完就去加-o,Maven 不会告诉你缺哪个元数据,只会用一句很笼统的解析失败糊过去。正确做法是先联网跑一次让依赖完整下载,再切离线。我一般只在网络受限的构建机上用-o,本地排查不开。
第四个坑是颜色和编码。有些终端里依赖树的符号会显示成乱码,尤其是 Windows 默认编码下的 cmd。解决办法是先把终端编码切到 UTF-8,或者直接把输出重定向到文件用编辑器看,规避终端渲染问题。
6.5 我个人的几条使用习惯
用久了之后,我把这套操作固化成了几个习惯动作。第一,接手任何新项目,先在根目录跑一次mvn dependency:tree -Dverbose -DoutputFile=baseline.txt,建立基线。第二,排查报错先-Dincludes聚焦,再决定要不要全量。第三,任何版本调整前后都存树并 diff,用数据说话。第四,把依赖树归档写进 CI,作为长期可追溯的资产。
这几个习惯不复杂,但能覆盖绝大多数依赖相关的问题场景。依赖管理真正难的地方不在于命令本身,而在于你有没有把它当成日常工具去用,而不是出了问题才想起来翻一下。真要说这中间有什么诀窍,那就是:多存快照、多看冲突、多做对比,时间长了,看一眼树就能猜到问题大概在哪一层。