CentOS Stream 9 安装 JDK 17:二进制归档包与环境变量配置

发布时间:2026/10/27 7:20:32
CentOS Stream 9 安装 JDK 17:二进制归档包与环境变量配置

1. 为什么在 CentOS Stream 9 上用源码方式装 JDK 17

先把结论说在前面:在 Linux 服务器上装 Java JDK 17,所谓“源码方式安装”,绝大多数场景指的并不是拿 OpenJDK 的 C++ 源码去跑 configure/make 编译出十几个 G 的中间产物,而是把发行方打包好的二进制归档包(tar.gz)解压到指定目录,再手工配置JAVA_HOME和PATH。这个叫法在运维圈子里已经约定俗成,和“yum 装”“dnf 装”相对。我在 CentOS Stream 9 上给几十台机器做过这套流程,也踩过不少只有实际动手才会遇到的坑,下面把完整过程和我理解的“为什么”一次讲清楚。

这套流程适合的人很明确:需要在同一台机器上保留多个 JDK 版本、需要精确控制 JDK 的安装位置、机器访问不了软件源或者内网源里的 JDK 版本太旧、以及给某些中间件(比如需要指定 JDK 路径才能启动的服务)做环境隔离的场景。如果你只是想在一台个人机器上写个 Hello World,那么源里直接装反而更快。但只要你后面要在生产环境跑服务,迟早会遇到“系统自带 java 和服务要求的 java 不是同一个”的问题,这时候手工安装带来的确定性就很值钱。

CentOS Stream 9 这个发行版有个特点需要提前说明:它是滚动更新的上游版本,软件仓库里的包版本比传统的稳定版更新得快,但同时也意味着你今天dnf install java-17-openjdk装上的小版本号,过几个月在另一台机器上重装可能就变了。对于要复现环境、要写部署文档、要给多台机器保持一致性的场景,这种不确定性是需要主动消除的。手工下载固定版本的归档包,把版本号写死在目录名里,是最直接的办法。

1.1 包管理器安装和手工归档包安装的取舍

把两种方式的差异摆到台面上,选择就很清楚了。

对比维度dnf 安装(java-17-openjdk)归档包手工安装
安装位置固定在/usr/lib/jvm/下,路径由包决定完全自定义,/usr/local、/opt都行
版本锁定跟随仓库,可能被更新覆盖目录名带版本号,不动就不变
多版本共存靠alternatives切换,能用但被动手动指定路径,切换粒度更细
升级方式dnf update一把梭解压新目录 + 改软链接,可回滚
依赖处理自动处理需要自己确认 glibc、字体等
卸载干净程度包管理器负责要记得删目录、删配置文件、删软链接
内网/离线环境需要搭本地源传一个 tar.gz 就完事

我自己的判断标准比较朴素:这台机器上只跑一个 Java 应用,而且版本要求不苛刻,用 dnf;这台机器要跑多个 Java 应用、版本要求各不相同,或者要给外部交付一个可复现的环境,用手工包。后一种情况在实际工作中占比不低,尤其是那些需要在同一台机器上并存 JDK 8 和 JDK 17 的老系统。

还有一个容易被忽略的点:dnf 装的 OpenJDK 会附带java-17-openjdk-headless这样的拆分包,如果误装了带图形依赖的完整包,会在没有桌面环境的服务器上额外拉进一批 X11 相关的库。手工装归档包时,JDK 是完整的一坨,压缩包里该有的都有,不会牵扯系统包依赖,这在精简过的服务器镜像上是个实打实的好处。

1.2 真正的“源码编译”是什么样,为什么我不推荐

为了把概念说清楚,还是简单交代一下真源码编译的路径。OpenJDK 的源码构建需要先准备一个“引导 JDK”(bootstrap JDK,通常要求比目标版本低一到两个大版本),然后配置 autoconf、编译工具链、各种开发库(libX11-devel、libFontconfig-devel、alsa-lib-devel、cups-devel等等),跑bash configure,再make images。整个过程在一台 4 核 8G 的机器上大致要 30 分钟到 2 小时不等,中间任何一步缺依赖都会中断,而且报错信息往往指向性不强。编译完得到的产物体积比官方包还大,因为没做 strip。

这条路适合什么人?适合要给 JDK 打补丁、要交叉编译到特殊架构、或者要做 JDK 层面性能调优(比如改 JVM 内部的某些常量)的团队。绝大多数业务开发者和运维不需要走这条路。官方发布的 tar.gz 已经是编译、测试、签名完成的成品,直接解压即用,没有任何理由再自己编一遍。

所以后面所有实操内容,都围绕“二进制归档包 + 手工配置”这条主线展开。这一点先说透,避免有人在网上看到“源码安装 JDK”的标题,结果一头扎进编译流程里出不来。

1.3 安装前的心理预期:哪些东西是会变的

在 CentOS Stream 9 上做这套安装,有几件事必须提前接受,否则后面会反复怀疑自己。

第一,系统里可能已经有一个 java 了。CentOS Stream 9 的最小化安装通常不带 JDK,但如果你装了某些依赖 Java 的软件包,或者镜像本身是定制过的,/usr/bin/java可能已经存在,指向一个你完全不知道的版本。安装前先which -a java看一眼,心里有数。

第二,环境变量的生效范围是有边界的。写在/etc/profile.d/里的脚本,只对“登录 shell”生效。你用 SSH 登录敲命令是登录 shell,所以看着一切正常;但 systemd 服务、cron 任务、ssh 主机 '命令'这种非交互执行,都可能读不到你的配置。这个坑我在实际交付中遇到过不止一次,后面单独讲。

第三,版本号要写死在目录里。别图省事解压成/usr/local/java,否则将来想同时放两个版本时就尴尬了。目录名带完整版本号,再做一个软链接指向它,升级时只改软链接,配置文件和启动脚本都不用动,这是最省心的做法。

2. 系统环境确认与目录规划

动手之前花五分钟做环境确认,能省掉后面半小时的排查。这段内容看起来啰嗦,但每一条检查我都见过因为跳过而出问题的案例。

2.1 三行命令确认系统底子

第一件事是确认发行版和架构。注意 CentOS Stream 9 的版本信息文件和传统 CentOS 不完全一样。

cat /etc/centos-release # CentOS Stream release 9 uname -m # x86_64 或 aarch64 getconf GNU_LIBC_VERSION # glibc 2.34

uname -m这一条尤其关键。现在 ARM 架构的服务器越来越多,如果你的机器输出是aarch64,却下了一个x64的包,解压后执行java -version会得到cannot execute binary file: Exec format error这种一眼看不懂的报错。JDK 17 对 glibc 的最低要求是 2.17,CentOS Stream 9 自带的 2.34 完全满足,这一条基本不用操心,但如果你的机器是从更老的系统原地升级上来的,还是确认一下比较稳妥。

再顺手看一眼磁盘空间。一个完整 JDK 17 解压后大约 300MB 左右,加上下载的压缩包,/usr/local所在分区至少要有 1GB 余量。用df -h /usr/local看一眼,如果/usr/local和/在同一个分区且使用率已经 80% 以上,先清理再动手。

df -h /usr/local free -h

内存这条不是给安装用的,是给后面验证用的。JDK 17 的 JVM 启动时会根据物理内存计算默认堆大小,在内存极小的容器里可能出现启动参数相关的告警。提前知道机器的内存量,验证阶段看到告警就不会慌。

2.2 版本选择:17 的哪个小版本,哪个发行版

JDK 17 是长期支持版本,小版本会持续更新。选哪个小版本,我的建议是:选当前最新的 17.x 的补丁版本,但不要盲目追最新的主版本。补丁版本包含安全修复和 bug 修复,升级风险很低;而跨大版本升级(比如 17 升到 21)涉及行为变化,需要单独评估。

至于发行版的选择,这块我给的是判断方法而不是具体推荐。市面上的 JDK 17 构建大致分几类:完全开源的社区构建、带商业支持条款的商业发行版、以及云厂商针对自家环境优化过的版本。选择时看三个点就够了:

  • 许可条款:确认你在生产环境的使用方式是否被允许,特别是要打包进容器镜像分发的场景。
  • 更新节奏:安全补丁跟进得快不快,有没有公开的更新记录可查。
  • 体积和组成:是否包含 JavaFX、是否做了 jlink 精简、是否只提供 headless 版本。

对于内部系统、不对外分发的场景,绝大多数开源构建都能满足。我在实际项目中倾向于固定使用同一个构建来源,把它写进团队的部署规范里,避免不同人装出不同东西。这一步的价值在于消除变量,而不是追求某个“最优解”。

提示:下载页面上的文件通常有tar.gz和zip两种。Linux 上用tar.gz,别下成zip再回头找解压工具。另外注意区分jdk-17_linux-x64_bin.tar.gz和jdk-17_linux-x64_bin.rpm,后者是给 rpm 系包管理器用的,手工安装用不上。

2.3 目录规划:为什么我不用 /opt

安装目录的选择,社区里常年有两派:/usr/local派和/opt派。我的习惯是/usr/local/java/,理由有三条。

第一,/usr/local在文件系统层级标准里的定位就是“本机自行安装的软件”,语义上最贴合。第二,绝大多数默认配置里的PATH已经包含/usr/local/bin,把软链接放进去天然可用,不需要额外改路径。第三,团队里其他人接手时,看到/usr/local就能猜到这是手工装的,减少沟通成本。

规划好的目录结构长这样:

/usr/local/src/ 下载的压缩包临时存放 /usr/local/java/jdk-17.0.11/ 实际解压出来的 JDK 目录 /usr/local/java/jdk17 -> jdk-17.0.11 软链接,配置里只引用这个 /usr/local/bin/java -> /usr/local/java/jdk17/bin/java /etc/profile.d/jdk17.sh 环境变量脚本

这个结构里最重要的设计是中间那层软链接。所有环境变量、启动脚本、systemd unit 文件,一律引用/usr/local/java/jdk17,永远不直接写带小版本号的路径。将来升级到 17.0.12,只需要解压新目录、把软链接重新指一下、验证通过后删掉旧目录,业务侧完全无感。如果直接把JAVA_HOME写成/usr/local/java/jdk-17.0.11,升级时就要满机器去找哪些地方写了这个路径,风险大得多。

注意:不要把 JDK 解压到/tmp下再想长期使用。很多加固过的系统会给/tmp挂noexec选项,/tmp下的二进制文件根本执行不了,你会看到Permission denied,而且这个报错和文件权限位看起来毫无关系,非常容易误判。同理,/home下也不建议放,某些 daemon 用户的 home 目录有限制。

3. 下载、校验与解压的正规流程

这段是纯操作,但每一步都有它存在的理由,我会把理由一起讲。

3.1 下载与网络受限时的替代路径

先在机器上建好目录,然后下载。为了命令可复制,这里用变量占位表示下载地址,实际执行时替换成你从发行方拿到的真实链接。

mkdir -p /usr/local/src cd /usr/local/src # 占位示意,把 URL 换成实际的下载地址 curl -fLO https://<下载地址>/jdk-17.0.11_linux-x64_bin.tar.gz ls -lh jdk-17.0.11_linux-x64_bin.tar.gz

curl的-f参数很关键,它让 HTTP 状态码非 2xx 时直接报错退出,而不是把一段 HTML 错误页面保存成.tar.gz文件。不带-f的话,你下载失败后得到的是一个几十 KB 的“压缩包”,解压时才报gzip: stdin: not in gzip format,排查方向会被带偏。

如果服务器不能直连外网,有三条路可走。一是从一台能上网的机器下载好后用scp或内部文件服务传过去;二是走内网的制品仓库,很多团队会把常用中间件包统一托管;三是如果目标机器上有可用的包管理器源,直接dnf download把 rpm 拉下来再提取也可以,但那样就不算手工安装了,这里不展开。无论走哪条路,都要保证传输过程不失真,传完用第 3.2 节的校验和核对一遍。

3.2 校验和验证:别省这三十秒

这一步在很多教程里被略过了,但我坚持每次都做。原因很实在:传输中断、代理缓存返回旧版本、下载源本身有问题,这三种情况都会导致你拿到一个“能解压、能跑,但内容有问题”的包,这类问题排查起来极其痛苦。

# 发行方一般会提供对应的 sha256 值 sha256sum jdk-17.0.11_linux-x64_bin.tar.gz

拿到输出的哈希值,和下载页面上公布的值逐位比对。如果发行方提供的是.sha256文件,更省事的做法是:

curl -fLO https://<下载地址>/jdk-17.0.11_linux-x64_bin.tar.gz.sha256 sha256sum -c jdk-17.0.11_linux-x64_bin.tar.gz.sha256 # 输出 OK 就说明包是完整的

sha256sum -c的返回值可以直接用于脚本判断,$?为 0 即校验通过。如果你在写自动化部署脚本,这两行就是标准的防呆环节。

3.3 解压、归属与权限

校验通过后解压。注意 JDK 归档包解压出来自带顶层目录jdk-17.0.11,所以解压目标选/usr/local/java就行,不要再手动套一层。

mkdir -p /usr/local/java tar -xzf jdk-17.0.11_linux-x64_bin.tar.gz -C /usr/local/java/ cd /usr/local/java ln -sfn jdk-17.0.11 jdk17 ls -l /usr/local/java/

ln -sfn里的-n是给“软链接已存在且指向目录”这种情况用的。如果jdk17已经是一个指向目录的软链接,不带-n的ln -sf会在目标目录内部再创建一个链接,形成/usr/local/java/jdk17/jdk17这种奇怪的嵌套。这个坑我在升级脚本里踩过一次,后来所有软链接操作都固定加-n。

权限方面,JDK 目录保持root:root所有、普通用户只读即可,不需要chmod 777。有些人为了让应用用户能读,直接一把chmod -R 777,这在安全审计里是明确的问题项。实际上755的目录权限,任何用户都能读和执行,够用了。

chown -R root:root /usr/local/java chmod -R 755 /usr/local/java

再补充一个实际会遇到的细节:有些 Java 应用需要字体渲染,比如生成图片验证码、导出带文字的 PDF。JDK 本身不带字体,依赖系统的 fontconfig。如果服务器是最小化安装,可能一个字体都没有,运行时抛出Fontconfig head is null之类的错误。提前装一下基础字体,能省掉后面一顿排查。

dnf install -y fontconfig dejavu-sans-fonts fc-cache -fv

如果你的应用完全不碰图形和文字渲染,这一步可以跳过。判断方法是看依赖里有没有java.awt、javax.imageio相关的调用。

4. 环境变量配置:细节都在边界条件里

环境变量这块,写对很容易,写“对得没有漏洞”不容易。很多人配完发现当前会话能用,换个用户、换个连接方式就不行了,问题基本都出在配置文件的加载时机上。

4.1 配置文件放在哪:三个位置的取舍

Linux 上能放环境变量的地方有好几个,作用范围完全不同。先把这个搞清楚,再决定写哪。

文件位置生效范围生效时机适合场景
/etc/profile.d/*.sh所有用户登录 shell 启动时系统级统一 JDK,推荐
/etc/profile所有用户登录 shell 启动时不推荐改,容易被系统更新覆盖
~/.bashrc当前用户每次交互式 bash 启动个人开发机、需要单独版本
~/.bash_profile当前用户登录 shell个人机器
systemd unit 的Environment=单个服务服务启动时生产服务,最稳

我的标准做法是:系统级配置写/etc/profile.d/jdk17.sh,生产服务再在 unit 文件里显式声明一次。双保险,看着冗余,但能避免“手动执行没问题、systemd 起服务就找不到 java”这类经典故障。

/etc/profile.d/这个目录有个加载规则需要注意:/etc/profile里是用for i in /etc/profile.d/*.sh的方式遍历加载的,所以文件名必须以.sh结尾,否则不会被读取。我见过有人把文件命名成jdk17.conf,然后对着echo $JAVA_HOME的空输出怀疑人生。另外文件权限要是644,如果误设成600,其他用户登录时读不到,也会静默失效。

4.2 变量逐个拆解:每个都有它的作用

新建配置文件:

cat > /etc/profile.d/jdk17.sh <<'EOF' export JAVA_HOME=/usr/local/java/jdk17 export JRE_HOME=${JAVA_HOME}/jre export CLASSPATH=.:${JAVA_HOME}/lib:${JRE_HOME}/lib export PATH=${JAVA_HOME}/bin:${PATH} EOF chmod 644 /etc/profile.d/jdk17.sh

这里逐条说明为什么要这么写。

JAVA_HOME指向软链接而不是实际目录,前面已经说过,是为了升级时不用改配置。几乎所有 Java 生态的中间件都读这个变量,Tomcat 的启动脚本、Maven 的mvn脚本、Gradle 的 wrapper,第一件事都是找JAVA_HOME。如果这个变量没设,它们会退而求其次去PATH里找java,找到哪个用哪个,行为不可控。

PATH的拼接顺序有讲究。${JAVA_HOME}/bin放在${PATH}前面,才能保证新装的 JDK 优先级高于系统里已有的/usr/bin/java。如果写成PATH=${PATH}:${JAVA_HOME}/bin,那么系统自带的旧版本会一直赢,你会看到java -version输出一个莫名其妙的版本号。

CLASSPATH这一条,说实话在 JDK 17 的时代基本是历史遗留。现代构建工具(Maven、Gradle)和启动脚本都会自己管理 classpath,显式设置CLASSPATH反而可能带来干扰。但我还是保留了,原因是有些老旧的脚本或者手写的java -cp命令会依赖它,保留一个包含当前目录.的最小配置,兼容性更好。如果你确定环境里没有这类东西,删掉这一行也没问题。

JRE_HOME同理。JDK 9 之后 JRE 就不再单独分发了,${JAVA_HOME}/jre这个目录在 JDK 17 里根本不存在。保留它是为了兼容那些还在读JRE_HOME的老脚本。这不是错误,是有意为之的兼容层,但你要知道它指向的目录不存在,别在排查问题时被它误导。

4.3 立即生效与分层验证

配置写完后,当前会话不会自动感知,必须重新加载。

source /etc/profile.d/jdk17.sh echo $JAVA_HOME # /usr/local/java/jdk17 which java # /usr/local/java/jdk17/bin/java java -version

java -version的输出有个细节:它是打到标准错误流(stderr)的,不是标准输出。所以在脚本里判断版本时,直接java -version | grep 17会拿到空结果,正确写法是java -version 2>&1 | grep 17。这个坑非常普遍,我第一次写部署脚本时就栽在这里,查了半小时以为是 JDK 没装好。

验证要分层做,一层层排除问题:

# 第一层:变量对不对 env | grep -E 'JAVA_HOME|JRE_HOME' # 第二层:命令找到的是不是你想的那个 which -a java ls -l $(which java) # 第三层:版本对不对 java -version 2>&1 # 第四层:编译器在不在(只装 JRE 的话这里会失败) javac -version # 第五层:运行时自报的家目录,最权威 java -XshowSettings:properties -version 2>&1 | grep -E 'java.home|java.version'

第五层的java.home是最可信的判断依据,因为它直接反映 JVM 自己认为的安装位置,不受PATH和软链接的干扰。如果JAVA_HOME和java.home不一致,说明你的PATH里混进了别的东西,回去查which -a java的输出。

最后跑个真实的编译执行,确保整条链路是通的:

mkdir -p /tmp/javatest && cd /tmp/javatest cat > Hello.java <<'EOF' public class Hello { public static void main(String[] args) { System.out.println("JDK version: " + System.getProperty("java.version")); System.out.println("Vendor: " + System.getProperty("java.vendor")); System.out.println("Heap max: " + Runtime.getRuntime().maxMemory() / 1024 / 1024 + " MB"); } } EOF javac Hello.java && java Hello

能看到版本号和堆大小输出,就说明编译、运行、JVM 参数解析全部正常。这一步看着多余,但它同时验证了javac可用、java可用、临时目录可执行、内存识别正常,一次性排掉四类问题。

5. 多版本共存:alternatives 的正确用法

一台机器上并存 JDK 8 和 JDK 17 是很常见的需求,尤其是老系统迁移期间。这时候光靠PATH顺序切换会很别扭,alternatives机制就是为这个场景设计的。

5.1 alternatives 到底在做什么

它的原理不复杂:/usr/bin/java本身不是真实的二进制文件,而是一个指向/etc/alternatives/java的软链接,而/etc/alternatives/java又是一个软链接,指向你注册的某个实际路径。切换版本时,改的是中间那一层链接,所有指向/usr/bin/java的地方自动跟着变。

/usr/bin/java -> /etc/alternatives/java -> /usr/local/java/jdk17/bin/java

理解了这条链,很多现象就好解释了。比如你发现/usr/bin/java指向的版本和你echo $JAVA_HOME不一致,那是因为PATH里${JAVA_HOME}/bin排在/usr/bin前面,直接命中了真实路径,绕过了 alternatives 这一层。两种机制共存时会互相干扰,需要明确以哪个为准。

我的建议是:系统级统一用/etc/profile.d/配置,alternatives 只用来管理/usr/bin下的命令,两者不要指望自动同步。换句话说,alternatives 是给“不读环境变量的场景”兜底的,比如某个脚本硬编码了/usr/bin/java,或者某个服务以nobody用户运行、读不到你的 profile 配置。

5.2 注册与切换的实操命令

注册的时候,最后那个数字是优先级,数字越大优先级越高,alternatives --auto会自动选优先级最高的。

# 注册 java alternatives --install /usr/bin/java java /usr/local/java/jdk17/bin/java 1700 # 一并注册其他常用命令,不然会出现 java 是 17、javac 是 8 的诡异情况 alternatives --install /usr/bin/javac javac /usr/local/java/jdk17/bin/javac 1700 alternatives --install /usr/bin/jar jar /usr/local/java/jdk17/bin/jar 1700 alternatives --install /usr/bin/keytool keytool /usr/local/java/jdk17/bin/keytool 1700 alternatives --install /usr/bin/jshell jshell /usr/local/java/jdk17/bin/jshell 1700

切换用:

alternatives --config java

它会列出所有已注册的候选,输入编号回车即可。切完再java -version确认一下。

如果机器上还装着系统源的 JDK,也要一并注册进去,这样才能在列表里看到两个候选:

# 假设系统源装的是 JDK 8 alternatives --install /usr/bin/java java /usr/lib/jvm/java-1.8.0-openjdk/bin/java 800 alternatives --install /usr/bin/javac javac /usr/lib/jvm/java-1.8.0-openjdk/bin/javac 800

注意:alternatives只改/usr/bin下的命令指向,不会改JAVA_HOME。如果你的应用脚本依赖JAVA_HOME,切换 alternatives 之后它拿到的还是原来的值。这就是前面说的“两套机制不同步”的问题,务必在部署文档里写清楚你们团队以哪一套为准。

还有一个容易忽略的:java和javac必须指向同一个 JDK 版本。曾经遇到过java是 17 而javac还是 11 的机器,编译能过、运行时抛UnsupportedClassVersionError,看着像代码问题,实际是环境问题。注册时把所有相关命令一起注册,就是防这个。

6. 验证之后的排查实录

这部分是我认为整篇文章最有价值的部分。前面所有步骤照着做基本都能成功,真正消耗时间的是“明明都做了但就是不生效”的情况。

6.1 检查生效范围的边界条件

配置写好了,source之后当前窗口能用,但换个场景就失效。这几个场景我都遇到过,逐个说明。

场景一:sudo执行时找不到 java。原因是sudo默认会重置环境变量(env_reset),并且使用secure_path里定义的路径,你的/etc/profile.d/配置被丢掉了。表现是java -version正常,sudo java -version报command not found。解决办法有三个:用绝对路径sudo /usr/local/java/jdk17/bin/java -version;用sudo -E保留环境(需要 sudoers 里允许);或者直接在/etc/sudoers的secure_path里加上 JDK 的 bin 目录。我一般推荐第一个,最简单,也不用改系统配置。

场景二:systemd 服务里找不到 java。这是最经典的一个。systemd 启动的服务不经过 shell,完全不读/etc/profile.d/。必须在 unit 文件里显式声明:

[Service] Environment="JAVA_HOME=/usr/local/java/jdk17" Environment="PATH=/usr/local/java/jdk17/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin" ExecStart=/usr/local/java/jdk17/bin/java -jar /opt/app/app.jar

PATH也要显式写,因为 systemd 的默认 PATH 里没有你的 JDK 目录。改完记得systemctl daemon-reload再重启服务,否则新配置不加载。

场景三:cron 任务里找不到 java。cron 的环境极其精简,PATH通常只有/usr/bin:/bin。在 crontab 里直接写java大概率失败。两个办法:在 crontab 顶部加JAVA_HOME=...和PATH=...的定义,或者在脚本里用绝对路径。我倾向后者,脚本自带环境依赖,不依赖外部配置。

场景四:ssh 主机 'java -version'找不到。非交互式执行的命令不加载/etc/profile,只加载部分配置。这就是前面提到的“看着一切正常”的陷阱,因为手动登录测试永远测不出来。用绝对路径,或者把变量也写进~/.bashrc(bash 在非交互模式下会读BASH_ENV指定的文件,路径匹配的话~/.bashrc也会被读,但行为因配置而异,不如用绝对路径稳)。

6.2 常见问题速查表

下面这张表是我这些年攒下来的,基本覆盖了安装 JDK 17 会踩到的九成问题。

现象大概率原因处理方式
java: command not found配置文件没加载或文件名不是.sh检查/etc/profile.d/jdk17.sh后缀和权限,重新登录
cannot execute binary file下载的架构不对(x64 装到 aarch64)uname -m确认后重新下载对应架构包
Permission denied执行 java所在目录挂载了noexec`mount
java -version版本不对PATH顺序问题或有旧版本抢占which -a java,调整 PATH 拼接顺序
javac和java版本不一致alternatives 只注册了其中一个把所有相关命令一起注册
gzip: stdin: not in gzip format下载失败,得到的是 HTML 页面用curl -f重新下载,校验 sha256
Fontconfig head is null系统缺字体dnf install fontconfig dejavu-sans-fonts
InaccessibleObjectExceptionJDK 17 强封装内部 API,老框架反射被拦加--add-opens java.base/java.lang=ALL-UNNAMED等参数
UnsupportedClassVersionError编译用的 JDK 比运行的高统一编译和运行环境,或降-source/-target
中文输出乱码locale 没设或file.encoding不对设LANG=zh_CN.UTF-8,或加-Dfile.encoding=UTF-8
服务启动后立即退出systemd 里没有JAVA_HOMEunit 文件里补Environment=
内存告警/堆大小异常容器里 JVM 读的是宿主机内存显式设-Xmx,或启用容器感知参数

6.3 几个只有实际用过才知道的坑

JDK 17 的强封装是真的会拦住东西。从 JDK 16 开始,--illegal-access=permit这个兼容开关被移除了,JDK 17 里默认就是强封装。一些依赖深度反射的老框架(各种老版本的序列化库、某些 ORM、某些工具类)会直接抛InaccessibleObjectException。解决方式是在启动参数里加--add-opens:

java --add-opens java.base/java.lang=ALL-UNNAMED \ --add-opens java.base/java.util=ALL-UNNAMED \ --add-opens java.base/java.nio=ALL-UNNAMED \ -jar app.jar

需要开哪个包,看报错信息里module X does not "opens Y"那一段,照着加就行。别图省事一次开十几个,--add-opens会影响 JIT 优化,开得越多性能损失越大。

容器里的内存识别问题。如果你的 JDK 跑在容器里,早期版本的 JVM 会读到宿主机的总内存,然后按比例算出一个巨大的默认堆,容器一启动就被 OOM Killer 干掉。JDK 17 已经支持容器感知(UseContainerSupport默认开启),但如果你在 Kubernetes 里限了limits.memory又没设-Xmx,还是建议显式指定堆大小,把控制权拿在自己手里。经验值是把-Xmx设成容器内存上限的 60% 到 70%,剩下的留给元空间、线程栈和堆外内存。

file.encoding的默认值陷阱。JDK 18 之前,file.encoding默认跟随系统 locale。如果你的服务器 locale 是POSIX或者空的,默认字符集可能是ANSI_X3.4-1968,读文件时中文直接变小问号。CentOS Stream 9 最小化安装默认 locale 往往是C.UTF-8,一般没事,但一旦是从更老的系统迁过来的环境就要小心。稳妥做法是启动参数里显式加-Dfile.encoding=UTF-8,别依赖环境。

软链接的绝对路径问题。用ln -s创建软链接时,如果用相对路径,链接的有效性依赖于当前工作目录,一旦目录移动就断了。所有软链接统一用绝对路径,能避免这类隐蔽故障。

# 对的 ln -sfn /usr/local/java/jdk-17.0.11 /usr/local/java/jdk17 # 有风险的 cd /usr/local/java && ln -sfn jdk-17.0.11 jdk17

这两条命令创建出来的链接,ls -l看着都正常,但行为不同。前者换个目录访问照样有效,后者在别的地方访问可能就指向不存在的目标。

7. 升级与干净卸载的处理方式

装完之后,日常运维还有两件事:将来怎么升小版本,以及万一装错了怎么卸干净。

7.1 小版本升级的三步走

假设要从 17.0.11 升到 17.0.12,流程是这样的。

# 第一步:新版本解压到新目录,不动老的 cd /usr/local/src # 下载并校验 17.0.12 tar -xzf jdk-17.0.12_linux-x64_bin.tar.gz -C /usr/local/java/ # 第二步:先把软链接切到新目录,重启一个非核心服务验证 ln -sfn /usr/local/java/jdk-17.0.12 /usr/local/java/jdk17 java -version 2>&1 # 第三步:确认业务正常后,删掉旧目录回收空间 rm -rf /usr/local/java/jdk-17.0.11

这里的关键是软链接切换本身是可逆的。切过去之后如果发现问题,一条命令就能切回来,因为旧目录还在。别一上来就删旧版本,那样出问题只能重装。

另外,如果你的机器上配了 alternatives,切换软链接之后还要重新指一下 alternatives,因为它记录的是绝对路径,软链接切换对它没有影响。这一点经常被忽略:

alternatives --install /usr/bin/java java /usr/local/java/jdk-17.0.12/bin/java 1712 alternatives --config java

至于大版本升级(比如 17 升 21),就不只是换个目录的事了。要评估废弃 API 的移除、GC 默认策略的变化、第三方库的兼容性,还要看应用启动参数里有没有被标记为废弃的选项。建议先在测试环境完整跑一轮,重点看启动日志里的WARNING和Unrecognized VM option这类提示。

7.2 卸干净的标准动作

手工装的东西,卸载也要手工来,别指望包管理器。按这个顺序做,基本不会有残留。

# 1. 清理 alternatives 注册 alternatives --remove java /usr/local/java/jdk17/bin/java alternatives --remove javac /usr/local/java/jdk17/bin/javac # 其他注册过的命令同理 # 2. 删除 /usr/local/bin 下的软链接(如果建过) rm -f /usr/local/bin/java /usr/local/bin/javac # 3. 删除环境变量配置 rm -f /etc/profile.d/jdk17.sh # 4. 删除安装目录 rm -rf /usr/local/java # 5. 清理下载的压缩包 rm -f /usr/local/src/jdk-17*.tar.gz # 6. 重新登录或 source 一下确认变量已消失 env | grep -i java

第 6 步很重要。如果env | grep -i java还有输出,说明还有别的地方在设这些变量,可能是~/.bashrc、可能是/etc/environment,也可能是某个应用自己的配置。不查清楚就装新版本,很容易出现两个版本配置打架的情况。

再补充一条经验:卸载前先确认没有正在运行的进程依赖这个 JDK。用jps -l或者ps -ef | grep java看一眼,如果有业务进程在跑,先停掉再删目录,否则进程虽然还能继续跑(因为文件句柄还在),但重启时就起不来了,而且报错信息会让你以为 JDK 装错了。

我个人在实际操作中的体会是,这套“归档包 + 带版本号的目录 + 稳定软链接 + profile.d 配置 + 必要时 alternatives 兜底”的组合,只要一次搭好,后面几年的升级几乎不用动脑子。真正容易翻车的从来不是解压和改配置,而是那些环境变量的生效边界——登录 shell 和非登录 shell 的差别、sudo重置环境、systemd 不读 profile,这三件事吃透之后,一台机器上跑三四个不同版本的 Java 应用就是很平常的事。最后分享一个小技巧:把整个流程写成一个幂等的 shell 脚本,用if [ -x /usr/local/java/jdk17/bin/java ]判断是否已经装过,重复执行也不会出错,交付新机器的时候一条命令跑完,比手工敲十几遍可靠得多。