首页 / Java 学习笔记 / 29

JAVA · Vol.III · DAY 29 · JVM 原理与调优

JVM 调优参数手册:30 个最常用参数速查

中级高频#JVM#调优#手册
第 1 站

开场:参数手册不是背单词,是开处方

面试里"JVM 参数你常用哪些"是最落地的一题。原理可以背,参数背不动——你说出 -Xmx4g 的那一刻,追问已经在路上:为什么是 4g 不是 8g?这个数字从哪来?参数和参数之间谁压谁?这一站先把定位讲清楚:参数手册解决两件事——第一,知道什么时候用哪个(场景到参数的映射);第二,知道参数之间的相互关系(互斥、覆盖、ergonomics 自动调整)。就像开处方:医生不是背药名,而是知道哪个药治哪个病、哪两个药不能一起吃、开完药还要复查化验单。

"JVM 参数你常用哪些?"

答。只答到 -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:SurvivorRatioEden : 单个 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:+UseSerialGCSerial 收集器(单线程)收集器视场景
-XX:+UseParallelGCParallel 收集器(吞吐优先)收集器视场景
-XX:+UseConcMarkSweepGCCMS 收集器收集器✗ JDK9 废弃、JDK14 移除
-XX:+UseG1GCG1 收集器收集器✓ JDK9+ 默认
-XX:+UseZGCZGC(超低停顿)收集器视场景
-XX:+UseShenandoahGCShenandoah(并发整理)收集器视场景
-XX:MaxGCPauseMillisGC 停顿软目标(毫秒)收集器
-XX:InitiatingHeapOccupancyPercentG1 并发标记触发水位收集器✗ JDK9+ 自适应
-XX:G1HeapRegionSizeG1 Region 大小(1~32MB)收集器视场景
-XX:G1MixedGCCountTargetMixed GC 分摊轮数收集器✗ 默认 8
-XX:G1HeapWastePercentRegion 可回收浪费阈值收集器✗ 默认 5%
-XX:ParallelGCThreads并行 GC 线程数收集器视场景
-XX:ConcGCThreads并发 GC 线程数收集器视场景
-XX:ActiveProcessorCount手动指定可用核数容器✓ 容器化服务
一句话核心

参数手册 = 场景到参数的映射表 + 参数之间的"谁压谁"关系。目录表先扫一遍混个眼熟,后面六站按组展开:堆、元空间、收集器、日志、诊断、生产模板。背目录,懂关系,上线前逐格打钩。

第 2 站

堆内存参数:从大到小,五层控制

堆参数按"控制粒度"从大到小分五层:堆整体(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 是公式,结果永远压公式。生产配置里两者二选一,都写等于埋雷。

未显式设 Xmn 时:年轻代 = Xmx ÷ (NewRatio + 1);Eden = 年轻代 × SurvivorRatio ÷ (SurvivorRatio + 1)

第三、四层: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。

堆(Heap) -Xms / -Xmx:生产设相等;默认 Xmx = 物理内存 1/4,容器内 = cgroup 限制 × 25% 年轻代 -Xmn 或 NewRatio(同时设置听 Xmn) Eden 8 份 S0 1 份 S1 1 份 老年代 老年代 = 堆 − 年轻代 NewRatio=2 时占堆的 2/3 大对象:Serial/ParNew 看 PretenureSizeThreshold; G1 看 Humongous Region SurvivorRatio = Eden : 单 Survivor(默认 8) | MaxTenuringThreshold = 晋升年龄(默认 15)
图 1 堆的分代结构与参数映射:外层定总盘子(Xms/Xmx),中层定切法(Xmn/NewRatio),内层定细节(SurvivorRatio/晋升/大对象)
一句话核心

堆参数五层控制:Xms=Xmx 先钉死总盘子;Xmn 和 NewRatio 二选一钉死年轻代(都设听 Xmn);SurvivorRatio 和 TenuringThreshold 是细调;大对象直通只认 Serial/ParNew;容器化记住三个 RAMPercentage 默认 25% / 1.5625% / 15%,显式 Xmx 会压过它们。

第 3 站

元空间参数:一个名字陷阱,一根保险丝

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 统一日志格式)长这样:

gc.log 片段 · 启动期元空间反复撞 20MB 阈值
# 启动第 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 别碰。名字陷阱 + 保险丝缺失,是元空间仅有的两个坑。

第 4 站

收集器参数:先选对收集器,再谈调参

调优的第一性原理:选对收集器,比调任何参数都重要——收集器定错了,参数调得再精也是南辕北辙。六个开关逐个过一遍(机制细节在「垃圾收集器全览」,这里只讲"什么时候用它"):

开关适用堆/CPU停顿特征一句话定位现状
-XX:+UseSerialGC堆 < 100MB / 单核STW,短单线程,简单到极致嵌入式、客户端
-XX:+UseParallelGC中大堆 / 多核STW,可较长吞吐量优先(Parallel Scavenge + Old)JDK 7/8 默认
-XX:+UseConcMarkSweepGC堆 < 8G / 多核并发标记,低停顿JDK8 时代的低延迟老生代方案JDK9 废弃,JDK14 移除
-XX:+UseG1GC4G~32G / 多核可预测停顿(软目标)Region 化,通用默认JDK9+ 默认
-XX:+UseZGC> 16G 或延迟极苛刻亚毫秒~几毫秒,与堆大小基本无关并发 + 读屏障/重定位屏障JDK11 EA,JDK15 转正
-XX:+UseShenandoahGC中大堆 / 多核并发整理,低停顿G1 的同类思路,用转发指针(BRO 卡)做并发压缩JDK12 EA,回炉到 JDK11
① 堆 < 100MB 且单核 CPU? Serial —— 嵌入式 / 单核小堆 ② 吞吐优先的批处理? Parallel —— ETL / 离线计算 ③ JDK8,低延迟,堆 < 8G? CMS(JDK9 废弃,JDK14 移除) JDK9+ 新项目别选,维护已终止 ④ P99 要求 < 10ms 或堆 > 16G? ZGC(JDK11 EA / JDK15+ 生产) Shenandoah 是备选 否(默认落点) G1 —— JDK9+ 默认 4G~32G 全能型,100ms 级停顿可接受
图 2 收集器选择决策树:从硬件(堆大小、CPU 核数)和延迟预算两个维度逐步排除,90% 的服务最后落在 G1
"既然 ZGC 停顿那么低,为什么不用 ZGC 全量替换 G1?"

答。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 两项。

第 5 站

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 配置

启动参数 · JDK 8 GC 日志 · 带轮转的生产配置
# 基础输出
-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+ 配置(统一日志)

启动参数 · JDK 9+ GC 日志 · 统一日志语法
# 统一日志: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 日志分析」。

第 6 站

诊断与排障参数:平时配好,出事不慌

诊断参数是 JVM 的黑匣子:平时占不了什么内存,出事时价值连城。核心原则一句话——和事配,别等出事再加,因为 OOM 和 JVM crash 发生的那一刻,你没有第二次启动的机会。

OOM 自动转储三件套

-XX:+HeapDumpOnOutOfMemoryError 配合 -XX:HeapDumpPath:一旦发生 OOM,JVM 在抛异常的同时自动写一份完整堆转储(效果等同 jmap -dump:live)。生产必配,两个细节决定它"关键时刻顶不顶用":其一,转储文件大小约等于堆已用大小,放在容量充足的独立磁盘,放在系统盘且系统盘只有 50G 时,一次 12G 的 OOM 转储直接写爆磁盘,雪上加霜;其二,转储要时间(大堆可能几十秒),期间进程还活着,别急着 kill。为什么必须自动?看下面这个追问:

"出 OOM 了,手动 jmap -dump 不就行了,为什么要配自动转储?"

答。三个现实问题。第一,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(轻量运行时)。

第 7 站

杂项参数 + 三套生产启动模板

最后四个"杂项"参数,个个短小,但都对应一类真实事故:

四个杂项参数

-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 服务场景,每个参数都带注释,直接拷走改数字

模板 A · 通用 Web · 8G 容器 / G1 / JDK11+
# 通用 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 的探测能力。

模板 B · 批处理 / ETL · 16G 容器 / Parallel / 吞吐优先
# 离线批处理: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)。

模板 C · 超低延迟 · 16G 容器 / ZGC / JDK17+
# 超低延迟服务: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 风险。三套模板的骨架完全一致——堆(相等)+ 元空间(阈值+保险丝)+ 收集器(场景选)+ 诊断(三件套),变的只有数字。

第 8 站

总结:知识地图 + 面试模板 + 高频追问

知识地图

本文全部知识点的结构(能默画出来就算内化):

  • 堆(五层控制):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 秒版(先说结论)

"我常用 30 个左右的参数,分六类:堆、容器、元空间、收集器、日志、诊断。最常用的:-Xms 等于 -Xmx 避免扩缩堆的 STW;-XX:MetaspaceSize 加 MaxMetaspaceSize,一个是首次 GC 阈值、一个是保险丝;收集器 90% 的场景用 G1,配 MaxGCPauseMillis=200ms;任何服务都配 OOM 自动转储三件套和带轮转的 GC 日志。容器化再加两个:MaxRAMPercentage 和 ActiveProcessorCount。最后我习惯用 -XX:+PrintFlagsFinal 验证最终生效值,不靠猜。"

3 分钟版(展开细节)

"第一层,收集器选择走决策树:堆小于 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 · 评论