把一个 Java 应用部署到 Linux 服务器上,很多人以为只要"装个 Java、把 jar 传上去、nohup 跑起来"就够了。跑起来确实只要这些,但跑得稳不稳、内存会不会被操作系统杀掉、日志乱码不乱码、GC 停顿会不会把接口拖死,全看你有没有把环境配对。这几件事之间没有明显的报错,问题往往在业务量上来之后才浮现。
这篇文章讲的就是 Java 应用落地前必须准备好的东西:JDK 到底该装哪个、怎么装(发行版包和手动解压两套)、JAVA_HOME 为什么要配以及怎么配、启动参数里那些 -Xms -Xmx -Xss 究竟在控制什么、GC 怎么选、为什么要加 -Dfile.encoding,最后讲清楚一个最容易被误解的关系——JVM 堆内存和物理内存之间该怎么留余量。
全程给 RHEL/CentOS/Rocky 与 Debian/Ubuntu 两套命令。会涉及内存参数的调整,配错了会导致进程被系统杀掉,所以每个参数都会说明它的实际影响。
一、Java 应用运行到底依赖什么
先理清一件事:运行一个已经打好的 jar 包,需要的不是完整的开发工具链,而是一个能执行字节码的运行时。但实际部署时,我们通常装的是 JDK 而不是 JRE,原因是运维阶段经常需要用到 JDK 自带的工具去诊断问题:jps 看进程、jstack 抓线程栈、jmap 看堆、jstat 看 GC、jcmd 发诊断命令。这些工具全在 JDK 里,JRE 没有。而且新版本的 JDK 早已不单独发布 JRE,所以"生产机器只装 JRE 更精简"这个说法基本过时了。
一个 Java 应用在服务器上跑起来,需要这几样:一个匹配版本的 JDK;应用自身的可执行产物(通常是 fat jar,依赖都打进去了);外部化的配置(数据库地址、密钥等);以及一个日志输出目录。除此之外,还要有"谁来守护它"——也就是进程管理方式,这块内容在 Spring Boot 部署那篇里详细讲,这里聚焦在 JDK 本身。
版本选择上给个经验结论:新项目直接用 LTS 版本,也就是 17 或 21;老项目如果依赖的框架版本低,可能还停在 8 或 11。不要盲目追求新版本,因为升级 JDK 常常牵动框架升级,是一整条链路的工程。判断方法很简单:本地开发用什么版本,生产就装同一个大版本,不要出现本地 17 生产 8 这种错配,那会在运行时以 UnsupportedClassVersionError 的形式报错。
| JDK 版本 | 状态 | 常见搭配 | 建议 |
|---|---|---|---|
| 8 | 仍在广泛使用 | Spring Boot 2.x | 老项目维持,新项目不要选 |
| 11 | LTS,使用减少 | Spring Boot 2.5+ | 有历史包袱时可用 |
| 17 | LTS,当前主流 | Spring Boot 3.x | 新项目首选 |
| 21 | LTS,较新 | Spring Boot 3.2+ | 新项目可选,注意框架兼容 |
二、JDK 安装:两套方式
方式一:用发行版自带的包(推荐)
这是最省心的方式,包管理器会处理依赖、注册到系统的 alternatives 机制、后续还能统一升级。
# RHEL / Rocky / AlmaLinux 装 JDK 17
sudo dnf install -y java-17-openjdk java-17-openjdk-devel
# CentOS 7(用 yum,仓库里可能只有 8 和 11)
sudo yum install -y java-11-openjdk java-11-openjdk-devel
# 只装运行时(不推荐,但某些极简镜像会这么做)
# sudo dnf install -y java-17-openjdk-headless
# Debian 12 / Ubuntu 22.04+
sudo apt update
sudo apt install -y openjdk-17-jdk
# Ubuntu 20.04 常见的版本
# sudo apt install -y openjdk-11-jdk
# 验证
java -version
javac -version
注意 -devel 后缀,它包含头文件、javac、以及前面提到的诊断工具。如果只装 -headless 版本,jps、jstack 这些命令可能不在,排障时会很被动。多花的那点磁盘空间换来的是诊断能力,值得。
方式二:手动解压 tar.gz(需要特定版本或脱离包管理)
包管理器的版本受发行版生命周期限制,比如某些老系统仓库里最高只有 11,但你需要 17,那就得手动装。去 JDK 发行版的官方下载页拿 tar.gz(市面上有多个免费发行版,比如 Oracle OpenJDK、Eclipse Temurin、Amazon Corretto、阿里 Dragonwell 等,选一个长期维护的即可),然后:
# 建目录,把 JDK 和运行时分开管理
sudo mkdir -p /usr/local/jdk
cd /usr/local/jdk
# 解压(文件名按实际下载的来)
sudo tar -xzf /tmp/jdk-17_linux-x64_bin.tar.gz
# 假设解压出 jdk-17.0.x 目录,建一个稳定的软链接,
# 以后升级只改软链接,不改所有引用路径
sudo ln -sfn /usr/local/jdk/jdk-17.0.x /usr/local/jdk/current
# 验证
/usr/local/jdk/current/bin/java -version
用软链接指向"当前版本"是个很实用的习惯。将来升级 JDK,只需要解压新版本、把 current 指过去、重启应用,回滚也就是把链接指回来。
如果希望系统范围内都能直接用 java 命令,可以用 alternatives 注册进去:
# RHEL 系
sudo alternatives --install /usr/bin/java java /usr/local/jdk/current/bin/java 200
sudo alternatives --install /usr/bin/javac javac /usr/local/jdk/current/bin/javac 200
sudo alternatives --config java # 交互式选择默认版本
# Debian / Ubuntu
sudo update-alternatives --install /usr/bin/java java /usr/local/jdk/current/bin/java 200
sudo update-alternatives --config java
两种方式的对比:
| 维度 | 发行版包 | 手动解压 |
|---|---|---|
| 安装难度 | 一条命令 | 下载解压加配置 |
| 版本选择 | 受仓库限制 | 任意版本 |
| 升级方式 | 包管理器 | 换目录改软链接 |
| 系统集成 | 自动 | 需手动配 alternatives |
| 适合 | 常规生产 | 需要特定版本或统一多机版本 |
三、JAVA_HOME 的意义与配置
JAVA_HOME 是一个环境变量,指向 JDK 的安装目录。很多 Java 工具(Maven、Gradle、Tomcat、各种启动脚本)会读它来定位 Java。系统里装了多个 JDK 时,JAVA_HOME 不设置或者设置错了,就会出现"命令行 java -version 显示 17,但应用实际用的是 11"这种诡异情况。
最关键的一点:JAVA_HOME 必须由运行应用的那个用户、在那个上下文里能看到。这一点在 systemd 托管服务时特别容易出错,因为 systemd 不读 /etc/profile,你在 profile 里设置的变量对服务来说根本不存在。
# 方式一:全局配置(影响所有交互式登录),/etc/profile.d/jdk.sh
sudo tee /etc/profile.d/jdk.sh > /dev/null <<'EOF'
export JAVA_HOME=/usr/lib/jvm/java-17-openjdk
export PATH=$JAVA_HOME/bin:$PATH
EOF
sudo chmod +x /etc/profile.d/jdk.sh
# 重新登录或 source 后生效
source /etc/profile.d/jdk.sh
# 方式二:systemd 服务专用,这是生产环境推荐的做法
# /etc/systemd/system/myapp.service 的 [Service] 段
Environment="JAVA_HOME=/usr/lib/jvm/java-17-openjdk"
Environment="PATH=/usr/lib/jvm/java-17-openjdk/bin:/usr/local/bin:/usr/bin:/bin"
# 查找系统里到底装了哪些 Java
ls -l /usr/lib/jvm/ # RHEL 系与 Debian 系都在这个路径下
sudo alternatives --config java # RHEL
sudo update-alternatives --config java # Debian
# 查清某个 Java 属于哪个安装
readlink -f $(which java)
四、常用 JVM 启动参数
启动参数分两类:以 -X、-XX 开头的 JVM 参数,和以 -D 开头的系统属性。前者控制 JVM 自身行为,后者会变成程序里能读到的属性。
内存相关的三个最基本的参数是 -Xms(初始堆)、-Xmx(最大堆)、-Xss(每个线程的栈大小)。生产环境的关键建议是:把 -Xms 和 -Xmx 设成同一个值。原因是如果两个值不同,JVM 会在运行中反复扩容和收缩堆,每次调整都伴随一次 GC,造成不必要的停顿。固定大小换来的是稳定的性能表现。
java -Xms2g -Xmx2g -Xss1m -jar app.jar
-Xss 控制线程栈,默认通常 512KB 到 1MB。栈太小时,递归深度大的代码会抛 StackOverflowError;栈太大时,每个线程占的内存变多,同样的物理内存能创建的线程数变少。一个粗略的量级:1MB 栈意味着 1000 个线程要占 1GB 内存,如果你的应用有大量线程(比如线程池开得很大),这个开销不能忽略。
GC 选择上,现在基本不需要纠结。JDK 8 时代 G1 还需要手动开,JDK 9 往后 G1 是默认,JDK 15 往后还有 ZGC、Shenandoah 这类低延迟收集器。经验结论:堆在 4G 到 32G 之间的常规 Web 应用,用默认的 G1 就很好;对停顿极度敏感(比如要求 P99 在几十毫秒内)且堆很大的场景,可以考虑 ZGC 或 Shenandoah,但它们对内存和 CPU 的要求更高。
# 显式指定 G1(JDK 9+ 已经是默认,这里只是明确写出)
java -XX:+UseG1GC -Xms2g -Xmx2g -jar app.jar
# 低延迟场景考虑 ZGC(JDK 15+ 生产可用)
# java -XX:+UseZGC -Xms8g -Xmx8g -jar app.jar
# 打印 GC 日志,排障必备。JDK 9+ 的写法
java -Xlog:gc*:file=/var/log/app/gc.log:time,uptime,level,tags:filecount=5,filesize=20M -jar app.jar
# JDK 8 的写法
# java -XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:/var/log/app/gc.log -jar app.jar
GC 日志非常重要但也常被忽略。当用户投诉"接口偶尔卡一下",如果没有任何 GC 日志,你只能猜;有了日志,你一眼就能看出是不是某次 Full GC 停顿了两秒。日志一定要配轮转(filecount 和 filesize),否则会无限增长。
编码相关参数是中文环境下的刚需。很多乱码问题的根源是 JVM 默认字符集。JDK 18 之前,file.encoding 的默认值取决于操作系统 locale,容器里经常是 POSIX 或 ANSI_X3.4-1968,导致中文变成问号或乱码。所以:
# 明确指定编码,不要依赖系统默认
java -Dfile.encoding=UTF-8 -Dsun.jnu.encoding=UTF-8 -jar app.jar
JDK 18 之后 file.encoding 默认就是 UTF-8,但显式写出来没有坏处,尤其是你的镜像基础环境不确定时。
其他几个常加的属性:-Duser.timezone=Asia/Shanghai 固定时区(否则容器里可能是 UTC,导致日志时间和业务时间对不上);-Dspring.profiles.active=prod 指定 Spring 环境(也可以用环境变量传);-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/var/log/app/ 让 JVM 在内存溢出时自动导出堆快照,这是事后分析 OOM 的唯一线索,强烈建议加上。
| 参数 | 作用 | 经验值 | 说明 |
|---|---|---|---|
| -Xms / -Xmx | 初始/最大堆 | 两者设相同 | 避免堆反复伸缩 |
| -Xss | 线程栈大小 | 512k-1m | 影响最大线程数与递归深度 |
| -XX:+UseG1GC | 垃圾收集器 | 默认即可 | 4-32G 堆的常规选择 |
| -Xlog:gc* | GC 日志 | 必开并轮转 | 排障第一线索 |
| -Dfile.encoding | 字符编码 | UTF-8 | 中文乱码的主因 |
| -Duser.timezone | 时区 | Asia/Shanghai | 避免时间错 8 小时 |
| -XX:+HeapDumpOnOutOfMemoryError | OOM 时导出堆 | 建议开启 | 事后分析必需 |
五、JVM 内存与物理内存的关系
这是最容易产生事故的地方。很多人认为"服务器 8G 内存,那 -Xmx 设 8G 就是用满",结果是进程被操作系统 OOM Killer 干掉。
真实情况是:JVM 占用的内存远不止堆。除了 -Xmx 指定的堆之外,还有几块大头。元空间(Metaspace)存放类的元数据,默认不设上限,实际使用从几十兆到几百兆不等。线程栈的总和,是线程数乘以 -Xss。JIT 编译后的代码缓存在代码缓存区,通常是几十到几百兆。还有直接内存(Direct Memory),NIO、Netty 这些框架大量使用,-XX:MaxDirectMemorySize 控制,默认约等于堆大小。此外 JVM 自身结构、GC 所需的辅助空间、以及本地库调用都会有开销。
把这些加起来,一个堆设 4G 的 Java 进程,实际常驻内存(RSS)往往在 5.5G 到 6.5G 之间,如果是 Netty 类应用还可能更高。所以一个可靠的估算方式是:
物理内存 ≥ 堆内存 × 1.3 ~ 1.5 + 1GB(留给操作系统和其他进程)
反过来说,8G 内存的机器,堆设 4G 到 5G 是安全的,设 7G 就会很危险。16G 的机器,堆设 8G 到 10G 比较稳妥。
用 top 或者 ps 看进程的 RSS 就能验证这个关系:
# 看 Java 进程的真实内存占用,RES 列就是常驻物理内存
top -p $(pgrep -f app.jar)
# 更精确:看堆的实际使用和提交量
jcmd $(pgrep -f app.jar) GC.heap_info
# 看 JVM 各内存区域的总览,包括元空间和直接内存
jcmd $(pgrep -f app.jar) VM.native_memory summary # 需提前加 -XX:NativeMemoryTracking=summary
风险提示:不要在同一个物理机上把多个 Java 应用的 -Xmx 都按"能设多大就设多大"来配。三个应用各自设 5G,机器一共 16G,一旦三个同时到达峰值就会触发 OOM Killer,而且被杀的是哪个进程不确定,通常是内存占用最大的那个,可能不是真正的元凶。给每个应用留出明确的内存预算,总预算不超过物理内存的 80%,这是基本纪律。
还要留意 swap。如果机器开了 swap 并且 JVM 开始使用它,GC 会因为访问换出的内存页而变得极慢,表现为"服务突然几分钟无响应"。生产环境的惯例是:给 Java 应用所在机器关闭 swap,或者把 vm.swappiness 调到 0 到 10。关掉 swap 的代价是内存真的不够时进程会被直接杀掉,但相比"整个服务假死"要好定位得多。
# 查看 swappiness 当前值
sysctl vm.swappiness
# 临时调整
sudo sysctl -w vm.swappiness=10
# 永久生效,写入 /etc/sysctl.conf 后执行 sysctl -p
echo 'vm.swappiness=10' | sudo tee -a /etc/sysctl.conf && sudo sysctl -p
六、多版本共存与配置检查清单
一台机器上同时跑着 JDK 8 的老应用和 JDK 17 的新应用是常态。前面的 alternatives 和软链接方案都能支持,关键原则是:每个应用显式声明自己用哪个 JDK,不要依赖系统默认。
在 systemd 服务里通过 Environment 指定 JAVA_HOME,启动脚本里用 $JAVA_HOME/bin/java 而不是裸 java,这样每个服务的版本就完全独立、互不干扰。
上线前的检查清单,照着过一遍:
# 1) Java 版本是否匹配应用要求
java -version
# 2) JAVA_HOME 是否正确,尤其是服务用户视角下的值
sudo -u appuser bash -c 'echo $JAVA_HOME'
# 3) 时区和时间是否正确
date
timedatectl
# 4) 日志目录是否存在且可写
ls -ld /var/log/app
# 5) 应用实际生效的启动参数
ps -ef | grep app.jar
cat /proc/$(pgrep -f app.jar)/cmdline | tr '\0' ' '
# 6) 内存是否有余量
free -h
第二条尤其重要。很多"服务起来但报错"的问题,根源就是服务用户看不到 JAVA_HOME。
常见问题
Q:报 UnsupportedClassVersionError 是什么原因?
编译用的 JDK 版本高于运行用的 JDK 版本。比如用 JDK 17 编译,运行时机器上装的是 JDK 11。确认生产机器版本不低于编译版本,或者在构建时用 --release 参数指定目标版本。
Q:应用跑一段时间被系统杀掉,日志里看不到任何异常?
这是典型的 OOM Killer。查系统日志确认:dmesg | grep -i 'killed process' 或 journalctl -k | grep -i oom。根因通常是 -Xmx 设得过大,没有给操作系统和其他进程留余量。
Q:日志里的中文是乱码?
先确认 -Dfile.encoding=UTF-8 有没有加,再确认写日志用的输出编码。注意 JDK 18 之前默认编码随环境变化,容器环境下特别容易出问题。同时检查终端或日志查看工具本身的编码设置,别把工具的问题当成应用的问题。
Q:接口偶尔卡顿一两秒,怎么查是不是 GC 导致的?
加 -Xlog:gc* 打印 GC 日志,看有没有 Full GC 以及停顿时间。如果日志显示一次 Full GC 停顿几百毫秒到几秒,那就是根因。下一步是用 jstat -gcutil 观察趋势,或者用 jmap -histo 看是什么对象堆积。
Q:怎么在不重启的情况下改 JVM 参数?
大部分内存参数(如 -Xmx)无法动态修改,只能重启。有一小部分可以通过 jinfo -flag 调整,但能改的很少。所以生产环境改参数就是一次重启,要按发布流程走。
总结
Java 应用在 Linux 上的环境配置,核心是把三件事做对。第一,JDK 版本要和应用编译版本匹配,优先用发行版包安装,需要特定版本再手动解压并用软链接管理切换;JDK 而不是 JRE,因为诊断工具在里面。
第二,JAVA_HOME 必须让运行应用的那个用户看到,systemd 托管时要写在服务文件里,不能只依赖 /etc/profile.d。启动参数里 -Xms 与 -Xmx 设成相同值、堆之外要留出 30% 到 50% 的物理内存、-Dfile.encoding=UTF-8 和 -Duser.timezone 显式指定、GC 日志和 OOM 堆快照一定打开,这几条是稳定的下限。
第三,把内存当成一笔预算来管理。单机多应用时给每个应用分配明确的堆上限,总和不超物理内存的 80%;同时关闭或大幅降低 swap,避免 GC 因为换页而假死。做好这些,你的 Java 服务才算真正具备了上生产的基础。
企业QQ咨询




