新机器上线,启动脚本里的 -Xmx 通常是从上一个项目抄来的。它可能来自一台 64G 的物理机,现在却跑在 4G limit 的容器里。参数本身没有对错,错的是它和当前的机器规格、GC 算法、可观测性需求不匹配。
这篇按四个决策点排一遍:堆开多大 → 用哪个收集器 → 出事时留下什么 → 平时怎么核对。每个参数给 JDK 17 下的默认值与失效条件,最后一张常见错配表。
1. 堆:三个数决定内存上限
堆的边界只有三个关键数:初始值、最大值、新生代占比。其余参数都是在这三个数划定的框里做微调。
$ java -XX:+PrintFlagsFinal -version 2>/dev/null | grep -E ' (Initial|Max)HeapSize|MaxRAMPercentage'
size_t InitialHeapSize = 268435456 {product} {ergonomic}
size_t MaxHeapSize = 4294967296 {product} {ergonomic}
double MaxRAMPercentage = 25.000000 {product} {default}
{ergonomic} 表示这个值由 JVM 自己算出来,不是你写的。上面这台机器物理内存 16G,所以 MaxHeapSize 落在 4G(1/4)。
| 参数 | 含义 | JDK 17 默认 | 什么时候必须显式写 |
|---|---|---|---|
-Xms |
初始堆大小 | 物理内存的 1/64 | 想避免启动后反复扩堆 |
-Xmx |
最大堆 | 物理内存的 1/4,受容器 limit 约束 | 生产环境一律显式写 |
-Xmn |
新生代固定大小 | 未设置,按 NewRatio 或自适应 |
只有 Parallel/Serial 下才建议用 |
-XX:NewRatio |
老年代/新生代比值 | 2 | 用 Parallel 且要调大新生代时 |
-XX:SurvivorRatio |
Eden/Survivor 比值 | 8 | 观测到 Survivor 溢出时 |
-XX:MaxRAMPercentage |
堆占可用内存百分比 | 25.0 | 容器里替代 -Xmx |
-Xms 与 -Xmx 设成相等是常规做法:堆不再动态伸缩,避免运行期扩堆带来的停顿与 RSS 抖动。代价是启动即占满,容器里要把这部分算进 limit。
优先级要记牢:-Xmx 高于 MaxRAMPercentage。两个都写时 -Xmx 生效,百分比被忽略。这是排查"为什么改了百分比没反应"的第一个原因。
2. 容器感知:为什么 limit 是 2G,堆却开了 4G
JDK 10 起 -XX:+UseContainerSupport 默认开启(8u191 起回移到 8),JVM 不再读宿主机 /proc/meminfo,而是读 cgroup 的内存限制。JDK 17 同时支持 cgroup v1 与 v2。
# cgroup v2 下确认容器真实上限
$ cat /sys/fs/cgroup/memory.max
2147483648
容器里推荐两种写法,二选一,不要混:
# 写法 A:绝对堆
java -Xms2g -Xmx2g -jar app.jar
# 写法 B:百分比,给不同规格的实例复用同一份脚本
java -XX:InitialRAMPercentage=50 -XX:MaxRAMPercentage=70 -jar app.jar
MaxRAMPercentage=70 不是保守,而是必需:堆之外还有元空间、线程栈、代码缓存、GC 自身的辅助结构、Direct Buffer、JNI 分配。这些都不在 -Xmx 里。
| 组成 | 大致规模 | 是否受 -Xmx 约束 |
|---|---|---|
| Java 堆 | -Xmx 指定 |
是 |
| Metaspace | 数十 MB 起,无上限则随类数增长 | 否 |
| 线程栈 | 每线程约 1MB(-Xss) |
否 |
| Code Cache | 默认上限 240MB | 否 |
| Direct Buffer | 默认等于 -Xmx 上限 |
否 |
| GC 辅助结构 | 堆的百分之几 | 否 |
最后一行是坑:-XX:MaxDirectMemorySize 默认值等于最大堆。一个 4G 堆的服务如果再吃 4G 堆外,RSS 就是 8G 起步。
3. 新生代:-Xmn 与 NewRatio 的取舍
-Xmn 直接把新生代钉死,NewRatio 按比例推导。两者互斥,写了 -Xmn 就忽略 NewRatio。
# Parallel GC:新生代 1G,老年代 3G(堆 4G)
java -Xms4g -Xmx4g -Xmn1g -XX:+UseParallelGC -jar app.jar
G1 下不要写 -Xmn。G1 的新生代大小是自适应的,它靠调整 young region 数量来命中 MaxGCPauseMillis。钉死新生代等于把 G1 唯一的手脚捆住,停顿目标随之失效。G1 场景要表达"我想要多短的停顿",写这个(MaxGCPauseMillis 是目标不是承诺,追不上时日志里会出现 to-space exhausted):
java -Xms4g -Xmx4g -XX:+UseG1GC -XX:MaxGCPauseMillis=200 \
-XX:G1NewSizePercent=5 -XX:G1MaxNewSizePercent=60 -jar app.jar
4. Metaspace:唯一没有默认上限的内存区
JDK 8 移除永久代后,类的元数据搬到了本地内存。-XX:MaxMetaspaceSize 默认不限,意味着它能一直吃到把机器吃死——而 -Xmx 完全管不到它。
$ jcmd 5123 VM.metaspace
# 典型输出形态(按 JDK 17 核对字段名)
Metaspace Used: 89 MB, Committed: 96 MB, Reserved: 1056768 KB
| 参数 | 作用 | 默认 | 建议 |
|---|---|---|---|
-XX:MaxMetaspaceSize |
元空间硬上限 | 不限 | 生产写 256m–512m |
-XX:MetaspaceSize |
首次触发元空间 GC 的水位 | 约 21MB | 一般不用动 |
-XX:CompressedClassSpaceSize |
压缩类空间 | 1G | 类极多时留意 |
-XX:+ClassUnloadingWithConcurrentMark |
G1 并发标记阶段卸载类 | 开启 | 保持开启 |
上限设了以后,超出的报错是这一行:
java.lang.OutOfMemoryError: Metaspace
这个报错和 Java heap space 的处理路径完全不同:堆 OOM 要去查对象,Metaspace OOM 要去查类是谁加载的——动态代理、CGLIB、脚本引擎、热部署残留的类加载器。设上限的价值就在于把它变成一个可定位的错误,而不是一次安静的 OOMKilled。
5. GC 选择:三种主流收集器的适用面
| 收集器 | 开关 | 停顿特征 | 适用 | 代价 |
|---|---|---|---|---|
| G1 | -XX:+UseG1GC(JDK 9+ 默认) |
可设目标,秒级以内 | 4G–32G 通用服务 | 吞吐略低于 Parallel |
| ZGC | -XX:+UseZGC |
亚毫秒级,与堆大小弱相关 | 大堆(数十 G)、低延迟 | 吞吐损失、内存占用高 |
| Parallel | -XX:+UseParallelGC |
全程 STW,停顿随堆增长 | 批处理、离线计算 | 延迟不可控 |
| Serial | -XX:+UseSerialGC |
单线程 STW | 小堆(<1G)、客户端 | 不适合服务端 |
选择逻辑很简单:先问能不能接受停顿。离线跑批只关心总时间,Parallel 吞吐最高;在线接口关心 P99,堆又不大,G1 够用;堆到几十 G 还想稳,ZGC。ZGC 在 JDK 15 转正,JDK 17 下不需要 UnlockExperimentalVMOptions;分代 ZGC 是 JDK 21 才有的事,别在 17 上找 -XX:+ZGenerational。
# ZGC 一组常用参数
java -Xms16g -Xmx16g -XX:+UseZGC -XX:+ZUncommit -XX:ZUncommitDelay=300 \
-Xlog:gc*:file=/var/log/app/gc.log:time,uptime,level,tags:filecount=5,filesize=50m \
-jar app.jar
6. GC 日志:统一日志(-Xlog)唯一正确写法
JDK 9 之后 -XX:+PrintGCDetails 那套全部废弃,只剩 -Xlog。格式是 -Xlog:选择器:输出:装饰器:滚动。
-Xlog:gc*:file=/var/log/app/gc.log:time,uptime,level,tags:filecount=5,filesize=20m
# 先试跑,确认格式再上生产
$ java -Xlog:gc* -version 2>&1 | head -3
[0.012s][info][gc] Using G1
7. OOM 时留下现场
没有转储的 OOM,事后只能靠猜。三个开关解决"死了以后还能查"。
java -XX:+HeapDumpOnOutOfMemoryError \
-XX:HeapDumpPath=/data/dump \
-XX:+ExitOnOutOfMemoryError \
-XX:+PrintFlagsFinal \
-jar app.jar
| 开关 | 行为 | 适用场景 |
|---|---|---|
-XX:+HeapDumpOnOutOfMemoryError |
OOM 时导出 hprof | 几乎必开 |
-XX:HeapDumpPath |
指定目录或文件名 | 目录要留足空间 |
-XX:+ExitOnOutOfMemoryError |
首次 OOM 直接退出(非 0 码) | 有编排系统拉起时 |
-XX:+CrashOnOutOfMemoryError |
OOM 时 abort 并生成 core | 需要 native 现场时 |
HeapDumpPath 指向目录时,文件名是 java_pid<pid>.hprof;指向一个不存在的目录不会报错,只是转储静默失败——这是最隐蔽的坑。转储期间 JVM 处于 STW,堆 8G 就要写 8G 文件。 |
ExitOnOutOfMemoryError 的语义要看清:它让进程退出,把"重启"交给外面的编排。如果进程被 supervisor 原地拉起而你又不看退出码,表现就是"服务每隔几分钟抖一下",比留着半死状态更难查。
8. 诊断开关:核对实际生效值
参数写了不等于生效。-XX:+PrintFlagsFinal 打印最终值,含默认值、ergonomic 推导、被后续参数覆盖的结果。
# 启动时打印全部最终值(建议只在排障时开)
$ java -XX:+PrintFlagsFinal -XX:+PrintCommandLineFlags -jar app.jar
# 线上不重启也能读
$ jcmd 5123 VM.flags
$ jcmd 5123 VM.command_line
$ jcmd 5123 VM.native_memory summary # 需启动时加 -XX:NativeMemoryTracking=summary
9. 排错:五种典型错配
| 现象 | 原因 | 处理 |
|---|---|---|
| 容器退出码 137,日志无 OOM | 堆 + 堆外 > cgroup limit,被内核 OOMKilled | 降 -Xmx 或提 limit,堆外单独设上限 |
改了 MaxRAMPercentage 无效 |
同时显式写了 -Xmx |
去掉 -Xmx,或改成百分比写法 |
| Full GC 每次停 2s 以上 | 堆过大且用 Parallel/未设停顿目标 | 换 G1 + MaxGCPauseMillis,或减小堆 |
OutOfMemoryError: Metaspace |
动态类无限增长或 MaxMetaspaceSize 过小 |
查类加载器泄漏,再评估上限 |
写了 HeapDumpPath 却没有 hprof |
目录不存在或不可写,转储静默失败 | 启动前 mkdir -p 并验证权限 |
137 那个值得单独说:OOMKilled 是内核杀的,JVM 来不及打任何日志,-XX:+HeapDumpOnOutOfMemoryError 也不会触发。看到 137 就该去看 cgroup 的 memory.events,而不是翻 GC 日志。
10. 边界:参数解决不了的部分
| 问题 | 参数能否解决 | 该做什么 |
|---|---|---|
| 内存泄漏 | 不能 | 堆快照 + 引用链定位 |
| 单次请求创建大对象 | 只能缓解 | 改代码,减少临时对象 |
| 线程池队列积压 | 不能 | 有界队列 + 拒绝策略 |
| 容器 CPU 限流导致变慢 | 部分 | -XX:ActiveProcessorCount 对齐配额 |
| GC 日志看不懂 | 不能 | 配可视化工具,或补 gc+phases |
-XX:ActiveProcessorCount 是容器时代少数值得记住的补充参数:JVM 默认按宿主机核数算 GC 线程与 ForkJoinPool 并行度,cgroup 只给 2 核时,按 32 核推导出来的线程数只会互相抢 CPU。
11. 自测
-Xmx与-XX:MaxRAMPercentage同时存在时,哪个生效?- 容器 limit 2G,
-Xmx2g为什么会退出码 137? - G1 下设置
-Xmn会破坏什么机制? OutOfMemoryError: Metaspace应该先查什么,而不是先调大上限?-Xlog:gc与-Xlog:gc*的差别在哪里?-XX:HeapDumpPath指向不存在的目录,现象是什么?