做 Java 开发的人,尤其是工作在 Linux 环境下的,基本都绕不开一个场景:本机同时装着好几个 JDK,老项目锁定在 JDK 8,新项目要求 JDK 11 起步,偶尔还要给特定环境编一份 JDK 17 的产物。每次换项目就去改 JAVA_HOME、改 PATH,改完忘了 source,终端里一堆 java -version 对不上,测试环境一跑就是 UnsupportedClassVersionError。我先后试过 update-alternatives、手动改环境变量、jenv,踩了一圈坑之后,最后还是自己写了个轻量的 JDK 版本切换器。这篇文章把我的完整思路、脚本实现和排坑心得都整理出来,需要的同学可以直接复制改路径,省得再走一遍弯路。
1. 为什么你需要一个 JDK 版本切换器
1.1 多版本共存的真实场景
很多人以为装个最新版 JDK 就一劳永逸了,真实项目根本不是这么回事。老框架在 JDK 8 上跑得好好的,换到 17 直接给你报模块访问错误;反过来,你在代码里用了 record 或者 switch 表达式这种新语法,切回 8 连编译都过不了。还有一种更磨人的情况,团队里不同服务的目标运行时版本不一致,本地开发和线上部署必须严格对应,不然打包出来没问题,跑到服务器上启动就报错。
我自己手上就有三个项目:一个维护了五年的老服务,pom 里锁死 JDK 8;一个中台系统要求 JDK 11;还有最近接的新项目,CI 流水线直接用的 JDK 17 镜像。以前我图省事,把三个版本的 JDK 压缩包全部解压到 /usr/local/java 下面,目录名还起得特别像,jdk8、jdk11、jdk17。每次切换项目,我就去改 /etc/profile 里的 JAVA_HOME,改完还要重新登录终端才放心。这套流程看着简单,实际用起来全是坑:改错一个字符、忘记 source、几个终端窗口状态不一致,每一样都能浪费半小时。
后来我在一次线上事故里彻底受够了。当时切完版本忘了验证,直接跑部署脚本,脚本里用的是 $JAVA_HOME/bin/java 启动,结果 JAVA_HOME 指向的还是旧 JDK,新代码里的 API 在老版本上不存在,启动直接失败。那次之后我就下定决心,一定要有个可靠的、可验证的版本切换工具。
1.2 一个好用的切换器应该满足什么
在动手写之前,我先梳理了需求。我需要的不是那种"能用就行"的临时方案,而是一个能长期稳定使用、切换结果可预期、出问题还能快速定位的工具。具体来说,至少要满足四点:
第一,能管理多个 JDK 版本,并且知道每个版本安装在哪里。版本号和路径的对应关系要一目了然,不能靠脑子记。第二,切换命令要简单,一条命令搞定,不需要记一长串 export。第三,切换后要立刻看到效果,当前终端马上生效,不需要重新登录或者重开窗口。第四,切换结果要可查,JAVA_HOME、PATH、java -version 三个信息能同时确认,避免"改了但没完全改"的模糊状态。
理清这四点之后,我再去对比了几种常见方案,发现没有一个是完全满足的,这才决定自己写脚本。下一部分我把对比过程详细讲一下,你就知道为什么现成方案总差那么点意思。
2. 方案选型:别急着写脚本,先对比一下
2.1 update-alternatives 的适用边界
Linux 上最"官方"的多版本切换工具是 update-alternatives,很多教程都会推荐。它的本质是维护一组符号链接,比如 /usr/bin/java 默认指向 /etc/alternatives/java,而 alternatives 又指向某一个具体的 JDK 路径。执行 sudo update-alternatives --config java 就能交互式选择要用的版本。这个方案系统级生效,对新装机的环境来说确实方便,不需要自己写任何东西。
但用在真实项目里有两个明显的问题。第一个问题是它只管命令本身,java、javac 这些可执行文件会被切换,但 JAVA_HOME 环境变量并不会跟着变,而 Maven、Gradle、Spring Boot 启动脚本这些工具恰恰只认 JAVA_HOME。你 update-alternatives 切得再欢,mvn 编译用的还是老版本,等于白切。第二个问题是它需要 sudo 权限,而且切换是全局的,想给不同终端窗口配不同版本就非常别扭,一个终端切到 8,另一个终端想要 17,做不到。
所以我的结论是:update-alternatives 适合服务器上固定一个默认版本的场景,比如生产环境的 OpenJDK 维护;放到日常多版本开发的笔记本上,就差了点意思。
2.2 手动改环境变量的痛点
另一条路是手动管理环境变量,也就是在 ~/.bashrc 或 /etc/profile.d/ 下写好某个版本的 JAVA_HOME 和 PATH,再 export 出去。这种方式最直观,也最容易理解,很多入门教程教的就是这套。但痛点很真实:每切一次版本就要改一次文件,改了之后当前终端不生效,得 source 一下;source 完如果 PATH 的拼接顺序不对,旧的 java 路径残留,然后整个环境就乱了。
我遇到过最典型的情况是 PATH 里同时存在两个 JDK 的 bin 目录,java 命令命中旧的那个,而 JAVA_HOME 指向新的,两边不一致。当时我反复确认 JAVA_HOME 是对的,但 java -version 就是不变,排查了半天才发现是 PATH 顺序的锅。因为 shell 解析 java 命令时,是沿着 PATH 目录从左往右找的,找到第一个就停止,排在后面的等价命令根本没机会被命中。
手动方案适合"一年换一次版本"的人,对频繁切换的日常开发来说,纯粹是给自己找麻烦。而且这种方式改的是全局配置文件,多人共用一台开发机的时候,还会影响到别人的环境。
2.3 为什么我选择自研脚本
后来我也试过 jenv 这类工具。jenv 的理念是对的,通过 shim 机制把版本切换做得很优雅,但装完还得跟 shell 钩子打交道,还要维护 ~/.jenv/versions 底下的版本目录,对大多数场景来说引入了一个不算轻的依赖。用了一段时间,总觉得黑盒成分太多,出了问题不好定位。
最后我决定自己写一个几十行的 bash 脚本。用关联数组维护"版本号到安装路径"的映射,切换时同时更新 JAVA_HOME 和 PATH,并且主动清理 PATH 里属于旧 JDK bin 的残留项。脚本只做一件事,切换逻辑透明确切,出问题也知道去查哪一行。现在用了一年多,稳定性和效率都没让我失望。就我的经验而言,工具越小、越贴合自己的环境,越好维护。完整代码和用法在下一部分,你可以直接复制回去改路径就能用。
2.4 动手前先把 JDK 装好并登记路径
写脚本之前有个前置工作容易被忽略:先把各个版本的 JDK 装好,并且把路径固定下来。这里分享下我的习惯。Oracle JDK 和 OpenJDK 都行,我一般从官网下载 tar.gz 压缩包,解压到 /usr/local/java 目录下,目录名带上完整版本号,比如 jdk-17.0.10、jdk-11.0.22。这样做的好处是路径稳定、可预期,脚本里登记的就是这些路径。
如果你用的是 Ubuntu 或者 Debian,也可以走 apt 安装,装完的 JDK 都在 /usr/lib/jvm 下,目录名类似 jdk-17-openjdk-amd64。这个没问题,脚本里把对应路径登记进去就行。但我个人更推荐统一用 tar.gz 解压的方式,因为 apt 仓库里的 OpenJDK 版本更新有滞后,而且不同发行版的目录命名习惯不一样,换台机器容易踩坑。
装好之后,我建议先手动验证一下:跑一下 /usr/local/java/jdk-17.0.10/bin/java -version,确认这个路径下的 java 能正常工作。这个验证非常重要,因为脚本只管切换,管不了 JDK 本身是否损坏。路径都没确认清楚就写进脚本,到时候切过去才发现是个坏的 JDK,排查起来更费劲。
3. 核心实现:写一个可靠的切换脚本
3.1 脚本设计要点
写这个脚本之前,我先想清楚了几个关键点,这里也分享给你。
第一个是"必须用 source 执行"。bash 脚本如果直接运行,它是在子进程里跑的,脚本里 export 的环境变量在脚本结束后就没了,当前终端根本不会变。所以切版本的本质是修改当前 shell 的环境,必须 source 脚本。我在脚本开头加了一层判断,如果检测到你是直接 bash 运行而不是 source,就直接提示并退出,避免误用。
第二个是 PATH 的清理顺序。正确的做法是先把 PATH 里所有 JDK 的 bin 路径剔除掉,再把新版本的 bin 追加到最前面,而不是简单地 export PATH="$JAVA_HOME/bin:$PATH"。后者会让 PATH 越拼越长,旧路径永远清理不干净,最后 java 命中的可能是任意一个版本,完全不可控。
第三个是要把检查放在前面。版本不存在、路径不存在就直接报错退出,别等 export 完了才发现路径是错的。脚本的容错要写在入口处,而不是写到一半再回头处理。
这三个点做到,脚本的可用性就有保障了。
3.2 完整脚本与用法说明
我用的是下面这份 bash 脚本,代码不长,但每个函数都有明确职责。直接贴出来给你看:
#!/usr/bin/env bash # jdk-switch.sh - Linux JDK 版本切换器 # 登记的 JDK 安装路径,请按自己的实际路径修改 declare -A JDK_MAP=( [8]="/usr/local/java/jdk1.8.0_202" [11]="/usr/local/java/jdk-11.0.22" [17]="/usr/local/java/jdk-17.0.10" [21]="/usr/local/java/jdk-21.0.2" ) # 清理 PATH 中所有已登记的 JDK bin 路径 clean_old_jdk_path() { local cleaned="" local IFS=':' for p in $PATH; do local matched=0 for v in "${!JDK_MAP[@]}"; do if [ "$p" = "${JDK_MAP[$v]}/bin" ]; then matched=1 break fi done if [ "$matched" -eq 0 ]; then cleaned="${cleaned:+$cleaned:}$p" fi done export PATH="$cleaned" } switch_jdk() { local version="$1" local jdk_path="${JDK_MAP[$version]}" if [ -z "$jdk_path" ]; then echo "不支持 JDK $version,可用版本: ${!JDK_MAP[@]}" >&2 return 1 fi if [ ! -d "$jdk_path" ]; then echo "JDK $version 路径不存在: $jdk_path" >&2 return 1 fi clean_old_jdk_path export JAVA_HOME="$jdk_path" export PATH="$JAVA_HOME/bin:$PATH" echo "已切换到 JDK $version" echo "JAVA_HOME=$JAVA_HOME" echo "PATH 前缀=${JAVA_HOME}/bin" java -version } if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then echo "请使用 source 加载: source ./jdk-switch.sh 17" >&2 exit 1 fi switch_jdk "$1"用的时候也很直接。先 source 脚本,再传版本号参数:
source ./jdk-switch.sh 17看到输出里显示"已切换到 JDK 17"和 java 版本信息,就说明切换成功了。想切回 8,再 source 一次传 8 即可。脚本会自动清理 PATH 里的旧 JDK 路径,不会留下脏数据。脚本对 bash 版本有要求,关联数组是 bash 4.0 开始支持的特性,Linux 发行版自带的 bash 基本都没问题,如果用 macOS 系统自带的 bash 3.2 会报错,换成新版 bash 或者改用其他脚本语言实现都行。
你不需要理解脚本每一行,但建议至少看明白 clean_old_jdk_path 这个函数在干什么。它做的事情是遍历当前 PATH 里的所有目录,如果某个目录正好等于登记过的某个 JDK 的 bin 路径,就剔除掉;剩下的保留。这样无论你之前切到哪个版本,PATH 里都不会残留旧 JDK 的影子。
3.3 底层原理:JAVA_HOME 和 PATH 是怎么影响 java 命令的
很多人切换 JDK 只盯着 java -version 看,其实没搞懂背后的机制。Linux 下执行 java 命令时,shell 会在 PATH 环境变量列出的目录里挨个找名为 java 的可执行文件,找到第一个就停止。所以 PATH 目录的先后顺序决定了你用的是哪个 java。如果 /usr/bin 排在前面,而 /usr/bin/java 又是 update-alternatives 维护的软链,那么无论你 export 什么 JAVA_HOME,java 命令都可能命中系统默认版本。
JAVA_HOME 则是给那些"不自己去 PATH 里找 java"的程序用的,典型的就是 Maven、Gradle,还有各种启动脚本里写 $JAVA_HOME/bin/java 的程序。它们不关心 PATH,只认 JAVA_HOME 这个变量。理解了这层关系,就明白为什么只改 JAVA_HOME 或者只改 PATH 都不行:改了 JAVA_HOME,java 命令的实际解析可能还是旧的;只改了 PATH,Maven 拿到的还是旧 JAVA_HOME。
我的脚本把两个变量一并处理,同时通过清理旧 bin 路径保证 java 命中的一定是刚切过去的那份。用一个生活化的类比来解释:PATH 就像小区门口的指路牌,写在最前面的路标会被第一个看到;JAVA_HOME 则是快递单上的收货地址,快递员只认这个地址,不看路牌。两个信息必须同时正确,包裹才能送到正确的人手里。
4. 实操验证与常见问题排查
4.1 切换后没生效?先按顺序查这三件事
脚本写完之后,我在自己机器上跑了一段时间,也帮同事排查过几次,总结了三个最高频的"切换没生效"原因,按排查顺序列给你。
第一,确认当前 shell 是否真的 source 了脚本。很多人直接 bash jdk-switch.sh 17 来跑,结果在当前终端里 java -version 完全没变,因为子进程里 export 的东西根本带不出来。这也是我在脚本里加了误用提示的原因,看到"请使用 source 加载"这句话,就说明你运行方式不对。
第二,执行 command -v java 看它指向哪里。如果结果是 /usr/bin/java,那很可能是系统通过 update-alternatives 预设的软链在起作用。这时候要看 PATH 里 /usr/bin 是不是在 $JAVA_HOME/bin 前面。这种场景下,你需要调整 PATH 顺序,或者确认脚本已经把新 bin 放在最前面。
第三,如果 command -v java 已经指向 $JAVA_HOME/bin/java,但 java -version 还是老的,多半是当前 shell 缓存了命令路径。bash 会把最近查找过的命令路径存进哈希表,再次执行时直接走缓存,不重新搜 PATH。执行 hash -r 清理一下哈希缓存再试就行。这三步基本能解决九成的"没生效"问题。
4.2 高频问题速查表
我把实际使用中遇到的典型问题整理成了下面这个表,每个都包含现象、原因和解决办法,方便你遇到问题时直接对着查:
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| java -version 没变化 | 直接 bash 执行脚本,export 没生效 | 改用 source 加载脚本 |
| command -v java 显示 /usr/bin/java | PATH 中 /usr/bin 排在 JDK bin 前面 | 调整 PATH 顺序,或确认脚本把 $JAVA_HOME/bin 放最前 |
| command -v java 正确但版本仍旧 | shell 命令路径哈希缓存 | 执行 hash -r 清除缓存 |
| Maven 编译用的 JDK 不对 | Maven 读的是 JAVA_HOME,不是 PATH | 切换后 echo $JAVA_HOME 确认,重新 source 后再跑 mvn |
| 新开终端又变回默认版本 | 切换只对当前 shell 生效 | 把默认版本 export 写入 ~/.profile |
| 提示不支持的版本 | JDK_MAP 里没有登记该版本 | 在脚本数组里补上对应版本号和路径 |
| 切换后其它命令报 PATH 错误 | clean 函数误删了非 JDK 路径 | 确认 clean 函数匹配规则只针对登记过的 JDK bin 目录 |
| source 时报语法错误 | bash 版本低于 4.0 | 升级 bash,或用兼容写法替代关联数组 |
4.3 我把这些坑都踩了一遍
这里再分享几个脚本之外的坑,都是我真实踩过的。
第一个坑是 Ubuntu 上 apt 安装的 openjdk 路径问题。apt 装出来的 JDK 都在 /usr/lib/jvm 下,而且目录名带版本号,比如 jdk-17-openjdk-amd64。这没问题。但如果你还装了 Oracle JDK 放在 /usr/local/java,两个来源的路径风格完全不同,脚本登记路径时一定要逐个确认目录真实存在。我建议在登记前先 ls 一眼,别凭印象写路径,否则切过去直接提示路径不存在,还以为是脚本 bug。
第二个坑是切换版本后马上跑 mvn -version,有时候版本号没变,其实不是脚本问题,是 mvn 这个 shell wrapper 里可能缓存了旧的 JAVA_HOME,或者你开了多个终端窗口,当前窗口的环境变量还是老的。多窗口环境下,每个窗口都得单独 source。我在实际工作中就吃过这个亏,终端 A 切到 17 编好了包,终端 B 没切,直接打包,结果 CI 里跑出来的产物对不上。
第三个坑是 shell 配置文件的优先级。如果你把默认 JAVA_HOME 写在 /etc/profile 里,又在 ~/.bashrc 里 source 切换脚本,不同终端登录时加载顺序不一样,可能造成行为不一致。我后来统一把默认版本写在 ~/.profile 里,切换脚本只负责临时切换,两者互不干扰。
5. 进阶用法:让切换器适配真实工作流
5.1 按项目目录自动选 JDK
脚本在手动切换场景下已经很顺手了,但如果你跟我一样经常在多个项目间跳来跳去,还可以再加一个更省心的功能:进入项目目录时自动切换对应 JDK 版本。
思路是在项目根目录放一个隐藏文件,比如 .jdk-version,里面写一行版本号:17。然后在 ~/.bashrc 里加一个钩子,每次 cd 到新目录时检查当前目录有没有这个文件,有就读取内容,自动调用脚本切换。我用的是 PROMPT_COMMAND 的方式,在每次提示符出现前执行一个检查函数,函数逻辑很轻量:
auto_switch_jdk() { if [ -f ".jdk-version" ]; then local target_version=$(cat .jdk-version | tr -d ' \n') local current_version=$(echo "$JAVA_HOME" | grep -o '[0-9]\+' | head -1) if [ -n "$target_version" ] && [ "$target_version" != "$current_version" ]; then source ~/tools/jdk-switch.sh "$target_version" fi fi } PROMPT_COMMAND="auto_switch_jdk"这个功能加上之后,基本就告别手动切版本了。进入老项目目录,终端自动切到 8;切到新项目目录,自动切到 17。这里判断当前版本的方式比较粗略,只抓了 JAVA_HOME 里第一个数字,对于 jdk-17.0.10 这种路径能正常工作。如果你的路径命名方式不一样,建议用更严谨的匹配方式,或者直接比较完整路径。
有一点要提醒:PROMPT_COMMAND 里的函数会在每次提示符渲染前执行,所以逻辑一定要轻量,别在里面做重活,否则终端会明显变卡。判断尽量用文件存在性和短字节读取,不要每次都去解析大文件。
5.2 与构建工具和 IDE 的配合
切换脚本管的是终端环境,但真实开发里还有两个环节容易忽略:构建工具和 IDE。
构建工具这边,Maven 和 Gradle 都通过 JAVA_HOME 找 JDK,所以在跑 mvn clean package 之前,先确认当前 shell 的 JAVA_HOME 是不是你要的那个版本,最直接的方式是看 mvn -version 输出里的 Java version 那行。另外 Gradle 还有自己的 daemon 进程,daemon 启动时会锁定一个 JDK 版本,如果你切换了 JDK,最好执行 gradle --stop 把旧 daemon 停掉,再重新构建,否则它可能一直用旧版本跑。
IDE 这边,IDEA 的 Project Structure 里有独立的 Project SDK 设置,IDE 配置和终端环境是两套体系,终端切到 JDK 8 不代表 IDEA 里也切了,反之亦然。我个人的做法是:终端环境用脚本管,IDEA 里每个项目单独指定 Project SDK,两者各司其职。如果你用 IDEA 的 Terminal 面板,它继承的是 IDE 启动时的环境变量,脚本切换在 IDEA 里不一定生效,所以别在 IDEA 内嵌终端里指望脚本能改变整个 IDE 的编译环境。
5.3 日常使用的几个习惯
脚本本身不算复杂,但真正让一套工具长期好用的是使用习惯。我在实际使用中总结了一个习惯清单:切完版本先跑一下 echo $JAVA_HOME && java -version 确认两个信息一致;跑构建工具前先 mvn -version 或 gradle -v 看一眼实际生效版本;新开终端如果默认版本不对,先检查 ~/.profile 和 ~/.bashrc 里有没有重复 export;遇到诡异问题先 hash -r 再试。这些习惯花不了几秒钟,但能省掉很多后面排查的时间。
另外一点,脚本不要写死在某个角落里,建议放到 ~/bin 或者你自己的工具目录里,并且建一个软链方便调用。我现在是直接 source ~/tools/jdk-switch.sh 17,简单稳定,不容易忘。这个切换器我用了快两年,没有出过一次切换错误,每次都能准确命中预期的 JDK 版本。如果你的场景跟我类似,多个版本常年共存、项目间切换频繁,这套方案可以直接拿去用,把路径改成你自己的就行。