Java 基础体系 · 第 78/100 篇。示例统一以 Java 25 LTS 为语言和 JVM 基线;框架示例使用与其兼容的现代稳定版本。
Maven 完整基础:生命周期、依赖、插件、BOM、仓库和可重复构建
Maven 是一个以 项目对象模型(Project Object Model,POM) 为中心的 Java 构建工具。它不仅负责把 .java 文件编译成 .class 文件,还负责测试、打包、依赖解析、插件执行、发布工件以及多模块工程的构建顺序。
本文以 Java 25 LTS 为目标版本,完整说明 Maven 中最容易混淆、也最影响构建结果的几个概念:
- POM 与坐标
- 生命周期、阶段和插件目标
- 依赖、传递依赖、作用域和冲突仲裁
dependencyManagement与 BOM- 插件配置与插件版本
- 本地仓库、远程仓库、镜像和认证
- 多模块 Reactor 构建
- 可重复构建、依赖锁定和故障诊断
Maven 的核心关系可以先概括为:
POM
├── 声明项目身份、依赖和构建配置
├── 选择 packaging,从而决定生命周期绑定
├── 通过生命周期阶段触发插件目标
└── 通过仓库解析依赖和插件
一、开始前:安装 JDK 25 和 Maven
Maven 本身运行在 JVM 上。构建项目时,Maven 使用的 JDK 与项目编译目标不一定相同,但实际编译器必须能够生成目标版本的字节码。
先检查环境:
java -version
mvn -version
典型输出应能确认:
Java version: 25
Java home: ...
Apache Maven version: ...
这里有三个容易混淆的版本:
-
运行 Maven 的 JDK 版本
由JAVA_HOME或 Maven Toolchains 决定。 -
编译器使用的 Java 版本
通常由 Maven Compiler Plugin 的release参数决定。 -
项目运行时的 JRE/JDK 版本
例如测试执行时实际使用的 Java 25。
对于 Java 25,推荐使用:
<properties>
<maven.compiler.release>25</maven.compiler.release>
</properties>
release 的意义不是简单地把字节码版本设置成 25。它会要求编译器同时使用对应 Java 平台的语言规则、类文件目标版本和公开 API 约束,避免出现“字节码版本较低,但错误调用了更高版本 JDK API”的问题。
相比之下:
<maven.compiler.source>25</maven.compiler.source>
<maven.compiler.target>25</maven.compiler.target>
只分别控制语言级别和类文件目标级别,不能完整表达对应平台 API。对现代 JDK 项目,优先使用 release。
Maven 和插件也有自己的 JDK 兼容范围。实际工程中应选择支持 Java 25 的 Maven 版本和插件版本,并显式固定这些版本,而不是依赖开发机当前安装的最新版本。
二、POM:Maven 构建的输入模型
POM 是 XML 格式的项目描述文件,默认文件名为:
pom.xml
一个最小的 Java 25 项目可以写成:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="
http://maven.apache.org/POM/4.0.0
https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>hello-maven</artifactId>
<version>1.0.0</version>
<properties>
<maven.compiler.release>25</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
</properties>
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>...</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>...</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>...</version>
<configuration>
<release>25</release>
</configuration>
</plugin>
</plugins>
</build>
</project>
示例中的 ... 不是可直接执行的版本号。实际项目应选择经过验证的插件和依赖版本,并将其写死。不能把 ... 原样放入 POM。
2.1 项目坐标
Maven 用坐标唯一标识一个工件,基本形式是:
groupId:artifactId:packaging:classifier:version
常见简写是:
groupId:artifactId:version
各部分含义如下:
groupId:组织、团队或项目组的命名空间,例如com.exampleartifactId:工件名称,例如hello-mavenversion:版本号,例如1.0.0packaging:打包类型,默认是jarclassifier:同一版本下的附加变体,例如sources、javadoc
例如:
com.example:hello-maven:1.0.0
默认对应:
hello-maven-1.0.0.jar
如果还发布源码包,可能会有:
hello-maven-1.0.0-sources.jar
这里的 sources 是 classifier,不是另一个 Maven 项目。
2.2 POM 继承与有效 POM
Maven 不只读取当前 pom.xml,还会合并:
- 当前项目 POM
- 父 POM
- 超级 POM
- 用户级
settings.xml - 全局
settings.xml - 命令行属性
最终结果称为 有效 POM(effective POM)。
可以查看有效配置:
mvn help:effective-pom
可以查看 Maven 读取到的 settings:
mvn help:effective-settings
当你发现“POM 中没有配置,但 Maven 却执行了某个插件”时,通常应先检查有效 POM,而不是猜测 Maven 内部行为。
三、生命周期:Maven 如何组织构建流程
3.1 生命周期、阶段和目标不是同一个概念
Maven 中有三层概念:
-
生命周期(lifecycle)
一组有顺序的构建阶段。 -
阶段(phase)
生命周期中的命名节点,例如compile、test、package。 -
插件目标(goal)
某个插件提供的具体操作,例如编译 Java、运行测试、生成 JAR。
例如:
mvn compile
表面上执行的是 compile 阶段,但 Maven 实际会找到当前项目 packaging 对应的绑定,并执行类似:
maven-compiler-plugin:compile
因此:
compile 是阶段
compiler:compile 是插件目标
不能把二者混为一谈。
3.2 三条标准生命周期
Maven 内置三条标准生命周期:
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
clean 生命周期
负责删除构建输出:
pre-clean
clean
post-clean
site 生命周期
负责生成项目站点和报告:
pre-site
site
post-site
site-deploy
这些生命周期相互独立。因此:
mvn clean
不会自动执行 compile;而:
mvn clean package
会先执行 clean 生命周期,再从 default 生命周期开始执行到 package。
3.3 阶段具有累积性
执行:
mvn test
并不只执行测试插件。它会依次执行从 default 生命周期起点到 test 的所有相关阶段:
validate
initialize
...
compile
...
test-compile
test
执行:
mvn package
通常会至少经历:
validate
compile
test-compile
test
package
如果执行:
mvn install
则会继续执行 package 之后的阶段,最终把工件安装到本地仓库。
构建路径可以抽象为:
flowchart LR
A[mvn clean package] --> B[clean 生命周期]
B --> C[validate]
C --> D[compile]
D --> E[test-compile]
E --> F[test]
F --> G[package]
G --> H[target/*.jar]
package 产生的是当前项目的构建产物;install 还会把它复制到本地仓库;deploy 则会发布到远程仓库。
3.4 packaging 决定默认绑定
packaging 决定 Maven 为生命周期阶段绑定哪些插件目标。
最常见的类型是:
<packaging>jar</packaging>
对于普通 Java JAR 项目,典型绑定包括:
| 阶段 | 典型目标 |
|---|---|
process-resources |
resources:resources |
compile |
compiler:compile |
process-test-resources |
resources:testResources |
test-compile |
compiler:testCompile |
test |
Surefire 的测试目标 |
package |
jar:jar |
install |
install:install |
deploy |
deploy:deploy |
这里的“典型”很重要:具体插件版本和 Maven 版本可能影响默认绑定细节。需要确认实际行为时,使用 mvn help:effective-pom 或打开详细日志。
常见 packaging 还包括:
pom:父项目、BOM 或聚合项目war:Web 应用归档ear:企业应用归档
一个 pom 项目通常不编译 Java 源码,但可以管理模块、依赖版本或插件配置。
3.5 直接调用插件目标
除了阶段,也可以直接调用插件目标:
mvn org.apache.maven.plugins:maven-compiler-plugin:compile
或者使用插件前缀:
mvn compiler:compile
完整坐标调用更明确,因为插件前缀可能依赖插件组搜索和本地元数据:
groupId:artifactId:version:goal
例如:
mvn org.apache.maven.plugins:maven-compiler-plugin:版本:compile
直接调用插件目标不会自动提供完整生命周期上下文。比如直接运行 compiler:compile,可能没有先执行资源处理、代码生成或依赖准备。因此,正常构建通常使用:
mvn clean verify
而不是把多个插件目标手工拼接起来。
四、插件:生命周期阶段真正执行工作的组件
插件是 Maven 执行具体任务的扩展组件。常见插件包括:
- Compiler Plugin:编译主代码和测试代码
- Surefire Plugin:运行单元测试
- Failsafe Plugin:运行集成测试
- Resources Plugin:复制和过滤资源
- Jar Plugin:生成 JAR
- Source Plugin:生成源码包
- Javadoc Plugin:生成 API 文档
- Enforcer Plugin:验证构建约束
- Dependency Plugin:分析、复制和预取依赖
插件由一个或多个目标组成:
插件:目标
例如:
compiler:compile
compiler:testCompile
jar:jar
4.1 插件版本必须显式固定
依赖版本不固定会导致依赖漂移,插件版本不固定同样会导致构建行为漂移。
推荐显式声明插件版本:
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>...</version>
<configuration>
<release>25</release>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>...</version>
</plugin>
</plugins>
</build>
<plugins> 中的插件会参与当前项目构建;<pluginManagement> 只提供默认配置和版本,除非插件同时出现在 <plugins> 中,否则不会自动执行。
例如:
<build>
<pluginManagement>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>...</version>
</plugin>
</plugins>
</pluginManagement>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
</plugin>
</plugins>
</build>
第一部分管理版本,第二部分启用插件。
4.2 绑定自定义目标
插件目标可以绑定到指定阶段:
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>exec-maven-plugin</artifactId>
<version>...</version>
<executions>
<execution>
<id>print-java-version</id>
<phase>verify</phase>
<goals>
<goal>exec</goal>
</goals>
<configuration>
<executable>${java.home}/bin/java</executable>
<arguments>
<argument>-version</argument>
</arguments>
</configuration>
</execution>
</executions>
</plugin>
当执行:
mvn verify
Maven 到达 verify 阶段时,才会执行这个 exec 目标。
一个 execution 的 id 用于区分同一个插件的多次执行。绑定目标时,必须考虑:
- 目标执行的阶段是否足够晚或足够早
- 它依赖的输入是否已经生成
- 它产生的输出是否会被后续阶段消费
- 是否会在
mvn test、mvn package和mvn verify中重复执行
4.3 编译、测试和集成测试的边界
普通单元测试通常放在:
src/test/java
并由 Surefire 在 test 阶段执行。
集成测试通常放在:
src/test/java
但命名为 *IT.java,由 Failsafe 在以下阶段执行:
pre-integration-test
integration-test
post-integration-test
verify
常见流程是:
pre-integration-test:启动测试依赖的服务integration-test:运行集成测试post-integration-test:停止服务和清理资源verify:检查集成测试结果
如果只执行:
mvn package
可能不会执行完整的集成测试闭环,因为 package 早于 verify。需要集成测试时,应执行:
mvn verify
五、依赖:从声明到类路径的完整过程
5.1 直接依赖和传递依赖
项目在 POM 中直接声明的依赖是直接依赖:
<dependency>
<groupId>org.example</groupId>
<artifactId>example-core</artifactId>
<version>1.2.3</version>
</dependency>
如果 example-core 又依赖 example-util,则当前项目会间接获得 example-util。后者称为传递依赖。
依赖关系可以表示为图:
当前项目
├── A:1.0
│ └── C:1.0
└── B:2.0
└── C:2.0
当前项目同时得到 C:1.0 和 C:2.0 的请求,但最终类路径通常只能选择一个版本。
5.2 依赖仲裁:为什么最终只有一个版本
Maven 的经典依赖仲裁规则是 nearest definition,即离当前项目最近的定义优先。
将依赖树表示为深度:
项目
├── A:1.0
│ └── C:1.0 深度 2
└── B:2.0
└── D:1.0
└── C:2.0 深度 3
C:1.0 距离项目更近,因此通常选择 C:1.0。
如果两个版本处于相同深度,经典 Maven 3 行为通常受声明顺序影响。这个规则容易因依赖添加顺序而改变,因此不能把“最终选中的版本”当作稳定的架构约束。
查看实际依赖树:
mvn dependency:tree
查看详细的冲突信息:
mvn dependency:tree -Dverbose
例如可能看到:
com.example:app
+- org.example:a:1.0
| \- org.example:c:1.0
\- org.example:b:2.0
\- org.example:c:2.0 (omitted for conflict with 1.0)
5.3 显式声明解决关键冲突
如果项目直接依赖某个库,就应在项目中明确声明它需要的版本:
<dependency>
<groupId>org.example</groupId>
<artifactId>c</artifactId>
<version>2.0</version>
</dependency>
这样 C 在当前项目层级被直接声明,通常会优先于更深层的传递版本。
但这并不保证二进制兼容。强行把 C:1.0 改成 C:2.0 可能导致:
NoSuchMethodErrorNoClassDefFoundErrorClassNotFoundException- 行为语义变化
- SPI 实现不兼容
因此,依赖仲裁只解决“选择哪个工件”,不负责证明该版本与调用方兼容。
5.4 作用域决定依赖进入哪些类路径
常用作用域如下:
| scope | 编译主代码 | 编译测试 | 运行主代码 | 测试运行 | 传递给下游 |
|---|---|---|---|---|---|
compile |
是 | 是 | 是 | 是 | 是 |
provided |
是 | 是 | 否,通常由容器提供 | 是 | 通常否 |
runtime |
否 | 是 | 是 | 是 | 是 |
test |
否 | 是 | 否 | 是 | 否 |
system |
是 | 是 | 取决于配置 | 取决于配置 | 否 |
例如 JUnit 只应进入测试类路径:
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>...</version>
<scope>test</scope>
</dependency>
数据库驱动常见为:
<dependency>
<groupId>org.example</groupId>
<artifactId>database-driver</artifactId>
<version>...</version>
<scope>runtime</scope>
</dependency>
Web 容器已经提供 Servlet API 时,应用通常使用:
<dependency>
<groupId>jakarta.servlet</groupId>
<artifactId>jakarta.servlet-api</artifactId>
<version>...</version>
<scope>provided</scope>
</dependency>
provided 的含义是“编译时需要,但部署运行环境负责提供”,不是“永远不会被打包”。最终行为还取决于 WAR、容器或其他打包插件的规则。
system 依赖直接指向本机文件,例如:
<scope>system</scope>
<systemPath>${project.basedir}/lib/vendor.jar</systemPath>
它依赖特定文件路径,不能正常从仓库解析,也会破坏跨机器构建,除非是在极特殊的遗留场景中使用。
5.5 optional 与 exclusions
optional 控制依赖是否继续传递给下游项目:
<dependency>
<groupId>org.example</groupId>
<artifactId>feature-x</artifactId>
<version>1.0.0</version>
<optional>true</optional>
</dependency>
如果项目 A 声明了 optional 依赖 B,那么 C 依赖 A 时,通常不会自动获得 B。
这适合表示:
- 某个可选功能
- 某个平台专用实现
- 只有直接使用者才应决定的适配器
exclusions 则是在某条依赖边上排除传递依赖:
<dependency>
<groupId>org.example</groupId>
<artifactId>feature-x</artifactId>
<version>1.0.0</version>
<exclusions>
<exclusion>
<groupId>org.example</groupId>
<artifactId>old-logging</artifactId>
</exclusion>
</exclusions>
</dependency>
排除是局部的:从某个依赖路径排除,并不等于从整个依赖图删除。如果另一个路径仍然引入该工件,它仍可能出现在最终类路径中。
六、BOM 与 dependencyManagement
6.1 dependencyManagement 不会自动引入依赖
下面的配置只管理版本:
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.example</groupId>
<artifactId>example-core</artifactId>
<version>1.2.3</version>
</dependency>
</dependencies>
</dependencyManagement>
它不会让 example-core 自动进入类路径。还必须在 <dependencies> 中声明:
<dependencies>
<dependency>
<groupId>org.example</groupId>
<artifactId>example-core</artifactId>
</dependency>
</dependencies>
前者是“规则”,后者是“实际使用”。
6.2 BOM 是什么
BOM 是 Bill of Materials,即一组经过协调的依赖版本清单。BOM 通常本身是:
<packaging>pom</packaging>
导入 BOM 的写法是:
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.example</groupId>
<artifactId>example-bom</artifactId>
<version>1.0.0</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
之后可以省略 BOM 管理范围内依赖的版本:
<dependencies>
<dependency>
<groupId>org.example</groupId>
<artifactId>example-core</artifactId>
</dependency>
<dependency>
<groupId>org.example</groupId>
<artifactId>example-json</artifactId>
</dependency>
</dependencies>
这里的 import 只在 dependencyManagement 中具有导入 BOM 的特殊意义。它不是运行时作用域,也不会把 BOM 本身放进应用类路径。
6.3 BOM、父 POM 和普通依赖的区别
三者解决的问题不同:
| 机制 | 主要作用 |
|---|---|
| 普通依赖 | 当前项目实际使用某个工件 |
dependencyManagement |
统一约束依赖版本、scope、exclusions 等默认值 |
| BOM | 以 POM 形式提供一组协调版本 |
| parent POM | 继承属性、依赖管理、插件管理和其他构建配置 |
一个项目可以同时:
- 继承一个父 POM
- 导入多个 BOM
- 直接声明自己的业务依赖
父 POM 的继承关系只有一条,而 BOM 可以在依赖管理中导入多个。多个 BOM 之间如果管理同一个坐标,最终结果需要检查有效 POM,不能仅凭文件阅读顺序猜测。
6.4 BOM 不保证所有依赖都兼容
BOM 提供的是一组版本选择,不是形式化兼容性证明。仍然需要验证:
- 主版本之间是否有二进制兼容问题
- 是否存在重复实现
- 运行时环境是否提供了另一版本
- 模块系统是否出现拆分包或读取边界问题
- 测试和生产类路径是否不同
可以使用:
mvn dependency:tree
并配合 Enforcer 等规则检查依赖收敛。例如,要求同一坐标不能出现多个版本:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-enforcer-plugin</artifactId>
<version>...</version>
<executions>
<execution>
<id>enforce-dependency-convergence</id>
<goals>
<goal>enforce</goal>
</goals>
<configuration>
<rules>
<dependencyConvergence/>
</rules>
</configuration>
</execution>
</executions>
</plugin>
这类规则可以把运行期冲突提前变成构建失败,但也可能暴露第三方库自身无法统一版本的问题,需要结合依赖树处理。
七、仓库:Maven 从哪里取得依赖和插件
Maven 仓库保存工件及其元数据。一个普通依赖通常至少涉及:
groupId/artifactId/version/
├── artifact.jar
├── artifact.pom
├── checksum
└── 其他元数据
7.1 本地仓库
默认本地仓库通常是:
~/.m2/repository
它承担三项作用:
- 缓存远程下载的依赖和插件
- 保存
mvn install安装的本地工件 - 在离线或网络故障时提供已缓存内容
可以指定其他本地仓库:
mvn -Dmaven.repo.local=/path/to/m2-repository verify
这对 CI 隔离缓存、不同项目使用不同缓存目录很有用,但多个进程同时写同一个仓库目录时仍可能发生锁竞争或缓存损坏,应由 CI 系统管理并发策略。
7.2 远程仓库、插件仓库和镜像
项目依赖通常配置在:
<repositories>
<repository>
<id>company-releases</id>
<url>https://repo.example.com/releases</url>
<releases>
<enabled>true</enabled>
</releases>
<snapshots>
<enabled>false</enabled>
</snapshots>
</repository>
</repositories>
插件仓库是另一类配置:
<pluginRepositories>
<pluginRepository>
<id>company-plugins</id>
<url>https://repo.example.com/plugins</url>
</pluginRepository>
</pluginRepositories>
依赖仓库和插件仓库概念上不同。一个仓库服务器可能同时提供二者,但 POM 配置中的用途并不相同。
企业环境常通过 settings.xml 配置镜像:
<settings>
<mirrors>
<mirror>
<id>company-mirror</id>
<mirrorOf>*</mirrorOf>
<url>https://repo.example.com/maven-group</url>
</mirror>
</mirrors>
</settings>
mirrorOf 的匹配范围决定哪些仓库请求会被重定向到该镜像。错误的镜像配置可能造成:
- 所有依赖都无法解析
- 插件能下载但项目依赖不能下载
- 内部仓库工件被错误转发到公共仓库
- 认证信息未匹配正确的
id
7.3 release、SNAPSHOT 和更新策略
1.0.0 通常表示 release 版本;1.0.0-SNAPSHOT 表示开发中的快照版本。
SNAPSHOT 不是一个永久固定文件。仓库可能通过元数据把它映射到不同时间生成的实际快照工件。因此,直接依赖 SNAPSHOT 会损害可重复构建。
仓库还会记录更新时间、校验和以及元数据。构建失败时,先区分是:
Could not find artifact
还是:
Could not transfer artifact
前者通常是坐标、版本或仓库内容问题;后者更可能是网络、证书、认证、代理或镜像问题。
强制刷新远程元数据:
mvn -U verify
-U 会要求 Maven 检查更新,不代表一定能修复问题,也可能增加网络请求并取得新的 SNAPSHOT。
离线构建:
mvn -o verify
如果本地缓存不完整,离线模式会立即失败。它不会凭空生成缺失依赖。
7.4 认证信息不应写入 POM
仓库账号和密码应放入 settings.xml 的 <servers> 中:
<settings>
<servers>
<server>
<id>company-releases</id>
<username>...</username>
<password>...</password>
</server>
</servers>
</settings>
这里的 id 必须与仓库或发布配置中的 id 匹配。密码不应提交到 Git,也不应直接写在项目 POM 中。
实际生产环境还应使用 CI Secret、短期令牌或加密凭据,并限制发布权限。
八、多模块项目与 Reactor
多模块项目通常有一个聚合 POM:
<project>
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>demo-parent</artifactId>
<version>1.0.0</version>
<packaging>pom</packaging>
<modules>
<module>demo-api</module>
<module>demo-app</module>
</modules>
</project>
目录结构:
demo-parent/
├── pom.xml
├── demo-api/
│ ├── pom.xml
│ └── src/
└── demo-app/
├── pom.xml
└── src/
demo-app 依赖 demo-api:
<dependency>
<groupId>com.example</groupId>
<artifactId>demo-api</artifactId>
<version>1.0.0</version>
</dependency>
当从根目录执行:
mvn clean verify
Maven Reactor 会分析模块间的项目依赖,并先构建被依赖模块,再构建依赖它的模块:
demo-api
│
▼
demo-app
这与简单按照 <modules> 文件顺序执行不同。模块声明顺序通常应保持清晰,但真正的构建顺序还取决于模块依赖关系。
只构建某个模块及其上游依赖:
mvn -pl demo-app -am verify
含义是:
-pl demo-app:选择指定项目-am:同时构建它依赖的 Reactor 项目
只构建指定模块而不构建上游:
mvn -pl demo-app verify
如果 demo-api 尚未安装到本地仓库,后者可能失败。
聚合和继承不是同一个概念:
<modules>表示“从这里聚合构建哪些模块”<parent>表示“当前 POM 继承谁的配置”
一个 POM 可以是聚合器但不被子模块继承,也可以是父 POM但不负责聚合任何模块。
九、从命令到文件的端到端示例
项目结构:
hello-maven/
├── pom.xml
├── src/
│ ├── main/
│ │ ├── java/com/example/App.java
│ │ └── resources/app.properties
│ └── test/
│ └── java/com/example/AppTest.java
主代码:
package com.example;
import java.io.IOException;
import java.io.InputStream;
import java.util.Properties;
public final class App {
private App() {
}
public static String message() throws IOException {
Properties properties = new Properties();
try (InputStream input = App.class.getResourceAsStream("/app.properties")) {
if (input == null) {
throw new IOException("app.properties not found");
}
properties.load(input);
}
return properties.getProperty("message");
}
public static void main(String[] args) throws IOException {
System.out.println(message());
}
}
资源文件:
message=hello from Maven
如果 POM 已配置 Java 25 编译:
mvn clean verify
构建过程中的关键输入输出是:
src/main/java
└── compile ──> target/classes/*.class
src/main/resources
└── resources ──> target/classes/app.properties
src/test/java
└── test-compile ──> target/test-classes/*.class
target/classes + target/test-classes + test dependencies
└── test ──> 测试结果
target/classes
└── package ──> target/hello-maven-1.0.0.jar
运行主类可以使用经过配置的执行插件,或者直接使用 JDK:
java -cp target/classes com.example.App
预期输出:
hello from Maven
这里直接使用 target/classes 运行,而不是运行 JAR,原因是这个示例只展示普通 classpath 项目。若要通过:
java -jar target/hello-maven-1.0.0.jar
运行,则还需要在 JAR 的 manifest 中配置 Main-Class,或者使用能够生成可执行归档的打包插件。普通 Maven JAR 默认不一定包含可执行入口配置。
十、可重复构建:同样输入为什么应得到同样结果
可重复构建(reproducible build) 是指在相同且明确的构建输入下,多次构建应得到等价的输出。这里的“等价”可能是:
- 字节级完全相同
- 经过规范化后内容相同
- 至少依赖解析结果、类文件和功能行为相同
要达到这个目标,必须先定义输入边界。
可以把构建结果抽象为:
输出 = F(源代码, POM, 父 POM, 依赖, 插件, JDK, Maven, 仓库内容, 环境变量, 时间, 时区)
只固定源代码和业务依赖还不够,因为插件、JDK、仓库元数据和环境也会影响输出。
10.1 固定项目和插件版本
以下内容都应避免使用动态版本:
<version>LATEST</version>
<version>RELEASE</version>
<version>1.0.0-SNAPSHOT</version>
尤其不要依赖:
LATESTRELEASE- 未固定的 SNAPSHOT
- 未固定的父 POM
- 未固定的插件版本
应固定:
- 当前项目版本
- 父 POM 版本
- 直接依赖版本
- BOM 版本
- 插件版本
- 插件传递依赖中会影响产物的关键版本
- Java 和 Maven 版本
Maven 本身并不自动提供类似某些包管理器的通用依赖锁文件机制。dependencyManagement 和 BOM 能集中管理版本,但它们不是对完整解析结果的不可变快照。
10.2 固定仓库内容,而不是只固定 URL
即使 POM 中写了固定版本,仓库内容也可能发生变化,尤其是:
- SNAPSHOT 被重新发布
- 私有仓库管理员替换了工件
- 镜像代理了不同来源
- 元数据发生变化
- 校验策略不一致
生产构建通常使用经过审核的内部代理仓库,并保留工件校验、审计和不可变发布策略。
可以检查依赖文件:
mvn dependency:tree
也可以预先下载依赖:
mvn dependency:go-offline
但 go-offline 不是完美的“离线构建证明”。某些插件可能动态解析额外工件,某些 profile 只有激活后才会引入依赖。最终验证仍应在隔离网络环境中执行:
mvn -o clean verify
10.3 固定 JDK、Maven 和环境
同一 Java 源代码在不同 JDK 中可能因为以下因素得到不同结果:
- 编译器诊断变化
- 注解处理器行为变化
- JAR 工具和归档实现变化
- 默认字符集变化
- 时区和本地化设置变化
项目可以通过 Maven Enforcer 约束 Java 和 Maven 版本,也可以使用 Maven Toolchains 选择指定 JDK。Toolchains 的作用是让 Maven 插件使用配置好的 JDK,而不是简单依赖当前 JAVA_HOME。
同时应显式设置:
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
</properties>
测试和代码生成还可能受到以下环境影响:
user.languageuser.timezone- 操作系统路径分隔符
- 文件系统排序顺序
- 当前时间
- 用户名和工作目录
- 环境变量
如果生成文件包含时间戳、绝对路径或随机值,即使依赖和 JDK 固定,JAR 也可能仍然不同。
10.4 JAR 为什么可能每次都不同
JAR 本质上是 ZIP 格式归档。归档差异可能来自:
- 文件条目的时间戳
- 文件加入归档的顺序
- 生成的 manifest
- 编译器生成的调试信息
- 插件写入的构建时间
- 资源过滤时注入的变量
因此,“源码相同”不等于“JAR 二进制相同”。
要验证两个构建结果:
sha256sum target/*.jar
如果哈希不同,需要进一步比较:
unzip -l target/app.jar
unzip -p target/app.jar META-INF/MANIFEST.MF
再检查:
- 文件顺序是否改变
- 时间戳是否改变
- manifest 是否包含动态字段
- class 文件是否由不同 JDK 生成
- 资源文件是否被过滤
是否能做到字节级重复,取决于所使用 Maven 版本、插件版本及其对可重复归档的支持。不能仅通过设置一个 Maven 属性就假设所有插件都已实现可重复输出。
十一、常用命令的实际含义
11.1 mvn clean package
mvn clean package
结果:
- 删除
target - 编译主代码
- 编译测试代码
- 运行测试
- 生成 JAR、WAR 或其他打包产物
它不会把产物安装到本地仓库,也不会发布到远程仓库。
11.2 mvn install
mvn install
它会执行到 install 阶段,并把产物和 POM 安装到本地仓库,例如:
~/.m2/repository/com/example/hello-maven/1.0.0/
本地其他项目随后可以通过坐标引用这个工件。
11.3 mvn deploy
mvn deploy
它会执行到 deploy 阶段,并把 release 或 snapshot 发布到配置的远程仓库。失败风险包括:
- 权限不足
- 仓库禁止覆盖 release
- 版本策略不匹配
- 远程连接中断
- POM 中包含错误的发布元数据
发布前应先执行:
mvn clean verify
确认测试和校验通过,再执行发布。
11.4 mvn dependency:tree
mvn dependency:tree
它回答的是:
最终依赖图中有哪些依赖?
哪些是直接依赖?
哪些是传递依赖?
哪些版本被冲突仲裁排除了?
如果运行期报类缺失,应比较:
- 编译类路径
- 测试类路径
- 最终打包内容
- 实际运行环境类路径
“编译成功”不能证明“运行时一定有这个类”。
十二、常见失败表现与诊断路径
12.1 release version 25 not supported
常见原因是 Maven 实际使用的 JDK 低于 25,或者编译插件版本过旧。
诊断:
mvn -version
重点查看 Maven 使用的 Java home,而不是只查看 shell 中的:
java -version
修复方向:
- 设置正确的
JAVA_HOME - 确认 Maven 使用 JDK 25
- 使用支持 Java 25 的 Compiler Plugin
- 清理后重新构建
12.2 Could not resolve dependencies
按顺序检查:
- 坐标是否拼写正确
- 版本是否真实存在
- 仓库是否包含该 release 或 snapshot
- settings 镜像是否覆盖了正确仓库
server.id是否匹配- 证书、代理和网络是否正常
- 本地缓存是否损坏
可以尝试:
mvn -U -X verify
其中:
-U强制检查更新-X输出调试日志
不要一看到解析失败就删除整个 ~/.m2。先定位具体工件,再删除对应目录,避免重新下载大量正常依赖。
12.3 NoSuchMethodError 或 NoClassDefFoundError
这通常不是编译错误,而是运行时类路径与编译时类路径不一致,或者版本仲裁选择了不兼容版本。
诊断步骤:
mvn dependency:tree -Dverbose
然后检查最终包:
jar tf target/app.jar
如果是外部 classpath 运行,还要检查启动命令中的所有 JAR。处理时不要盲目排除依赖,应确认:
- 哪个库调用了该 API
- 哪个版本实际被选中
- 调用方需要哪个版本
- 另一个库是否与该版本兼容
12.4 “我配置了 pluginManagement,为什么插件没执行?”
因为:
<pluginManagement>
...
</pluginManagement>
只负责管理,不负责启用。需要在:
<plugins>
...
</plugins>
中声明该插件,或者由 packaging 的默认绑定触发它。
12.5 “我导入了 BOM,为什么依赖没进来?”
BOM 通常放在:
<dependencyManagement>
中,只提供版本管理。仍然需要在:
<dependencies>
中声明实际使用的依赖。
十三、构建配置的分层取舍
一个稳定的 Maven 工程通常把配置分成三层:
项目 POM
放项目本身必须知道的内容:
- 坐标
- 直接依赖
- Java release
- 必须执行的插件
- 项目级测试和打包规则
父 POM 或公司级 POM
放多个项目共享的内容:
- 插件版本
- 编译编码
- Enforcer 规则
- 测试报告
- 发布约定
- 公共仓库策略
settings.xml
放机器或环境相关的内容:
- 仓库镜像
- 私服认证
- 代理
- 本地仓库路径
- 环境 profile
把密码写进 POM 是错误的分层;把某个项目独有的编译规则写入全公司父 POM,也会增加无关项目的耦合。
十四、一个可验证的构建检查序列
对 Java 25 Maven 项目,可以按以下顺序建立基线:
mvn -version
mvn help:effective-pom
mvn dependency:tree -Dverbose
mvn clean verify
mvn -o clean verify
每一步验证不同问题:
-
mvn -version
确认实际 Maven、Java 和 Java home。 -
help:effective-pom
确认继承、插件绑定和依赖管理合并后的结果。 -
dependency:tree -Dverbose
确认传递依赖和版本冲突。 -
clean verify
在干净输出目录上完成编译、测试、集成校验和打包前验证。 -
-o clean verify
验证当前本地仓库是否包含构建所需内容,并减少网络因素。
如果最后一步失败,失败本身说明构建依赖尚未被完整缓存,或者某些插件、profile、代码生成步骤仍然隐式依赖网络。
Maven 的生命周期解决“什么时候做什么”,插件解决“具体怎么做”,依赖解析解决“从哪里取得哪些类”,BOM 和 dependencyManagement 解决“多个项目如何统一版本”,仓库解决“工件如何保存和传输”,而可重复构建则要求把这些输入和环境共同固定下来。只有把这些层次区分清楚,Maven POM 中的每一行配置才有可解释、可诊断的因果关系。
系列导航与关联阅读
- 系列入口:Java 完整学习路线:从 Java 25 语言与 JVM 到 Spring、微服务和生产交付
- 上一篇:JVM ClassLoader 泄漏:线程、缓存、Driver、热部署和诊断
- 下一篇:Gradle 完整基础:Task、配置缓存、依赖、插件和多模块构建
官方资料
本文依据 Java、Spring 与相关项目官方文档重新梳理;正文、示例与生产清单由 WR BLOG 编写。

评论
0 条讨论