Administrator
发布于 2020-05-07 / 10064 阅读
76

JVM 参数模板:一个生产环境该配什么

七个服务七套 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 过。

InitialRAMPercentageMaxRAMPercentage 设成一样,等价于老式写法 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:+UseConcMarkSweepGCJDK 9 废弃,14 已移除换 G1
GC 日志不滚动磁盘写满,JVM 卡死配 filecount 和 filesize
HeapDumpPath 指向容器内路径重启后 dump 丢失挂 volume
-Xmx 设成容器限制的 90%堆外内存没算进去,被 OOMKilled用 MaxRAMPercentage=70

就写到这。如果哪天你也被《JVM 参数模板:一个生产环境该配什么》里同一个坑绊住,回来翻这篇,能省半小时。

参考