前阵子帮人搭一台干净的构建机,系统是最小化安装,连编译工具链都没有。我按着记忆里那套“装 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 8 | 52 | 3.6 至 3.9 | 大量存量工程仍在使用,插件生态最全 |
| JDK 11 | 55 | 3.6 至 3.9 | 第一个移除部分旧模块的 LTS,注意javax相关迁移 |
| JDK 17 | 61 | 3.8 至 3.9 | 强封装更严格,反射类插件容易出问题 |
| JDK 21 | 65 | 3.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-jdkdnf系对应的是:
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都可能是空的。
排查我一般按这个顺序走:
- 先确认变量到底有没有进来:在目标场景里执行
env | grep -i java或者直接echo $JAVA_HOME - 确认
PATH里有没有你的 JDK 和 Maven 目录:echo $PATH - 确认命令本身在不在:用绝对路径执行一次,比如
/opt/maven/bin/mvn -v - 如果能用绝对路径跑通,说明纯粹是环境变量没加载,去对应的配置文件里补
针对具体场景的补法不一样。定时任务的话,在 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 environment | JAVA_HOME指向的是运行时而非开发包 | 检查该目录下有无bin/javac,没有就补装 |
JAVA_HOME is not defined correctly | 变量指向了不存在的路径或软链接已断 | readlink -f追一下,确认目标真实存在 |
mvn: command not found | PATH未包含 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级别的兼容性提示,确认干净之后再推给主力工程。
我在实际操作中的体会是,环境搭建这件事的价值不在于“装成功”的那一刻,而在于装完之后半年内不用再回头折腾。为了达到这个状态,多花十分钟把目录规划清楚、把配置分层放对位置、把验证脚本写好,回头看都是划算的。