1. 为什么一台机器上非要装第二个 JDK
先说场景。很多团队的业务机器上原本跑着一套老服务,JDK 8 稳稳当当跑了好几年,谁也不想动它。某天来了个新需求:编译一个只支持高版本的新组件,或者要临时验证一个在新 JDK 上才能跑起来的构建流程。这时候最省事的想法是"直接升级现有 JDK",但只要动手过的人都清楚,老服务依赖的启动脚本、环境变量、第三方库的字节码兼容性,任何一处对不上,第二天凌晨出问题的就是你。
所以真正稳妥的做法是让两个 JDK 并存,各管各的,谁也不污染谁。装第二个 JDK 不难,难的是"隔离"两个字——很多人装完之后发现java -version依然是老版本,或者老服务重启后莫名报错,根子就在环境变量的作用域和优先级没理清。
这篇内容解决的就是这件事:在一台已经装着旧版 JDK 的 CentOS 8 机器上,把第二个 JDK 完整装好,并且做到切换干净、互不干扰。适合两类人看:一是有一定 Linux 基础、能看懂PATH和软链接的新手运维,二是被"装完不生效"折腾过、想搞清楚原理的开发。文中的路径和版本号你可以替换成自己的,但思路和顺序建议照抄,因为顺序错了排查成本会翻好几倍。
需要提前明确一点:CentOS 8 的软件源状态比较特殊,官方镜像仓库已经迁移,直接用yum install java这类命令往往不顺利。我下面走的路线是离线压缩包 + 手动配置,好处是完全不依赖网络源,坏处是每一步都得自己来。这条路线在任何发行版上都通用,学会了换机器也不慌。
2. 动手之前先摸清家底:现有 JDK 到底装在哪
2.1 三条命令定位现有环境
装第二套之前,必须先把第一套的位置、来源、配置方式全部搞清楚。很多"隔离失败"的案例,本质是机器上存在两处以上的 Java 配置,改了一处漏了另一处。
依次执行下面几条命令,把结果记下来:
which java readlink -f $(which java) java -version echo $JAVA_HOMEwhich java告诉你当前 shell 调用的是哪个可执行文件;readlink -f顺着软链接一路追到真实路径,这一步最关键,因为/usr/bin/java几乎肯定是个软链接;java -version确认版本;echo $JAVA_HOME看环境变量是否已设置,输出为空说明没有全局配置。
接着查系统级配置文件和包管理器记录:
rpm -qa | grep -i jdk ls -l /usr/lib/jvm/ cat /etc/profile ls /etc/profile.d/rpm -qa能看出旧 JDK 是不是当年用包管理器装的。如果是,alternatives机制可能已经接管了java命令的指向,这一点后面第 4 章会专门处理。/usr/lib/jvm/是多数发行版存放 JDK 的惯例目录,如果里面有东西,说明至少有一套是通过包管理器或手动放在这里的。
2.2 判断旧 JDK 的配置来源
这一步决定了你后续改哪里。常见的配置位置有四种,我按优先级从高到低排:
| 配置位置 | 生效范围 | 优先级 | 说明 |
|---|---|---|---|
| 当前 shell 手动 export | 仅当前会话 | 最高 | 关掉终端即失效,最容易造成"明明配了却不生效"的假象 |
~/.bashrc/~/.bash_profile | 单个用户 | 高 | 只影响当前用户,切换用户就变 |
/etc/profile.d/*.sh | 全部用户 | 中 | 系统级,推荐把新配置放这里 |
/etc/profile | 全部用户 | 中 | 老配置常在这里,改动需谨慎 |
用grep -r "JAVA_HOME" /etc/profile /etc/profile.d/ ~/.bashrc ~/.bash_profile 2>/dev/null一条命令把上面所有位置扫一遍,输出里出现的每一行都要逐一确认内容。我见过最坑的情况是:/etc/profile里写了一份旧路径,/etc/profile.d/里又写了一份新的,两份互相覆盖,最终生效的取决于文件名的加载顺序(按字母序)。这种配置下你再叠加第三份,结果基本靠猜。
提示:在做任何修改前,先备份。
cp /etc/profile /etc/profile.bak.$(date +%F)这条命令花不了两秒钟,但能救你一次远程登录失败。
2.3 确认磁盘空间与位数
JDK 解压后通常占 300MB 到 500MB,加上临时文件和后续可能的多版本共存,建议/opt或你选定的安装目录所在分区至少有 2GB 空闲。用df -h /opt看一眼,再用uname -m确认架构是x86_64还是aarch64,下载的包必须和架构一致,下错了解压能成功但执行会报 "cannot execute binary file",这个错很容易被误判成权限问题。
3. 拿到安装包:下载、校验、解压的门道
3.1 选哪个版本,怎么选
第二个 JDK 的版本选择要看你的真实目的。如果是为了跑新构建,选当前主流的长期支持版本即可;如果是为了复现某个特定的构建环境,那就严格对齐版本号,差一个小版本都可能出问题。
我建议优先选压缩包(tar.gz)形式而不是 rpm 或 deb 包。原因很直接:压缩包解压即用,不往系统目录里写东西,不注册服务,不修改alternatives,删除时一个rm -rf就干净了。而包管理器安装会自动接管/usr/bin/java,反而给你增加隔离难度——这也是为什么我不推荐在已有环境的机器上用包管理器装第二个 JDK。
版本号里还有个细节:1.8.0_xxx这种命名是 JDK 8 时代的写法,JDK 9 之后统一叫9、11、17、21。脚本里做版本判断时千万别用字符串前缀匹配1.8之外的值,容易翻车。另外注意区分 JDK 和 JRE,我们要装的是 JDK(含编译器),JRE 里没有javac,装完你会发现编译命令找不到。
3.2 传输与校验
从内网文件服务器或本地下载好后,用scp传到目标机器:
scp jdk-xx_linux-x64_bin.tar.gz user@host:/tmp/传完先算校验和,和官方提供的值比对:
sha256sum /tmp/jdk-xx_linux-x64_bin.tar.gz这一步不能省。大文件传输中断、磁盘写入错误都会导致压缩包损坏,而损坏的包有时能解压出大部分文件,只在某个lib下的库文件上出问题,跑起来报的错莫名其妙。花三十秒校验,能省掉两小时的怀疑人生。
3.3 解压到独立目录
安装位置建议单独开一个目录,不要塞进/usr/lib/jvm,也不要和旧 JDK 同级混放。我自己习惯用/opt/jdk/版本号这种结构:
mkdir -p /opt/jdk tar -zxvf /tmp/jdk-xx_linux-x64_bin.tar.gz -C /opt/jdk/解压后确认目录结构和可执行文件:
ls /opt/jdk/jdk-xx/bin/ | head -20 /opt/jdk/jdk-xx/bin/java -version注意这里是用绝对路径直接调用,不依赖任何环境变量。如果这一步能正常打印版本号,说明包本身没问题,后面出问题一定是配置问题。这个"先验证二进制、再验证环境变量"的排查顺序,是我处理这类问题时的固定套路,能把问题范围一刀切成两半。
4. 隔离的核心:PATH、JAVA_HOME 与 alternatives 三者的关系
4.1 环境变量到底谁说了算
要理解隔离,先得理解 shell 找命令的规则。你敲java的时候,shell 会按PATH里从左到右的顺序依次查找,找到第一个就停,后面的不再看。所以"哪个 JDK 生效"完全取决于谁的bin目录在PATH里排得更靠前。
JAVA_HOME则是给应用程序看的,很多启动脚本、构建工具(如 Maven、Gradle 的 wrapper)会读这个变量去定位 JDK。它和PATH是两个独立的东西,只改一个不改另一个,就会出现"命令行 java 是新版、但程序跑起来还是老版"的经典错位。
正确的配置必须让两者指向同一个目录:
export JAVA_HOME=/opt/jdk/jdk-xx export PATH=$JAVA_HOME/bin:$PATH关键在$JAVA_HOME/bin拼在$PATH前面,这样新 JDK 的bin优先级最高。如果写成PATH=$PATH:$JAVA_HOME/bin,新路径排在最后,几乎肯定被前面的老路径挡住,于是你改了配置却看不出任何变化——这是最高频的踩坑点,没有之一。
4.2 alternatives 机制要不要碰
如果旧 JDK 是通过包管理器装的,系统里很可能启用了alternatives,它在/usr/bin/java和真实的 JDK 之间插了一层软链接管理。这种情况下,即使你配好了PATH,某些不读PATH、直接调用/usr/bin/java的脚本仍然会用老版本。
先查看现状:
alternatives --display java如果输出里能看到多个候选项,说明机制已启用。我的建议是:新建的第二个 JDK 不要注册进 alternatives。理由是这样能保持边界清晰——alternatives 管的是系统默认,而我们的目标是"给特定用户或特定项目用新版",注册进去反而把两套环境又搅在一起了。
反过来,如果旧 JDK 确实是通过 alternatives 管理的,而你想让新 JDK 成为全局默认,那才需要注册:
alternatives --install /usr/bin/java java /opt/jdk/jdk-xx/bin/java 200 alternatives --config java后面的数字是优先级,越大越优先。但请注意,这只改了java命令,javac得单独再注册一次,否则编译时依然走老版本。这一对不一致是无数"编译失败但版本号看着没错"问题的源头。
4.3 三种隔离粒度,按需选一种
隔离不是只有一种做法,粒度不同,适用场景也不同:
| 隔离粒度 | 配置位置 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|---|
| 用户级 | ~/.bashrc | 只有某个账号需要新版 | 影响面最小,最安全 | 换账号就失效 |
| 会话级 | 手动 export 或写成脚本 source | 临时验证、一次性构建 | 完全不留痕迹 | 关终端即失效 |
| 系统级 | /etc/profile.d/jdk-xx.sh | 全机器统一用新版 | 所有用户一致 | 影响旧服务,需评估 |
绝大多数"装第二个 JDK"的真实需求,用用户级就够了。老服务通常由专用账号启动,那个账号继续读老配置,互不干扰;新需求在你的账号下跑,配置只挂在你自己身上。这是风险最低的方案,也是我实际项目中默认采用的。
5. 落地配置:把新 JDK 挂到指定用户身上
5.1 写一份独立的 profile 脚本
不要在~/.bashrc里直接堆 export 语句,而是单独建一个文件,需要时 source,不想用就删,管理起来清爽。在用户家目录下建jdk-env.sh:
cat > ~/jdk-env.sh <<'EOF' # 新 JDK 隔离环境 export JAVA_HOME=/opt/jdk/jdk-xx export PATH=$JAVA_HOME/bin:$PATH export CLASSPATH=.:$JAVA_HOME/lib EOF然后让登录时自动加载,在~/.bashrc末尾追加一行:
echo 'source ~/jdk-env.sh' >> ~/.bashrc这里用单引号'EOF'包裹 heredoc 很重要,它能阻止变量在写入时被提前展开。如果写成不带引号的EOF,$JAVA_HOME和$PATH会在创建文件的那一刻就被替换成当前值,等你以后换版本再改脚本时就完全不生效了,而文件内容看起来"完全正常",排查起来极其难受。
5.2 让配置立即生效并验证
source ~/jdk-env.sh which java java -version echo $JAVA_HOME三个输出必须互相印证:which java指向/opt/jdk/jdk-xx/bin/java,java -version显示新版本,JAVA_HOME指向/opt/jdk/jdk-xx。任何一项对不上,就回到第 4.1 节检查PATH的拼接顺序。
再验证编译器:
javac -version很多时候java已经切过去了,javac还在老位置,因为某些旧配置里单独写死了javac的路径。javac版本和java版本必须一致,否则会出现"编译通过但运行报 UnsupportedClassVersionError"的情况。
5.3 用脚本做隔离切换,而不是靠记忆
如果你经常在两个环境间来回切,手敲 export 迟早出错。写两个小脚本:
cat > ~/use-jdk-new.sh <<'EOF' #!/bin/bash export JAVA_HOME=/opt/jdk/jdk-xx export PATH=$JAVA_HOME/bin:$PATH echo "已切换到新 JDK: $(java -version 2>&1 | head -1)" EOF cat > ~/use-jdk-old.sh <<'EOF' #!/bin/bash export JAVA_HOME=/usr/lib/jvm/old-jdk export PATH=$JAVA_HOME/bin:$PATH echo "已切换到旧 JDK: $(java -version 2>&1 | head -1)" EOF chmod +x ~/use-jdk-new.sh ~/use-jdk-old.sh注意这两个脚本必须用source执行而不是直接运行。直接执行会在子 shell 里改变量,脚本一结束变量就没了,回到父 shell 依然是老环境。用.或source才是在当前 shell 里生效:
source ~/use-jdk-new.sh这个小细节坑过太多人。判断方法很简单:执行完立刻echo $JAVA_HOME,如果值没变,八成就是用了直接执行的方式。
6. 装完之后最容易踩的五个坑
6.1 坑一:改了配置但新开的终端不生效
现象是当前终端切过去了,关掉重开又变回老版本。原因通常是改了~/.bashrc,但登录时加载的其实是~/.bash_profile(或者~/.profile),后者不会自动读前者。解决办法是在~/.bash_profile里加一行引入:
if [ -f ~/.bashrc ]; then source ~/.bashrc fi很多发行版的默认~/.bash_profile里本来就有这段,但被前人改过之后可能就丢了。这是"配置生效范围"问题,不是配置内容问题,所以只看配置文件内容永远找不到原因。
6.2 坑二:sudo之后 JDK 变了
sudo默认会重置环境变量,切到 root 的配置。所以你在自己账号下是新的,sudo java -version却显示老的,这不代表配置错了。如果确实需要 sudo 下也用新 JDK,用sudo -E保留环境,或者直接在 root 的配置里也加上。我的做法是尽量不给 sudo 下的命令配 JDK,需要编译就用普通账号,权限问题和环境问题分开处理,免得两件事缠一起。
6.3 坑三:cron 任务里 JDK 还是旧的
cron 执行时环境极其精简,几乎只有系统默认的PATH,不读~/.bashrc。所以你在终端里测得好好的脚本,丢进 cron 就跑挂,报的还是"找不到 java"或版本不符。
解决办法有两个,我推荐第二个:
- 在 crontab 顶部显式声明:
JAVA_HOME=/opt/jdk/jdk-xx、PATH=/opt/jdk/jdk-xx/bin:/usr/bin:/bin; - 更稳的是在脚本内部用绝对路径调用 java,并在脚本开头自己 export 一遍,让脚本自带环境,不依赖调用它的人。
第二种做法的好处是脚本可移植,换机器、换调度方式都不用改配置。
6.4 坑四:临时文件和缓存串味
java.io.tmpdir默认是/tmp,多版本并存时一般不会冲突,但构建缓存会。Maven 的~/.m2/repository、Gradle 的~/.gradle/caches是共享的,用不同 JDK 编译同一个项目时,缓存里的字节码版本可能不匹配,表现是编译莫名其妙失败或者增量构建产出错误结果。
遇到这种"结论诡异"的构建问题时,先mvn clean或清理缓存目录再试,能排除一大半玄学。如果长期要两个版本并行构建同一项目,建议给每个版本指定独立的缓存目录:
export MAVEN_OPTS="-Dmaven.repo.local=/opt/m2repo/jdk-xx"6.5 坑五:删掉旧 JDK 之后才发现依赖它
有人图省事,装完新版就把旧版rm -rf了,结果老服务起不来。请注意系统里可能还有其他东西依赖着那个路径,尤其是在/usr/lib/jvm下被包管理器登记过的。真要卸载,走包管理器命令让系统自己处理依赖关系,或者至少先lsof/grep一圈确认没有残留引用。装第二个 JDK 的初衷就是共存,不是替换,把这句话记在脑子里能避免很多返工。
7. 验证隔离是否真的干净:四个连续检查
配置写完之后不要急着交付,按下面顺序过一遍,全部通过才算真正隔离成功。
第一步,在目标用户下开一个全新的登录会话(不是su而是完整重新登录,或用bash -l模拟),执行java -version和which java,确认新版本生效且路径正确。
第二步,用启动老服务的那个账号登录,同样执行一遍,确认它仍然是老版本。这一步是整个隔离的验收核心——只验证新环境能用是不够的,还必须验证老环境没被影响。
第三步,检查env | grep -i java的输出,看有没有意料之外的变量残留,比如某些应用自带的_JAVA_OPTIONS、JAVA_TOOL_OPTIONS,这些变量的优先级高于命令行参数,会悄悄改变 JVM 行为。
第四步,实际跑一次编译加运行的最小闭环:
echo 'public class T { public static void main(String[] a){ System.out.println(System.getProperty("java.version")); } }' > T.java javac T.java && java T rm -f T.java T.class输出应该和javac -version一致。这一步能同时验证编译器、运行时和类路径三者是否指向同一套 JDK,比单纯敲-version更有说服力。
8. 我的几点实操体会
装第二套 JDK 这件事本身技术含量不高,真正花时间的永远是"隔离得干不干净"。我这些年的经验可以浓缩成几句实话。
先查后装,永远别跳过摸底。机器是别人装的、配置是前人写的,你以为的"系统默认"可能早就被改过三层了。花五分钟把PATH、JAVA_HOME、alternatives、profile 文件全部看清楚,能省掉后面几小时的困惑。
能用用户级就别用系统级。影响面越小,出问题的概率越低,责任边界也越清楚。系统级配置看着"一次配好全机器通用"很诱人,但只要机器上跑着老业务,这个诱人的方案就是在给自己埋雷。
路径一律写绝对路径。启动脚本、cron 任务、构建配置里,凡涉及 JDK 路径的地方都用绝对路径,不要指望"当时的环境变量"。环境变量是给交互式操作准备的,自动化流程应该自带完整上下文。
改配置之前先备份,改完先验证再交付。备份命令上面给过了,验证就按第 7 章那四步走。这套流程我执行了很多次,虽然每次多花十分钟,但从来没因为环境问题在半夜被叫起来过——这笔账怎么算都划算。
最后补一个小技巧:把版本信息和服务清单整理成一张表贴在运维记录里,写清楚"哪个服务用哪个 JDK、由哪个账号启动、配置文件在哪"。这台机器一年后可能是别人接手,那张表就是救命的东西。