JVM 参数调优经验:堆/栈/GC 参数最佳实践
面试官:生产环境的 JVM 参数你怎么配?
这个问题没有标准答案,但面试官想听的是:你有没有真正调过线上 JVM,踩过坑,知道每个参数为什么这么设。
直接背参数列表没用,关键是要理解每个参数背后的权衡——吞吐 vs 延迟、内存 vs CPU、易用性 vs 可控性。
一、堆内存参数:稳定压倒一切
1. -Xms 和 -Xmx 务必设成一样
-Xms4g -Xmx4g理由:JVM 在运行时扩容堆需要向操作系统申请连续内存,这涉及 mmap 系统调用和缺页中断。我们的生产环境实测:动态堆的 GC 停顿时间方差是固定堆的 3-5 倍(从 10ms 波动到 200ms)。原因:
- 扩容时触发
mmap,可能被 OS 调度打断 - 缩容时释放内存页,TLB 刷新的开销在 NUMA 架构下尤其明显
JVM 扩容/缩容时序图:
时间线 →
业务线程分配对象 → 堆空闲低于 MinHeapFreeRatio(40%)
→ JVM 触发 GC(如果 GC 后空闲仍低于阈值)
→ G1CollectedHeap::expand()
→ os::commit_memory()
→ mmap(MAP_ANONYMOUS) 系统调用
→ 虚拟内存分配成功,物理页未分配
→ 业务线程访问新分配内存 → Page Fault → 物理页分配
→ 引入不可预期的停顿(100μs-1ms 每页)面试追问:如果 -Xms 小于 -Xmx,JVM 什么时候会扩容? → 答:下一次 Minor GC 后,如果堆空闲率低于 40%(-XX:MinHeapFreeRatio 默认 40),且当前堆小于 -Xmx,则触发扩容。扩容走 G1CollectedHeap::expand() → os::commit_memory() → mmap(MAP_ANONYMOUS),整个过程需要 STW 吗?不需要 STW,但 page fault 发生在后续业务线程访问内存时,产生不可预期的停顿。
2. 新生代大小:-Xmn 或 -XX:NewRatio
# 方式一:显式指定新生代大小
-Xmn1.5g
# 方式二:比例控制
-XX:NewRatio=2 # 老年代:新生代 = 2:1,即新生代占 1/3核心权衡:新生代不是越大越好。我们有个数据服务,4 核 16G 堆,新生代从 6G 降到 4G 后,Minor GC 频率从 3 秒一次升到 2 秒一次,但每次停顿从 80ms 降到 20ms,P99 延迟反而降了 40%。因为新生代越小,每次 GC 需要扫描的存活对象越少。
经验公式:对于 Web 服务,新生代大小 ≈ 每秒对象分配量 × GC 间隔期望(秒)。比如每秒分配 200MB、期望 10 秒一次 Minor GC → 新生代 ≈ 2GB。实际根据 jstat -gcutil 的 YGC 频率反向调整。
如何计算每秒对象分配量(Allocation Rate):
# 方式一:jstat -gcutil 得出 YGCT/YGC 平均每次停顿时间
# 方式二:GC 日志
# [GC pause (G1 Evacuation Pause) (young) 512M->128M(2048M), 0.015s]
# 两次 GC 之间的间隔 = 15s → 每秒分配量 = (512M - 128M) / 15s ≈ 25MB/s
# 方式三:jstat -gc <pid> 1000
# EU(Eden Used)的变化量 / 时间间隔3. 元空间:不要忘了设上限
-XX:MetaspaceSize=256m -XX:MaxMetaspaceSize=256mJDK 8 元空间默认无上限(只受本地内存限制),这在 Spring Boot 热部署、CGLIB 动态代理频繁的场景下,元空间膨胀是 OOM 的常见原因之一。
MetaspaceSize 不是初始大小,是触发 GC 的阈值。 设为 256m 的意思是:当元空间使用量达到 256m 时,触发一次 Full GC 检查。如果还继续增长到 MaxMetaspaceSize,则 OOM。
实际案例:某团队用 Spring Cloud Gateway + 动态路由,每次更新路由规则通过 CGLIB 生成新类,一周后元空间膨胀到 1.2GB,触发了容器 OOMKill。加 -XX:MaxMetaspaceSize=256m 后,Full GC 触发频率从 0 次/天变为 5-10 次/天,但每次只有几十毫秒,代价可接受。
排查元空间泄漏的步骤:
# 1. 监控元空间使用量
jstat -gc <pid> 5000 | awk '{print $11}'
# 2. 开启类加载日志
-XX:+TraceClassLoading -XX:+TraceClassUnloading
# 3. 导出类加载历史
jcmd <pid> GC.class_stats4. 预分配物理内存
-XX:+AlwaysPreTouch启动时一次性分配物理内存,避免运行时因缺页中断(Page Fault)导致 GC 停顿。代价:启动时间增加 30-60 秒(16G 堆约 30 秒,64G 堆约 120 秒),而且容器会立刻占用指定内存,导致 Kubernetes 调度时的内存压力上升。
什么时候不加:容器内存敏感、快速扩缩容的 Serverless 场景。缺页中断的代价在大多数场景下远小于启动时间。
PreTouch 与容器化冲突的真实案例:某团队在 Kubernetes 上用 4 副本、每副本 8G 堆的服务,开了 AlwaysPreTouch 后,Node 节点内存立刻被占满,Pod 调度一直 Pending。排查发现 --memory request 设的是 8G,但 Node 预留内存不够同时给 4 个 Pod 分配物理内存。解决方案:要么关 PreTouch,要么增加 Node 资源或减副本数。
二、GC 选型详解:不只是速查表
选型速查表
| 堆大小 | JDK 版本 | 推荐 GC | 吞吐量 | 典型停顿 | 适用场景 |
|---|---|---|---|---|---|
| < 4GB | 8+ | Parallel | 最高(99%+) | 100-500ms | 批处理、离线计算 |
| 4-64GB | 11+ | G1 | 高(98-99%) | 50-200ms | 常规 Web 服务 |
| > 64GB | 17+ | ZGC | 中(95-98%) | <10ms | 低延迟场景、大内存服务 |
Parallel GC:被低估的王者
很多人面 Spring Boot 就直接上 G1,但 4G 以下堆 Parallel 的吞吐比 G1 高 5-10%。原因:
- Parallel GC 的 Young GC 是完全并行的,所有线程一起做标记-复制
- G1 的 Young GC 虽然也是并行,但多了 Region 级别的扫描和 Remembered Set 维护,有额外开销
实测数据:4G 堆、QPS 5000 的 Spring Boot 服务,Parallel GC 的 CPU 消耗比 G1 低 8%,GC 总时间减少 12%。
Parallel GC 的吞吐量公式:
吞吐量 = 应用运行时间 / (应用运行时间 + GC 总暂停时间)- -XX:GCTimeRatio=19 → 目标 GC 时间 ≤ 总时间的 5%(1/(1+19))
- -XX:MaxGCPauseMillis=200 → 目标停顿 ≤ 200ms(Parallel 对此参数做近似,不保证)
- 注意:Parallel 的
MaxGCPauseMillis是启发式的,不是硬约束。G1 才是硬约束。
G1 核心参数详解
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200 # 目标停顿时间
-XX:ParallelGCThreads=8 # 并行线程数,约为 CPU 核数的 5/8
-XX:ConcGCThreads=2 # 并发线程数,为 ParallelGCThreads 的 1/4
-XX:InitiatingHeapOccupancyPercent=45 # 触发并发标记的堆占用阈值G1 的 Region 设计:堆被分成 1MB-32MB 的 Region(根据堆大小自动计算,公式 region_size = 1MB << log2(heap_size / 2048))。每个 Region 被标记为 E(Eden)、S(Survivor)、O(Old)或 H(Humongous,大对象)。G1 的 Young GC 只回收 Eden Region,Mixed GC 回收 Old Region 和 Eden Region 一起,这就是 G1 不需要像 CMS 那样做 Full GC 的原因。
G1 Region 分配示意图:
堆内存(16GB,Region Size = 4MB)
┌────┬────┬────┬────┬────┬────┬────┬────┬────┬────┬────┬────┬────┬────┬────┬────┐
│ E │ E │ E │ E │ E │ S │ O │ O │ O │ O │ O │ O │ H │ H │ H │ H │
│ │ │ │ │ │ │ │ │ │ │ │ │(3M)│(3M)│(3M)│(3M)│
│ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │
│ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │
│ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │
│ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │
└────┴────┴────┴────┴────┴────┴────┴────┴────┴────┴────┴────┴────┴────┴────┴────┘
#0 #1 #2 #3 #4 #5 #6 #7 #8 #9 #10 #11 #12 #13 #14 #15
Eden Eden Eden Eden Eden Surv Old Old Old Old Old Old Hum Hum Hum HumIHOP(-XX:InitiatingHeapOccupancyPercent) 是 G1 调优最关键的参数。默认 45% 意味着整个堆占用达到 45% 时启动并发标记周期。
G1 的并发标记流程(含时序):
时间 ──────────────────────────────────────────────────────────────►
│ Initial Mark (STW) │ Concurrent Mark │ Remark (STW) │ Cleanup (STW) │
│ GC Roots 标记 │ 遍历堆标记存活 │ 处理引用变更 │ 计算 Region │
│ 停顿: 5-10ms │ 耗时: 1-3s │ 停顿: 10-50ms │ 停顿: 1-5ms │
│ │ (并发,不 STW) │ │ │
└──────────────────────┴───────────────────┴─────────────────┴─────────────────┘
在此期间业务线程继续分配对象到老年代
如果老年代满了 → Concurrent Mode Failure → Full GCIHOP 调高 vs 调低的影响:
- 调低(如 35%):更早开始并发标记,GC 更频繁,CPU 开销大,但能避免 Concurrent Mode Failure
- 调高(如 55%):推迟并发标记,但如果标记周期内老年代被填满,退化为 Full GC,停顿秒级
判断方法:看 GC 日志中的 initial-mark 触发时老年代占用。如果 initial-mark 触发时老年代已占 70%+,说明 IHOP 太低(标记周期还没完成老年代就满了);如果 initial-mark 频繁触发但老年代始终不到 50%,说明 IHOP 太高,太早触发标记浪费 CPU。
ZGC:彩色指针和染色指针
-XX:+UseZGC -Xms16g -Xmx16g -XX:ConcGCThreads=4ZGC 的核心创新是染色指针(Colored Pointers):在 64 位指针的高位借 4 bit 做状态标记(Finalizable、Remapped、Marked0、Marked1),GC 过程中通过指针着色来标记可达性,不需要对象头里的标记位,所以不需要 STW 去刷标记位。
64 位染色指针 bit 布局:
63 62 61 60 59-48 47-0
┌───┬───┬───┬───┬──────┬──────────────────────────────────────┐
│ F │ R │ M0│ M1│ 保留 │ 地址偏移 │
└───┴───┴───┴───┴──────┴──────────────────────────────────────┘
│ │ │ │
│ │ │ └─ Marked1:标记位 1
│ │ └───── Marked0:标记位 0
│ └───────── Remapped:已重映射
└───────────── Finalizable:终结器引用ZGC 的并发压缩:ZGC 在并发阶段做内存压缩,通过 remap 指针来更新对象引用。这要求指针在并发访问时是原子的——64 位机器上指针读写本来就是原子的,所以 ZGC 不需要像 G1 那样用 Remembered Set 来跟踪跨 Region 引用。
ZGC 并发标记流程:
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ Pause │ │ Concurrent│ │ Pause │ │ Concurrent│ │ Pause │
│ Mark Start│───►│ Mark │───►│ Mark End │───►│ Relocate │───►│ Relocate │
│ (STW) │ │ (并发) │ │ (STW) │ │ (并发) │ │ End(STW)│
│ <1ms │ │ │ │ <1ms │ │ │ │ <1ms │
└──────────┘ └──────────┘ └──────────┘ └──────────┘ └──────────┘代价:ZGC 的吞吐比 G1 低 3-5%(因为并发标记和 remap 的 CPU 开销),且内存占用更高(多颜色指针需要额外内存对齐)。只有延迟敏感且堆 > 64GB 的场景才值得上 ZGC。
三、日志参数:线上必加
# JDK 9+
-Xlog:gc*:file=gc.log:time,uptime,level,tags
# JDK 8
-XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:gc.log
# 必加
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/path/to/dump.hprofHeapDumpOnOutOfMemoryError 是救命参数——OOM 时自动 dump 堆,事后分析泄漏根因,比 OOM 后手动 jmap 靠谱得多。
GC 日志怎么读:用 gceasy.io 上传 gc.log,它会自动生成:
- 吞吐量:应用运行时间 / (应用运行时间 + GC 总时间)
- Pause Duration:每次 GC 停顿的分布(P50/P95/P99/Max)
- Concurrent Mode Failure:如果出现,说明 IHOP 或堆大小不合理
- Object Allocation Rate:每秒分配量,用于判断新生代大小是否合理
如何手动解析 GC 日志(一条 G1 Young GC 日志拆解):
[GC pause (G1 Evacuation Pause) (young) 512M->128M(4096M), 0.025s]
[GC pause (G1 Evacuation Pause) (young) 640M->160M(4096M), 0.030s]- 512M → 128M:GC 前堆使用 512MB,GC 后 128MB,回收了 384MB
- (4096M):总堆大小
- 0.025s:停顿时间 25ms
- 对 512M 和 640M 做差:每次 GC 前堆使用量在增加,说明分配速率 > GC 回收速率
- 如果连续几次 GC 后堆使用量持续上升,说明老年代在被填满
GC 日志中的危险信号:
# 危险信号 1:Concurrent Mode Failure
[Full GC (Allocation Failure) 15.8G->1.2G(16G), 8.5s]
# 危险信号 2:To-space overflow(G1 特有)
[GC pause (G1 Evacuation Pause) (young) (to-space overflow) 6.8G->6.5G(16G), 0.5s]
# 说明 Survivor 区放不下晋升对象,部分对象直接进入老年代
# 危险信号 3:Humongous Allocation 频繁触发 concurrent mark
[GC pause (G1 Humongous Allocation) (young) (initial-mark) 7.2G->3.5G(16G), 0.3s]
# 频繁出现 → 大对象分配有问题四、调优案例:G1 的 IHOP 调优实战
场景
某支付服务,堆 16GB,JDK 11 + G1。GC 日志显示:
[GC pause (G1 Evacuation Pause) (young) 500M->200M(16G), 0.015s]
[GC pause (G1 Evacuation Pause) (young) 600M->250M(16G), 0.018s]
...
[GC pause (G1 Humongous Allocation) (young) (initial-mark) 7.2G->3.5G(16G), 0.3s]
[GC concurrent-root-region-scan-start]
[GC concurrent-mark-start]
...
[Full GC (Allocation Failure) 15.8G->1.2G(16G), 8.5s]问题:Full GC 出现了,停顿 8.5 秒,服务超时率 100%。
分析:IHOP 默认 45%,但老年代在并发标记周期还没完成时就被填满了,触发 Concurrent Mode Failure,退化为 Serial Old 做 Full GC。
详细排查:
- 看 GC 日志中的
initial-mark触发时老年代大小:7.2GB 堆占用,16GB 堆 - 新生代占 6GB → 老年代约 10GB。7.2GB 中老年代贡献约 7.2 - 6 = 1.2GB(Eden 在 initial-mark 前已有一部分被回收)→ 实际老年代占用约 72% - 并发标记周期持续约 2 秒,这 2 秒内业务分配了约 400MB 对象,其中大部分晋升到老年代
- 老年代从 72% 升到 85%+,触发 Concurrent Mode Failure
调优:
-XX:InitiatingHeapOccupancyPercent=35
-XX:G1ReservePercent=15 # 预留 15% 的堆空间,给浮动垃圾用为什么降 IHOP 而不是扩堆:这台机器只有 16GB 可用,扩堆要动容器规格。而且问题的核心是并发标记周期太短,没来得及完成。降低 IHOP 让标记更早开始,标记周期在 2 秒内完成,老年代还没满就结束了。
结果:Full GC 消失,最大停顿从 8.5s 降到 120ms,P99 延迟从 5s 降到 200ms。
五、G1 和 CMS 的对比:为什么 G1 取代了 CMS
| 维度 | CMS | G1 |
|---|---|---|
| 堆布局 | 连续分代 | 不连续 Region(1-32MB) |
| 并发标记 | 标记-清除 | 标记-复制(Mixed GC) |
| 内存碎片 | 严重,最终需要 Full GC 整理 | 无碎片,Region 复制自动整理 |
| 停顿可预测性 | 差(CMS 的 Remark 阶段不可控) | 好(通过 MaxGCPauseMillis 控制) |
| 大对象处理 | 直接进老年代 | 通过 Humongous Region 管理 |
| JDK 支持 | JDK 9 被标记为 Deprecated,JDK 14 移除 | 默认 GC(JDK 9+) |
CMS 的致命问题:CMS 的 Remark 阶段需要扫描整个老年代,如果老年代中有大量跨代引用,Remark 停顿会从几十毫秒飙升到几秒。G1 通过 Remembered Set 精确跟踪跨 Region 引用,Remark 只扫描变更的 Region,停顿时间与堆大小无关。
CMS 的碎片化演进过程:
时间线 →
初始状态:老年代连续空间
│ 分配 A(2MB) B(3MB) C(1MB) D(4MB) E(2MB)
│ [A][B][C][D][E]
│ GC 回收 B、D
│ [A][ ][C][ ][E] ← 出现碎片
│ 分配 F(4MB) — 需要连续 4MB 但找不到 → Full GC
│ [A][C][E][F] ← Full GC 整理后
│ 循环往复,碎片越来越多G1 通过 Region 复制规避了这个问题:不管对象在原 Region 中如何分布,Mixed GC 直接把存活对象复制到另一个 Region,永远不产生碎片。
六、实战红线:不要抄参数模板
网上流传的"JVM 参数模板"大多是给测试环境用的,直接搬到生产上会出事:
# ❌ 不要无脑抄这个
-Xms4g -Xmx4g -Xmn2g -XX:+UseG1GC -XX:MaxGCPauseMillis=50问题:
- 新生代 2g 太大了,老年代只剩 2g,大对象直接进老年代,老年代很快满
MaxGCPauseMillis=50太激进,G1 为了满足 50ms 目标,每次只回收很少的 Region,反而导致 GC 频率上升- 没有 IHOP、日志、HeapDump 参数
正确做法:每次只改一个参数,通过 A/B 对比验证。工具推荐:
- GC 日志分析:
gceasy.io或GCViewer - 实时监控:Prometheus + Grafana(
jvm_memory_used_bytes、jvm_gc_pause_seconds) - 在线诊断:Arthas 的
dashboard和vmtool
一个完整的生产级 JVM 参数模板(16G 堆,G1):
# 堆内存
-Xms16g -Xmx16g
-XX:MaxMetaspaceSize=256m
# GC 选型
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200
-XX:InitiatingHeapOccupancyPercent=35
-XX:G1ReservePercent=15
-XX:ParallelGCThreads=8
-XX:ConcGCThreads=2
# 日志
-Xlog:gc*:file=gc-%t.log:time,uptime,level,tags:filecount=5,filesize=50m
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/opt/app/dumps
# 其他
-XX:+AlwaysPreTouch
-XX:+DisableExplicitGC
-XX:+PrintCommandLineFlags
-XX:+ExitOnOutOfMemoryError参数说明:
-XX:+DisableExplicitGC:屏蔽System.gc(),防止 RMI 或 NIO 框架触发 Full GC-XX:+ExitOnOutOfMemoryError:OOM 时直接退出,让 Kubernetes 重启 Pod,避免僵尸进程-XX:+PrintCommandLineFlags:启动时打印所有被修改的 JVM 参数,方便排查
七、面试高频追问
追问 1:-Xss 设置多大合适?
默认 1MB(JDK 11+ Linux x64)。线程栈深度取决于递归深度和方法栈帧大小。对于大多数 Web 服务,256KB 就够,64KB 的 netty worker 线程都没问题。
经验公式:线程数 × 栈大小 ≤ 容器内存 × 20%。比如 4GB 容器、200 个线程,每个线程栈 1MB 就占 200MB,如果容器还有堆 3GB + 元空间 256MB,留给线程栈的空间不足。这时应该把 -Xss256k 改小。
如何测试栈深度:
// 测试当前栈大小下的最大递归深度
public class StackDepthTest {
private int depth = 0;
public void recursiveCall() {
depth++;
recursiveCall();
}
public static void main(String[] args) {
StackDepthTest test = new StackDepthTest();
try {
test.recursiveCall();
} catch (StackOverflowError e) {
System.out.println("Max depth: " + test.depth);
}
}
}
// -Xss256k → 深度约 2000
// -Xss1m → 深度约 8000
// -Xss2m → 深度约 16000追问 2:G1 的 Humongous 对象分配有什么坑?
G1 把超过 Region 大小 50% 的对象当作 Humongous,直接分配在连续的 Humongous Region(Old 区)。Humongous 分配不会触发 Young GC,而是直接触发 concurrent mark。
实战踩坑:某服务用 byte[8MB] 做缓存,8MB 在 16GB 堆中 Region 大小是 4MB,8MB 超过 50% → 都是 Humongous。每次分配都触发 concurrent mark,CPU 增加了 20%。解决方案:把大对象拆成 1MB 的 chunk,放回 Eden 正常分配。
代码层面的解决方案:
// ❌ 踩坑:8MB byte[] 触发 Humongous 分配
byte[] cache = new byte[8 * 1024 * 1024];
// ✅ 正确:拆分成 1MB 的 chunk,Eden 正常分配
byte[][] cacheChunks = new byte[8][];
for (int i = 0; i < 8; i++) {
cacheChunks[i] = new byte[1024 * 1024];
}
// 或者用 DirectBuffer 直接分配堆外内存
ByteBuffer buffer = ByteBuffer.allocateDirect(8 * 1024 * 1024);Humongous 的另一个坑:Humongous Region 不会在 Young GC 中被回收,只在 concurrent mark 中被回收。如果 Humongous 对象是短命的,就浪费了很大空间。短命大对象 → 直接 OOM 的常见原因。
追问 3:-XX:+UseAdaptiveSizePolicy 要不要开?
Parallel GC 默认开启,G1 默认关闭。建议生产环境关闭。自适应策略会动态调整新生代大小,虽然理论上能自动适应负载,但实际观测到:负载波动时,AdaptiveSizePolicy 调整滞后,导致新生代大小频繁震荡,GC 停顿时间方差变大。
AdaptiveSizePolicy 的副作用案例:某定时任务服务,每天凌晨 3 点跑批处理,白天空闲。开启 AdaptiveSizePolicy 后,白天新生代缩到 500MB,凌晨 3 点任务启动时分配量激增,新生代还来不及扩容,频繁 Minor GC,任务延迟从 5 分钟膨胀到 30 分钟。关闭后固定新生代大小,问题消失。
追问 4:-XX:+UseCompressedOops 和 -XX:+UseCompressedClassPointers 什么时候失效?
这两个默认开启(JDK 8+),在堆 ≤ 32GB 时有效。堆超过 32GB 时,压缩指针失效,每个对象引用从 4 字节回归 8 字节,内存占用增加约 15-20%。
# 堆 32GB 时,压缩指针开启
-Xms32g -Xmx32g # 压缩指针有效
# 堆 33GB 时,压缩指针自动关闭
-Xms33g -Xmx33g # 压缩指针失效,内存占用增加 15%所以堆大小有一道"暗线":32GB 以下用压缩指针,32GB 以上膨胀。如果你需要 35GB 有效堆,建议设 32GB 然后用堆外内存补,或者直接上 64GB 堆并接受 15% 的额外开销。
追问 5:G1 的 -XX:G1HeapRegionSize 怎么设?
默认不设,JVM 自动计算。但手动设置可以控制 Humongous 阈值:
# 16G 堆,自动 Region Size = 4MB
# Humongous 阈值 = 4MB * 50% = 2MB
# 超过 2MB 的对象都走 Humongous 分配
# 手动设 8MB
-XX:G1HeapRegionSize=8m
# Humongous 阈值 = 8MB * 50% = 4MB
# 大对象进 Humongous 的门槛提高了什么时候手动设:如果业务中有大量 3-4MB 的对象,建议把 Region Size 调大,让它们走正常分配而不是 Humongous。但 Region Size 越大,G1 的并行粒度越粗,停顿可能变长。
八、总结
JVM 参数调优的核心原则就一句话:先监控,再调优,不盲目。
- 堆内存:
-Xms=-Xmx,元空间设上限,必加HeapDumpOnOutOfMemoryError - GC 选型:小堆用 Parallel,中堆用 G1,大堆用 ZGC,不要无脑 G1
- G1 核心:IHOP 是调优的关键,看 GC 日志判断高低,Humongous 对象要小心
- 日志:GC 日志 + HeapDump 是线上故障排查的起点
- 红线:不要复制网上参数模板,每次只改一个参数,用 A/B 验证
面试官如果追问"遇到过什么调优案例",把上面那个支付服务的 IHOP 调优说清楚,再补一个 Humongous 对象的坑,比背十个参数名更有说服力。如果面试官问起 JVM 参数调优的盲区,可以提压缩指针的 32GB 暗线和 -XX:+DisableExplicitGC 的必要性,这两个点能展示你真的踩过生产坑。
最后的建议:把本节生成的完整参数模板保存到你的项目仓库里,每次新服务上线直接复制改堆大小和 IHOP 即可。但记得每次只改一个参数,上线后观察 GC 日志一周再调下一个参数。