ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Linux 下 JDK 与 Maven 环境搭建:版本选型、配置与验证

Linux 下 JDK 与 Maven 环境搭建:版本选型、配置与验证

前阵子帮人搭一台干净的构建机,系统是最小化安装,连编译工具链都没有。我按着记忆里那套“装 JDK、装 Maven、写环境变量”的流程走了一遍,mvn -v输出正常,看着一切妥帖,结果真正跑构建的时候直接报了一句No compiler is provided in this environment. Perhaps you are running on a JRE rather than a JDK?。查了半天才发现,JAVA_HOME指向的那个目录里压根没有javac,只有一个运行时。这件事让我意识到,“装 JDK 和 Maven”这件事在网上被写得太轻描淡写了,绝大多数教程只告诉你敲哪几行命令,却不告诉你这几行命令背后到底装了什么、环境变量为什么要这么写、装完之后怎么验证才算数。

这篇东西想解决的就是这个断层。它不讲“复制粘贴就能跑”的一锤子买卖,而是把在 Linux 上落地 JDK 和 Maven 这条链路拆开:版本怎么选、三条安装路径各自适合什么场景、JAVA_HOME这类环境变量为什么总在非交互场景里失效、Maven 的settings.xml里哪些配置会悄悄改变依赖解析行为、装完之后用什么最小工程验证工具链真的可用。适合刚接手构建环境搭建的新手,也适合那些“装过很多次但每次都要重新查一遍”的老手——很多细节确实值得记下来,尤其是在服务器上、在 CI 容器里、在多版本并存的机器上,坑的形状完全不一样。

文中所有命令和目录布局都是通用写法,apt系和dnf系的差异我会分别标出来,你在自己机器上照着改一下包名就能用。

1. 版本选型:先定 JDK 与 Maven 的搭配,再谈安装

1.1 为什么系统仓库里的 JDK 总是落后一两个大版本

这是新手最容易困惑的地方:明明官方已经发布到很新的版本了,敲apt search openjdk出来的最全的选项却还是几个旧版本。原因不难理解——发行版维护者要考虑的是整个软件仓库的依赖一致性,一个 JDK 大版本升级会牵动几十个包的运行时依赖,还要经过长时间的安全补丁验证。他们宁可选一个已经跑了几年、已知问题都被踩平的版本,也不愿意把最新的东西塞进稳定仓库。

这带来的直接后果是:如果你只是想让机器上有个能跑的 Java 环境,仓库版本完全够用;但如果你要构建一个明确要求某个较新语言特性的工程,仓库版本就可能是拦路虎。判断标准很简单——先看工程的构建脚本里写的目标版本,再看依赖框架的最低要求,两者取高,然后去确认仓库里有没有这个版本。没有的话,就走压缩包手动安装的路线,别硬扛着装个旧版本然后用一堆兼容参数去糊。

还有一点要提醒:仓库里同时存在jdk和jre两类包,名字很像,内容差很远。带-jdk或-devel后缀的才是包含编译器、调试器、文档工具的完整开发包,不带后缀或者带-jre的只有运行时。我见过不止一次有人在服务器上装了纯运行时包,然后纳闷为什么javac命令不存在——这不是环境变量的问题,是东西压根没装。

1.2 JDK 与 Maven 的版本兼容边界怎么查

Maven 本身是 Java 写的,它对 JDK 有最低版本要求,这个要求随着 Maven 大版本演进而变化。粗略的规律是:较新的 Maven 3.9 系列要求 JDK 8 起步,而更早的 Maven 3.6、3.7 系列对 JDK 7 还有一定兼容。反过来,JDK 太新也会带来麻烦——某些老版本的构建插件在新 JDK 上会因为内部 API 变化而报错,尤其是那种几年没更新过的代码生成插件。

所以版本选型不是“都装最新的”,而是找一个交集。我通常的查法是:打开工程的pom.xml,看maven-compiler-plugin里配的source和target(或者新版本用的release),这就是工程期望的 JDK 基线;再看maven-enforcer-plugin有没有强制要求;最后看构建机上实际能装的 JDK 有哪些。三者对齐之后,再把 Maven 版本定在“官方文档明确支持这个 JDK”的区间里。

下面这张表是我自己整理的常见版本对应关系,用来快速排除明显不搭的组合:

JDK 版本编译产物 class 文件主版本号常见适配的 Maven 区间需要注意的点
JDK 8523.6 至 3.9大量存量工程仍在使用,插件生态最全
JDK 11553.6 至 3.9第一个移除部分旧模块的 LTS,注意javax相关迁移
JDK 17613.8 至 3.9强封装更严格,反射类插件容易出问题
JDK 21653.9 及以上新特性多,但部分构建插件尚未跟进

表里那个“class 文件主版本号”很有用。当你看到Unsupported class file major version 65这类报错时,意思就是“某个工具正在用低于 21 的 JDK 去读一个用 21 编译出来的 class 文件”,问题出在运行工具的那个 JDK 上,而不是编译参数上。把这个号记住,排查时能省一大圈。

1.3 一个容易被忽略的前置判断:这台机器到底要装几套

在动手之前先问自己一个问题:这台机器未来会不会同时需要多个 JDK 版本?如果只是跑一个固定工程,装一套就够,环境变量写死最省心。但如果是构建机、跳板机,或者你要在上面同时维护几个不同代际的工程,那就必须提前规划目录结构,否则后面每次切换都要改环境变量、重开终端,非常难受。

我的做法是统一放在一个父目录下,比如/opt/jdk/下面按版本号建子目录,再用一个名为current的软链接指向当前使用的那一套,环境变量只引用这个软链接。这样切换版本就变成了改一行软链接指向,不用碰任何配置文件。代价是多一层间接,readlink -f一下才能看到真实路径,排查时记得这一点。

2. JDK 的三条安装路径与各自的目录布局

2.1 包管理器路线:认准开发包后缀,接受它的目录约定

包管理器安装是最省事的,一条命令解决下载、解压、注册、创建符号链接所有事。以apt系为例,装一个完整开发包大致是这样:

sudo apt update sudo apt install -y openjdk-17-jdk

dnf系对应的是:

sudo dnf install -y java-17-openjdk-devel

注意两边的后缀不一样,apt系用-jdk,dnf系用-devel,这是命名习惯差异,不是版本差异。装完之后 JDK 会落在/usr/lib/jvm/下面,目录名通常形如java-17-openjdk-amd64或者java-17-openjdk-17.x.x,具体取决于发行版和架构。你可以用ls -l /usr/lib/jvm/一眼看到。

这条路线的好处是全自动,坏处是它会在系统层面注册 alternatives(后面会细讲),而且你没法自由选择具体的补丁版本。另外apt系安装时可能会顺带装一堆推荐包,机器上多出几十兆无关内容;想干净一点可以加--no-install-recommends。我一般会在服务器上加上这个参数,能少装就少装。

还有个小细节:装完之后不要急着写JAVA_HOME。先ls一下那个目录,确认里面有bin/javac,这是判断“装的是不是开发包”最直接的方法:

ls /usr/lib/jvm/java-17-openjdk-amd64/bin/javac

有输出就说明装对了,没输出就说明只装了运行时,需要补装开发包。

2.2 压缩包路线:解压、软链、归属权三步走

需要指定版本、或者机器上不允许用包管理器的时候,就走手动解压这条路。流程看着简单,但每一步都有讲究。

第一步是拿到压缩包并校验。这一步很多人会跳过,我建议至少在构建机上做一次完整性校验,因为下载中断导致的半截包解压出来会缺文件,报错还特别隐晦:

# 下载后先核对官方提供的校验文件 sha256sum -c openjdk-17_linux-x64_bin.tar.gz.sha256

第二步是解压到目标目录。我习惯放在/opt/下,按版本建独立目录,避免将来升级时覆盖:

sudo mkdir -p /opt/jdk sudo tar -xzf openjdk-17_linux-x64_bin.tar.gz -C /opt/jdk/

解压出来的目录名很长,带构建号,比如jdk-17.0.9这种。为了后续引用方便,建一个不带构建号的软链接:

sudo ln -sfn /opt/jdk/jdk-17.0.9 /opt/jdk/current

第三步是处理归属权和权限。如果你是用普通用户解压的,整个目录会归属到那个用户名下,将来别的用户跑构建时可能因为没读权限报错。统一归到 root 并保持可执行、可读、不可写是最稳妥的:

sudo chown -R root:root /opt/jdk/jdk-17.0.9 sudo chmod -R 755 /opt/jdk/jdk-17.0.9

注意:chmod -R 755会把所有文件都设成可执行,虽然对 JDK 这种目录影响不大,但严格来说不够精确。更规范的做法是只对目录设 755,对普通文件设 644。如果在意权限审计,可以用find配合分别设置。

这条路线最大的优势是完全可控:版本你自己定、目录你自己规划、想装几套装几套。代价是环境变量得自己写,而且系统的 alternatives 机制不会自动感知到这套新装的 JDK,这点后面单独讲。

2.3 版本管理工具路线适合什么场景

还有一类做法是用专门的版本管理工具来装 JDK,一条命令切换版本,用起来确实顺手。这类工具的原理通常是:把各个版本解压到一个隐藏目录下,通过改 shell 的PATH和JAVA_HOME来切换。

它适合个人开发机,尤其是需要频繁在不同 JDK 版本之间跳来跳去的人。但我不建议在有严格环境定义的构建机上用它,原因有三:它依赖 shell 初始化脚本,在非交互式场景(比如 CI 里直接执行命令)下可能不生效;它引入了一个额外的依赖,出问题时排查面变宽;它的目录布局是工具私有的,别的运维同学上来看不懂。

服务器上还是老老实实手动装、手动配,越笨的办法越好维护。

2.4 三种路径的横向对比

把三条路径摆在一起看,选择其实很清楚:

维度包管理器压缩包手动版本管理工具
版本可选范围受限于仓库任意较多
补丁版本可控性低高中
系统集成度高(自动注册)低(需手动配)中
多版本共存支持但笨重天然支持很方便
非交互场景可靠性高高偏低
适合场景单版本服务器构建机、生产机个人开发机

我自己的习惯是:临时虚拟机、只管跑一个工程的场景用包管理器;正式构建机、需要长期维护的多版本场景用手动解压。这套组合用了几年,没出过什么难缠的问题。

3. JAVA_HOME 与环境变量:那些“命令找不到”的真实成因

3.1 profile、profile.d 与 bashrc 的加载顺序差异

环境变量写在哪里,决定了它在什么场景下生效。Linux 上这几个文件的分工经常被混淆:

  • /etc/profile:全局配置,登录 shell 启动时读一次
  • /etc/profile.d/*.sh:同样是全局,被/etc/profile依次 source,推荐把自定义配置放这里,按功能拆成独立文件
  • ~/.bash_profile或~/.profile:当前用户的登录 shell 配置
  • ~/.bashrc:当前用户的交互式非登录 shell 配置

关键点在“登录 shell”和“非登录 shell”的区别。你本地开一个终端连上去,通常是登录 shell,会读/etc/profile和~/.bash_profile;但你在已经登录的会话里再敲bash启动一个子 shell,那就是非登录 shell,只读~/.bashrc。所以如果你把JAVA_HOME只写在~/.bashrc里,某些场景下会“突然失效”。

我的建议很明确:机器级别的环境变量统一放/etc/profile.d/下面,单独建一个文件。这样无论谁登录、用什么 shell 类型,只要走的是登录流程,都能拿到:

# /etc/profile.d/jdk.sh export JAVA_HOME=/opt/jdk/current export PATH="$JAVA_HOME/bin:$PATH"

注意PATH的拼接顺序,$JAVA_HOME/bin必须放在最前面。放后面的话,系统自带的/usr/bin/java会先被命中,你的环境变量等于白写——这是最隐蔽的一类问题,echo $JAVA_HOME看着完全正常,which java却指向别的地方。

3.2 alternatives 机制和手动 PATH 到底谁说了算

用了包管理器安装的机器上,/usr/bin/java通常不是一个真实文件,而是一个符号链接:/usr/bin/java -> /etc/alternatives/java -> /usr/lib/jvm/xxx/bin/java。这套机制叫 alternatives,作用是在多个已安装版本之间切换默认指向。

它和手动配的PATH是两个独立系统,谁生效取决于PATH里的顺序。如果你把$JAVA_HOME/bin放在最前面,那手动配置赢;如果放在后面,alternatives 赢。两边不一致的时候,症状就是:

$ echo $JAVA_HOME /opt/jdk/current $ java -version openjdk version "11.0.20" # 咦,明明是 17?

排查方法很简单,用which -a java把所有命中的路径列出来,看第一个是谁;再用readlink -f $(which java)追到真实文件位置。两个命令一组合,谁在生效一目了然。

如果确定要用 alternatives 管理,那就把它管理完整。java和javac是两个独立的 alternatives 条目,只切java不切javac,就会造出“运行时是 17、编译器是 11”这种精神分裂的状态。apt系提供了批量切换的命令:

# 列出所有已注册的 JDK sudo update-java-alternatives -l # 一次性切换整套工具 sudo update-java-alternatives -s java-17-openjdk-amd64

用这个比一条条--config靠谱得多,它会把java、javac、javadoc、jar等一整套一起改掉。

3.3 非交互式场景下环境变量失效的排查顺序

这是踩坑最多的地方。表现是:本地终端里mvn -v好好的,一放到定时任务里、一放进服务脚本里、一用远程命令直接执行,就报JAVA_HOME not found或者mvn: command not found。

根本原因是这些场景压根不加载 shell 的配置文件。定时任务的执行环境极简,PATH可能只有/usr/bin:/bin;服务管理器启动的进程更是连HOME都可能是空的。

排查我一般按这个顺序走:

  1. 先确认变量到底有没有进来:在目标场景里执行env | grep -i java或者直接echo $JAVA_HOME
  2. 确认PATH里有没有你的 JDK 和 Maven 目录:echo $PATH
  3. 确认命令本身在不在:用绝对路径执行一次,比如/opt/maven/bin/mvn -v
  4. 如果能用绝对路径跑通,说明纯粹是环境变量没加载,去对应的配置文件里补

针对具体场景的补法不一样。定时任务的话,在 crontab 顶部直接写变量赋值;服务化的话,在服务单元里显式声明环境变量或指定一个环境变量文件;远程直接执行命令的话,用bash -lc "命令"强制走登录 shell 流程,或者干脆在命令里内联变量。

还有一类更隐蔽的情况:用sudo执行命令。默认情况下sudo会重置环境变量,走一套自己的secure_path,你精心配的PATH直接没了。这个不是 bug,是安全设计。要么用sudo -E保留环境(需要配置允许),要么就在sudo后面写绝对路径。

提示:判断一个命令到底跑了哪个二进制,which有时候会骗人(比如命中 shell 别名或函数)。更可靠的是type -a java,它会把别名、函数、哈希缓存、真实路径全部列出来。

4. Maven 安装与 settings.xml 的关键配置

4.1 解压安装与目录约定

Maven 的安装比 JDK 简单得多,它就是一个纯 Java 程序,没有任何本地依赖,解压即用。唯一的前置条件是 JDK 已经就位,因为 Maven 启动脚本本身要用 Java 跑。

流程和手动装 JDK 高度类似:

sudo tar -xzf apache-maven-3.9.6-bin.tar.gz -C /opt/ sudo ln -sfn /opt/apache-maven-3.9.6 /opt/maven

然后加进PATH:

# 追加到 /etc/profile.d/maven.sh export MAVEN_HOME=/opt/maven export PATH="$MAVEN_HOME/bin:$PATH"

目录名里带版本号、软链接不带版本,这套做法和 JDK 部分一致,图的就是升级时改一处软链接即可,配置文件不用动。

这里有个命名细节值得说一下。环境变量到底叫MAVEN_HOME还是M2_HOME,社区里两种写法都能见到。老版本的 Maven 脚本读的是M2_HOME,新版本已经不再强依赖这个变量,只靠PATH里能找到mvn就够了。我现在的习惯是只设MAVEN_HOME供自己脚本引用,不指望 Maven 自己去读它。这样反而干净,不会因为变量名猜错而白折腾。

4.2 全局配置与用户配置的覆盖关系

settings.xml有两个位置,作用范围不同:

  • ${maven.home}/conf/settings.xml:全局配置,影响这台机器上所有使用这套 Maven 的用户
  • ${user.home}/.m2/settings.xml:用户配置,只影响当前用户

两者的关系不是“后者完全替换前者”,而是层层叠加。Maven 会先把全局配置读进来,再用用户配置里的同名元素去覆盖或合并。具体行为按元素类型区分:镜像、服务器凭据、profile 这几类是合并语义,用户级的同名项优先;而像localRepository这种单值配置,用户级一旦设置就直接盖掉全局。

这个机制的实际意义在于:机器级别的公共配置(比如统一的镜像地址)写在全局文件里,由运维统一维护;个人特殊需求(比如临时切到一个测试仓库)写在用户文件里,不影响别人。反过来说,如果你在全局文件里写死了某个仓库,又发现有人的构建行为和你预期不符,第一件事就是去看他~/.m2/settings.xml里有没有偷偷覆盖。

改完这个文件不需要重启任何东西,下次执行mvn就生效。这一点比很多配置系统友好。

4.3 镜像配置里 mirrorOf 的取值陷阱

mirrors是settings.xml里最容易配错的部分,因为mirrorOf的取值有一套自己的匹配语法,写错一个字,行为天差地别。

取值写法实际含义常见误用后果
central只拦截中央仓库最保险,但私服配置不会生效
*拦截所有仓库工程里自定义的私服全部被劫持
external:*拦截所有非本机、非文件的仓库相对安全,本机仓库不受影响
repo1,repo2只拦截指定 id 的仓库精确但维护成本高
*,!repo1拦截所有,但排除 repo1私服场景的标准写法

*这条最坑。很多人图省事写*,结果工程pom.xml里定义的公司内部仓库请求全被导到公共镜像上,然后报一堆“找不到构件”。这类报错看起来像依赖冲突,实际上是镜像配置把请求劫持走了。正确写法是external:*,!内部仓库id,明确把内部仓库排除在外。

另一个高频问题是较新版本的 Maven 对明文http协议仓库的默认拦截。从 3.8.1 开始,Maven 内置了一条拦截规则,把所有走http的外部仓库请求直接挡掉,报错信息大概是Blocked mirror for repositories。如果你确实需要访问一个只支持 http 的内部仓库,解法是在mirrors里为它显式定义一条镜像,并把blocked设为false:

<mirror> <id>internal-http</id> <mirrorOf>internal-repo-id</mirrorOf> <url>http://repo.internal.example.com/repository/maven-public/</url> <blocked>false</blocked> </mirror>

注意:把blocked设为false等于主动放弃了传输层保护,只应该在完全可信的内网环境里这么做。公网仓库一律走加密协议,不要图省事。

mirrorOf还有个容易被误解的点:镜像的匹配对象是仓库的 id,不是 URL。你写了mirrorOf为某个名字,但工程里仓库的 id 是另一个名字,那就匹配不上,镜像不会生效。排查“为什么镜像没起作用”时,先在mvn -X的调试输出里找到实际请求的仓库 id,再回来对一遍。

4.4 本地仓库路径与清理策略

本地仓库默认在~/.m2/repository,所有下载的构件都存在这里。这个默认值在个人机器上没问题,但在构建机上会带来两个隐患:一是磁盘空间,几个月下来几十 GB 很正常;二是多用户场景下的权限混乱,A 用户下的构件 B 用户读不到,各自重复下载。

生产环境我一般会把它挪到一个独立挂载点:

<settings> <localRepository>/data/maven-repo</localRepository> </settings>

挪完之后要做两件事:把目录归属设成构建用户可读写,以及在所有需要跑构建的用户配置里都加上这一行。权限这块尤其要注意,如果目录是 root 建的、用户是普通账号,第一次下载就会Permission denied,而这个报错往往被误认为是网络问题。

清理策略上,我不建议直接rm -rf整个仓库。更稳妥的是用 Maven 自带的依赖插件做定向清理:

# 清理本次构建未使用的发布版本构件 mvn dependency:purge-local-repository -DactTransitively=false -DreResolve=false

或者定期找出长期未访问的文件删掉。仓库里的_remote.repositories和*.lastUpdated文件也可以顺手清一清,后者是下载失败的标记,残留下来会导致某些依赖一直不重试,直到你手动删掉或者加-U参数强制刷新。

5. 用最小工程验证工具链是否真的可用

5.1 手写一个 pom 做端到端验证

mvn -v通过不代表工具链可用,它只证明了 Maven 能启动。真正要验证的是:Maven 能不能找到JAVA_HOME、能不能调用javac、能不能下载依赖、能不能打出包。这四件事任何一环断了,-v都是看不出来的。

我习惯用一个手写的最小工程来验证,比起用模板生成器更可控,也不用联网下载模板:

<?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 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <groupId>local.smoke</groupId> <artifactId>toolchain-check</artifactId> <version>1.0.0</version> <packaging>jar</packaging> <properties> <maven.compiler.release>17</maven.compiler.release> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> </properties> </project>

配套一个源码文件放在src/main/java/下,随便写个带main方法的类就行。然后执行:

mvn -B clean package

这条命令覆盖了完整链路:clean验证插件能下载、compile验证编译器能调用、package验证打包插件能工作。跑通之后看target/目录下有没有生成的 jar,有就成了。

这里我特意用了maven.compiler.release而不是传统的source/target。原因是在 JDK 9 之后,source/target只约束语法和字节码版本,并不约束你调用的 API 是不是目标版本里存在的——你完全可能写出一个语法上是 Java 8、但调用了 Java 17 才有的方法的类,编译能过,运行时才炸。release参数会同时约束 API 范围,是更严格的做法。

5.2 报错对照表与按序排查法

装完之后常见的报错就那么几类,我把它们和对应的根因整理了一下,遇到问题时按这个表对,能省不少时间:

报错关键片段真实原因处理方向
No compiler is provided in this environmentJAVA_HOME指向的是运行时而非开发包检查该目录下有无bin/javac,没有就补装
JAVA_HOME is not defined correctly变量指向了不存在的路径或软链接已断readlink -f追一下,确认目标真实存在
mvn: command not foundPATH未包含 Maven 的bin目录检查PATH拼接位置和配置文件加载
Unsupported class file major version 65构建插件的运行 JDK 低于编译目标 JDK统一JAVA_HOME与编译器目标版本
Blocked mirror for repositories较新版本对明文 http 仓库的默认拦截为内部仓库显式配置镜像并放行
Could not transfer artifact ... Connection timed out网络不通或镜像地址配错先用curl直连仓库地址验证可达性
Permission denied写入本地仓库仓库目录归属与构建用户不匹配修正目录属主或换到用户可写路径

排查的顺序我固定成“三层”:先看变量层(JAVA_HOME、PATH是否正确)、再看二进制层(java、javac、mvn是否都在、版本是否一致)、最后看配置层(settings.xml的镜像和仓库)。绝大多数问题在第二层就能定位,因为版本不一致几乎必然导致后续连锁报错。

有一点特别值得强调:mvn -v输出的Java version和java -version输出的版本,必须一致。如果不一致,说明 Maven 用的 JDK 和你终端里的 JDK 不是同一个,通常是JAVA_HOME和PATH的组合结果不同导致的。这种不一致在平时可能看不出来,一旦构建需要特定 JDK 特性就会暴露,而且报错信息完全指不到根因上。

6. 多版本共存与切换:为长期维护做准备

6.1 软链接加切换脚本的轻量方案

一台机器上同时维护几代工程是很常见的,尤其是迁移期,老工程还在 JDK 8 上跑,新工程已经上了 JDK 17。这时候最忌讳的是“每次切换改一次环境变量文件再重新登录”,既慢又容易忘。

用软链接做切换,配合一个小脚本,是成本最低的方案。目录规划如下:

/opt/jdk/ ├── jdk-8u392 ├── jdk-11.0.21 ├── jdk-17.0.9 └── current -> jdk-17.0.9

切换脚本就一行核心动作:

#!/usr/bin/env bash set -euo pipefail target="$1" candidate="/opt/jdk/${target}" if [ ! -x "${candidate}/bin/javac" ]; then echo "目标目录不可用: ${candidate}" >&2 exit 1 fi ln -sfn "${candidate}" /opt/jdk/current echo "已切换到 $(readlink -f /opt/jdk/current)"

两个细节:ln -sfn的-n不能省,否则当current已经是软链接指向目录时,ln会把它当成目录,在目录里面再建一个链接,结果就是一个套娃;脚本开头先校验bin/javac存在,避免把一个残缺目录切成默认。

这个方案的局限是全局生效——切换一次,所有终端、所有工程的默认 JDK 都变了。所以它适合“这台机器当前阶段主要跑某一代工程”的场景。如果需要同一个用户同时跑不同代际的工程,就得靠项目级锁定了。

6.2 项目级锁定:让工程自己声明要哪套 JDK

Maven 提供了几种让工程自己声明工具链的方式,适合多版本共存环境。

最轻量的是在工程根目录建一个.mvn/jvm.config文件,指定 Maven 启动时用的 JVM 参数。不过它只能调参数,不能换 JDK 本体。

真正换 JDK 要靠 toolchains 机制。在~/.m2/toolchains.xml里声明机器上有哪些 JDK:

<toolchains> <toolchain> <type>jdk</type> <provides> <version>17</version> <vendor>custom</vendor> </provides> <configuration> <jdkHome>/opt/jdk/jdk-17.0.9</jdkHome> </configuration> </toolchain> </toolchains>

然后在工程的pom.xml里启用对应的插件去读取这份声明。这样无论当前JAVA_HOME指向什么,工程都会按自己声明去用指定的 JDK 编译。代价是要多维护一个toolchains.xml,而且有些插件对它的支持不完整,实测下来编译环节基本没问题,但个别做字节码增强的插件仍然会读JAVA_HOME。

更简单的替代方案是直接用mvn -Dmaven.compiler.release=17这类命令行参数覆盖,或者在工程的.mvn/maven.config里写死默认参数。这种做法的好处是零额外配置,坏处是只管编译目标,管不到插件运行时用的 JDK。

我实际的取舍是:编译目标用release参数锁死,跨代际的项目再补一份toolchains.xml。两套机制叠起来,出错概率就很低了。

6.3 升级路径与回滚预案

最后说一个经常被忽略的话题:装完之后怎么升级。

包管理器路线的升级是一句话的事,apt upgrade会把补丁版本带上,但大版本不会跨,想跨版本得装新的包名再切换。手动路线的升级是“下载新包、解压新目录、改软链接指向”,改软链接之前老版本目录原封不动,所以回滚就是把软链接指回去,一条命令的事。

这就是为什么我坚持每个版本独立目录、用软链接做指针——它天然带了一个成本几乎为零的回滚机制。真正危险的做法是把新版本直接解压覆盖到老目录上,一旦新版本有问题,你连退回去的地方都没有。

升级后要做三件事确认:java -version和mvn -v的版本号都对、最小工程能完整构建、已有的构建脚本没被版本变化影响。第三步最容易被跳过,但恰恰是最容易出问题的——某些构建脚本里硬编码了 JDK 路径,升级之后路径变了,脚本跑不起来,而这种失败往往在流水线跑到一半才暴露。

更换 Maven 大版本时还要多看一眼插件兼容性。较新版本的 Maven 对一些老旧插件的行为有调整,尤其是那些直接依赖 Maven 内部 API 的插件。稳妥的做法是先在非关键工程上跑一轮完整构建,观察输出里有没有WARNING级别的兼容性提示,确认干净之后再推给主力工程。

我在实际操作中的体会是,环境搭建这件事的价值不在于“装成功”的那一刻,而在于装完之后半年内不用再回头折腾。为了达到这个状态,多花十分钟把目录规划清楚、把配置分层放对位置、把验证脚本写好,回头看都是划算的。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进