1. 一个每天都在用、却很少有人真正看过的命令
先问个问题:你在 IDEA 或者命令行里执行 mvn install 的时候,有没有想过它和 mvn package 到底差在哪?如果只是把项目打成 jar 包,为什么还要多一个 install 的步骤?更关键的是,install 之后那个 jar 包到底被放到了哪里、你的项目又是怎么在依赖另一个本地模块时找到它的?
我早期用 Maven 的时候,就是“背命令”的状态:clean 是清缓存,package 是打包,install 是装到本地仓库——至于“装”这个过程到底发生了什么、中途执行了哪些插件、哪些生命周期阶段被触发了、为什么有时候 install 会跑测试而有时候又不会,完全是一团浆糊。直到有次在同事电脑上调试一个多模块项目,发现他那边 install 一次就几分钟,我这边却要跑十几分钟,才意识到:install 这个命令背后藏着一整条构建管道,而我们对它的理解往往停留在“会生成 jar”这个层面。
这篇就从头到尾拆一遍 Maven install 的完整执行链路,包括它挂在哪个生命周期、依次触发哪些 phase、每个 phase 背后是哪些插件在干活,以及 install 之后本地仓库里到底多了哪些文件、这些文件是怎么被其他项目消费的。最后结合我实际踩过的坑,聊几个 install 相关的典型问题和诊断思路。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. install 在整个 Maven 生命周期里的确切位置
2.1 Maven 的三套生命周期,别搞混
Maven 官方把构建过程分成了三套相互独立的生命周期:clean(清理)、default(主构建)、site(站点文档)。install 属于 default 生命周期,而我们最常配对的 mvn clean install 里,clean 其实调用的是另一套生命周期里的 clean phase。
所以严格来说,mvn clean install 是让 Maven 先执行 clean 生命周期中的 clean 阶段,再执行 default 生命周期里 install 之前的全部阶段。这个“全部阶段”,就是理解 install 的关键。
default 生命周期从 validate 开始,依次经过 initialize、generate-sources、process-sources、generate-resources、process-resources、compile、process-classes、generate-test-sources、process-test-sources、generate-test-resources、process-test-resources、test-compile、process-test-classes、test、prepare-package、package、pre-integration-test、integration-test、post-integration-test、verify,最后才是 install 和 deploy。
每一阶段都有明确的语义:
compile编译主代码;process-classes对编译产物做字节码增强、资源过滤之类的后处理(少见但存在);test执行测试(默认是 Surefire 插件跑 JUnit/TestNG);package把编译产物按项目打包类型打成 jar/war/ear;verify做一些额外的质量检查(比如 Sonar 扫描、集成测试结果校验);install把打包产物安装到本地仓库;deploy则进一步把产物上传到远程仓库。
关键点在于:执行 install 这个 phase 时,Maven 会严格按照上述顺序依次执行 install 之前的所有 phase。也就是说,install 不是一条孤立的命令,它是一个漫长构建流程的终点站——编译、测试、打包这些环节在 install 之前已经全部完成了。
2.2 为什么 install 默认会触发测试?
理解了上面的 phase 顺序,另一个常见的疑问也就迎刃而解了:为什么执行 mvn install 时经常看到 JUnit 相关的输出?因为 test phase 排在 install 之前,是必经之路,除非显式配置跳过。
这也是很多人不喜欢在频繁构建时用 install 的原因:测试偶尔比较慢,或者某些集成测试依赖外部环境,跑一次全量测试的代价很高。于是常见的做法是:
bash复制mvn install -DskipTests
-DskipTests 只跳过测试的执行,但会编译测试代码;如果你想连测试代码的编译也跳过,就用:
bash复制mvn install -Dmaven.test.skip=true
二者的差别建议实验一次,看控制台输出,体会就非常直观。
2.3 真正发布到远程仓库的是哪个命令
install 的终点是本地仓库(默认是 ${user.home}/.m2/repository),它只对本机生效。如果你想把构件分享给团队其他人或 CI 服务器统一管理,需要用 deploy 命令,或者配置 maven-deploy-plugin 在 install 之后执行上传。很多新人在本地 install 之后以为别人也能依赖到,其实别人根本拉不到。
这一节的结论是:install 不是“打包 + 上传”的组合,它是“打包 + 安装到本地仓库”的一整条流水线。
3. 从源码到产出:install 触发的一条完整构建流水线
这一节我们按时间顺序,把 install 一路触发的所有关键环节逐个过一遍,看看每个 phase 到底调用了什么插件、做了什么操作。
3.1 从 validate 到 process-resources:项目准备与资源拷贝
在 validate 和 initialize 阶段,Maven 会做基础的项目状态校验、配置属性初始化。对于大多数常规项目,这两个阶段没有明显的日志输出、也不直接产生文件,但它们是后面所有步骤的前提。
真正产生视觉感知的第一个主要环节是 process-resources,它由 maven-resources-plugin 执行。这个插件做的事情是把 src/main/resources 目录下的资源文件(配置文件、模板、XML、properties 等)拷贝到 target/classes 下(如果项目构建输出目录被修改了,则是对应的其他路径)。
为什么这一步很重要?因为紧接着 compile 编译出的 .class 文件也会输出到 target/classes,资源文件和类文件必须最终拼在一起,才能组成一个可运行或可被依赖的 jar。很多“资源配置生效不了”的问题,根源就是资源没有进入 target/classes,install 时自然也没有被打进 jar。
如果你需要额外引入不在默认资源目录内的文件,可以在 pom 里这样配置:
xml复制<build>
<resources>
<resource>
<directory>src/main/resources</directory>
</resource>
<resource>
<directory>src/main/profiles</directory>
<filtering>true</filtering>
</resource>
</resources>
</build>
filtering 的 true 表示允许对资源文件做变量替换(比如 application.properties 里的 ${db.url} 被替换成 profile 中配置的真实值)。这是构建实践中非常常用的一个能力,也是排错时常常被忽视的一个点。
3.2 compile 与 test-compile:编译期才是性能关键
compile 由 maven-compiler-plugin 的 compile goal 完成,负责把 src/main/java 下的所有 Java 源码编译成 .class 文件,输出到 target/classes。默认情况下,它只增量编译——如果你没有做 clean,Maven 会比对源文件与 class 文件的修改时间,只编译变更过的文件。这也就是为什么 mvn clean install 往往比 mvn install 慢一大截的原因:clean 把整个 target 目录清空了,编译相当于从零开始。
注意:
maven-compiler-plugin默认使用的 source/target 版本受 Maven 默认配置影响。不同 Maven 版本对应不同的默认 Java 版本,如果你明明本地装了 JDK 17,构建产物却被编译成了 Java 8 字节码,多半是没在 pom 里显式声明<maven.compiler.source>和<maven.compiler.target>,或者没有用<java.version>这类属性。建议所有新项目都强制锁定这两个配置,否则换台机器、换个 Maven 版本,行为就可能不一致。
test-compile 阶段同样由 compiler 插件执行,编译 src/test/java 下的测试代码到 target/test-classes。有些项目的测试代码量巨大,这个阶段会比较耗时,这也是 install 和 package 无法免掉的开销。
3.3 test:Surefire 的执行细节与“跳过”姿势
test phase 默认绑定的是 maven-surefire-plugin 的 test goal。它会扫描 target/test-classes 中符合命名规则的测试类(默认是 *Test.java、Test*.java、*Tests.java、*TestCase.java),并按类加载、执行。执行完成后会在 target/surefire-reports 下生成 .txt 和 .xml 格式的测试报告。
如果某个测试失败,install 会在 test phase 就中断——后面的 package、install 都不会执行。默认行为如此,这是 Maven 有意为之的“fail-fast”设计。如果你想在一次构建中把能跑的测试都跑完再统一看失败情况,可以用:
bash复制mvn install -Dmaven.test.failure.ignore=true
这个参数不要在生产发布场景下用,但在本地调试、CI 阶段先收集全量失败结果时非常好用。
3.4 package:jar/war 的生成逻辑与 FAT JAR 的坑
package phase 的默认绑定插件取决于 <packaging> 类型。最常用的是 maven-jar-plugin(jar:jar),把 target/classes 连同项目元数据(pom.xml、pom.properties 等)打成 jar 包,输出到 target/ 目录。
这个 jar 只是一个“普通 jar”:它包含项目的 .class 和资源,但是不包含第三方依赖。很多人第一次接触 Spring Boot 时,构建出的 jar 跑不起来,报 ClassNotFoundException,就是因为默认 jar 不打包依赖。Spring Boot 项目之所以能 java -jar 直接运行,是因为它用了 spring-boot-maven-plugin 的 repackage goal,把普通 jar 重新组织成了包含 BOOT-INF/lib 下所有依赖的“可执行 FAT JAR”。
如果你的项目用 maven-assembly-plugin 或 maven-shade-plugin 来打 FAT JAR(比如某些微服务框架、自研工具链),在 install 阶段一个常见的坑是:install 装进本地仓库的到底是普通 jar 还是有依赖的 fat jar,取决于插件的 goal 绑定在哪个 phase。
一般来说,maven-assembly-plugin 的 single goal 绑定到 package phase,尽量在 install 前完成打包,这样安装进本地仓库的才是完整的、可直接运行的 fat jar。如果绑定错了 phase 或者配置了多个执行器,本地仓库里的可能是个残缺的小 jar,调用方依赖它时会报 NoClassDefFoundError。
3.5 verify:容易被忽略的安全与质量关卡
verify phase 在 Maven 默认生命周期里通常是空的,但很多插件会在这里挂上钩子。最常见的一类是 maven-enforcer-plugin——它可以在 verify 阶段强制校验依赖版本冲突、JDK 版本范围、禁止某些危险依赖等。如果你配置了 enforcer 规则,install 在 verify 阶段就可能在“打包完成后”立刻暴露出问题。
另一个常见的是 SonarQube 的 sonar-maven-plugin 的 sonar goal,通常在 verify 阶段执行静态代码扫描。很多团队把 Sonar 挂在 CI 的 mvn verify 上,也就是说:如果你在本地直接 install,不会触发 Sonar 扫描;但 CI 走 verify 则会。这也是“本地构建成功、CI 却失败”的原因之一。
我把 verify 单独拿出来说,是想提醒你:install 的完整流程并不只是“编译-测试-打包-放仓库”这么简单,只要 pom 里暗藏了任何绑定在 verify 的插件,install 也会执行它。
4. install 之后,本地仓库到底发生了什么变化
4.1 靠 maven-install-plugin 把什么写进了仓库
install phase 的默认绑定是 maven-install-plugin 的 install goal。它做的事可以拆成下面几件:
- 把
pom.xml安装到<localRepository>/<groupId路径>/<artifactId>/<version>/下,文件名是<artifactId>-<version>.pom; - 把
package阶段生成的 jar/war 安装到同目录下,文件名形如<artifactId>-<version>.jar; - 如果有源码包(通过
maven-source-plugin生成),也会一并安装,方便 IDE 在 Debug 时关联源码。 - 生成
_remote.repositories文件,记录这个构件是从哪个远程仓库拉取的(或标记为本地安装)。
不妨亲自去本地仓库看一眼。以我常用的一个内部工具库为例,它的 ~/.m2/repository/com/example/common-utils/1.2.3/ 目录结构大概长这样:
code复制common-utils-1.2.3.jar
common-utils-1.2.3.pom
_remote.repositories
_remote.repositories 的内容通常类似:
code复制com.example:common-utils:jar:1.2.3=local
注意,这里的 local 表示这个 jar 是 install 到本地仓库的,不是从某个远程仓库下载的。如果你是通过 IDEA 的 Maven 面板点击 install 按钮,同样会产生这些文件。
4.2 为什么安装的仓库位置会被“顶掉”?
上面说的是默认情况。在实际工作中,很多人会为了隔离不同项目的依赖,自己指定 localRepository。改法有三种:
- 修改
~/.m2/settings.xml里的<localRepository>; - 在 IDEA 的 Maven Settings 里指定
User settings file的路径,并在对应 settings.xml 里改 localRepository; - 用
-Dmaven.repo.local=/path/to/repo临时指定。
这个“本地仓库到底在哪里”非常重要,因为你的项目在依赖某个本地模块时,Maven 并不会去编译那个模块的源码,而是直接去本地仓库找对应的 jar。如果你的两个项目使用了不同路径的本地仓库,哪怕你 install 了无数次,另一个项目也永远拉不到。
这个坑我踩过不止一次。有一次帮同事排查“明明 install 成功、依赖方却一直报找不到构件”,最后发现他把 IDEA 的
Local repository改到了项目内的repo/目录,而依赖方项目用的还是默认路径。
4.3 本地仓库的“不可覆盖”陷阱:重复 install 时的行为
再深挖一层:你执行了两次 mvn install,同一个版本的 jar 第一次构建后,第二次会把旧的覆盖掉吗?
对于 maven-install-plugin 来说,它默认直接写入。也就是说,如果坐标完全一致,第二次 install 会直接覆盖同名 jar 和 pom;但如果本地仓库中该文件正在被别的进程占用(比如 IDEA 正在索引),在 Windows 平台上偶尔会报“文件被占用”的错,Linux/macOS 上一般不会。
覆盖虽然直接,但这里有一个隐患:如果某次 install 因为打包失败提前中断,或者安装的是残缺文件,那么本地仓库里已存在的旧版本可能没被替换(或替换了一半),依赖方拉取到的还是旧版本,但你却以为已经用上了新版本。
我在工作中就遇到过一次:同事改了公共代码,执行 mvn install -DskipTests 成功,但本地仓库里的 jar 是因为 maven-compiler-plugin 增量编译异常而重新用旧 class 打的——这个问题非常隐蔽。从那之后,凡是涉及共享基础库的变更,我基本都会先 mvn clean install 或者至少确认 target/classes 下的 class 确实更新了,再通知别人拉取。
5. install 和 package、verify、deploy 的边界,别再用错
很多初学者会把这几个命令混为一谈,但实际上它们的语义天差地别。我建议从“产物去向”和“生命周期阶段”两个维度来记忆:
| 命令 | 触发的最后一个 phase | 产物去向 | 典型使用场景 |
|---|---|---|---|
mvn compile |
compile | target/classes | 只检查是否能编译通过 |
mvn test |
test | target/test-classes + 测试报告 | 只跑测试 |
mvn package |
package | target/xxx.jar | 只想在项目内产出文件 |
mvn install |
install | 本地仓库 | 供本机其他模块或项目依赖 |
mvn deploy |
deploy | 远程仓库(Nexus/Artifactory) | 团队成员共享、CI 发布 |
更形象点说:package 是把饭做好放在自家厨房,install 是把饭放进冰箱(本地仓库),而 deploy 是把饭快递给全公司(远程仓库)。多模块项目里你之所以必须先 install 公共模块,是因为另一个模块在解析依赖时默认去本地仓库(冰箱)里找,而不是现场去隔壁厨房“化缘”。
另外,还有一个值得注意的细节:deploy 默认依赖 install 吗?在生命周期语义上,deploy 排在 install 之后,如果你执行 mvn deploy,它会先经过 install 阶段——也就是说,它会先把构件安装到本地仓库,再去上传远程仓库。不要把这个“先本地后远程”的顺序忽略,因为如果你本地仓库权限或磁盘有问题,deploy 可能第一步就失败,而远程仓库反而没收到任何东西。
6. 实战:从一次“install 后依赖不生效”说起,手把手排查
6.1 现象描述
在介绍基本原理之后,我来还原一个很典型的排查过程。假设你有一个多模块项目:
code复制parent-pom
├── module-common
└── module-web
module-web 依赖 module-common。你改了 module-common 的一个类,然后在 module-common 目录执行:
bash复制mvn install -DskipTests
成功结束。但回到 module-web,启动时报 NoClassDefFoundError 或者调用的还是旧方法、旧字段。你百思不得其解:明明刚刚 install 成功了呀。
6.2 排查链路
这类问题我用的排查顺序,按“成本从低到高”排列如下:
第一步,先确认安装到本地仓库的 jar 中到底有没有你要的新类/新方法。直接去本地仓库把 module-common-xxx.jar 解压,或用 javap 看目标类的字节码:
bash复制javap -classpath ~/.m2/repository/com/example/module-common/1.0.0-SNAPSHOT/module-common-1.0.0-SNAPSHOT.jar com.example.module.common.SomeClass
如果 class 里没有新方法,说明 install 的根本是旧产物。
第二步,看 install 日志里 jar 的生成时间与你源码修改时间是否匹配。Maven 构建时如果开了 -o(离线模式)或者 target/classes 目录下残留了大量旧 class,而 compiler 插件认为它们没有变更(时间戳校验,导致增量编译未触发),就可能用旧 class 打成新 jar。
简单粗暴的解决办法:mvn clean install。如果 clean 之后一切正常,那基本可以断定是增量编译的坑。
第三步,检查版本号是否一致。两种常见偏差:
- module-common 的 pom 里版本是
1.0.0-SNAPSHOT,但 module-web 依赖写的是1.0.0-RELEASE,你 install 的 SNAPSHOT 永远不被引到; - 两个模块的 groupId/artifactId 在协调时大小写、路径符号有差异,肉眼看不出来,但 Maven 会把它们当作完全不同的两个构件。
第四步,确认本地仓库是否被多个仓库位置“分流”。就像前面提到的,IDEA 的 local repository 是否和命令行 Maven 的 localRepository 一样?你命令行 install 到了一个私有 repo,IDEA 里的 Maven 依赖解析却用默认 ~/.m2/repository,自然不生效。这也是多环境开发中非常隐蔽的一个坑。
第五步,把 module-common 也加到 IDE 里作为模块依赖,而不是依赖 jar。这种方式在开发期能最大限度避免 install 环节带来的困惑。配合 IDEA 的 Delegating build actions to Maven 选项,让 IDE 直接通过 Maven 生命周期编译并解析模块内部依赖,基本可以做到“改了源码立即生效”。
6.3 类似场景的预防措施
基于上面这些经历,我后来在团队里定了几条很实在的规范:
- 公共模块和业务模块耦合紧密时,开发期尽量用 IDE 的模块依赖,只在发版或让其他不相关项目使用时才执行 install。
- 涉及公共二方库修改,执行 install 前先跑
mvn clean install -DskipTests,不要图省事跑不带 clean 的增量 install。 - pom 的
<revision>或<version>统一用属性变量管理,避免多模块版本号手写不一致。 - 如果 CI 环境构建出的产物总是和本地不一致,考虑把
~/.m2/repository挂到缓存目录,同时定期清理_remote.repositories和*.lastUpdated文件。
7. 高级:跳过测试的坑、LastUpdated 的迷惑行为与 IDEA 的差异
install 看似简单,但在特定场景下还有几个容易“翻车”的延伸问题。
7.1 skipTests 不等于不编译测试代码
前面说过 -DskipTests 与 -Dmaven.test.skip=true 的差别。我再给一个更完整的对照:
| 参数 | 测试代码是否编译 | 测试是否执行 | 常见用途 |
|---|---|---|---|
| 无 | 是 | 是 | 常规构建 |
-DskipTests |
是 | 否 | 快速打包、跳过执行 |
-Dmaven.test.skip=true |
否 | 否 | 彻底跳过整个测试 |
-Dmaven.test.failure.ignore=true |
是 | 是,但失败不终止 | 收集所有失败 |
团队里如果有同事不清楚这些参数的区别,建议直接把上表贴到项目 README 里,能省下不少答疑时间。
7.2 _remote.repositories 与 .lastUpdated 文件的迷惑点
当你的项目依赖某个远程仓库中的 SNAPSHOT 版本构件时,Maven 默认每天只检查一次更新(取决于仓库配置的 updatePolicy)。所以即便远端已经发布了新版本,你本地 mvn install 后,依赖方在同一天内重复构建,可能仍然拉取的是之前缓存的旧 SNAPSHOT。
如果你确定远端有更新,又不想等隔天,可以用:
bash复制mvn install -U
这里的 -U 表示强制检查 SNAPSHOT 更新。很多人以为 -U 是更新本地仓库的依赖,其实它只是强制刷新 SNAPSHOT 的远程元数据。
另一个容易误判的是 .lastUpdated 文件。当你请求某个依赖时远程仓库找不到(比如网络波动、groupId写错),Maven 会在本地仓库生成一个 xxx.lastUpdated 文件并缓存一段时间。在缓存时效内,即使你修复了网络或坐标,再次构建仍然会直接报“找不到依赖”,而不会重新尝试下载。解决方式:手动删掉对应的 .lastUpdated 文件,或者再执行一次带 -U 的 install。
7.3 IDEA 的 install 按钮和命令行的 install 有差别吗?
先说结论:对于同一个 Maven 配置,IDEA 的 install 按钮本质是执行 mvn install,动作本身没有区别。但有两点体验不同:
- IDEA 默认勾选了“Delegate IDE build/run actions to Maven”,这会影响你在 IDEA 里直接 Run 时的构建行为,但对 Maven 面板中的命令执行没有影响。
- IDEA 会使用自己的本地仓库索引(Maven Repositories 索引),即使命令行 install 成功了,IDEA 有时需要
Reload All Maven Projects或者Reimport才能刷新依赖列表,否则它会显示旧的依赖。如果你在 IDEA 里改了 pom 但没触发 reimport,就很容易出现“命令行能编译、IDEA 里一片飘红”的诡异现象。
8. 我的实操建议:怎么用 install 才不容易踩坑
回头再看 mvn install 这个命令,它的本质其实就一句话:把当前项目完整的构建成果安装进本地仓库,供其他项目在依赖解析时使用。
基于这个本质,我把这些年在各种项目里沉淀出的使用习惯整理一下:
- 频繁联调阶段,尽量别用
mvn install作为唯一交付方式,优先让 IDE 吃本地模块目录。 - 发布二方库之前,在干净的目录里执行一次
mvn clean install --no-snapshot-updates(或者去掉--no-snapshot-updates),确认从零构建能成功。 - 调整 pom 的依赖后,如果发现 IDE 还是引用旧依赖,执行一次
mvn clean install -U,再回 IDEReload All Maven Projects,能解决大多数“构建产物与依赖不一致”的怪问题。 - 诊断依赖相关问题时,不要凭感觉猜,优先看三处:本地仓库里的 jar 内容、pom 里实际解析到的依赖树(
mvn dependency:tree)、_remote.repositories和.lastUpdated的状态。 - 快照版本的依赖尽量只在内部联调使用,正式发布务必使用固定版本号,否则 install 到本地仓库后,另一个项目可能因为快照检查策略没有拉取到最新版,白白浪费时间。
实际操作的体会是,install 并不是一个需要“背参数”的命令,而是要把它的执行链路和生命周期阶段牢牢刻在脑子里:从 compile 到 package,再到写进本地仓库,每一步都是前一步产物的输入。理解了这条流水线,遇到“install 成功但依赖不生效”“install 耗时异常长”“install 后本地仓库文件缺失”这类问题,自然就知道该往哪个方向查了。
