Maven 教程
Maven 是 Java 世界的项目构建与依赖管理工具。写单个文件时 javac 一个、java 跑一个还够用;一旦项目用上第三方 jar、要打包分发,手工拼 classpath 就撑不住了。Maven 把「依赖从哪来、代码放哪、怎么编译打包」这套事标准化下来。
本文只讲 Maven 本身(构建 + 依赖);Gradle、CI/CD、私服搭建不在范围内。阅读前需要会命令行,装好 JDK(见 Java 背景和基本结构)。
1. Maven 是什么
1. 从「jar 地狱」说起
手工用库是这样的:要发个 HTTP 请求,得下 httpclient.jar,可它又依赖 httpcore.jar、commons-logging.jar……版本还得对得上——这版 httpclient 只认那版 httpcore。项目一多,几十个 jar 摆在 lib/ 里,升级一个牵动一片,删错一个编译就炸。这个状态叫依赖地狱(dependency hell)。
Maven 把这件事从「手工管理文件」变成「声明坐标」——每个库用它的 g/a/v 三件套标识(groupId / artifactId / version,详见第 4 节),Maven 认这组坐标去取。
与其他生态对照
| 生态 | 依赖管理 | 构建 |
|---|---|---|
| Java | Maven / Gradle | Maven / Gradle |
| C / C++ | 手工下库 | Make / CMake |
| Node.js | npm / pnpm | npm |
| Python | pip | 脚本 / Make |
2. 它替你管两件事
- 依赖管理:你只声明「我要用
mysql-connector-j8.4.0」,Maven 自动去仓库下载它,把它的传递依赖也一并解析出来,统一放进 classpath。不用再手动下 jar、手动-cp拼。 - 构建:把「编译 → 测试 → 打包 → 安装」固定成命令,一条
mvn package从源码出 jar。
对照 C:相当于把 Make/CMake 干的活(构建)和「去官网下库、手动摆好头文件和.lib」干的活(依赖),合成一套标准工具。零散敲javac是更原始的状态。
3. POM:项目的说明书
Maven 项目的核心是一个 XML 文件 pom.xml(Project Object Model,项目对象模型)。项目坐标、依赖、插件、构建配置全写在里面。看到 pom.xml 就知道这是个 Maven 项目。
4. 约定优于配置
Maven 规定好默认的目录结构、输出目录、生命周期。照约定放文件就几乎不用配置,要偏离约定时才去 pom 里改。这是它「开箱即用」的原因。
5. 它不只是构建工具
所有构件都用坐标(g/a/v)在全球范围内唯一标识,任何一个项目都能靠坐标引用仓库里的任何构件——这让 Java 生态有了统一的「包管理中心」。对照其它语言:Node 有 npm,Python 有 pip,Java 这边就是 Maven 仓库,而 Maven 兼做客户端与构建工具(Gradle 也用它同一套仓库)。
2. 项目结构
Maven 的标准目录(约定优于配置的具体体现):
src/main/java:源代码的根。包名com.example.app对应子目录src/main/java/com/example/app/。也就是说,目录结构必须与package声明一致。src/main/resources:非代码资源,构建时被复制到 classpath 根下。db.properties这类配置文件放这里,代码里就能当 classpath 资源读。src/test/*:测试代码与测试资源,只在跑测试时参与,不打包。target/:构建输出目录,编译出的.class、打的 jar 都在这。每次构建重新生成,只属于产物、不进版本库,写进.gitignore。
之所以是 src/main/java 而不是「随便放」,就是第 1 节的约定优于配置:Maven 知道去哪儿找源码、去哪儿放产物,你才不用每次告诉它。
在 IDEA 里:直接用 IDEA 打开 pom.xml(或包含它的目录),它会自动识别为 Maven 项目并导入依赖;右侧边栏的 Maven 面板可以双击执行生命周期。IDEA 内置的 Maven 和自己的命令行 Maven 用的是同一套本地仓库。.gitignore 至少要有:
3. 安装与配置 ★
3.1 前置:JDK
Maven 自己不编译 Java,它调用 JDK。先确认有 JDK、且 JAVA_HOME 指向它:
没有的话先装 JDK,见 Java 背景和基本结构。
3.2 安装
macOS(Homebrew):
通用(解压式):下载 apache-maven-x.y.z-bin.tar.gz(或 .zip),解压到任意目录,把其中的 bin 加进 PATH:
Windows 则把解压后的 bin 目录加进系统环境变量 Path。无论哪种方式,都要保证 JAVA_HOME 在环境变量里,Maven 靠它找 JDK。
3.3 验证
输出三行左右:
能看到就装好了。如果 Maven 版本对、Java 那行却报错或指向 JRE,回头看 JAVA_HOME。
3.4 settings.xml:本地仓库与镜像
Maven 有两处配置文件:
| 位置 | 作用域 | 建议 |
|---|---|---|
$MAVEN_HOME/conf/settings.xml | 全机器(所有用户) | 少动 |
~/.m2/settings.xml | 当前用户 | 改这个,不动安装目录、升级不丢 |
~/.m2/ 是 Maven 的用户目录,本地仓库和 settings 都在这里。用户级文件不存在时新建一个即可。下面是一份最小可用的 ~/.m2/settings.xml(独立文件,不在 pom.xml 里):
里面主要就两项:
localRepository(本地仓库):下载的 jar 存哪。默认~/.m2/repository,位于用户主目录、通常就在系统盘;jar 越攒越多会占掉可观空间,想挪到别的磁盘就改这里。mirror(镜像):Maven 默认从国外中央仓库取包,国内很慢,第一次构建常卡在下载。*表示所有仓库请求都改走这个地址;若只想代理中央仓库、不影响别的仓库,写成central。
4. pom.xml 骨架
最小的可构建 pom.xml——下面是整个文件, 是根元素:
modelVersion:POM 模型的版本,固定4.0.0,照抄。project上的xmlns/xsi是 XML 命名空间与 schema 声明:Maven 构建不依赖它,但带上 IDE 才能按 POM 的 XSD 校验、补全,所以照抄即可。真正必须的只有下面的modelVersion。groupId/artifactId/version:坐标,三件套唯一确定一个构件。packaging:产物类型,jar(默认,不写也是 jar)/war/pom。
坐标(g/a/v)
| 元素 | 含义 | 惯例 |
|---|---|---|
groupId | 所属组织 | 域名倒写:com.example、org.apache |
artifactId | 项目(构件)名 | 小写短横线:my-app、spring-boot-starter-web |
version | 版本号 | 1.0.0、1.0-SNAPSHOT |
三点说明:
- 坐标不仅标识你自己,也是引用别人的方式——引入依赖时写的就是对方的 g/a/v,例如
com.mysql:mysql-connector-j:8.4.0。 - 版本号主体(如
1.0)自己定,但末尾的-SNAPSHOT是 Maven 的保留后缀,拼写和位置固定、不能改写。SNAPSHOT表示「开发中快照」——同一个版本号可以反复覆盖重发、会被重新拉取;但正式版本(1.0.0)一旦发布就不该再改。 - 命名允许的字符有限(字母、数字、
-、_),别用中文或空格。
properties:自定义变量(写在 下,与 groupId 同级)
定义后用 ${...} 引用。maven.compiler.release 告诉编译器按哪个 Java 版本编译,能避免 JDK 版本与目标版本不匹配的报错;project.build.sourceEncoding 统一源码编码,防乱码。这两个几乎是每个项目的标配。
5. 依赖管理 ★
5.1 dependencies:声明依赖
写在 下,与 groupId 同级:
不写 会报错。声明后 Maven 自动下载并放进编译与运行 classpath。坐标去 mvnrepository 搜库名,页面上直接给出 g/a/v,复制粘贴即可。
5.2 scope:依赖的作用范围
写在某个依赖内部(嵌在 → → 某个 里),和 / 同级:
不写 就是默认的 compile。四种常用取值:
| scope | 编译 | 测试 | 运行 | 打包 | 典型例子 |
|---|---|---|---|---|---|
compile(默认) | ✅ | ✅ | ✅ | ✅ | 通用库(Jackson) |
test | ❌ | ✅ | ❌ | ❌ | JUnit |
provided | ✅ | ✅ | ❌ | ❌ | servlet-api(容器提供) |
runtime | ❌ | ✅ | ✅ | ✅ | JDBC 驱动 |
compile是默认值,不写就是它。test只在测试代码里可见,主代码想引用会编译失败。provided编译时要用、运行时由容器提供,所以不打进包——典型是 Servlet API,Tomcat 里已经有了,你打进去反而冲突。runtime编译时不直接调它的类(靠反射/SPI 加载),运行时才需要,所以进包但不参与编译。
另有 system(引用本地文件路径,不可移植,不推荐)、import(只在 dependencyManagement 里用),认得即可。
5.3 传递依赖
依赖会传染:A 依赖 B、B 依赖 C,你引入 A,Maven 就自动把 B 和 C 一起解析进来。省事,代价是依赖树会膨胀、版本会打架。比如你的直接依赖引了 commons-logging:1.1,另一条传递链引了 commons-logging:1.2,同一个库出现了两个版本——这就是依赖冲突。
5.4 依赖冲突与仲裁
同一构件被不同路径引入不同版本时,Maven 按两条规则选一个(就近原则):
- 最短路径优先:谁离你「更近」用谁的。以「依赖层数」算路径长度。直接依赖长度为 1,永远赢过传递来的(≥2)。
- 例:你直接声明
A:1.0,而B → C → A:2.0,则用A:1.0。
- 例:你直接声明
- 最先声明优先:路径长度相同时,谁在
dependencies里先写用谁的。- 例:
A:1.0和A:2.0都是长度 2 的传递依赖,则写在前面的那条链带来的版本胜出。
- 例:
排查冲突:
打印整棵依赖树(含坐标与 scope),顺着看每个构件从哪条路径来、最终选了哪个版本。想只看某个库:
5.5 exclusions:排除传递依赖
不想让某个传递依赖进来(版本不对、有漏洞、用不上),在引入它的那条 内部排掉(位置同样在 → → 里):
exclusion 只写 g/a 即可,不必写版本。
5.6 dependencyManagement:统一版本
dependencyManagement 用在多模块项目:顶层父 pom 统一登记各子模块要用的依赖版本(多模块见第 8 节)。它只登记版本、不真的引入依赖——子模块要用某个库时照常写 ,但可以省掉 ,版本从这里的父 pom 取。
父 pom(写在它的 下):
子模块(写在它自己的 下)——引用同一坐标就不用再写版本:
一句话区分:dependencies 是「我依赖它」,dependencyManagement 是「如果谁依赖它,用这个版本」。
5.7 optional:可选依赖
写在依赖内部( → → 里),和 、 同级:
加了它之后,这个依赖不会被传递给下游——别人依赖你时,你的可选依赖不会自动变成他的。适合「只在特定场景用、不想强加给使用者」的库。
注意方向和 5.5 的 exclusions 相反:exclusions 挡的是传进来的(我不要),optional 挡的是传出去的(我不给)。
6. 构建流程 ★
6.1 三套生命周期
Maven 把构建拆成三套互相独立的生命周期,每套是一串阶段(phase):
| 生命周期 | 作用 | 主要阶段 |
|---|---|---|
clean | 清理 | pre-clean → clean → post-clean |
default | 构建(核心) | validate → compile → test → package → verify → install → deploy |
site | 生成站点文档 | pre-site → site → post-site → site-deploy |
关键规则:执行某个阶段,会先依次执行它前面的所有阶段。比如 mvn package 实际依次跑了 validate → compile → test → package——所以打包前会自动编译、自动跑测试;测试不过,打包就中断。
三套生命周期彼此独立,所以跨生命周期时,命令里写多个阶段就按书写的先后执行:mvn clean package 是先跑 clean 生命周期(删 target/)、再跑 default 到 package(编译测试打包)。顺序不能随手换——写成 mvn package clean 就变成「先打包、再删掉 target/」,等于白干。(只有同一个生命周期内写多个阶段时,顺序才由生命周期定,永远向后推进。)
6.2 default 各阶段含义
compile:编译src/main/java到target/classestest:编译并运行src/test/java里的单元测试package:把编译产物打成 jar/war,产出到target/install:把包装进本地仓库(3.4 里localRepository配的那个目录),同机器其他项目才能依赖到它deploy:上传到远程仓库(私服)
中间还有 resources(复制资源)、test-compile 等细阶段,日常不用记,知道大流程即可。
6.3 阶段与插件 goal 的绑定
Maven 核心只管流程骨架,真正干活的是插件:每个阶段(phase)绑定了一个或多个插件的目标(goal)。常用的绑定:
| 阶段 | 默认绑定的插件目标 |
|---|---|
resources | maven-resources-plugin:resources |
compile | maven-compiler-plugin:compile |
test | maven-surefire-plugin:test |
package | maven-jar-plugin:jar(war 则是 maven-war-plugin:war) |
install | maven-install-plugin:install |
deploy | maven-deploy-plugin:deploy |
日常用默认插件就够了;要定制(换编译版本、打可执行 jar、加代码检查)才在 里配置插件及其 goal。
6.4 常用命令
- 一次可以写多个阶段,按顺序执行。
-DskipTests跳过测试执行(仍编译测试代码):mvn clean package -DskipTests。-U强制更新 SNAPSHOT 依赖(忽略本地缓存)。-o离线模式,只用本地仓库。-e出错时打印完整堆栈,-X打印 debug 日志,排查构建问题时有用。
6.5 打包产物
target/my-app-1.0-SNAPSHOT.jar:普通 jar,只含你自己的类,不含依赖。所以它单独跑不起来——java -jar会因找不到依赖的类而报错。要让一个 jar 能独立运行,得打胖 jar(fat jar):把自己的类和全部依赖合并进同一个 jar,并写明入口类;由maven-shade-plugin/maven-assembly-plugin生成。- war:
packaging为war的 Web 项目产物,丢进 Tomcat 的webapps目录部署。它和 jar 的区别:jar 是通用包(库或程序),无固定内部结构,由 JVM 运行或作依赖引用;war 是 Web 应用专用包,内部按约定放WEB-INF/(web.xml、类、lib/里的依赖 jar)和页面资源,由容器加载部署——war 里其实就装着 jar。 - install 的意义:只有装进本地仓库,别的项目才能用 g/a/v 依赖到它——多模块之间的依赖就靠这一步。
7. 仓库
Maven 从三种仓库找构件,顺序:本地 → 远程(镜像 / 私服)→ 中央。
| 仓库 | 位置 | 说明 |
|---|---|---|
| 本地仓库 | ~/.m2/repository | 下载过的都缓存在这,永远先查这里 |
| 中央仓库 | repo.maven.apache.org | 全世界公共构件,通常经镜像访问 |
| 私服 | 公司内网 | Nexus / Artifactory,代理中央 + 存内部构件 |
本地仓库按坐标组织成目录:groupId 的 . 换成 /,再接 artifactId/version/。例如 com.mysql:mysql-connector-j:8.4.0 落在:
构件下不动时,删掉对应目录(或整个本地仓库)重新构建即可——本地仓库随时能重建,是纯缓存,删了不会丢东西。
私服是企业内网的中间仓库:对外代理中央仓库(并缓存),对内存放公司自研构件。好处是快、可控、能审计,还能保存不对外发布的内部库。你自己搭私服不在本文范围,知道有这一层就行。
8. 模块管理:聚合与继承
项目变大时拆成多个模块(如 common、dao、web):每个模块是一个独立的小 Maven 工程,各有自己的 pom.xml;顶层再放一个 pom 把它们串起来,一条命令整体构建。顶层这个叫父 pom(或聚合 pom),被它管的模块叫子模块。父 pom 对子模块有两种关系,可单用也可合用:
别把「模块」和 Java 的「包」(package)混了:模块是一个独立工程(有自己的pom.xml、src/、坐标,产出一个 jar);包只是模块内部源码的目录分组。一个模块里有很多包,一个包只属于一个模块。
聚合(aggregation):父 pom 用 列出子模块(写在父 pom 的 下),一条命令构建全部。
里填的是子模块所在目录的相对路径(相对父 pom),不是坐标——common 就指同级那层 common/ 目录(里面有自己的 pom.xml)。它常和该模块的 artifactId 同名,但填的是位置。
继承(inheritance):子模块用 声明父 pom(写在子模块的 下,通常紧跟 ),继承父的配置——依赖版本(靠 dependencyManagement)、properties、插件、groupId 等。
两者常一起用:父 pom 既聚合子模块,又被子模块继承。区别一句话——聚合是「一起构建」,继承是「共享配置」。父 pom 的 packaging 必须是 pom(它不产出 jar),并且它通常只放配置、不放业务代码。
典型结构:
在父目录执行 mvn clean install,会按模块间依赖顺序依次构建全部子模块。
9. properties 与 profile
properties:pom 内的变量(写在 下,与 groupId 同级),${...} 引用,减少重复、方便统一改。
除自定义外,也能引用 Maven 内置的变量。它们不来自代码,而是来自 Maven 解析出的项目模型与运行环境:
${project.*}:当前 pom 里的信息,如${project.version}(就是这份 pom 的 )、${project.groupId}、${project.build.directory}(构建输出目录,默认target)${settings.*}:来自settings.xml,如${settings.localRepository}${env.*}:环境变量,如${env.JAVA_HOME}${basedir}:pom 所在的目录
profile:按条件切换配置( 写在 下)。比如开发/生产两套配置,用 -P 激活:
db.url 不是 Maven 的固定标签,是自定义属性(名字随你起),值是一串 JDBC 连接地址;定义后在 pom 别处用 ${db.url} 引用。不同 profile 给同一属性不同的值,切 profile 就换了值——在命令行激活上面的 prod 就写 mvn package -Pprod(-P 的值贴着或空一格都行,-P prod 等价)。
profile 还能按条件自动激活(17 之类),或定义在 settings.xml 里做「只在某台机器生效」的配置。日常知道能手动 -P 切换即可。
10. 实战:把手工作坊改造成 Maven 工程
第一阶段那种 javac Hello.java 的裸工程,一旦要用第三方库(比如 MySQL 驱动)就得手动下 jar、手动 -cp。Maven 化之后:源码挪进 src/main/java、依赖写进 pom、一条命令构建。
下面这个例子用「能否拿到 MySQL 驱动」来验证依赖确实被拉进来了——不需要本机装 MySQL,跟着敲就能跑通。
1. 建目录
2. 写 pom
展开收起
3. 写 App.java
DriverManager.getDriver 只查「哪个驱动认这个 URL」,不会真的建立连接,所以不需要本机有 MySQL。能打印出驱动类名,就说明 Maven 把 mysql-connector-j 下载并放进了 classpath——本节要演示的正是这一点。
4. 构建
第一次会去下载 MySQL 驱动及其依赖(配了镜像才快),之后缓存进本地仓库。构建日志会打印每个阶段(clean → compile → test → package),产物在 target/demo-1.0-SNAPSHOT.jar。
5. 运行
最直接的是在 IDEA 里点 App 的 main 方法旁的运行按钮。若要在命令行跑同一个主类:
拆解这行:exec:java 是「调用 exec-maven-plugin 的 java 目标」,写法是 插件前缀:目标——它和 clean / package 那种生命周期阶段不是一回事;-Dexec.mainClass=... 是传给该插件的参数,指定要运行的全限定类名。
跑出来会打印 驱动已就位: com.mysql.cj.jdbc.Driver v8.4 之类——Maven 把驱动下载并放进了 classpath,工程跑通。
对比改造前后:不用再手动找 jar、手动拼 classpath;换机器只要 mvn package 就能重建整个可运行工程。这就是后面 JDBC 命令行工具要用的地基。
附录
常见报错对照
| 报错 | 原因 | 解决 |
|---|---|---|
mvn: command not found | PATH 没配 | 把 Maven bin 加进 PATH |
No compiler is provided... Did you mean JAVA_HOME | 找到的是 JRE 不是 JDK,或 JAVA_HOME 没设 | 装 JDK,JAVA_HOME 指向 JDK 根 |
Could not find artifact / Could not resolve dependencies | 坐标写错,或镜像没配、仓库里没有 | 核对 g/a/v;配阿里云镜像 |
invalid target release: 17 | maven.compiler.release 高于当前 JDK | 改成 JDK 支持的版本,或升级 JDK |
程序包 xxx 不存在 / cannot find symbol | 依赖没加,或 scope 不对 | 补 dependency;检查 scope |
| 依赖版本不符预期 | 传递依赖冲突 | mvn dependency:tree 定位,用 exclusions / dependencyManagement 压 |
... was cached in the local repository, resolution will not be reattempted | 之前下载失败被缓存 | 加 -U 强制更新 |
| 下载卡住 / 极慢 | 走的国外中央仓库 | 配镜像 |
Could not transfer ... Connection refused | 代理 / 网络 / 私服不可达 | 检查网络与代理,或换镜像 |
pom 元素位置速查
pom.xml 里每个元素写在哪儿:
| 元素 | 写在哪 |
|---|---|
modelVersion | 下 |
groupId | 下 |
artifactId | 下 |
version | 下 |
packaging | 下 |
parent | 下 |
properties | 下 |
dependencyManagement | 下 |
dependencies | 下 |
modules | 下 |
profiles | 下 |
build | 下 |
dependency | 内( 里也有一个同名 ) |
scope | 内 |
optional | 内 |
exclusions | 内 |
exclusion | 内 |
module | 内 |
profile | 内 |
plugin | → 内 |
settings.xml的settings/localRepository/mirrors/mirror属于 settings.xml,不在 pom 里。
术语速查
| 术语 | 含义 |
|---|---|
| POM | Project Object Model,即 pom.xml,项目配置的总文件 |
| 坐标(g/a/v) | groupId / artifactId / version,全局唯一标识一个构件 |
| 构件(artifact) | 一个可被依赖的产物,如一个 jar |
| 生命周期 | clean / default / site 三套构建流程 |
| 阶段(phase) | 生命周期中的一步,如 compile、package |
| 插件(plugin) | 真正执行任务的组件,如 maven-compiler-plugin |
| 目标(goal) | 插件里的一个具体任务,绑定到阶段上执行 |
| scope | 依赖的作用范围:compile / test / provided / runtime |
| 传递依赖 | A 依赖 B、B 依赖 C,则 A 间接依赖 C |
| 依赖仲裁 | 冲突时按「最短路径优先、最先声明优先」定版本 |
| 胖 jar(fat jar) | 把自己的类与依赖打进同一个可执行 jar |
常用命令速查
| 命令 | 作用 |
|---|---|
mvn -v | 看版本 / 环境 |
mvn clean | 清 target/ |
mvn compile | 编译 |
mvn test | 跑测试 |
mvn package | 打包 |
mvn install | 打包 + 装进本地仓库 |
mvn dependency:tree | 看依赖树 |
mvn clean package -DskipTests | 清理打包、跳过测试 |
mvn -U clean package | 强制更新依赖后重新构建 |
mvn -e -X ... | 打印完整错误 / debug 日志 |
参考资料
- Apache Maven 官方文档:https://maven.apache.org/guides/
- 依赖坐标查询:https://mvnrepository.com/