JAVA · Vol.III · DAY 29 · JVM 原理与调优
JVM 调优参数手册:30 个最常用参数速查
开场:参数手册不是背单词,是开处方
面试里"JVM 参数你常用哪些"是最落地的一题。原理可以背,参数背不动——你说出 -Xmx4g 的那一刻,追问已经在路上:为什么是 4g 不是 8g?这个数字从哪来?参数和参数之间谁压谁?这一站先把定位讲清楚:参数手册解决两件事——第一,知道什么时候用哪个(场景到参数的映射);第二,知道参数之间的相互关系(互斥、覆盖、ergonomics 自动调整)。就像开处方:医生不是背药名,而是知道哪个药治哪个病、哪两个药不能一起吃、开完药还要复查化验单。
答。只答到 -Xms/-Xmx/-Xmn 就停,只能证明你写过启动脚本。完整答案三层:先按堆、容器、元空间、收集器、日志、诊断六类列出 30 个核心参数和默认值;再讲关系——Xms 为什么等于 Xmx、Xmn 和 NewRatio 都设了听谁的、MetaspaceSize 为什么不能留默认;最后补一句"我用 -XX:+PrintFlagsFinal 验证过最终生效值"。调优是"症状 → 处方 → 复诊"的闭环,参数只是处方上那一行字。
三层能力模型
把"会用参数"拆成三层,面试分层回答,自己排障也分层定位:
第一层:知道是什么。每个参数的作用、默认值、单位。比如 -Xmx 默认是物理内存的 1/4(JDK 8 容器环境则是 cgroup 内存限制的 25%)。这些"默认值"本身就要背——因为留默认值是最常见的生产事故来源:你以为 JVM 会用满容器内存,其实它只敢用 1/4。
第二层:知道什么时候用。场景到参数的映射:容器化服务必配 -XX:MaxRAMPercentage 和 -XX:ActiveProcessorCount;Web 服务用 G1 加停顿目标;批处理用 Parallel 加大堆;任何服务都配 OOM 自动转储。背参数不如背这张映射表,场景只会多不会少。
第三层:知道相互关系。同名参数谁覆盖谁、哪两个参数互斥、ergonomics 会在你看不见的地方改默认值。这一层是分水岭——90% 的"参数不生效"问题都出在这里,也是面试官最喜欢挖的地方。
30 参数总目录
下表是全文的目录:30 个核心参数(后文各站再补 10 个诊断与杂项参数,共 40 个)。"生产必配"列回答"哪些参数不配就会出事"——新服务上线前,把带 ✓ 的项逐格打钩。
| 参数 | 一句话作用 | 分类 | 生产必配? |
|---|---|---|---|
-Xms | 堆初始大小 | 堆 | ✓ 与 Xmx 相等 |
-Xmx | 堆最大大小 | 堆 | ✓ |
-Xmn | 年轻代大小(初始=最大) | 堆 | 视场景 |
-XX:NewSize | 年轻代初始大小 | 堆 | ✗ 用 Xmn 即可 |
-XX:MaxNewSize | 年轻代最大大小 | 堆 | ✗ 用 Xmn 即可 |
-XX:NewRatio | 老年代 : 年轻代比例 | 堆 | ✗ 默认 2 |
-XX:SurvivorRatio | Eden : 单个 Survivor 比例 | 堆 | ✗ 默认 8 |
-XX:MaxTenuringThreshold | 对象晋升老年代的年龄阈值 | 堆 | ✗ 默认 15 |
-XX:PretenureSizeThreshold | 超过该值的大对象直接进老年代 | 堆 | ✗ 仅 Serial/ParNew |
-XX:MaxRAMPercentage | 容器内:最大堆占内存 % | 容器 | ✓ 容器化服务 |
-XX:InitialRAMPercentage | 容器内:初始堆占内存 % | 容器 | ✗ |
-XX:MinRAMPercentage | 容器内:最小堆占内存 % | 容器 | ✗ |
-XX:MetaspaceSize | 元空间首次 GC 触发阈值 | 元空间 | ✓ 128~256m |
-XX:MaxMetaspaceSize | 元空间上限(默认不限) | 元空间 | ✓ |
-XX:CompressedClassSpaceSize | 压缩类指针空间大小 | 元空间 | ✗ 默认 1G |
-XX:-UseCompressedClassPointers | 关闭压缩类指针 | 元空间 | ✗ 一般不动 |
-XX:+UseSerialGC | Serial 收集器(单线程) | 收集器 | 视场景 |
-XX:+UseParallelGC | Parallel 收集器(吞吐优先) | 收集器 | 视场景 |
-XX:+UseConcMarkSweepGC | CMS 收集器 | 收集器 | ✗ JDK9 废弃、JDK14 移除 |
-XX:+UseG1GC | G1 收集器 | 收集器 | ✓ JDK9+ 默认 |
-XX:+UseZGC | ZGC(超低停顿) | 收集器 | 视场景 |
-XX:+UseShenandoahGC | Shenandoah(并发整理) | 收集器 | 视场景 |
-XX:MaxGCPauseMillis | GC 停顿软目标(毫秒) | 收集器 | ✓ |
-XX:InitiatingHeapOccupancyPercent | G1 并发标记触发水位 | 收集器 | ✗ JDK9+ 自适应 |
-XX:G1HeapRegionSize | G1 Region 大小(1~32MB) | 收集器 | 视场景 |
-XX:G1MixedGCCountTarget | Mixed GC 分摊轮数 | 收集器 | ✗ 默认 8 |
-XX:G1HeapWastePercent | Region 可回收浪费阈值 | 收集器 | ✗ 默认 5% |
-XX:ParallelGCThreads | 并行 GC 线程数 | 收集器 | 视场景 |
-XX:ConcGCThreads | 并发 GC 线程数 | 收集器 | 视场景 |
-XX:ActiveProcessorCount | 手动指定可用核数 | 容器 | ✓ 容器化服务 |
参数手册 = 场景到参数的映射表 + 参数之间的"谁压谁"关系。目录表先扫一遍混个眼熟,后面六站按组展开:堆、元空间、收集器、日志、诊断、生产模板。背目录,懂关系,上线前逐格打钩。
堆内存参数:从大到小,五层控制
堆参数按"控制粒度"从大到小分五层:堆整体(Xms/Xmx)→ 年轻代(Xmn/NewRatio)→ 年轻代内部(SurvivorRatio)→ 晋升规则(TenuringThreshold)→ 大对象(PretenureSizeThreshold)。一层套一层,像俄罗斯套娃:外面定总盘子,里面定切法。下面逐层拆。
第一层:-Xms 与 -Xmx
-Xms 堆初始大小,-Xmx 堆最大大小。生产铁律:Xms = Xmx,相等设置。原因有三:其一,堆扩展本身是 STW(Stop The World)操作——JVM 要向操作系统要新内存、重排已用空间,频繁扩缩容等于周期性给自己加停顿;其二,Xms 小于 Xmx 时,JVM 默认会在 GC 后尝试缩堆,缩到 MinHeapFreeRatio 以下再涨回来,内存像在"呼吸",监控曲线全是锯齿,排障时你会怀疑人生;其三,Xms=Xmx 后堆地址空间一次分配到位,压缩类指针和对象头里的 32 位窄引用(Narrow OOP)的行为完全可预测。默认值怎么算的?ergonomics(源码在 src/share/vm/ergos/ergonomics.cpp):-Xms 默认物理内存的 1/64,-Xmx 默认 1/4。一台 32G 机器什么都不配,堆上限就是 8G——这就是"容器里 JVM 为什么这么抠"的根源。
第二层:年轻代的三个参数
-Xmn 直接指定年轻代大小(初始 = 最大)。例:-Xmx8g -Xmn3g,年轻代 3g、老年代 5g。-XX:NewSize 和 -XX:MaxNewSize 是细粒度版本:分别指定年轻代的初始和最大值,让年轻代在 GC 后按需缓慢长大。生产上 90% 的场景一个 -Xmn 就够;真要精细控制,就写成 NewSize = MaxNewSize = Xmn 三者等值,把年轻代也钉死,道理和 Xms=Xmx 一样。
-XX:NewRatio 是另一个思路:不直接定年轻代,而是定老年代 : 年轻代 = N : 1,默认 N = 2,即年轻代占整个堆的 1/3。它和 Xmn 是"公式"与"结果"的关系,这里有个高频易错点:
"Xmn 和 NewRatio 都设了,到底听谁的?"听 -Xmn 的。JVM 启动时若发现 Xmn 被显式设置,就由它直接推导年轻代大小,NewRatio 被忽略——日志里甚至不会提示你。记住一句话:Xmn 是结果,NewRatio 是公式,结果永远压公式。生产配置里两者二选一,都写等于埋雷。
第三、四层:SurvivorRatio 与 MaxTenuringThreshold
-XX:SurvivorRatio 定 Eden 与单个 Survivor 区的比例,默认 8。年轻代被切成 10 份:Eden 8 份、S0 和 S1 各 1 份(任意时刻只用一个 Survivor,另一个永远空着,这是复制算法的代价)。为什么默认 8 而不是 1?因为弱分代假说成立:绝大多数对象朝生夕灭,Survivor 只需容纳"熬过几次 Minor GC"的那一小撮幸存者,给太大纯属浪费。
-XX:MaxTenuringThreshold 定晋升年龄,默认 15。对象每熬过一次 Minor GC,年龄 +1(记在对象头的 age 字段),到达阈值就进老年代。Serial/ParNew 严格执行这个阈值;G1 会动态调整(survivor 区装不下时提前晋升)。什么时候该调小?当你的对象平均存活时间只有 2~3 次 GC 时,默认 15 会让它们在 Survivor 里反复腾挪,拷贝成本翻倍——把阈值降到 6,让它们早点进老年代,Minor GC 反而更干净。
第五层:大对象直通
-XX:PretenureSizeThreshold:对象大小超过该值,跳过 Eden 直接分配进老年代,默认 0(关闭)。注意两个坑:第一,它只在 Serial/ParNew 下有效——分配路径(oopFactory 分配前检查)只有这两个收集器会查这个 flag,Parallel 有自己的大对象策略,G1 干脆不用它;第二,G1 里的大对象走 Humongous Region:对象超过 Region 大小的一半就直接占一个或多个 Humongous Region,想调就调 -XX:G1HeapRegionSize(下一站细讲),而不是去碰 PretenureSizeThreshold。面试说"G1 里 PretenureSizeThreshold 无效",这句话本身就是加分项。
容器化:三个 RAMPercentage
JDK 8u191+(JDK 10+ 原生)开始,JVM 能读懂 cgroup 的内存限制,三个参数全部以容器内存的百分比表示:-XX:MaxRAMPercentage 默认 25.0、-XX:InitialRAMPercentage 默认 1.5625、-XX:MinRAMPercentage 默认 15.0。为什么默认只给 25%?因为 JVM 看不到堆外内存(元空间、Direct Buffer、线程栈、mmap 的代码段),宁可保守。一个容器只跑一个 Java 进程时,25% 就是白白浪费——生产上一般提到 60~75%,例如 8G 容器配 -XX:MaxRAMPercentage=75.0,等效 Xmx 约 6G。注意优先级:显式设置 -Xmx 后,三个 RAMPercentage 全部失效——显式值永远压过 ergonomics。
堆参数五层控制:Xms=Xmx 先钉死总盘子;Xmn 和 NewRatio 二选一钉死年轻代(都设听 Xmn);SurvivorRatio 和 TenuringThreshold 是细调;大对象直通只认 Serial/ParNew;容器化记住三个 RAMPercentage 默认 25% / 1.5625% / 15%,显式 Xmx 会压过它们。
元空间参数:一个名字陷阱,一根保险丝
JDK 8 永久代(PermGen)下线,类元数据搬进 元空间(Metaspace)——它默认不再受 -Xmx 管辖,而是直接用本地内存(native memory)。这带来四个参数,其中两个是生产的必修课。
MetaspaceSize:名字是陷阱
-XX:MetaspaceSize 听起来像"元空间初始大小",其实是第一次 Full GC(日志里叫 Metadata GC)的触发阈值。JVM 启动时类加载持续进行,元空间用量一旦超过这个阈值,就触发一次针对元空间的 GC——注意这不是"分配初始空间",而是"报警线"。默认值由 ergonomics 决定,在 20MB 量级(随 JDK 版本略有差异)。问题来了:一个正常的 Spring + MyBatis 服务,启动期加载的类元数据轻松 40~80MB,不设这个参数,启动阶段就会撞线,GC 日志里出现以 Metadata GC Threshold 为原因的停顿,每次都要扫一遍类空间。处方:观察服务稳定运行 24 小时后的元空间占用(jstat -gcmetacapacity <pid> 或 jcmd <pid> VM.metaspace),把 MetaspaceSize 设到略高于稳态值,一般 128m~256m。设高一点没坏处——它只是阈值,不是预留,不会真的占那么多内存。
MaxMetaspaceSize:默认不限量 = 没装保险丝
-XX:MaxMetaspaceSize 才是元空间真正的上限,默认值是"不限制"(受 OS 可用内存约束)。生产必须显式设置(常见 512m),原因看下面这个易错点:
"不设 MaxMetaspaceSize 没事,元空间够用。"错。动态代理(CGLIB/ByteBuddy 生成的类)、自定义 ClassLoader(热部署框架)、脚本引擎(Nashorn/Groovy)泄漏时,类只进不出,元空间无限增长。不设上限的后果不是 OutOfMemoryError: Metaspace,而是OS 内存被吃光,容器被 cgroup OOM Killer 直接杀掉——进程暴毙、没有任何 Java 堆转储、K8s 事件里只有 OOMKilled,排查难度翻倍。设了上限,泄漏会表现为进程内可捕获的 OOM:有堆转储、有 GC 日志、有 OnOutOfMemoryError 钩子。一句话:MetaspaceSize 是报警线,MaxMetaspaceSize 是保险丝,报警线要设对,保险丝必装。
CompressedClassSpaceSize 与 UseCompressedClassPointers
JVM 启动时会从元空间里划出一块连续的压缩类空间存放类的元数据,-XX:CompressedClassSpaceSize 控制这块的大小,默认 1GB。"压缩"指实例的对象头里用 32 位压缩类指针指向类元数据(每个实例省 4 字节,64 位系统上对象多时这是实打实的节省)。两个注意点:其一,这块空间在启动时一次性分配、之后不能扩,所以它是虚拟地址预留,不是物理占用,默认 1G 一般不用动;其二,-XX:-UseCompressedClassPointers 可以整体关闭压缩类指针,只在极端场景(比如元空间需求超过 1G 且不想放弃压缩收益的纠结方案)才考虑,生产上这个开关别碰。
小案例:启动期 Metadata GC 频发
某服务上线后,启动 10 分钟内出现 7 次几十毫秒到几百毫秒的停顿,GC 日志(JDK 11 统一日志格式)长这样:
# 启动第 0.9 秒:Metadata GC Threshold 触发,堆没满,元空间先报警
[0.942s][info][gc] GC(3) Pause Young (Metadata GC Threshold) 512M->402M(4096M) 87.214ms
[0.942s][info][gc,metaspace] GC(3) Metaspace: 20411K->19855K(2560000K)
# 第 40 秒、第 95 秒……同样的原因反复出现,直到类加载收敛
[40.213s][info][gc] GC(9) Pause Young (Metadata GC Threshold) 812M->655M(4096M) 112.556ms
[40.213s][info][gc,metaspace] GC(9) Metaspace: 39822K->38107K(2560000K)
读法:原因列写的是 Metadata GC Threshold,说明不是堆压力、是元空间撞阈值;Metaspace 行显示用量从 20MB 涨到近 40MB。处方一行:-XX:MetaspaceSize=256m,重启后启动期 Metadata GC 归零。日志每个字段的完整拆解在「GC 日志分析」,这里只演示"从日志现象反推参数处方"的路径。记住这个模式:日志给症状,参数开处方,重启后复诊。
元空间三件套:MetaspaceSize 是首次 GC 阈值(默认 20MB 量级,设到稳态值上方 128~256m);MaxMetaspaceSize 是保险丝(默认不限量,必设);CompressedClassSpaceSize 默认 1G 别碰。名字陷阱 + 保险丝缺失,是元空间仅有的两个坑。
收集器参数:先选对收集器,再谈调参
调优的第一性原理:选对收集器,比调任何参数都重要——收集器定错了,参数调得再精也是南辕北辙。六个开关逐个过一遍(机制细节在「垃圾收集器全览」,这里只讲"什么时候用它"):
| 开关 | 适用堆/CPU | 停顿特征 | 一句话定位 | 现状 |
|---|---|---|---|---|
-XX:+UseSerialGC | 堆 < 100MB / 单核 | STW,短 | 单线程,简单到极致 | 嵌入式、客户端 |
-XX:+UseParallelGC | 中大堆 / 多核 | STW,可较长 | 吞吐量优先(Parallel Scavenge + Old) | JDK 7/8 默认 |
-XX:+UseConcMarkSweepGC | 堆 < 8G / 多核 | 并发标记,低停顿 | JDK8 时代的低延迟老生代方案 | JDK9 废弃,JDK14 移除 |
-XX:+UseG1GC | 4G~32G / 多核 | 可预测停顿(软目标) | Region 化,通用默认 | JDK9+ 默认 |
-XX:+UseZGC | > 16G 或延迟极苛刻 | 亚毫秒~几毫秒,与堆大小基本无关 | 并发 + 读屏障/重定位屏障 | JDK11 EA,JDK15 转正 |
-XX:+UseShenandoahGC | 中大堆 / 多核 | 并发整理,低停顿 | G1 的同类思路,用转发指针(BRO 卡)做并发压缩 | JDK12 EA,回炉到 JDK11 |
答。ZGC 的低停顿不是免费的:它靠读屏障 + 重定位屏障实现并发处理,每个对象访问都多一次屏障检查,CPU 开销比 G1 高几个百分点,内存占用也略高。停顿预算 200ms 的 Web 服务,G1 用更少的 CPU 就能达标;只有 P99 卡在 10ms 以内、或者堆大到 16G 以上让 G1 的停顿开始失控时,ZGC 才值这个开销。一句话:延迟预算决定收集器,而不是收集器决定预算。
G1 调参四件套
-XX:MaxGCPauseMillis:停顿软目标,默认 200ms(Parallel 收集器也认这个参数)。"软"的含义:G1 据此估算"一轮 Young GC / Mixed GC 最多回收多少 Region",但回收量太小会导致老年代积压,G1 宁可通过一次更长的停顿换长期稳定。所以它是个期望值不是承诺——设 1ms 的下场后面追问表里细说。
-XX:InitiatingHeapOccupancyPercent(IHOP):并发标记启动水位,默认 45%——堆占用到 45% 就启动并发标记,为 Mixed GC 准备"哪些 Region 该回收"的脏卡信息。JDK9+ 起 IHOP 是自适应的(JVM 根据历史回收速率动态调整),手动设死的价值很小,一般交给自适应。
-XX:G1HeapRegionSize:Region 大小,1~32MB,默认 Xmx ÷ 2048(8G 堆 → 4MB Region)。什么时候调大?当你的业务对象普遍偏大(比如 2MB 的报文缓冲、10MB 的图片解码缓存),超过 Region 一半就成了 Humongous 对象,直接进 Humongous Region,回收时机被特殊化、还容易造成碎片——把 Region 调到 16m 或 32m,让"大对象"变小。注意 Region 大小是启动期定死的,运行期不能改。
-XX:G1MixedGCCountTarget(默认 8)和 -XX:G1HeapWastePercent(默认 5%):Mixed GC 要把"清理老年代"这个脏活摊到若干轮里做,CountTarget 是摊几轮;WastePercent 是"回收收益"门槛——一个 Region 里可回收的比例不足 5% 就不值得为它 STW,跳过。这两个参数默认值已经够用,调参手册里属于"知道它存在"级别,真到要动它,说明你已经在看 GC 日志里的 Mixed GC 效率了。
并行度:GC 线程数与容器核数
-XX:ParallelGCThreads 控制 STW 阶段的并行度,默认值由 CPU 核数公式决定(≤8 核时约为核数的一半,更多核时约 5 + (n-8) × 5/8);-XX:ConcGCThreads 控制并发阶段线程数,约为 ParallelGCThreads 的 1/4。容器场景有个经典翻车点:
"容器限了 2 核,JVM 的 GC 线程自动适配,不用管。"只对一半。JDK 10+(JDK 8u191+ 部分能力)能读 cgroup CPU 限制,自动把 ParallelGCThreads 压到 2;但老版本 JDK 或某些 cgroup v1 配置下,JVM 看到的是宿主机 32 核,于是开 16 个 GC 线程去抢 2 个核——GC 期间 16:2 超调度,上下文切换风暴,停顿不降反升,业务线程还跟着遭殃。处方:JDK10+ 用 -XX:ActiveProcessorCount=2 显式钉死(JDK 10+ 参数,直接覆盖容器探测),老版本只能靠升级 JDK。容器化服务的两个必查项:内存看 MaxRAMPercentage,CPU 看 ActiveProcessorCount。
收集器选择走决策树(堆大小 + 核数 + 延迟预算 → 90% 落在 G1);G1 调参核心就两个:MaxGCPauseMillis 是软目标默认 200ms,IHOP 默认 45% 交给自适应;容器场景永远检查 MaxRAMPercentage 和 ActiveProcessorCount 两项。
GC 日志参数:调优的眼睛,两套语法
没有 GC 日志的调优是盲人摸象——你改了参数,不知道效果是好是坏,只能靠 RT 监控猜。GC 日志是调优闭环里的"复诊化验单",生产必开,且必须开轮转(GC 很频繁时日志文件增长极快,不轮转会写爆磁盘)。JDK 8 和 JDK 9+ 是两套完全不同的语法,而且不能混用:JDK 9+ 的统一日志(unified logging)把 -XX:+PrintGCDetails 这类 flag 全部干掉了,在 JDK 11 上写 JDK 8 的日志参数,JVM 只会在启动日志里打印一行 "Ignoring option PrintGCDetails; support was removed in 8.0" 然后静默忽略——不报错,但你的日志文件根本不存在,排障时才发现,黄花菜都凉了。
JDK 8 配置
# 基础输出
-XX:+PrintGCDetails # 详细 GC 日志(每次 GC 的 before/after/耗时)
-XX:+PrintGCDateStamps # 用日期时间戳(能对上业务日志的时间轴)
-XX:+PrintGCApplicationStoppedTime # 打印所有 STW 停顿(不止 GC,还有 safepoint)
-XX:+PrintTenuringDistribution # 打印年龄分布(调 MaxTenuringThreshold 的原料)
# 输出位置与轮转:10 个文件 × 20M,约 200M 上限
-Xloggc:/data/logs/gc-%t.log
-XX:+UseGCLogFileRotation
-XX:NumberOfGCLogFiles=10
-XX:GCLogFileSize=20M
JDK 9+ 配置(统一日志)
# 统一日志:Xlog:日志标签:目标:装饰器:轮转
-Xlog:gc*,safepoint:file=/data/logs/gc.log:time,uptime,level,tags:filecount=10,filesize=20M
# 字段拆解:
# gc* —— 所有 gc 开头的标签(gc、gc-task、gc-phase、gc-ref、gc-heap…)
# safepoint —— 全部 STW 事件,排查"停顿不全是 GC"必备
# time,uptime —— 墙上时间 + JVM 启动后秒数(对照业务日志用 time)
# level,tags —— 输出日志级别与标签列(第 3 站案例里的 [gc,metaspace] 就是 tags)
# filecount/filesize —— 轮转 10 份 × 20M,与 JDK 8 配置等效
配好日志后,一行典型的 G1 输出长这样:[0.942s][info][gc] GC(3) Pause Young (G1 Evacuation Pause) 1234M->876M(4096M) 15.2ms。读法三要素:GC 序号 + 原因(括号里)→ 回收前后堆占用(箭头前后)→ 总堆 → 停顿毫秒数。原因列是诊断的第一入口:G1 Evacuation Pause 是正常年轻代回收,Metadata GC Threshold 说明元空间撞阈值(第 3 站的案例),Allocation Failure 是常规分配失败,Full GC 则意味着收集器扛不住了。逐字段、逐原因的完整解剖在「GC 日志分析」,本文只负责把"日志先配上"这一步做实——先有化验单,再谈看化验单。
GC 日志生产必开 + 必轮转(10 份 × 20M 是标配);JDK 8 用 PrintGCDetails 一族,JDK 9+ 用 -Xlog:gc* 统一语法,两套不能混用、用错只会静默忽略;读日志先认"原因列",完整拆解去「GC 日志分析」。
诊断与排障参数:平时配好,出事不慌
诊断参数是 JVM 的黑匣子:平时占不了什么内存,出事时价值连城。核心原则一句话——和事配,别等出事再加,因为 OOM 和 JVM crash 发生的那一刻,你没有第二次启动的机会。
OOM 自动转储三件套
-XX:+HeapDumpOnOutOfMemoryError 配合 -XX:HeapDumpPath:一旦发生 OOM,JVM 在抛异常的同时自动写一份完整堆转储(效果等同 jmap -dump:live)。生产必配,两个细节决定它"关键时刻顶不顶用":其一,转储文件大小约等于堆已用大小,放在容量充足的独立磁盘,放在系统盘且系统盘只有 50G 时,一次 12G 的 OOM 转储直接写爆磁盘,雪上加霜;其二,转储要时间(大堆可能几十秒),期间进程还活着,别急着 kill。为什么必须自动?看下面这个追问:
答。三个现实问题。第一,K8s 里 OOM 后容器随时可能被杀掉(exit code 137),等你登上 Pod,现场已经没了;第二,大堆的 jmap -dump 本身要 STW 很久,期间服务不可用,业务方会催你重启——重启之后现场就没了;第三,"发现 OOM → 连上机器 → 敲命令"这个链路要几十秒,而 OOM 后 JVM 可能因为堆里还有大量存活对象根本 dump 不出完整现场。自动转储是 JVM 写的遗书,必须在它还活着的时候写好。
-XX:OnOutOfMemoryError:OOM 时执行一条外部命令,把"处理 OOM"自动化。典型用法:-XX:OnOutOfMemoryError="curl -fsS -X POST http://alert.local/hook -d 'host=%h;pid=%p'" 直接打告警;或者干脆 kill -9 %p 自杀,让 K8s 立刻拉新容器(%p 占位符是进程号,%h 是主机名)。注意命令是同步执行的,别在里面写一个会 hang 的操作,否则 OOM 处理本身把进程卡死。
hs_err:JVM 崩溃现场
-XX:ErrorFile 指定 JVM 致命错误(不是 OOM,是 SIGSEGV、assert 失败这类把 JVM 自己搞崩的故障)时生成的 hs_err_pid<pid>.log 的位置。默认写在当前工作目录——容器里当前目录经常是只读的,或者容器一重启就丢,等于没写。生产配 -XX:ErrorFile=/data/hs_err/hs_err_pid%p.log。这个文件里有线程栈、寄存器、加载的库、GC 状态,是排查 native crash 的唯一线索,「CPU 100% 排查」里遇到 jstack 都打不出来、进程直接消失的场景,第一反应就是找 hs_err。
PrintFlagsFinal:别猜,查
-XX:+PrintFlagsFinal 在启动时打印所有参数的最终生效值——命令行设的、ergonomics 推出来的、参数间相互覆盖后的结果,一个不落地全列出来,每行带注释说明来源。-XX:+PrintFlagsRanges 是加强版,额外打印每个参数的取值范围和默认值。它是"参数到底生效没有"这个问题的标准答案,配合 grep 使用:-XX:+PrintFlagsFinal | grep MaxHeapSize。为什么需要它?因为"参数写了不生效"有五种典型原因,每一种都猜不出来,只能查:
"我在启动脚本里写了参数,但实际行为跟没写一样。"五种原因按出现频率排:① 拼写错误——JDK 8 对拼错的 -XX 参数静默忽略(不报错!),JDK 9+ 才会直接启动失败,老系统上这是头号杀手;② 版本不支持——比如 JDK 11~14 的 ZGC 是实验性特性,不加 -XX:+UnlockExperimentalVMOptions 就不认;③ ergonomics 改写了默认值——你没写 Xmx,但容器里它的"默认值"已经是 25% cgroup 限制,你以为的默认值(1/4 物理内存)在容器里根本不存在;④ 与另一参数互斥覆盖——Xmn 压 NewRatio 就是典型(第 2 站);⑤ 容器 JDK 版本不认 cgroup——JDK 8u191 之前的版本在容器里按宿主机内存算默认值,8G 容器里的 JVM 以为自己有 128G。验证路径统一为:-XX:+PrintFlagsFinal | grep 参数名(启动时)、jinfo -flags <pid>(运行时全量)、jcmd <pid> VM.flags(运行时,更轻量)。排障第一原则:用生效值说话,不用记忆说话。
NMT 与类加载跟踪:堆外与类的显微镜
-XX:NativeMemoryTracking=summary(或 detail)开启本地内存跟踪:把堆外内存按线程栈、代码缓存、内部结构、符号、GC 本身等类别拆开统计。用法是临时开启排查,因为 detail 模式有 1~5% 的性能开销,不适合常驻。典型流程:启动时开 NMT,运行时 jcmd <pid> VM.native_memory baseline 打基线,过一阵 jcmd <pid> VM.native_memory summary.diff 看差值——哪个类别涨了多少,一目了然。堆外内存增长排不出方向时,NMT 是第一个该开的灯(更深入的堆外排查见「JVM 运行时数据区」)。
-XX:+TraceClassLoading / -XX:+TraceClassUnloading:打印每一次类加载/卸载(类名 + 加载它的 ClassLoader)。类加载器泄漏("Metaspace OOM 但就是找不到谁在泄漏")时的武器:看日志里哪个 ClassLoader 的类只进不出。注意日志量巨大(一个 Web 服务每秒几十行),同样临时开、定位完就关。
诊断参数是和事配:HeapDumpOnOutOfMemoryError + HeapDumpPath + OnOutOfMemoryError 三件套管 OOM,ErrorFile 管 JVM 崩溃,PrintFlagsFinal 管"参数到底生效没有",NMT 和类加载跟踪管堆外与类泄漏的深挖。记住验证三板斧:PrintFlagsFinal(启动)、jinfo -flags(运行时)、jcmd VM.flags(轻量运行时)。
杂项参数 + 三套生产启动模板
最后四个"杂项"参数,个个短小,但都对应一类真实事故:
四个杂项参数
-Xss(线程栈大小):默认 1MB(Linux x64)。它管的是每个线程的 Java 栈,属于线程私有区,不吃 -Xmx 的额度——这是很多人忽略的堆外大头。两个方向:深递归场景(调用链动辄上千层,比如没写好的互相递归、超大 JSON 树的递归解析)出现 StackOverflowError,调到 2m;反过来,线程数是堆外内存的隐形乘法器——1 万个线程 × 1MB 栈 = 10GB 堆外,高并发短任务的服务把 Xss 压到 512k,一次省一半。算线程预算时,永远把 Xss 乘进去。
-XX:+UseTLAB:TLAB(Thread Local Allocation Buffer)让每个线程在 Eden 里预先圈一小块内存,分配对象不用抢全局锁,默认开启。它属于"知道它存在就行"的参数,生产上没有任何理由去动它。机制细节(TLAB 满了怎么办、指针碰撞 vs 空闲列表)在「对象创建全过程」。
-XX:SoftMaxHeapSize(JDK 12+):给 G1/ZGC 一个软上限——堆占用超过它时,GC 会更积极地回收,努力让堆落回软限以下,把多用的内存还给操作系统;但它是"软"的,分配压力真来了,堆依然可以涨到 Xmx。典型配方:-Xms12g -Xmx16g -XX:SoftMaxHeapSize=10g,稳态只占 10G 左右,高峰期允许冲到 16G。适合"容器内存给得宽裕、但想控制稳态占用"的场景(ZGC 尤其配它,因为 ZGC 能真正并发地把堆缩回去)。
-XX:+DisableExplicitGC / -XX:+ExplicitGCInvokesConcurrent:管的是 System.gc() 这个"定时炸弹"。DirectByteBuffer 池快满时,Netty 等框架会主动调 System.gc() 触发回收;在 G1/CMS 下,System.gc() 默认触发的是 Full GC(STW)。两个参数两条路:DisableExplicitGC 直接忽略所有 System.gc()(此时 Netty 会改用 Unsafe 路径清理 Direct 内存,Netty 4.1+ 支持);ExplicitGCInvokesConcurrent 不忽略,但把 System.gc() 改造成触发并发收集而不是 Full GC。直接内存紧张(Direct buffer memory OOM)的服务,两者至少配一个,直接内存的来龙去脉见「JVM 运行时数据区」。
三套生产启动模板
参数学的目的是拼出能用的启动命令。下面三套模板覆盖 90% 的 Java 服务场景,每个参数都带注释,直接拷走改数字。
# 通用 Web 服务:8G 容器,G1,P99 停顿预算 200ms
-Xms6g -Xmx6g # 相等,留约 2G 给堆外(元空间/Direct/线程栈/代码段)
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200 # 软目标,与 Web 的 P99 预算对齐
-XX:MetaspaceSize=256m # 阈值贴稳态,杜绝启动期 Metadata GC
-XX:MaxMetaspaceSize=512m # 保险丝
-XX:MaxDirectMemorySize=1g # Netty 直接内存上限(默认 = Xmx,显式钉死)
-Xss512k # 高并发短任务,栈减半
-XX:ActiveProcessorCount=4 # 容器 4C,钉死 GC 线程数计算依据
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/data/dumps/
-XX:ErrorFile=/data/hs_err/hs_err_pid%p.log
-Xlog:gc*,safepoint:file=/data/logs/gc.log:time,uptime,level,tags:filecount=10,filesize=20M
为什么这样配:① Xmx 只给容器的 75%,堆外四兄弟(元空间、Direct、线程栈、mmap 代码段)约 2G 的份额必须留足,这是"Xmx 不能占容器 100%"的最直接体现;② MetaspaceSize 贴稳态值,把启动期 Metadata GC 掐死在摇篮里;③ ActiveProcessorCount 显式声明,不赌 JDK 版本对 cgroup 的探测能力。
# 离线批处理:16G 容器,Parallel,吞吐优先,单次停顿可接受 500ms
-Xms12g -Xmx12g
-XX:+UseParallelGC # Parallel Scavenge + Parallel Old,吞吐最大化
-XX:MaxGCPauseMillis=500 # 目标放宽,用停顿换吞吐
-XX:ParallelGCThreads=12 # 16C 容器,钉死并行度(默认公式约 12)
-XX:MetaspaceSize=256m
-XX:MaxMetaspaceSize=512m
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/data/dumps/
-XX:ErrorFile=/data/hs_err/hs_err_pid%p.log
-Xlog:gc*,safepoint:file=/data/logs/gc.log:time,uptime,level,tags:filecount=10,filesize=20M
为什么这样配:① 批处理没有延迟预算,吞吐量(单位时间处理多少数据)是 KPI,Parallel 的 STW 虽然长但回收效率最高;② 堆给到容器的 75%,离线作业类加载少,元空间保险丝 512m 足够;③ 显式设 ParallelGCThreads,防止容器超卖核数时 GC 线程打架(第 4 站的 crack)。
# 超低延迟服务:16G 容器,ZGC(JDK17+,非实验性),P99 停顿 < 10ms
-Xms12g -Xmx12g
-XX:+UseZGC # 停顿与堆大小基本无关,亚毫秒~毫秒级
-XX:SoftMaxHeapSize=10g # 稳态压到 10g,压力来了允许涨回 12g
-XX:ConcGCThreads=4 # 并发线程限 4 个,别让 GC 吃掉业务 CPU
-XX:MetaspaceSize=256m
-XX:MaxMetaspaceSize=512m
-XX:MaxDirectMemorySize=1g
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/data/dumps/
-XX:ErrorFile=/data/hs_err/hs_err_pid%p.log
-Xlog:gc*,safepoint:file=/data/logs/gc.log:time,uptime,level,tags:filecount=10,filesize=20M
为什么这样配:① ZGC 的卖点是不停顿,但它的并发阶段要占 CPU——ConcGCThreads 必须显式限流,否则 GC 线程和业务线程抢核,延迟反而变差(第 4 站 think 里算过这笔账);② SoftMaxHeapSize 让稳态占用低于 Xmx,容器账单好看了,峰值还有 2G 缓冲;③ 注意 JDK 版本:JDK 15 之前 ZGC 是实验性特性,启动要加 -XX:+UnlockExperimentalVMOptions,模板按 JDK 17+ 写就免了。
杂项四参数各管一类事故:Xss 管栈与线程预算(记得乘线程数),UseTLAB 别碰,SoftMaxHeapSize 管稳态占用,ExplicitGC 两兄弟管 System.gc() 的 Full GC 风险。三套模板的骨架完全一致——堆(相等)+ 元空间(阈值+保险丝)+ 收集器(场景选)+ 诊断(三件套),变的只有数字。
总结:知识地图 + 面试模板 + 高频追问
知识地图
本文全部知识点的结构(能默画出来就算内化):
- 堆(五层控制):Xms=Xmx 钉总盘子;Xmn 与 NewRatio 二选一(都设听 Xmn,Xmn 是结果、NewRatio 是公式);SurvivorRatio 默认 8、TenuringThreshold 默认 15;PretenureSizeThreshold 仅 Serial/ParNew,G1 大对象看 Humongous Region。
- 容器(版本线 8u191 / 10+):MaxRAMPercentage 默认 25% 要提到 60~75%、ActiveProcessorCount 钉核数;显式 Xmx 会压过全部 RAMPercentage。
- 元空间(两坑):MetaspaceSize 是首次 GC 阈值(默认 20MB 量级 → 设 128~256m),MaxMetaspaceSize 是保险丝(默认不限量 → 必设 512m);CompressedClassSpaceSize 默认 1G 别碰。
- 收集器(决策树):堆与核数加延迟预算逐步排除 → Serial / Parallel / CMS(JDK14 移除)/ ZGC(P99<10ms 或 >16G)/ 默认 G1;G1 核心参数 MaxGCPauseMillis 默认 200ms(软目标)、IHOP 默认 45%(JDK9+ 自适应)、Region 默认 Xmx÷2048;并行度受容器核数影响。
- 日志(两套语法):JDK 8 用 PrintGCDetails 一族,JDK 9+ 用 -Xlog:gc* 统一日志,不能混用、用错静默忽略;必开轮转 10 份 × 20M。
- 诊断(和事配):OOM 三件套(HeapDump+Path、OnOutOfMemoryError)、ErrorFile 管 crash、PrintFlagsFinal 验证生效值、NMT 管堆外、TraceClassLoading 管类泄漏;参数不生效的五种原因(拼写/版本/ergonomics/互斥/容器探测)。
- 模板(三套骨架):通用 Web(G1)、批处理(Parallel)、超低延迟(ZGC+SoftMaxHeapSize);骨架 = 堆相等 + 元空间阈值与保险丝 + 收集器 + 诊断,变的只有数字。
面试回答模板
"我常用 30 个左右的参数,分六类:堆、容器、元空间、收集器、日志、诊断。最常用的:-Xms 等于 -Xmx 避免扩缩堆的 STW;-XX:MetaspaceSize 加 MaxMetaspaceSize,一个是首次 GC 阈值、一个是保险丝;收集器 90% 的场景用 G1,配 MaxGCPauseMillis=200ms;任何服务都配 OOM 自动转储三件套和带轮转的 GC 日志。容器化再加两个:MaxRAMPercentage 和 ActiveProcessorCount。最后我习惯用 -XX:+PrintFlagsFinal 验证最终生效值,不靠猜。"
"第一层,收集器选择走决策树:堆小于 100MB 加单核用 Serial;吞吐优先的批处理用 Parallel;JDK8 低延迟且堆小于 8G 时代会用 CMS,现在 JDK14 已经移除;P99 要求小于 10ms 或堆大于 16G 用 ZGC;剩下的 90% 落在 G1。第二层,三套生产模板:通用 Web 是 G1 加 Xmx 取容器内存的 75%,MetaspaceSize 贴稳态值;批处理是 Parallel 加停顿目标放宽到 500ms;超低延迟是 ZGC 加 SoftMaxHeapSize 控稳态,还要限 ConcGCThreads 防止 GC 线程吃业务 CPU。第三层,参数间的关系:Xmn 压 NewRatio、显式 Xmx 压 RAMPercentage、JDK 8 对拼错的参数会静默忽略——这是参数不生效的头号原因。最后说验证:启动时 PrintFlagsFinal 加 grep,运行时 jinfo -flags 或 jcmd VM.flags。举个实战例子:某服务启动期反复出现 Metadata GC Threshold 停顿,日志元空间用量 20MB 起步,一行 -XX:MetaspaceSize=256m 解决——这就是从日志现象反推参数处方的完整闭环。"
高频追问表
| 追问 | 答案要点 |
|---|---|
| Xmn 和 NewRatio 都设了,听谁的? | 听 Xmn。Xmn 是结果,NewRatio 是公式,结果压公式;两者二选一,都写等于埋雷。 |
| MetaspaceSize 和 MaxMetaspaceSize 区别? | 前者是首次 Metadata GC 的触发阈值(报警线,默认 20MB 量级),后者是元空间上限(保险丝,默认不限量)。设稳态值上方 + 必设上限。 |
| 为什么 -Xmx 不要设成容器内存的 100%? | 堆外还有四兄弟:元空间、Direct 内存、线程栈(Xss × 线程数)、mmap 代码段。占满 100%,堆外没额度,容器被 OOM Killer 杀。 |
| G1 的 MaxGCPauseMillis 设 1ms 会怎样? | 它只是软目标,设 1ms 后每轮回收量太小,老年代积压追不上分配速度,最终退化成长时间 Full GC——软目标不是承诺。 |
| 容器里 JVM 参数要注意什么? | 三条:JDK 版本线(8u191 / 10+ 才完整支持 cgroup);MaxRAMPercentage 默认 25% 要调;ActiveProcessorCount 钉核数,防 GC 线程按宿主机核数超调度。 |
| 怎么确认参数真的生效了? | 启动时 -XX:+PrintFlagsFinal | grep 参数名;运行时 jinfo -flags pid 或 jcmd pid VM.flags。用生效值说话,不用记忆说话。 |
调参像开处方:症状 → 处方 → 复诊。记三个数字(Xmx ≈ 容器 75%、MetaspaceSize ≈ 256m、停顿目标 200ms)、两条版本线(8u191 / 10+)、三板斧验证(PrintFlagsFinal / jinfo / jcmd),处方就不会开错。下一篇「GC 日志分析」教你怎么读复诊的化验单——把日志里每一行 GC 输出的字段和原因都拆开看。
Comments · 评论