一台机器上同时挂着两套 Java 应用,一套跑在老框架上动不了,另一套又必须用新版本的语言特性,这种局面在 CentOS 8 上太常见了。标题里说的“安装第二个 JDK 隔离环境”,本质就是解决这件事:在系统自带的那份 OpenJDK 之外,再落一份独立版本的 JDK,两份各管各的应用、互不干扰,切的时候不出岔子。我见过太多人上来就yum install一个新版本,装完发现java -version变了,老应用第二天起不来,回滚回滚不干净。也见过有人把环境变量改来改去,结果 cron 任务和 systemd 服务读的根本不是同一份配置。这篇就按我实际操作的顺序拆一遍:先盘现状、再定目录、然后装第二份、注册切换、最后把隔离做到进程级、会话级、服务级这三层。不管你是刚接手一台祖传服务器,还是本地要做双版本联调,这里面的步骤和坑都能直接抄。
1. 为什么非得装第二个,而不是升级了事
1.1 三类最常撞上的真实场景
第一类是存量应用被版本锁死。某公司内部的一套报表系统,跑在很早的 Spring 版本上,反射调用依赖了一堆在新 JDK 里被强封装掉的内部 API,升级 JDK 立刻抛InaccessibleObjectException。改代码?那是排期三个月的事。这种时候你唯一能做的就是让老应用继续待在老 JDK 上,新东西另开一摊。
第二类是构建工具链的硬性要求。新拉下来的一个项目,pom.xml里写着<maven.compiler.release>17</maven.compiler.release>,或者 Gradle 的 toolchain 直接指定语言版本。你在 8 上编译,报错信息还特别绕,往往是一堆invalid target release或者语法不识别。有人图省事去改构建脚本,把 release 降到 8,结果引入的依赖自己又要求 11+,越改越乱。
第三类是中间件附带了自己的 JDK 依赖。有些组件安装包里直接塞了个 jre 目录,有些启动脚本里写死了JAVA_HOME=/usr/lib/jvm/xxx。你换全局环境变量,它照样按写死的路径走,两种版本同时存在于内存里,日志里全是莫名其妙的类找不到。
这三类场景的共同点是:需求不是“换”,而是“并存”。所以第一步要做的不是下载,是想清楚你要的是哪个层次的隔离。
1.2 几条路线摆在一起比一比
我把实际可选的方案列成一张表,你可以对照自己的情况挑。这里没有绝对的好坏,只有适不适合你的运维方式。
| 方案 | 隔离粒度 | 切换成本 | 适用场景 | 主要风险 |
|---|---|---|---|---|
| 直接升级现有 JDK | 无 | 低 | 单应用、可停机验证 | 老应用大概率挂,回滚麻烦 |
| 多版本共存 + alternatives | 系统级默认值 | 中(需显式切换) | 多用户共用、需要统一入口 | 切完影响所有未指定路径的程序 |
| 多版本共存 + 环境变量脚本 | 会话级 | 低(source 一下) | 开发调试、临时验证 | 不读 profile 的进程拿不到 |
| 独立系统账号 + 独立 profile | 用户级 | 中 | 服务化部署、多团队共用机器 | 需要额外维护账号权限 |
| 容器内自带 JDK | 进程级、彻底 | 低 | 长期并存的正式方案 | 增加一层运行时复杂度 |
我个人的默认组合是:alternatives 管“系统默认”,环境变量脚本管“临时切换”,systemd 单元写死绝对路径管“正式服务”。三条腿走路,各管一段,互相不打架。为什么不用容器一步到位?因为很多存量环境里根本没有容器运行时,而且老应用的运维流程、日志采集、监控探针全是按宿主机进程配的,搬进容器反而是给自己找活干。这是基于常见运维实践的经验判断,你要是新建环境,容器当然是更干净的选择。
1.3 这次操作的目标定义
把话说清楚,免得做着做着跑偏。我们要达成的验收标准是四条:
- 系统原有的 JDK 保持可用,
java -version在默认状态下输出不变; - 新装的第二个 JDK 独立解压在自己的目录,不覆盖任何系统文件;
- 能按需切换到第二个 JDK,且切换后
java、javac、jps、jcmd全部指向同一份; - 服务启动时可以不依赖任何 shell 配置文件,直接锁定版本。
这四条里,第四条最容易被忽略,也是事故高发区。后面第 4 章会专门讲。
2. 动手前的盘点:别急着下载,先搞清楚现状
2.1 定位系统里已经存在的 Java
上服务器第一件事不是java -version,那只能看到 PATH 里第一个命中的。我一般按这个顺序查四遍:
# 1. 看命令实际解析到哪 which -a java javac readlink -f "$(which java)" # 2. 看 alternatives 体系里注册了哪些 alternatives --list | grep -i -E 'java|jre' update-alternatives --display java # 3. 看包管理器装了什么 rpm -qa | grep -i -E 'jdk|jre|openjdk' # 4. 看环境变量里有没有人偷偷塞过 env | grep -i -E 'java|jdk|jre'输出里最容易踩的坑是:which java指向/usr/bin/java,而/usr/bin/java是一个一百多字节的符号链接,readlink -f跟下去才看到真正的家目录在/usr/lib/jvm/下面。如果你只看which的结果就以为 JDK 装在/usr/bin,后面卸载的时候会一通乱删。
还有一个细节:CentOS 8 上的 OpenJDK 是通过 module 机制提供的。dnf module list java*能看到 AppStream 里挂着 8 和 11 两条流。这意味着你看到的“系统自带 JDK”,可能是某次dnf install顺手带进来的,也可能是 module 流固定下来的。搞清楚来源,卸载的时候才知道该用dnf remove还是直接删目录。
注意:CentOS 8 的软件仓库地址在生命周期结束后会失效,
dnf直接跑可能报 404。做查询之前先把仓库配置里的地址指向归档位置,否则rpm -qa能跑、dnf却什么都干不了,很容易误判成“系统坏了”。
2.2 磁盘余量与基础依赖确认
第二个 JDK 解压出来通常是 300MB 上下,加上日志和临时文件,我会按 1GB 预留。查一下目标分区:
df -h /opt ldd --version | head -n 1 free -mldd --version是为了确认 glibc 版本。新版 JDK 对 glibc 有最低要求,如果系统比较老,解压出来的java一执行就报version 'GLIBC_2.xx' not found,这种错误信息看着像缺库,实际上是版本不够,装再多的库文件也没用,只能换一个更保守的 JDK 构建版本。这个判断我吃过一次亏,当时花了半天在yum install各种兼容包,最后才发现方向完全错了。
free -m是顺手看一眼内存。如果你打算在两个 JDK 上都跑应用做对比测试,堆内存要提前规划好,别让两个 JVM 抢同一块内存把机器拖垮。按经验,容器或虚拟机里单 JVM 的堆上限用-XX:MaxRAMPercentage=50比写死-Xmx更稳,因为宿主机内存变化时它会跟着调整。
2.3 目录规划与命名约定
我坚持一个原则:手动装的 JDK 一律不放/usr/lib/jvm。那个目录是包管理器的地盘,dnf remove或者一次系统升级可能把它扫掉,而且混在一起以后你自己都分不清哪个是哪个。
我的约定是:
/opt/jdk/ # 手动安装的 JDK 统一父目录 ├── jdk-8u412/ # 具体版本,永久保留 ├── jdk-17.0.9/ # 具体版本,永久保留 ├── jdk8 -> jdk-8u412 # 软链接,脚本里只引用这个 └── jdk17 -> jdk-17.0.9 # 软链接,脚本里只引用这个版本号目录用完整版本,软链接用大版本。所有脚本、服务单元、环境变量都只引用软链接。好处是将来把jdk17从17.0.9换成17.0.10,只需要改一条ln -sfn,全机器生效,不用翻任何一个配置文件。这个习惯我保持了几年,每次小版本升级都是几分钟的事。
至于为什么不直接用/opt/jdk17当真实目录——因为那样就没法保留旧版本做快速回滚了。软链接切换加旧目录保留,回滚就是ln -sfn一条命令,比重装快得多。
2.4 权限位与访问控制
解压出来的文件默认属主是执行解压操作的那个账号。如果 JDK 目录只有 root 能读,其他账号跑应用就会在启动阶段报Permission denied,而错误信息往往指向一个看起来毫不相干的配置文件,排查起来极其绕。
我统一处理成:
chown -R root:root /opt/jdk chmod -R 755 /opt/jdk755意味着所有账号可读可执行,只有 root 可写。这足够日常使用,又防止普通账号误改 JDK 里的文件——这种事真发生过,有人把一个 jar 塞进了jre/lib/ext,然后整个机器上所有 Java 程序的表现都变了,查了两天才定位到。
如果安全策略更严,可以建一个专门的组,把需要跑 Java 的账号加进去,目录设成750,属组设为那个组。这在多团队共用同一台机器的场景下是值得的。
3. 安装第二个 JDK 的完整实操
3.1 取包与完整性校验
优先拿 tar.gz 二进制包,不要拿 rpm。原因很直接:tar 包是自包含的,解压即用,删除即卸,不往系统里写任何注册信息,天然适合做“第二份”这个定位。rpm 包会往/usr/lib/jvm和 alternatives 里写东西,装完就跟系统自带的那份混在一起了,反而破坏隔离。
mkdir -p /opt/jdk cd /opt/jdk # 用 curl 或 wget 拿到 tar.gz,文件放这里 ls -lh jdk-*.tar.gz # 校验 sha256sum jdk-*.tar.gz拿到哈希值之后,一定要跟发布方公布的哈希逐位比对。这一步很多人跳过,觉得内网下载没问题。但传输截断是很常见的——文件大小看起来对,解压到一半报Unexpected EOF,你会先怀疑磁盘、再怀疑内存,最后才想到是包不完整。先比哈希,省下这一圈。
提示:如果下载链路上有代理缓存或者经过多次中转,建议同时在两台机器上各下一次做交叉比对,哈希一致再分发。这个动作在批量部署时特别值。
3.2 解压、归位与软链接
cd /opt/jdk tar -zxf jdk-17.0.9_linux-x64_bin.tar.gz # 目录名可能带平台后缀,改成语义化的名字 mv jdk-17.0.9 jdk-17.0.9 # 建软链接,只指向已存在的目录 ls -d /opt/jdk/jdk-17.0.9 && ln -sfn /opt/jdk/jdk-17.0.9 /opt/jdk/jdk17 # 验证结构 ls -l /opt/jdk/jdk17/bin/javaln -sfn里的-n是个关键参数。如果目标位置已经有一个指向目录的软链接,不加-n,ln会顺着旧链接把新链接创建到里面去,结果就是/opt/jdk/jdk17/jdk17.0.9这种诡异嵌套。我踩过这个,当时切换版本切了三次才发现链接层级越来越深,readlink -f输出长得离谱。
解压完先跑一下确认能执行:
/opt/jdk/jdk17/bin/java -version如果这里就报GLIBC相关错误,回到 2.2 节说的,换一个更保守的构建版本,别往下走了。
3.3 用 alternatives 注册第二份 JDK
CentOS 的 alternatives 是一套“同功能多实现”的管理机制。java、javac、jps这些命令,本质上都是/etc/alternatives/下的中间链接,再指向具体实现。这套机制的价值在于:它让“系统默认用哪个”变成一个可以持久化、可查询、可回滚的配置,而不是靠谁在.bashrc里 export 了什么东西。
注册的语法是alternatives --install <链接路径> <名称> <实现路径> <优先级>。注意这里要用具体版本目录,不要用软链接,否则 alternatives 记录的是链接路径,将来改链接它不会跟着变。
# java alternatives --install /usr/bin/java java /opt/jdk/jdk-17.0.9/bin/java 2000 # javac alternatives --install /usr/bin/javac javac /opt/jdk/jdk-17.0.9/bin/javac 2000 # 常用工具,按需注册 for t in jar jps jcmd jstack jmap jstat keytool; do [ -x /opt/jdk/jdk-17.0.9/bin/$t ] && \ alternatives --install /usr/bin/$t $t /opt/jdk/jdk-17.0.9/bin/$t 2000 done # 看看现在注册成什么样 alternatives --display java | head -n 20优先级数字怎么给?规则是数字大的优先,在非交互场景下自动选大的。我一般这么分档:
| 优先级 | 用途 | 说明 |
|---|---|---|
| 1000 段 | 系统包管理器安装的版本 | 保持默认不动,便于识别 |
| 2000 段 | 手动装的新版本 | 需要通过--config显式切换 |
| 9000 段 | 临时试验版本 | 用完记得--remove |
关键点:不要通过把新版优先级调到比系统版低来“保持默认不变”。那样你会失去在新版和旧版之间切换的能力——--config虽然能选,但每次系统更新都可能把顺序重置。正确做法是让新版本优先级略高,然后手动切回系统版,让当前选中状态落在系统版上。这样既保留了双方的注册记录,又明确了当前默认。
# 交互式选择,输入编号 alternatives --config java alternatives --config javac切换完成后必须三个命令一起验证,缺一不可:
java -version javac -version readlink -f "$(which java)"我见过太多次java切了、javac没切的情况。编译时用的是新版语法,运行时用的是老版 JVM,报出来的错误是UnsupportedClassVersionError,看着像代码问题,其实是环境没对齐。
3.4 环境变量层的隔离:三套写法与加载顺序
alternatives 管的是“不指定路径时用哪个”。但很多时候你需要在同一个账号的不同会话里用不同版本,这个只有环境变量能做到。
先讲清楚加载顺序,这是所有坑的根源。CentOS 的登录 shell 会按文件名 ASCII 顺序依次执行/etc/profile.d/*.sh。也就是说99-jdk.sh一定在java.sh之后执行。如果你的脚本放在前面,后面的脚本里一旦有export PATH=/usr/bin:$PATH,就会把你的 JDK 路径挤到后面去。
我用的写法,存成/etc/profile.d/99-custom-jdk.sh:
# 只在变量未被显式设置时才注入,尊重已有的自定义 if [ -z "${JAVA_HOME:-}" ]; then export JAVA_HOME=/opt/jdk/jdk17 fi case ":${PATH}:" in *":${JAVA_HOME}/bin:"*) ;; *) export PATH="${JAVA_HOME}/bin:${PATH}" ;; esac export CLASSPATH=.:${JAVA_HOME}/lib三个设计意图值得说一下。第一,if [ -z ... ]保证不会覆盖用户自己在.bashrc里设的值,避免两个文件打架。第二,case判断避免重复执行 source 时 PATH 越来越长——这个坑很隐蔽,运维脚本反复 source 几次,PATH 里就堆了七八个同样的路径,虽然不影响功能,但排查问题时看着头大。第三,CLASSPATH只加.和 JDK 自带库目录,绝对不要在这里加应用自己的 jar 包,那是构建工具的事。
还有一套是用户级的,写在~/.bashrc或者~/.bash_profile末尾。适合只影响自己、不动全局的场景。注意区分这两个文件:.bashrc在交互式非登录 shell 里也会读,.bash_profile只在登录 shell 读。cron 任务两者都不读,这是第 5 章要讲的坑。
3.5 写一个随开随用的切换脚本
环境变量方案适合长期设定,但要临时在几个版本间来回跳,每次手敲 export 太笨。写一个脚本,用source调用:
#!/usr/bin/env bash # 文件名: /usr/local/bin/jdkenv # 用法: source jdkenv 17 或 source jdkenv 8 _jdk_switch() { local ver="$1" local dir case "${ver}" in 8|1.8) dir=/opt/jdk/jdk8 ;; 11) dir=/opt/jdk/jdk11 ;; 17) dir=/opt/jdk/jdk17 ;; "") echo "当前: ${JAVA_HOME:-未设置}" [ -x "${JAVA_HOME}/bin/java" ] && "${JAVA_HOME}/bin/java" -version return 0 ;; *) echo "不支持的版本: ${ver}" >&2 return 1 ;; esac if [ ! -x "${dir}/bin/java" ]; then echo "路径不可执行: ${dir}/bin/java" >&2 return 1 fi export JAVA_HOME="${dir}" export PATH="${dir}/bin:$(echo "${PATH}" | tr ':' '\n' | grep -v -E "^${dir}/bin$|^/usr/lib/jvm/" | paste -sd: -)" hash -r echo "JAVA_HOME=${JAVA_HOME}" java -version 2>&1 | head -n 1 } _jdk_switch "$@"几个细节。hash -r是必须的——bash 会缓存命令的解析结果,你不清缓存,which java已经指向新路径了,但直接敲java还是跑老的。这个问题会在脑子里转好几个弯:明明 PATH 对了,怎么版本还是旧的?type -a java一看才知道是缓存作祟。
grep -v那段是为了把 PATH 里旧的所有 JDK 路径都剔掉再插入新的,防止路径越积越多,也防止/usr/lib/jvm下的系统 JDK 抢在前面。
source调用和bash调用是两回事。如果用bash jdkenv 17执行,环境变量只在那个子 shell 里生效,退出就没了。所以这个脚本必须在当前 shell 里 source。我习惯在脚本开头加个判断提示,或者干脆在文档里写清楚。
4. 让隔离真正落到实处的四个层级
4.1 进程级隔离:PATH 与 JAVA_HOME 的配合陷阱
很多人以为设了JAVA_HOME就等于换了 JDK,这个理解只对了一半。JAVA_HOME本身只是一个普通环境变量,操作系统不会因为它指向哪里就改变java命令的行为。真正决定跑哪份 JVM 的是 PATH 里哪个java先被找到。
所以判定顺序是这样的:PATH 决定执行哪个二进制,JAVA_HOME 决定某些工具和脚本认为 JDK 在哪。两者不一致的时候,怪事就来了——比如 Maven 用JAVA_HOME找编译器,用 PATH 找运行时,编译用 17、运行用 8,测试全过、上线全崩。
我的原则是:两者必须由同一个动作同时设定。上面的jdkenv脚本就是把JAVA_HOME和PATH一起改的,改完立刻hash -r并打印实际版本,形成闭环。
验证的时候别只看java -version,那输出可能被JAVA_TOOL_OPTIONS之类的变量注入额外内容,而且不同发行版输出格式还不一样。更稳的做法是直接问 JVM 自己:
java -XshowSettings:properties -version 2>&1 | grep -E 'java.home|java.version'java.home输出的是 JVM 实际运行的目录,java.version是实际版本号。这两个才是硬证据。
4.2 会话级隔离:干净 shell 与临时环境
有时候你要在一个完全不受现有配置影响的环境里验证东西,比如怀疑是某个环境变量导致的诡异问题。这种场景下我会开一个干净的 shell:
env -i HOME=/root TERM=xterm /bin/bash --noprofile --norc export JAVA_HOME=/opt/jdk/jdk17 export PATH="${JAVA_HOME}/bin:/usr/bin:/bin" java -versionenv -i清空所有环境变量,--noprofile --norc跳过所有启动脚本。这个 shell 里跑出来的结果,就是“假设什么都没配”的基线。排查环境问题时,先用这个方法确认问题在不在环境变量里,能省掉大量猜测。
这个技巧我在给别的团队做支持时用得最多。对方说“我这个脚本在我机器上跑得好好的”,我就让他开个干净 shell 跑一遍,八成问题当场复现——因为他本地.bashrc里有一行别人给他加的export。
注意:
env -i起的 shell 里没有LANG,中文路径和中文日志输出可能变成问号。需要的话手动带上LANG=en_US.UTF-8。
4.3 服务级隔离:systemd 单元锁死绝对路径
这是最关键也最容易被忽略的一层。前面说的/etc/profile.d/那套东西,只有登录 shell 才会读。systemd 起服务的时候,根本不经过 shell,你的JAVA_HOME它一个字都看不到。
所以服务化的应用,启动路径必须写死在单元文件里:
[Unit] Description=demo-service After=network.target [Service] Type=simple User=appuser WorkingDirectory=/srv/demo Environment="JAVA_HOME=/opt/jdk/jdk17" Environment="LANG=en_US.UTF-8" ExecStart=/opt/jdk/jdk17/bin/java -XX:MaxRAMPercentage=50 \ -jar /srv/demo/app.jar Restart=on-failure RestartSec=5 LimitNOFILE=65535 [Install] WantedBy=multi-user.target三个要点。第一,ExecStart用绝对路径指向具体版本目录,不是软链接、不是java。用软链接的好处是升级方便,坏处是服务启动那一刻链接指向什么它就跑什么,出问题时你无法从配置文件里看出来当时用的是哪个版本。我倾向于在正式服务里写死具体版本,升级时改配置、daemon-reload、重启,每一步都留痕。
第二,Environment里的JAVA_HOME是给应用自己读的(很多框架会读它去找 tools.jar 或者做诊断),不是为了找 java 命令。两者各司其职。
第三,LimitNOFILE建议显式写上。JDK 17 对文件句柄的消耗比 8 高一些,尤其是用了 NIO 和大量 socket 的场景,默认的 1024 在高并发下会报Too many open files,而且报错位置往往在很深的调用栈里。
写完单元文件后验证实际生效的配置:
systemctl daemon-reload systemctl restart demo-service systemctl show demo-service -p Environment ps -eo pid,args | grep '[j]ava'ps那一行能看到真实的启动命令行,是最直接的证据。看ExecStart里的路径对不对,比看任何配置文件都靠谱。
4.4 用户级隔离:一个账号一套栈
如果这台机器要长期给多个团队用,最省心的隔离方式是按账号分。每个账号的.bash_profile里设自己的JAVA_HOME,互不影响,权限边界也天然清晰。
useradd -m -s /bin/bash appteam-a su - appteam-a cat >> ~/.bash_profile <<'EOF' export JAVA_HOME=/opt/jdk/jdk11 export PATH="${JAVA_HOME}/bin:${PATH}" EOF注意su -后面那个横杠不能省。su appteam-a只是切换身份,环境变量还是从当前 shell 继承的,你会看到切换完JAVA_HOME还是原来那个,然后陷入“配置文件明明改了怎么不生效”的困惑。su -才是完整的登录 shell,会重新走一遍 profile 加载流程。
这种方式的额外好处是:日志、临时文件、进程属主全都按账号分开,出了问题一眼就能定位是哪个团队的应用。
4.5 容器化:最彻底的兜底
如果环境允许,把需要新版本的这部分应用放进容器是最干净的。镜像里自带 JDK,和宿主机上装了几个版本、PATH 怎么排完全无关,进程级隔离做到极致。
这里要提醒的是镜像里 JDK 的选择:优先用带明确版本标签的基础镜像,别用latest。同一个镜像标签在不同时间拉下来可能对应不同的小版本,线上排查问题时这一点会让人抓狂。另外,容器里跑 JVM 记得关注内存感知——JDK 10 之后默认能识别 cgroup 限制,但老版本需要显式加参数,否则容器里拿到的还是宿主机总内存,堆开到把容器 OOM 掉。
5. 常见问题排查实录
5.1 速查表
| 现象 | 最可能的原因 | 快速验证 | 处理方式 |
|---|---|---|---|
java -version变了但javac没变 | 只切了 java 的 alternatives | readlink -f $(which javac) | 对 javac 单独执行--config |
| PATH 对了但命令还是老版本 | bash 命令哈希缓存 | type -a java | hash -r或重开 shell |
| 服务重启后版本没变 | systemd 不读 profile | systemctl show <svc> -p Environment | 单元文件里写绝对路径 |
启动报UnsupportedClassVersionError | 编译与运行版本不一致 | 看报错里的 class 版本号 | 版本号换算后对齐两侧 |
jps报Protocol error | jps 与目标 JVM 主版本不一致 | 对比java -version与jps -version | 用同版本 jps,或直接看 /proc |
解压报Unexpected EOF | 包不完整 | sha256sum比对 | 重新下载 |
执行报GLIBC_x.xx not found | 系统库版本太低 | ldd --version | 换更保守的构建版本 |
| 其他账号跑不起来 | 目录权限不足 | su - 其他账号试跑 | chmod -R 755 |
Too many open files | 句柄限制 | ulimit -n | 单元文件加LimitNOFILE |
| 内存吃满被 OOM | 堆设置未感知容器 | java -XshowSettings:vm -version | 改用MaxRAMPercentage |
5.2 一个典型的“假成功”案例
某次给一个批次任务换版本,操作员做完是这样的:java -version显示新版本,mvn -v显示的 Java 版本也是新的,跑单测全绿。上线后批次任务日志里全是老版本的特性缺失报错。
排查花了一个小时,最后发现是定时任务用的是另一种调度方式,它在 crontab 里执行,而 crontab 的 PATH 只有/usr/bin:/bin,环境变量一个都没有。所以它跑的是系统默认那份。
解决办法有两个方向:一是在 crontab 文件顶部显式声明环境,二是把启动逻辑包成一个脚本,脚本内部自己设定绝对路径。我选后者,因为可读性更好,也方便在脚本里加日志把实际用的版本打出来。
#!/usr/bin/env bash export JAVA_HOME=/opt/jdk/jdk17 export PATH="${JAVA_HOME}/bin:/usr/bin:/bin" echo "[$(date '+%F %T')] JDK: ${JAVA_HOME}" >> /var/log/batch/version.log exec "${JAVA_HOME}/bin/java" -jar /srv/batch/batch.jar "$@"那个echo那行看起来多余,但它是后面所有排障的基础。出了问题时第一件事就是翻日志确认“当时到底用的是哪个版本”,有这个记录,判断时间从半小时缩短到几秒。
5.3 class 文件版本号换算
遇到UnsupportedClassVersionError,报错信息里会带一个数字,比如class file version 61.0。这个数字对应的是编译时的 JDK 主版本,做个减法就行:版本号 - 44 = Java 主版本。对照表如下,抄下来贴在工位上很实用:
| class 版本 | Java 版本 | class 版本 | Java 版本 |
|---|---|---|---|
| 52 | 8 | 60 | 16 |
| 53 | 9 | 61 | 17 |
| 54 | 10 | 62 | 18 |
| 55 | 11 | 63 | 19 |
| 56 | 12 | 64 | 20 |
| 57 | 13 | 65 | 21 |
| 58 | 14 | ||
| 59 | 15 |
看到61.0就知道是 17 编译的,运行环境是 8,两边差着九个版本,不用再猜。
5.4 排障时最该先做的三件事
第一,确认现象发生在哪一层。是交互式 shell 里、还是脚本里、还是服务里?这三层的环境加载逻辑完全不同,混在一起查就是瞎猜。
第二,拿到硬证据而不是间接推断。java -version是间接的,-XshowSettings:properties输出的java.home才是直接的。ps出来的命令行是最硬的。
第三,一次只改一个变量。我见过有人同时改 PATH、改 alternatives、改.bashrc,结果好了也不知道是哪个改对了,坏了也不知道回滚哪个。慢一点,稳一点。
6. 踩过坑之后才明白的几个细节
6.1 永远不要手改 /usr/bin/java 这个链接
/usr/bin/java在 CentOS 上是指向/etc/alternatives/java的符号链接,而/etc/alternatives/java再指向真实实现。有人图省事直接ln -sf /opt/jdk/jdk17/bin/java /usr/bin/java,把中间那层覆盖掉了。
后果是:alternatives --config从此失效,你怎么切都不生效,因为它改的是/etc/alternatives/java,而/usr/bin/java已经不走那条路了。更麻烦的是系统更新时,包管理器会认为这个文件被本地修改过,产生一堆警告,后续dnf操作都可能受影响。
正确的做法只有一个:通过alternatives --install注册,通过--config切换,通过--remove卸载。这个规矩我认死理,因为破坏它之后的修复成本远高于遵守它的成本。
6.2 JAVA_TOOL_OPTIONS 是排查利器
JVM 启动时会读环境变量JAVA_TOOL_OPTIONS,读到了就打一行Picked up JAVA_TOOL_OPTIONS: xxx到 stderr。这个特性用来做临时诊断特别好:设一个-XX:+PrintFlagsFinal或者-Xlog:gc,所有 Java 进程启动时都会带上,不用改任何一个启动脚本。
但要记住它是全局影响的。设了之后,机器上每一个 Java 进程都会读到,包括构建工具、包括运维脚本里调用的那些小工具,输出里会多出一堆干扰行。所以这类变量只适合临时排查,用完立刻 unset。我见过有人为了排查一个 GC 问题,把这个变量写进了/etc/profile.d,然后忘了删,导致全机器的日志量翻了三倍。
6.3 升级版本用软链接,正式服务写死路径
这两个原则看起来矛盾,其实分工明确:软链接服务于“我想快速试一个新版本”,写死路径服务于“我要确定这段时间跑的就是这个版本”。
具体实施上,临时验证的脚本、开发环境的启动命令用/opt/jdk/jdk17这样的软链接;正式服务的 systemd 单元用/opt/jdk/jdk-17.0.9这样的具体路径。升级的时候,先把具体路径的目录准备好,验证通过,再改软链接让临时环境跟上,最后改服务配置。每一步都能单独回滚。
6.4 把版本信息写进启动日志
这是最省事也最有效的习惯。任何服务启动的时候,第一行日志打印三样东西:java.version、java.home、启动命令。加这几行代码的成本几乎为零:
System.out.printf("boot: java.version=%s, java.home=%s%n", System.getProperty("java.version"), System.getProperty("java.home"));有了它,以后再遇到“版本不对”的问题,连登录都不用,翻日志第一行就完了。我在好几个项目里推过这个做法,一开始有人觉得啰嗦,出过两次事故之后,大家都会主动加了。
6.5 一份完整的收支清单
最后把这套方案的边界说清楚,免得你照着做完发现不适用。
收益:两份 JDK 完全独立,删除第二份不影响第一份;切换成本低,三条命令以内;正式服务锁定版本,不受运维操作影响;升级小版本只改一条软链接。
代价:多占约 600MB 到 1GB 磁盘(两份 JDK 加日志缓冲);需要额外维护 alternatives 注册记录,删除时必须用--remove而不能直接删目录,否则会残留一堆失效链接;不同账号看到的环境可能不一致,需要额外沟通成本。
想清楚这两面,再决定要不要在同一台机器上做双版本。如果你的应用数量少、版本要求统一,老老实实用一个版本反而更省心。这套东西的价值,只在“确实需要并存且短期无法统一”的场景下才成立——而现实中,这种场景比想象中多得多。