七个服务七套 JVM 参数,我整理了一份模板
4 月底盘了一遍生产上 7 个微服务的启动参数,发现全是复制粘贴来的,来源各不相同:
user-service: -Xms512m -Xmx2g
order-service: -Xms4g -Xmx4g -Xmn2g -XX:+UseG1GC
promo-service: -Xmx1g
report-service: -Xms2g -Xmx2g -XX:+UseConcMarkSweepGC -Xmn1g
问题不少:user-service 的 Xms 和 Xmx 不一致(运行时要动态扩容,会有抖动);order-service 用了 G1 还设了 -Xmn(G1 不建议固定新生代大小);promo-service 连 GC 收集器都没指定,8 核机器跑默认配置。
我整理了一份统一模板,这篇把每一项为什么这么配写清楚。
JDK 8 的模板
我们大部分服务还在 JDK 8u242 上:
java \
-server \
-XX:+UseContainerSupport \
-XX:InitialRAMPercentage=70.0 \
-XX:MaxRAMPercentage=70.0 \
-XX:MetaspaceSize=256m \
-XX:MaxMetaspaceSize=512m \
-XX:MaxDirectMemorySize=512m \
-XX:+UseG1GC \
-XX:MaxGCPauseMillis=200 \
-XX:InitiatingHeapOccupancyPercent=45 \
-XX:+HeapDumpOnOutOfMemoryError \
-XX:HeapDumpPath=/data/dumps \
-XX:+ExitOnOutOfMemoryError \
-Xloggc:/data/logs/gc.log \
-XX:+PrintGCDetails \
-XX:+PrintGCDateStamps \
-XX:+PrintTenuringDistribution \
-XX:+UseGCLogFileRotation \
-XX:NumberOfGCLogFiles=14 \
-XX:GCLogFileSize=100M \
-XX:+DisableExplicitGC \
-Djava.security.egd=file:/dev/./urandom \
-Dfile.encoding=UTF-8 \
-jar app.jar
逐项说明
堆大小:用百分比,别写死
容器环境下我不写 -Xms4g -Xmx4g,改成按容器内存限量的百分比:
-XX:+UseContainerSupport
-XX:InitialRAMPercentage=70.0
-XX:MaxRAMPercentage=70.0
这两个参数从 JDK 8u191 开始支持,会读取 cgroup 的内存限制。Pod 限 4 GB 的话,堆就是 2.8 GB。
为什么是 70% 不是 80%?因为容器里的 4 GB 包含了:Java 堆、元空间、线程栈(每个线程默认 1 MB)、Code Cache、DirectBuffer(Netty 会用)、GC 本身的开销。我们有个服务用了 Netty,堆外内存能占到 600 MB,写死 -Xmx3g 配 4 GB 限制就被 OOMKilled 过。
InitialRAMPercentage 和 MaxRAMPercentage 设成一样,等价于老式写法 Xms = Xmx,避免运行期堆扩容带来的抖动。这个习惯很老但依然对。
元空间和堆外,这两个不含在堆里
-XX:MetaspaceSize=256m
-XX:MaxMetaspaceSize=512m
-XX:MaxDirectMemorySize=512m
JDK 8 里类的元数据在 Metaspace(本地内存),默认最大是不限的(实际受限于物理内存)。我们有个用 Groovy 动态脚本的服务,跑了三天 Metaspace 涨到 3.2 GB 把容器撑爆。加上限之后至少是快速失败而不是拖垮整机。
MetaspaceSize 是触发首次 Full GC 的阈值,不是初始大小。设 256m 意味着 Metaspace 用到 256 MB 才第一次做元空间的 GC,避免启动期反复 GC。
MaxDirectMemorySize 是 Netty、NIO 用的堆外内存上限。默认等于 -Xmx,在容器里就很危险了。显式设成 512m 安全一些。
GC 收集器:8 上选 G1,别再用 CMS
| 收集器 | 适用场景 | 我们的结论 |
|---|---|---|
| Parallel GC(JDK 8 默认) | 吞吐量优先,能接受长停顿 | 只给离线任务用 |
| CMS | 低延迟,但 JDK 9 标记废弃,JDK 14 移除 | 不用,迟早要迁 |
| G1 | 堆 4 GB 以上、停顿可控在 200 ms 内 | 主力 |
| Serial GC | 单核、堆小于 100 MB | 只在本地开发机默认生效 |
CMS 的问题是内存碎片(用标记清除算法),运行久了会退化成 Serial Full GC,一次几十秒。我们老的报表服务就中过招,凌晨 3 点一次 47 秒的 Full GC。
G1 的两个关键参数:
-XX:MaxGCPauseMillis=200
-XX:InitiatingHeapOccupancyPercent=45
MaxGCPauseMillis 是目标不是保证。G1 会据此动态调整分区数,设太小(比如 20)会导致它疯狂地做小回收,反而降低吞吐。200 ms 对 Web 服务是合理的。
InitiatingHeapOccupancyPercent 是并发标记周期的触发水位,默认 45。我们有个服务出现过"Old 区涨到 70% 才开始并发标记,来不及就 Full GC"的情况,把 IHOP 调到 35 之后解决了。
GC 日志:一定要开滚动
这个我吃过亏。早期只写了 -Xloggc:/data/logs/gc.log,没配滚动,跑了四个月,日志文件 47 GB,把数据盘写满了。写满之后 GC 日志写不进去,JVM 直接卡住。
-XX:+UseGCLogFileRotation
-XX:NumberOfGCLogFiles=14
-XX:GCLogFileSize=100M
14 个文件 × 100 MB,最多 1.4 GB,保留两周左右。加 -XX:+PrintGCDateStamps 是为了日志里有可读的时间戳(否则只有 JVM 启动后的相对秒数,对不上监控的时间轴)。
-XX:+PrintTenuringDistribution 打印对象年龄分布,排查对象过早晋升时很有用。看这段:
2020-04-28T09:14:22.815+0800: 18463.221: [GC pause (G1 Evacuation Pause) (young)
Desired survivor size 33554432 bytes, new threshold 15 (max 15)
- age 1: 6243184 bytes, 6243184 total
- age 2: 1192080 bytes, 7435264 total
- age 3: 426400 bytes, 7861664 total
如果 age 1 特别大、age 2、3 急剧减少,说明对象朝生夕死,很健康。如果 age 15 还有很多,说明有对象一直存活,要看看是不是缓存没设过期。
OOM 之后:既要留 dump,也要快速重启
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/data/dumps
-XX:+ExitOnOutOfMemoryError
两个坑:
- dump 路径必须是挂载出来的 volume。容器里的路径在容器重启后就没了。我们第一次配的时候写的是
/tmp,OOM 之后 Pod 被 K8s 重启,dump 文件一起消失了,等于没配。现在统一挂/data/dumps到 NFS。 - 4 GB 堆的 dump 文件大约 3.5 GB,要确认磁盘够。dump 的过程会 STW,可能几十秒,所以要有
ExitOnOutOfMemoryError让它快速退出、让 K8s 拉起新实例。
-XX:+ExitOnOutOfMemoryError 是 JDK 8u92 之后才有的。它的价值在于:OOM 之后 JVM 往往处于半死不活的状态(能响应健康检查但处理不了请求),不如干脆退出,让编排系统重新拉一个。
两个小项
-XX:+DisableExplicitGC
-Djava.security.egd=file:/dev/./urandom
DisableExplicitGC 是禁止 System.gc()。有些库(比如早期的 Netty、某些压缩库)会主动调用它,触发一次 Full GC。禁掉之后需要用堆外内存的组件要留意,Netty 现在会检测到这个参数并改用 Unsafe.freeMemory。
java.security.egd 是随机数源。默认是 /dev/random,在熵池不足时会阻塞。容器里熵池通常很小,Tomcat 生成 Session ID 时可能卡住几十秒。改成 /dev/./urandom(注意那个多余的 ./ 是必须的,绕开 JDK 里的路径判断)。
JDK 11 的模板
我们新上的两个服务用了 JDK 11.0.6。GC 日志的参数整个换了(统一日志框架):
java \
-server \
-XX:+UseContainerSupport \
-XX:MaxRAMPercentage=70.0 \
-XX:MaxMetaspaceSize=512m \
-XX:MaxDirectMemorySize=512m \
-XX:+UseG1GC \
-XX:MaxGCPauseMillis=200 \
-Xlog:gc*:file=/data/logs/gc.log:time,uptime,level:filecount=14,filesize=100M \
-XX:+HeapDumpOnOutOfMemoryError \
-XX:HeapDumpPath=/data/dumps \
-XX:+ExitOnOutOfMemoryError \
-Dfile.encoding=UTF-8 \
-jar app.jar
变化:
-Xloggc、-XX:+PrintGCDetails这些全部废弃,统一成-Xlog一个参数。语法是-Xlog:[选择器]:[输出]:[装饰器]:[输出选项]。- JDK 9 之后默认就是 G1,但显式写上没坏处。
-XX:+UseContainerSupport在 JDK 11 是默认开启的。- 元空间的大小在 JDK 11 里默认管理得更好,但上限还是建议加。
JDK 11 上我们特意试过 ZGC(-XX:+UnlockExperimentalVMOptions -XX:+UseZGC),停顿确实都在 10 ms 以内,但吞吐量比 G1 低了 12% 左右,而且当时还是实验特性,没敢放生产。
几个常见的错误配置
| 错误写法 | 问题 | 正确做法 |
|---|---|---|
G1 配 -Xmn2g | 固定新生代大小会关掉 G1 的自动调优 | 去掉,让 G1 自己算 |
-Xms512m -Xmx4g | 运行期扩容有抖动,Full GC 风险 | 两个设成一样 |
-XX:+UseConcMarkSweepGC | JDK 9 废弃,14 已移除 | 换 G1 |
| GC 日志不滚动 | 磁盘写满,JVM 卡死 | 配 filecount 和 filesize |
| HeapDumpPath 指向容器内路径 | 重启后 dump 丢失 | 挂 volume |
-Xmx 设成容器限制的 90% | 堆外内存没算进去,被 OOMKilled | 用 MaxRAMPercentage=70 |
就写到这。如果哪天你也被《JVM 参数模板:一个生产环境该配什么》里同一个坑绊住,回来翻这篇,能省半小时。