CentOS Stream 9 手动部署 JDK 17:tar.gz 与环境变量
装 JDK 这件事很多人第一反应是dnf install java-17-openjdk-devel一行搞定。我在早期项目里也这么干过直到有一次线上排查问题发现同一批机器上java -version打出来的构建号居然不一样——有的机器被基础镜像的仓库升过一次小版本有的没升。代码没动、配置没动行为却出现了细微差异。那次之后凡是需要长期稳定的服务器我都改成手动下载 tar.gz 归档包、固定路径、固定版本地部署 JDK 17。这篇就把我在 CentOS Stream 9 上反复做过很多遍的这套流程完整写出来包括目录怎么规划、环境变量写在哪一层、怎么让 systemd 服务和定时任务也认这个 JDK以及几个我实打实踩过的坑。不管你是刚接手一台新机器的新手还是已经在运维岗上摸爬滚打几年的老手下面的步骤都可以直接照着抄。1. 先把源码安装这个叫法掰扯清楚1.1 tar.gz 归档包不是源码是预编译二进制国内技术圈有个约定俗成的说法把用 tar.gz 压缩包解压部署的方式统称为源码安装用来和yum/dnf/rpm这类包管理器安装区分开。严格讲这个叫法是不准确的——你从 OpenJDK 各构建方下载的jdk-17.x.yN-linux-x64.tar.gz里面装的是已经编译好的字节码工具链和本地库不需要configure、不需要make、也不需要本地装 GCC。真正从源码构建 JDK 是另一回事要拉几万个源文件、装一整套 boot JDK 和依赖编译几个小时起步日常部署完全没必要。所以我这篇文章讲的是归档包方式部署但既然大家习惯叫源码安装标题也就沿用这个说法。理解这一点很重要正因为它是预编译二进制所以换机器、换环境的成本极低拷贝过去解压就能用这也是它在离线环境、内网环境、需要固定版本的场景下比包管理器更受欢迎的根本原因。1.2 包管理器安装到底差在哪我不否认dnf的便利性。CentOS Stream 9 的默认仓库里就有java-17-openjdk和java-17-openjdk-devel一条命令下去依赖、alternatives软链接、systemd 集成全部帮你处理好。问题出在三个地方第一是版本不可控。仓库里的包会跟随发行版更新你没法阻止某次dnf update顺带把 JDK 从 17.0.9 升到 17.0.11。绝大多数情况下没影响但如果你跑的是对 GC 行为敏感的服务或者依赖某个特定版本才修复的 bug这种被动升级就很讨厌。第二是路径不可控。包管理器装出来的东西散落在/usr/lib/jvm/java-17-openjdk-17.x.x.x-x.el9.x86_64/这种又长又带发行版后缀的目录里脚本里写死路径很难看写软链接又要多绕一层。第三是构建方不可控。仓库里的 JDK 构建方是固定的如果你想用某个特定构建方发布的、带额外优化的版本包管理器给不了。1.3 归档包方式的代价我也得说清楚天下没有白吃的午餐。手动部署意味着你要自己负责版本升级时手动替换软链接、自己配置alternatives如果想让系统级java命令指向它、自己保证环境变量在各个会话类型下都能生效、自己处理卸载。这些工作量不大但必须一次做对做对之后一劳永逸。下面几节就是把这套一次做对的流程拆开讲。提示如果你的机器只跑一个固定的 Java 服务且不关心小版本漂移用 dnf 装其实更省事。归档包方式的价值主要体现在版本锁定、多版本共存、离线部署这三类场景。2. 选包阶段的三个判断点选错了后面全是白工2.1 构建方怎么选功能一致支持策略不一致JDK 17 是 LTS 版本围绕它的开源构建有不少来源。它们都通过了同一套 TCK 兼容性测试跑业务代码的结果是一致的差异主要在安全补丁的跟进速度和支持周期承诺上。挑选时我看三件事是否明确承诺跟进上游 LTS 的安全更新补丁延迟大概多久是否提供校验用的 SHA256 或签名文件这个必须有见 3.2 节归档包是否同时提供 x64 和 aarch64 两种架构。至于哪个更快这类说法我的经验是在常规业务负载下几个主流构建之间的性能差异基本落在测量噪声范围内不值得为此纠结。真要抠性能去做 JVM 参数调优和代码优化的收益要大得多。2.2 架构和 libc两个必须核对的前提在下载之前先在这台机器上敲两条命令uname -m ldd --version | head -n 1第一条告诉你 CPU 架构。CentOS Stream 9 跑在常规 x86 服务器上会输出x86_64跑在 ARM 服务器或某些云主机上会输出aarch64。下载页面上linux-x64对应 x86_64linux-aarch64对应 ARM拿错了解压出来执行会直接报cannot execute binary file。第二条告诉你 glibc 版本。CentOS Stream 9 自带的是 glibc 2.34这个版本足够新装 JDK 17 不会有任何兼容问题。但如果你是从更老的系统比如 CentOS 7glibc 2.17迁移过来的习惯要知道JDK 17 的官方构建通常要求 glibc 2.17 以上某些构建方要求更高。跨机器拷贝 JDK 目录时目标机器的 glibc 版本不能低于源机器否则会报GLIBC_2.xx not found。2.3 从文件名里读出全部信息以jdk-17.0.119-linux-x64.tar.gz为例逐段拆解片段含义需要关注什么jdk完整开发工具包不要下成jre前缀JDK 11 之后官方已不再单独提供 JRE 归档17.0.11功能版本 更新版本17 是 LTS 主版本后面的数字是补丁号9构建号同一补丁版本可能有多个构建一般取最大的linux目标操作系统不是windows、不是macosx64CPU 架构对应x86_64tar.gz打包格式还有.zip、.tar.gz.sha256.txt等伴随文件下载时务必把.sha256.txt那个小文件一起下下来它是你验证完整性唯一的依据。3. 下载、校验与落盘前的系统体检3.1 目录规划三种方案我选第二种在 CentOS 系上部署第三方软件路径选择有三种常见做法各自的取舍不一样方案路径示例优点缺点放/opt/opt/jdk-17.0.11语义清晰第三方软件的惯例位置部分老脚本默认不查/opt放/usr/local/usr/local/jdk-17.0.11符合 FHS和系统包管理的/usr/lib/jvm不冲突PATH 优先级天然靠前无放用户家目录~/jdk-17不需要 root适合开发机多用户共享时权限麻烦不推荐用于服务端我固定用第二种版本化的实体目录放/usr/local/jdk-17.0.11再建一个不带版本号的软链接/usr/local/jdk-17指向它。这样做的好处是升级时只换软链接环境变量里写的永远是/usr/local/jdk-17配置文件一次都不用改。这套命名习惯建议从第一天就定下来后面会省很多事。3.2 下载与 SHA256 校验的标准动作cd /usr/local/src # 用 curl 下载归档包和它的校验文件可以直接复制页面上的链接 curl -LO https://归档包地址/jdk-17.0.119-linux-x64.tar.gz curl -LO https://归档包地址/jdk-17.0.119-linux-x64.tar.gz.sha256.txt下完之后立刻校验别偷懒sha256sum jdk-17.0.119-linux-x64.tar.gz cat jdk-17.0.119-linux-x64.tar.gz.sha256.txt两边字符串肉眼对齐即可也可以直接让sha256sum -c帮你比对注意校验文件里通常包含原始文件名可能需要--ignore-missing或先改一下文件名对齐。这一步绝对不能跳过——大文件传输中断导致的截断、镜像同步不完整、网络中间环节出问题都会让你拿到一个能解压但某个 jar 损坏的包后面排查起来极其痛苦报错信息五花八门根本想不到是包的问题。我在早期就吃过一次亏一个tools.jar相关的诡异报错查了大半天最后重新下一遍就好了。3.3 落盘前的四项体检正式解压前花一分钟确认这几件事df -h /usr/local # 磁盘空间JDK 17 解压后约 300MB getenforce # SELinux 状态 ulimit -n # 当前文件句柄上限 timedatectl | grep Time zone # 时区磁盘空间一般不是问题但/usr/local单独挂载且分得很小的镜像要注意。SELinux 在 CentOS Stream 9 上默认是 enforcing把 JDK 放在/usr/local下一般不会触发上下文问题因为默认策略对这个路径相对宽容但如果后面你要把 JDK 目录挪到/home或者自建路径下就可能需要处理fcontext。文件句柄上限和 JDK 本身的安装没关系但和生产服务关系极大。JDK 17 的 NIO 和网络连接会消耗大量 fd默认的 1024 在高并发下就是灾难。这个值在 systemd 服务里要单独设后面第 5.3 节会讲到。4. 解压、软链接与系统级命令接管4.1 解压动作里的两个细节cd /usr/local tar -xzf /usr/local/src/jdk-17.0.119-linux-x64.tar.gz ls -ld jdk-17.0.119第一个细节归档包解压出来的顶层目录名通常带号如jdk-17.0.119这个加号在 shell 里虽然合法但在某些自动化脚本、URL 拼接、甚至某些老版本工具里会带来麻烦。我的习惯是解压后立刻重命名成干净的版本号mv jdk-17.0.119 jdk-17.0.11 ln -sfn /usr/local/jdk-17.0.11 /usr/local/jdk-17 ls -l /usr/local/jdk-17注意ln -sfn里的-n如果目标已经是一个指向目录的软链接不加-n会在链接内部再创建一个链接形成/usr/local/jdk-17/jdk-17.0.11这种嵌套结构非常隐蔽。这个坑我在升级时踩过表现在JAVA_HOME指向的目录里找不到bin报错信息完全不指向真正原因。第二个细节如果你下载的归档包结构比较特殊有的构建方最外层多套了一层目录可以用tar -xzf xxx.tar.gz --strip-components1 -C /usr/local/jdk-17.0.11直接剥掉外层省去后面 mv 的操作。4.2 权限和属主不要图省事 chown -RJDK 目录的属主保持root:root权限保持755就够了。我见过不少部署脚本图省事直接chown -R appuser:appuser /usr/local/jdk-17理由是应用用户要读写。这完全是误解——Java 运行时不需要往 JDK 目录里写任何东西。授予写权限只会带来两个后果一是应用进程被入侵后可以篡改 JDK 二进制直接提权二是配置被误改后很难发现。正确的做法是chown -R root:root /usr/local/jdk-17.0.11 chmod -R 755 /usr/local/jdk-17.0.11如果确实有脚本需要写临时文件让它写到-Djava.io.tmpdir指定的独立目录里和 JDK 安装目录彻底分开。4.3 用 alternatives 管住系统级 java 命令把 JDK 加到PATH里只能解决你自己这个会话能用的问题。有些软件包括一些 RPM 包、某些守护进程、某些第三方安装脚本会直接调/usr/bin/java不走你的PATH。要覆盖这种情况用alternatives注册alternatives --install /usr/bin/java java /usr/local/jdk-17/bin/java 2000 alternatives --install /usr/bin/javac javac /usr/local/jdk-17/bin/javac 2000 alternatives --config java最后一条命令会弹出一个交互菜单让你选择当前生效的版本2000是优先级数值数字越大越优先。这里有个容易误解的点alternatives --install之后不一定会立刻生效如果系统里已经存在其他 java 候选可能是原来那个优先。所以一定要跑alternatives --config java手动选一次然后用alternatives --display java确认当前指向。只注册java和javac就够了不必把jar、javadoc、jshell全部纳入管理那样维护成本高且没意义。真要执行这些工具时用绝对路径或者靠PATH就足够了。5. 环境变量写在哪一层决定了后面出不出幺蛾子5.1 profile.d 和 bashrc 的分工要说清环境变量配置文件有好几个位置作用域完全不同混用就是事故的开始文件生效范围适用场景/etc/profile所有用户的登录 shell系统级全局但直接改它不优雅/etc/profile.d/*.sh所有用户的登录 shell推荐模块化好维护好删除~/.bashrc单个用户的非登录 shell开发机个人配置~/.bash_profile单个用户的登录 shell同上我固定用/etc/profile.d/jdk17.sh。理由是改一个独立文件、删除时不留痕迹、升级时只需要改软链接不用动配置、对所有用户一致。给文件起带 JDK 版本号的名字比如jdk17.sh也是刻意的将来如果同时要装 JDK 21两个文件可以并存靠PATH顺序或者按需 source 来切换。5.2 一份可以直接抄的配置文件cat /etc/profile.d/jdk17.sh EOF # JDK 17 environment export JAVA_HOME/usr/local/jdk-17 export JRE_HOME${JAVA_HOME} export CLASSPATH.:${JAVA_HOME}/lib:${JRE_HOME}/lib export PATH${JAVA_HOME}/bin:${PATH} EOF chmod 644 /etc/profile.d/jdk17.sh逐个变量解释一下这几个都有讲究JAVA_HOME末尾绝对不能带斜杠。写成/usr/local/jdk-17/的话很多构建工具尤其是那些用字符串拼接构造路径的会拼出/usr/local/jdk-17//bin/java这种双斜杠大部分时候能跑但少数脚本在字符串比较时会判错属于典型的平时没事出事要命。JRE_HOME为什么还留着JDK 11 开始官方就不再单独发布 JRE 了JDK 目录里没有独立的jre子目录。但大量老一代的启动脚本、老版本的 Tomcat 启动脚本、各种祖传运维脚本还在读$JRE_HOME。直接让它指向JAVA_HOME是最省事的兼容方案不要为了干净把它删掉。CLASSPATH里的.是当前目录。这个设置有利有弊好处是临时跑个没打包的 class 方便坏处是存在被当前目录下同名恶意 class 加载的风险。生产环境如果严格一点可以把.去掉。不过现代 Java 应用基本都用-jar或-cp显式指定CLASSPATH环境变量的实际影响已经很小了。PATH里的顺序是${JAVA_HOME}/bin:${PATH}JDK 放最前面。这是因为有些系统自带/usr/bin/java如果路径在后面你就永远用的是旧的那个。写完配置后不要用source /etc/profile.d/jdk17.sh了事那样只有当前会话生效而且容易掩盖问题。正确做法是重新登录或者用bash -l开一个干净的登录 shell 验证bash -lc echo $JAVA_HOME; which java; java -version5.3 非交互式会话和 systemd 服务为什么读不到这是新手最容易懵的地方。/etc/profile.d/下的脚本只在登录 shelllogin shell中执行而下面这些场景统统不是登录 shellssh server java -version这种远程执行单条命令的方式cron 定时任务systemd service 的ExecStart某些 CI/CD 的远程部署脚本。在这些场景里JAVA_HOME是空的PATH里没有 JDK 的 bin 目录脚本直接报java: command not found。解决办法有三种我按推荐度排序第一种在 systemd service 文件里显式声明。这是最干净的做法[Service] EnvironmentJAVA_HOME/usr/local/jdk-17 EnvironmentPATH/usr/local/jdk-17/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin LimitNOFILE65535 ExecStart/usr/local/jdk-17/bin/java -jar /opt/app/service.jar注意Environment里设置的PATH会整体覆盖掉 systemd 的默认 PATH所以必须把标准的几个目录都写全否则连sh、mkdir这些基础命令都找不到了。这个坑非常经典。第二种在启动脚本里硬编码绝对路径。把java换成${JAVA_HOME}/bin/java或者干脆写死/usr/local/jdk-17/bin/java同时在脚本头部显式export JAVA_HOME。这样脚本不依赖任何环境最稳。第三种用ExecStart/bin/bash -lc ...强制走登录 shell。能用但不推荐因为它会额外加载一堆 profile 脚本行为不够可预期而且多了个 bash 进程层级systemctl stop时信号传递可能出问题。6. 验收五层检查别只跑 java -version6.1 第一层基础三连java -version javac -version echo $JAVA_HOMEjava -version的输出会打到标准错误而不是标准输出所以如果你在脚本里java -version | grep 17抓不到东西加个21就好了。输出正常的话应该看到类似openjdk version 17.0.11 2024-04-16的行以及OpenJDK 64-Bit Server VM的说明。确认版本号和构建号一致别出现javac是 17 而java是 11 这种错配——这说明PATH里混进了别的 JDK。6.2 第二层编译运行冒烟测试mkdir -p /tmp/jdktest cd /tmp/jdktest cat Hello.java EOF public class Hello { public static void main(String[] args) { System.out.println(JDK System.getProperty(java.version)); System.out.println(HOME System.getProperty(java.home)); } } EOF javac Hello.java java Hello这一步的意义不在于验证能不能打印字符串而是验证编译器和运行时是同一套。java.home输出的路径应该在你/usr/local/jdk-17下面。如果它指向/usr/lib/jvm/...说明运行时被系统自带的 JDK 抢走了。6.3 第三层JVM 内部信息核对java -XshowSettings:properties -version 21 | grep -E java.home|java.version|java.vendor java -XX:PrintFlagsFinal -version 21 | grep -E MaxHeapSize|InitialHeapSize这两条能看出 JVM 实际拿到的堆大小。在物理机上默认最大堆通常是物理内存的 1/4但在容器里如果没开容器感知或者容器限制是通过 cgroup v1 设的JDK 17 可能读不到准确的内存上限从而按宿主机内存去算堆结果被 OOM Killer 干掉。JDK 10 之后默认开启UseContainerSupportJDK 17 在这方面已经很成熟了但如果你的容器内存限制是-m 2g而机器是 64G记得验证一下MaxHeapSize是不是在 512MB 左右2G 的 1/4。顺带提一句容器场景下我更推荐用-XX:MaxRAMPercentage75.0而不是写死的-Xmx这样镜像在不同规格的机器上都能自适应。6.4 第四层图形相关依赖检查如果你的应用涉及验证码生成、报表导出、图片处理那它大概率会调用 AWT/Swing 的绘图能力而这需要系统里有字体。精简版 CentOS 镜像和新装的 Stream 9 最小化安装都可能没有fc-list | wc -l rpm -qa | grep -i fontconfig如果fc-list命令不存在或者返回 0装一下dnf install -y fontconfig dejavu-sans-fonts不装的后果是运行时报java.lang.InternalError: Cant connect to X11 window server或者更隐蔽的Fontconfig head is null报错信息完全不会提示你缺字体。这个坑我在一个做验证码的服务上踩过排查了半天最后发现是镜像裁剪掉了字体包。另外记得加上-Djava.awt.headlesstrue服务端不需要真实显示设备强制 headless 模式可以避免 JVM 去尝试连接显示服务。6.5 第五层时区和字符集java -XshowSettings:properties -version 21 | grep -E user.timezone|file.encodingJDK 17 有一个重要变化file.encoding的默认值从跟随系统 locale 变成了 UTF-8JEP 400。这解决了很多历史遗留的中文乱码问题但也可能让某些依赖平台默认编码的老代码行为改变——本来在 GBK 环境下能正常读的文件现在按 UTF-8 读了。如果你从 JDK 8 迁移上来遇到乱码先想想这个变化。时区方面user.timezone默认取系统时区。如果系统时区是 UTC 而业务需要东八区要么改系统时区要么在启动参数里加-Duser.timezoneAsia/Shanghai。别指望两边不一致能自动对齐日志时间戳错 8 小时排查起来很费劲。7. 我在实际操作中踩过的几个坑7.1 改了配置却不生效八成是 PATH 顺序问题现象很典型明明echo $JAVA_HOME是对的但java -version还是旧版本。先用两条命令定位which -a java echo $PATH | tr : \nwhich -a会列出PATH里所有匹配的 java。如果第一个不是/usr/local/jdk-17/bin/java那就是顺序问题。常见原因有两个一是在jdk17.sh里写成了export PATH${PATH}:${JAVA_HOME}/bin把 JDK 放最后了二是这个用户自己的~/.bashrc里又追加了一段 PATH把系统的覆盖了。后者特别隐蔽因为~/.bashrc在/etc/profile.d/之后执行。还有一种情况是 shell 的命令哈希缓存。如果你在当前会话里之前执行过javabash 会缓存它的路径之后即使PATH变了也会继续用缓存的那个。执行hash -r清一下就好。7.2 升级小版本时软链接的-n陷阱前面提过升级时执行ln -sfn /usr/local/jdk-17.0.14 /usr/local/jdk-17。如果漏掉-n而/usr/local/jdk-17本身已经是指向目录的软链接ln会跟随链接进到那个目录里创建一个叫jdk-17.0.14的子链接。结果是/usr/local/jdk-17/bin依然指向旧版本你怎么升级版本号都不变。排查方法ls -l /usr/local/jdk-17 readlink -f /usr/local/jdk-17readlink -f会解析出最终的真实路径一眼就能看出指向哪。如果发现嵌套了先rm -f /usr/local/jdk-17/内部那个链接再重新用-n建。7.3 升级后旧版本目录不敢删我的习惯是升级时先建新软链、验证、保留旧目录至少一个发布周期。验证内容包括服务能正常启动、健康检查通过、日志里 GC 行为没有异常变化、连续跑满 24 小时没有内存泄漏迹象。确认稳定后再删旧目录。删之前可以先看有没有进程还在用旧路径ls -l /proc/*/exe 2/dev/null | grep jdk lsof D /usr/local/jdk-17.0.11 2/dev/null | head如果有 Java 进程还挂在旧版本的libjvm.so上说明它是在升级前启动的删目录会出问题先滚动重启服务。7.4 卸载和彻底回退万一要回到系统自带的 JDK步骤是反着来但顺序很重要# 1. 先切换 alternatives alternatives --config java alternatives --config javac # 2. 删除或重命名 profile.d 配置避免下次登录还生效 mv /etc/profile.d/jdk17.sh /etc/profile.d/jdk17.sh.bak # 3. 删软链接 rm -f /usr/local/jdk-17 # 4. 最后才删实体目录 rm -rf /usr/local/jdk-17.0.11顺序反过来会出问题——先删实体目录的话alternatives指向的目标就悬空了/usr/bin/java变成一个坏链接任何调用都会报No such file or directory而且报错信息里指向的是/etc/alternatives/java容易让人一头雾水。真遇到了alternatives --remove java /usr/local/jdk-17/bin/java清掉悬空项即可。最后分享一个我觉得很值的小习惯把整套部署动作写成一个幂等的 shell 脚本变量里放版本号函数里做已存在则跳过的判断。这样将来装 JDK 21、装 JDK 25 都是改一行变量的事也不用担心换台机器忘了哪一步。脚本我一律放在/usr/local/src/deploy/下面和归档包放一起谁接手都能看明白这台机器上的 JDK 是怎么来的、什么时候装的、装的是哪个构建。