前言
JVM 调优面试题,开场往往是:
线上服务 Full GC 频繁,你怎么排查和调优?
90% 的人回答:加堆内存、换 G1。
但面试官追问几个问题就卡壳:
- 调优前必须监控哪些指标? ------ 很多人只盯着堆内存
- Young GC 频繁和 Full GC 频繁的根因一样吗? ------ 调优方向完全不同
- JVM 监控告警体系应该怎么做? ------ 不只是"OOM 时告警"
- 容器环境 OOM Killed 和 JVM OOM 有什么区别? ------ 一个被系统杀,一个被 JVM 抛异常
这篇文章不堆参数,而是给出一套从监控 → 定位 → 调优 → 验证的完整方法论,并结合真实场景给出可直接落地的参数模板。
一、📋 JVM 核心组成与调优对象
1.1 四大模块
JVM 整体可分为四大模块,其中运行时数据区 和垃圾回收系统是调优的核心对象:

| 模块 | 核心职责 | 是否参与调优 |
|---|---|---|
| 类加载子系统 | 加载 class 文件到内存 | 间接(元空间大小) |
| 运行时数据区 | 对象存储、方法执行 | 核心 |
| 执行引擎 | 解释 + JIT 编译执行字节码 | 间接(编译线程数) |
| 垃圾回收系统 | 自动回收无用对象 | 核心 |
1.2 运行时数据区

| 区域 | 线程 | 存储内容 | 常见异常 |
|---|---|---|---|
| 堆 | 共享 | 对象实例、数组 | Java heap space |
| 方法区/元空间 | 共享 | 类元数据、常量池 | Metaspace |
| 虚拟机栈 | 私有 | 栈帧(局部变量、操作数栈) | StackOverflowError |
| 本地方法栈 | 私有 | Native 方法栈帧 | StackOverflowError |
| 程序计数器 | 私有 | 当前字节码行号 | 唯一不会 OOM |
1.3 堆内存分代结构
堆是 GC 的主战场,采用分代设计:

对象晋升老年代的 4 种情况:
- 年龄达到阈值 :默认 15(
-XX:MaxTenuringThreshold) - 大对象直接晋升 :超过
-XX:PretenureSizeThreshold - Survivor 放不下:Minor GC 时存活对象 > Survivor 容量
- 动态年龄判断:Survivor 同年龄对象总大小 > 50%
二、🔍 GC 算法与回收器速览
2.1 判定垃圾:可达性分析
从 GC Roots 出发向下搜索,不可达的对象即为可回收垃圾。
GC Roots 包括:
- 虚拟机栈中引用的对象
- 方法区中静态变量引用的对象
- 常量引用的对象
- 本地方法栈中 JNI 引用的对象
- 被同步锁持有的对象
2.2 三种基础 GC 算法
| 算法 | 思路 | 优点 | 缺点 | 适用区域 |
|---|---|---|---|---|
| 复制算法 | 存活对象复制到空白区域,清空原区域 | 无碎片、速度快 | 浪费空间 | 年轻代 |
| 标记-清除 | 标记存活对象,清除未标记对象 | 不需要移动对象 | 产生碎片 | 老年代(CMS) |
| 标记-整理 | 标记后把存活对象移到一端 | 无碎片 | 移动对象开销大 | 老年代(Parallel Old/G1) |
2.3 主流垃圾回收器
| 回收器 | 特点 | 适用场景 | JDK |
|---|---|---|---|
| Serial | 单线程串行回收 | 客户端程序、小内存 | 任意 |
| Parallel | 多线程并行,吞吐量优先 | 后台批处理、计算密集型 | JDK 8 默认 |
| CMS | 并发标记清除,低停顿 | 已废弃,JDK 14 删除 | 5-14 |
| G1 | 分区 + 分代,可预测停顿 | 绝大多数在线业务 | 9+ 默认 |
| ZGC | 并发回收,亚毫秒级停顿 | 大内存、极致低延迟 | 15+ 正式 |
JDK 17 的选择主要在 G1 和 ZGC 之间。CMS 已删除,Parallel 适合吞吐优先但暂停长。
三、💻 对象分配与 GC 过程实例
3.1 一个对象到底存在哪?
基于 JDK 8+ ,看一段代码:
arduino
public class MemoryDemo {
// 静态变量引用存在 Class 对象(堆),指向的对象实体在堆
private static User staticUser = new User("静态用户");
public void testMethod() {
// 基本类型 → 栈的局部变量表
int num = 100;
// 对象引用存栈,new 出的对象实体存堆
User localUser = new User("本地用户");
// 字符串字面量:对象在堆的字符串常量池,引用在栈
String str = "test_str";
// 数组对象实体在堆,引用在栈
int[] arr = new int[10];
// Native 方法调用时使用本地方法栈
nativeMethod();
}
public native void nativeMethod();
static class User {
private String userName;
public User(String userName) { this.userName = userName; }
}
}
| 元素 | 存储位置 |
|---|---|
new User(...) 对象本体 |
堆 |
staticUser 引用 |
堆中 Class 对象 |
localUser、str、arr 引用 |
虚拟机栈(局部变量表) |
num = 100 |
虚拟机栈(基本类型值) |
"test_str" 字符串对象 |
堆(字符串常量池) |
MemoryDemo.class、User.class 元数据 |
元空间 |
testMethod() 字节码 |
元空间 |
nativeMethod() 执行 |
本地方法栈 |
核心区别:堆存对象本体,栈存引用/基本类型值,元空间存类描述。
3.2 Young GC 完整执行过程
假设 JVM 参数:
ini
-Xms20M -Xmx20M -Xmn10M -XX:SurvivorRatio=8 -XX:MaxTenuringThreshold=15
- 堆总大小 20MB,年轻代 10MB,老年代 10MB
- 年轻代:Eden 8MB,S0/S1 各 1MB
阶段 1:对象分配到 Eden
arduino
// 每个数组 512KB,连续分配 14 个 → 7MB
byte[] b1 = new byte[512 * 1024];
byte[] b2 = new byte[512 * 1024];
// ... b3 ~ b14
- 14 个对象全部在 Eden 区分配,Eden 已用 7MB,剩余 1MB
- S0、S1 均为空
阶段 2:Eden 不足,触发第一次 Young GC
arduino
// 再分配 2MB,Eden 剩余 1MB 放不下
byte[] b15 = new byte[2 * 1024 * 1024];
触发 Young GC:
- 标记存活对象 :从 GC Roots 出发,假设只有
b1、b2被引用(共 1MB) - 复制存活对象到 S0 :把
b1、b2复制到空的 S0 区,年龄设为 1 - 清空 Eden:整个 Eden 标记为空闲
- 分配新对象 :把
b15(2MB)放入 Eden - 角色互换:S0 变 From 区,S1 变 To 区

阶段 3:对象晋升老年代
每经历一次 Young GC,存活对象年龄 +1。当年龄达到 15 时,下一次 GC 会直接复制到老年代。
补充两种特殊晋升:
- 大对象直接晋升 :超过
-XX:PretenureSizeThreshold直接在老年代分配- 动态年龄判定:Survivor 同年龄对象总大小 > 50%,年龄 ≥ 该值的对象直接晋升
3.3 为什么需要两个 Survivor 区?
单 Survivor 的问题:回收后会产生碎片。Survivor 里既有存活对象又有死亡对象留下的空位,无法保证内存连续,违背复制算法初衷。
两个 Survivor 的设计:永远保持一个完全空闲,另一个存放存活对象。每次 GC 把 Eden + From 的存活对象统一复制到空的 To 区,紧密排列,零碎片。
| 方案 | 优点 | 缺点 |
|---|---|---|
| 只有 Eden | 简单 | 存活对象直接晋升老年代,Full GC 频繁 |
| Eden + 1 Survivor | 有缓冲带 | 产生碎片,复制算法优势丧失 |
| Eden + 2 Survivor | 零碎片 + 高利用率 | 浪费 10% 空间(默认 8:1:1) |
| Eden + 3+ Survivor | 更复杂 | 收益不明显,空间被拆小 |
四、🚨 JVM 监控告警体系
4.1 监控层级

4.2 底线级监控
| 监控项 | 告警规则 | 意义 |
|---|---|---|
| Java 进程存活 | 进程消失立即告警 | 发现崩溃/OOM Killer |
| 进程 CPU 使用率 | 持续 5 分钟 > 80% 预警 | 死循环/GC 占用 CPU |
| 进程 RSS 内存 | 持续超过容器上限 85% 告警 | 避免堆外内存泄漏被系统杀 |
| 文件描述符 | 超过系统上限 80% 告警 | 连接/文件流泄漏 |
4.3 核心级监控
| 监控项 | 告警规则 | 意义 |
|---|---|---|
| 堆内存使用率 | > 80% 持续 3 分钟预警 | OOM 前兆 |
| 老年代使用率(Full GC 后) | > 75% 预警,> 85% 严重 | 内存泄漏/容量不足 |
| 元空间使用率 | > 80% 持续告警 | 动态类生成过多 |
| Young GC 频率 | > 10 次/分钟 预警 | Eden 过小/临时对象过多 |
| Full GC 频率 | 1 小时 ≥ 1 次预警 | 系统卡顿元凶 |
| 单次 GC 停顿 | Young GC > 100ms / Full GC > 1s 告警 | 响应抖动 |
4.4 稳定性与故障级监控
| 监控项 | 告警规则 | 意义 |
|---|---|---|
| 线程总数 | 持续增长或超过阈值 | 线程泄漏 |
| BLOCKED 线程数 | 持续 > 50 告警 | 严重锁竞争 |
| 死锁 | 检测到立即告警 | 业务功能失效 |
| 类加载数 | 持续增长无回落 | 元空间泄漏前兆 |
| OOM 异常 | 出现立即最高优先级告警 | 故障已经发生 |
4.5 常用监控工具栈
csharp
# 指标采集
JMX / Prometheus + JMX Exporter
# 可视化
Grafana(内置 JVM 监控模板)
# 线上诊断
Arthas / async-profiler / JFR
# GC 日志分析
GCViewer / GCEasy
# 堆内存分析
Eclipse MAT
五、🛠️ JVM 调优五步法和参数
5.1 调优五步法

| 步骤 | 做什么 | 工具 |
|---|---|---|
| 采集基线 | GC 次数、停顿、内存使用率、业务 RT/QPS | jstat、GC 日志、监控 |
| 定位问题 | Young GC 频繁?Full GC 频繁?停顿长?OOM? | 现象对照表 |
| 分析根因 | 内存泄漏?对象晋升过快?大对象?参数不合理 | jmap + MAT |
| 制定方案 | 代码优化 + 参数调整 + 架构优化 | - |
| 压测验证 | 灰度对比前后指标 | 压测平台 |
5.2 现象 → 根因对照表
| 现象 | 核心问题方向 | 排查重点 |
|---|---|---|
| Young GC 频繁、单次停顿短 | 新生代过小 / 临时对象过多 | Eden 容量、对象创建速率 |
| Full GC 频繁、老年代持续上涨 | 内存泄漏 / 对象过早晋升 / 堆不足 | 堆快照、晋升速率 |
| GC 单次停顿时间过长 | 回收器选型错误 / 堆过大 / 大对象 | GC 日志、Region 大小 |
| OOM | 内存不足 / 泄漏 / 栈深 / 元空间耗尽 | OOM 类型、堆快照 |
5.3 常用调优参数
堆内存基础配置
ini
# 初始堆内存,建议与最大值一致
-Xms4g
# 最大堆内存
-Xmx4g
# 新生代大小
-Xmn1.5g
# Eden 与 Survivor 比例,默认 8:1:1
-XX:SurvivorRatio=8
# 对象晋升老年代的年龄阈值
-XX:MaxTenuringThreshold=15
# 大对象直接晋升阈值
-XX:PretenureSizeThreshold=1m
元空间配置(JDK 8+)
ini
-XX:MetaspaceSize=256m
-XX:MaxMetaspaceSize=512m
垃圾回收器选型
ruby
# 吞吐量优先:Parallel(JDK 8 默认)
-XX:+UseParallelGC
-XX:+UseParallelOldGC
# 低延迟优先:G1(JDK 9+ 默认,推荐在线业务)
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200
# 极致低延迟:ZGC(JDK 15+ 正式)
-XX:+UseZGC
# JDK 21+ 分代 ZGC
-XX:+ZGenerational
日志与诊断配置
ruby
# JDK 9+ 开启 GC 详细日志,按大小滚动
-Xlog:gc*,gc+age=trace:file=gc-%t.log:tags,uptime,time,level:filecount=10,filesize=100m
# OOM 时自动生成堆转储
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/var/log/heapdump.hprof
# 禁用显式 System.gc()
-XX:+DisableExplicitGC
5.4 生产启动参数模板
JDK 17 + G1 通用模板:
ruby
java \
-Xms4g -Xmx4g \
-Xmn1g \
-XX:MetaspaceSize=256m -XX:MaxMetaspaceSize=512m \
-XX:+UseG1GC \
-XX:MaxGCPauseMillis=200 \
-XX:G1HeapRegionSize=16m \
-XX:+HeapDumpOnOutOfMemoryError \
-XX:HeapDumpPath=/var/log/heapdump.hprof \
-Xlog:gc*,gc+heap=trace:file=/var/log/gc.log:time,uptime,level,tags:filecount=10,filesize=100m \
-jar app.jar
JDK 21 + 分代 ZGC 模板:
ruby
java \
-Xms8g -Xmx8g \
-XX:+UseZGC \
-XX:+ZGenerational \
-XX:MaxGCPauseMillis=50 \
-XX:+HeapDumpOnOutOfMemoryError \
-Xlog:gc*:file=/var/log/gc.log:time,uptime,level,tags:filecount=10,filesize=100m \
-jar app.jar
容器环境模板:
ruby
java \
-XX:+UseContainerSupport \
-XX:InitialRAMPercentage=50 \
-XX:MaxRAMPercentage=70 \
-XX:+UseG1GC \
-XX:MaxGCPauseMillis=200 \
-jar app.jar
六、🎯 典型场景调优方案
场景 1:Young GC 频繁,接口响应抖动
现象:Young GC 每秒数次,单次停顿几十毫秒,接口 RT 波动大。
优化方向:
ini
# 1. 调大新生代
-Xmn2g
# 2. 调整 Survivor 比例
-XX:SurvivorRatio=8
# 3. 代码层减少临时对象
ini
// ❌ 错误:循环内大量创建临时对象
for (int i = 0; i < list.size(); i++) {
String temp = new String("item" + i); // 每次循环都 new
}
// ✅ 正确:复用对象或使用 StringBuilder
StringBuilder sb = new StringBuilder();
for (int i = 0; i < list.size(); i++) {
sb.append("item").append(i);
}
场景 2:Full GC 频繁,系统周期性卡顿
现象:数分钟一次 Full GC,每次停顿数百毫秒到数秒。
排查与优化:
ini
# 1. 导出堆快照
jmap -dump:live,format=b,file=heap.hprof <pid>
# 2. MAT 分析 Leak Suspects
# 3. 常见根因:ThreadLocal 未 remove、静态集合只增不减、缓存无过期
csharp
// ❌ 错误:ThreadLocal 未清理
threadLocal.set(user);
// ✅ 正确:finally 中 remove
try {
threadLocal.set(user);
} finally {
threadLocal.remove();
}
场景 3:容器环境 OOM Killed
现象:K8s Pod 频繁 OOM Killed,应用未输出 OOM 日志。
根因 :JVM 没感知容器内存限制,-Xmx4g 实际使用超过容器 limit。
ruby
# ❌ 错误:容器 limit 4g,但 -Xmx4g 加上元空间/直接内存后超出
-Xmx4g
# ✅ 正确:让 JVM 感知容器内存
-XX:+UseContainerSupport
-XX:MaxRAMPercentage=70
场景 4:GC 暂停时间过长
现象:G1 暂停 500ms+,超过 MaxGCPauseMillis 目标。
ini
# 降低目标暂停时间
-XX:MaxGCPauseMillis=100
# 减小 Region 大小,每次回收数量少
-XX:G1HeapRegionSize=8m
# 增加并行 GC 线程数
-XX:ParallelGCThreads=16
场景 5:元空间 OOM
现象 :报错 java.lang.OutOfMemoryError: Metaspace,常见于动态代理、CGLIB、热部署场景。
排查:
bash
# 查看类加载统计
jcmd <pid> VM.classloader_stats
# 查看元空间使用详情
jcmd <pid> VM.metaspace
优化:
ini
# 1. 设上限,避免耗尽系统内存
-XX:MaxMetaspaceSize=512m
# 2. 排查重复类加载、动态类未卸载
# 3. 使用弱引用缓存动态生成的类
场景 6:堆外内存泄漏
现象:RSS 持续增长,堆内存正常,最终容器 OOM Killed。常见于 Netty/NIO 使用直接内存未释放。
排查:
ini
# 开启 Native Memory Tracking(NMT)
-XX:NativeMemoryTracking=summary
# 查看堆外内存使用
jcmd <pid> VM.native_memory summary
java
// ❌ 错误:手动分配直接内存未释放
ByteBuffer buffer = ByteBuffer.allocateDirect(1024 * 1024);
// 忘记释放
// ✅ 正确:使用 Netty 池化 ByteBuf,自动回收
ByteBuf buf = PooledByteBufAllocator.DEFAULT.buffer(1024);
try {
// 使用 buf
} finally {
buf.release(); // 必须释放
}
场景 7:线程数过多
现象 :报错 java.lang.OutOfMemoryError: Unable to create new native thread。
根因:线程泄漏、线程池配置过大、未使用线程池。
perl
# 查看线程数
jstack <pid> | grep -c "java.lang.Thread.State"
# 查看线程状态分布
jstack <pid> | grep "java.lang.Thread.State" | sort | uniq -c
优化:
bash
# 减小栈大小以支持更多线程
-Xss512k
# 限制线程池最大线程数
# 业务线程池:corePoolSize、maximumPoolSize 合理化
scss
// ❌ 错误:每次请求都 new Thread
new Thread(() -> { ... }).start();
// ✅ 正确:使用线程池
ExecutorService executor = Executors.newFixedThreadPool(50);
executor.submit(() -> { ... });
场景 8:接口响应时间周期性抖动
现象:接口 RT 每隔几秒或几分钟出现一次尖峰。
排查思路:
- 先看 GC 日志,确认是否与 GC 停顿时间吻合
- 再看 Young GC 频率,判断是否 Eden 过小
- 最后看是否有 Full GC
ruby
# 开启 GC 日志并记录时间戳
-Xlog:gc*:file=/var/log/gc.log:time,uptime,level,tags
scss
# 典型症状:Young GC 每隔 2 秒一次,停顿 50ms
[10:00:02.123] GC(100) Pause Young 256M->230M(512M) 45ms
[10:00:04.456] GC(101) Pause Young 256M->230M(512M) 48ms
[10:00:06.789] GC(102) Pause Young 256M->230M(512M) 52ms
优化:
bash
# 调大新生代,让 Young GC 间隔变长
-Xmn2g
# 或优化代码,减少临时对象创建
场景 9:老年代水位持续上涨
现象:老年代使用率逐小时上升,Full GC 后回落很少。
排查步骤:
ini
# 1. 导出堆快照
jmap -dump:live,format=b,file=heap.hprof <pid>
# 2. MAT 打开 → Leak Suspects
# 3. 查看 Dominator Tree,找大对象和保留集
# 4. 查看 Histogram,看哪些类实例数异常
常见根因与修复:
| 根因 | 表现 | 修复 |
|---|---|---|
| ThreadLocal 未 remove | 线程池场景下对象只增不减 | finally 中 remove |
| 静态集合只增不减 | 缓存无过期策略 | 加过期时间 / LRU |
| 大对象频繁创建 | 老年代快速占满 | 对象池 / 复用 |
| 对象过早晋升 | Survivor 区过小 | 调大 -Xmn / SurvivorRatio |
七、🔍 GC 日志分析实战
GC 日志是 JVM 调优最重要的第一手资料。
7.1 如何开启 GC 日志
ruby
# JDK 9+ 统一日志格式
-Xlog:gc*,gc+heap=trace,gc+age=trace,safepoint:file=/var/log/gc.log:time,uptime,level,tags:filecount=10,filesize=100m
7.2 G1 GC 日志解读
scss
[2026-07-14T10:00:01.456][1.456s][info][gc] GC(0) Pause Young (G1 Evacuation Pause) 24M->5M(256M) 5.123ms
[2026-07-14T10:00:05.789][5.789s][info][gc] GC(1) Pause Young (G1 Evacuation Pause) 128M->45M(256M) 12.456ms
[2026-07-14T10:00:12.123][12.123s][info][gc] GC(5) Pause Full (System.gc()) 198M->186M(256M) 485.6ms
| 字段 | 含义 |
|---|---|
GC(0) |
第 0 次 GC |
Pause Young |
年轻代 GC |
Pause Full |
Full GC |
24M->5M |
GC 前后堆使用量 |
(256M) |
当前堆总大小 |
5.123ms |
GC 停顿时间 |
7.3 关键指标提取
perl
# 统计每小时 Full GC 次数
grep "Pause Full" gc.log | awk -F'[T:]' '{print $2}' | cut -c1-13 | sort | uniq -c
# 统计 Young GC 平均停顿时间
grep "Pause Young" gc.log | grep -oP '\d+.\d+ms' | awk '{sum+=$1; count++} END {print sum/count "ms"}'
7.4 什么样的 GC 日志需要关注?
| 现象 | 问题 | 行动 |
|---|---|---|
| Full GC 频率 > 1 次/小时 | 老年代压力或内存泄漏 | 堆快照分析 |
| Young GC 单次 > 100ms | 新生代过大或对象复制多 | 调整 -Xmn |
| Full GC 后回收很少 | 内存泄漏或堆不足 | MAT 分析 |
| GC 耗时占比 > 10% | GC 成为性能瓶颈 | 换回收器或优化代码 |
八、🧰 MAT 内存泄漏分析实战
Eclipse MAT 是分析堆快照的首选工具。
8.1 生成堆快照
ruby
# OOM 时自动生成
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/var/log/heapdump.hprof
# 手动触发
jmap -dump:live,format=b,file=heap.hprof <pid>
8.2 MAT 分析三板斧
- Leak Suspects:自动分析可疑泄漏点
- Dominator Tree:查看对象的支配树,找保留集最大的对象
- Histogram:按类统计实例数量和占用内存
8.3 实战案例:ThreadLocal 泄漏
MAT 报告提示:
kotlin
One instance of "java.lang.Thread" loaded by "<system class loader>" occupies 512,345,672 bytes.
The instance is referenced by "java.lang.ThreadLocal$ThreadLocalMap".
代码修复:
csharp
// ❌ 错误
private static ThreadLocal<UserContext> context = new ThreadLocal<>();
public void handle() {
context.set(new UserContext());
// 业务逻辑
}
// ✅ 正确
public void handle() {
context.set(new UserContext());
try {
// 业务逻辑
} finally {
context.remove(); // 必须清理
}
}
九、🚨 Arthas 线上诊断实战
Arthas 是阿里开源的线上诊断神器,无需重启服务。
9.1 常用命令速查
bash
# 查看 JVM 概览
dashboard
# 查看线程状态
thread -n 10 # CPU 占用前 10 的线程
thread <tid> # 查看具体线程栈
# 查看方法耗时
trace com.example.Service handleMethod '#cost>100' -n 5
# 查看对象大小
vmtool --action getInstances --className java.lang.String --limit 10
# 查看 GC 情况
jvm
# 火焰图分析 CPU
profiler start
profiler stop --file /tmp/flame.html
9.2 实战:定位 CPU 飙高
shell
# 1. 找到 CPU 占用最高的线程
thread -n 1
# 2. 输出类似:
# "http-nio-8080-exec-5" Id=55 cpuUsage=95%
# at com.example.Service.heavyComputation(Service.java:42)
# 3. 查看该方法调用链
trace com.example.Service heavyComputation
9.3 实战:定位内存泄漏
bash
# 查看大对象
vmtool --action getInstances --className com.example.User --limit 100
# 统计类实例数
jvm
# 查看 classLoader 相关统计
十、⚠️ 常见误区
| 误区 | 错误 | 正确 |
|---|---|---|
| 内存越大越好 | 堆过大导致单次 Full GC 极长 | 大堆配 G1/ZGC,控制单次停顿 |
| 新生代越大越好 | 单次 Young GC 耗时增加 | 平衡频率与单次时长 |
| 盲目抄网上参数 | 业务特征不同,效果差异大 | 基于自身压测数据调优 |
| 只调参数不优化代码 | 代码问题靠参数掩盖不了 | 先代码优化,再调参数 |
| 生产环境盲调 | 可能引发更严重问题 | 测试环境验证,灰度上线 |
| 追求 0 GC | Minor GC 是正常行为 | 关注 Full GC 和长停顿 |
十一、📝 总结
一句话总结:
JVM 调优的本质是 GC 调优 ,核心流程是 监控 → 定位 → 调优 → 验证 。先通过监控告警发现 Full GC 频繁、老年代高位、OOM 等信号,再用 jstat/jmap/MAT 定位根因,最后通过调整堆大小、分代比例、回收器选型等手段优化。记住:先代码后参数、先观测后动手、生产不盲调。
速记核心:
| 要点 | 一句话 |
|---|---|
| 调优对象 | 运行时数据区 + 垃圾回收系统 |
| 分代设计 | 年轻代复制算法,老年代标记-清除/整理 |
| JDK 17 选择 | G1 通用,ZGC 大内存低延迟 |
| 调优流程 | 监控 → 定位 → 调优 → 验证 |
| 监控核心 | Full GC、老年代水位、GC 停顿 |
| 参数铁律 | -Xms = -Xmx,元空间设上限 |
| 常见误区 | 内存越大越好、只调参数不优化代码 |
调优前后效果对比(典型线上案例) :
| 指标 | 优化前 | 优化后 | 变化 |
|---|---|---|---|
| Full GC 频率 | 1.2 次/小时 | 0 次/周 | ✅ 消除 |
| 单次 Full GC 停顿 | 800ms | - | 不再触发 |
| Young GC 频率 | 12 次/分钟 | 3 次/分钟 | ✅ 降低 75% |
| 平均 GC 停顿 | 45ms | 15ms | ✅ 降低 67% |
| P99 接口 RT | 520ms | 180ms | ✅ 降低 65% |
| 堆使用率峰值 | 92% | 65% | ✅ 更健康 |
这个对比来自一次真实的 ThreadLocal 泄漏修复 + 新生代扩容 + G1 参数调优的线上案例。
十二、高频面试题
Q1: JVM 调优的目标是什么?
降低 GC 停顿时间、降低 Full GC 频率、避免 OOM、在成本内最大化吞吐量、提升稳定性。
Q2: Young GC 频繁怎么调优?
调大新生代(-Xmn)、优化代码减少临时对象、调整 Survivor 比例。不要只加堆内存,因为老年代变大不一定解决 Young GC 频繁问题。
Q3: Full GC 频繁怎么排查?
① 看 Full GC 后老年代是否仍高位(判断内存泄漏)② jmap 导出堆快照 ③ MAT 分析 Leak Suspects ④ 检查 ThreadLocal、静态集合、缓存过期 ⑤ 调整新生代减少过早晋升。
Q4: 容器环境 OOM Killed 和 JVM OOM 有什么区别?
JVM OOM 是堆/元空间等 JVM 内存区域不足,会抛 OutOfMemoryError;容器 OOM Killed 是进程 RSS(含堆外内存、元空间、直接内存等)超过容器 limit,被系统 kill,JVM 可能来不及抛异常。
Q5: -Xms 和 -Xmx 为什么要设成一样?
避免运行时堆伸缩触发 Full GC,减少内存分配波动,提升响应稳定性。
Q6: G1 和 ZGC 怎么选?
堆 4-16GB、通用在线业务选 G1;堆 > 16GB、延迟敏感场景选 ZGC;JDK 21+ 新项目可考虑 Generational ZGC。
Q7: 老年代 Full GC 后水位仍高说明什么?
说明存在内存泄漏或内存容量不足。正常服务 Full GC 后老年代应该大幅回落到稳定基线。
Q8: 怎么排查 ThreadLocal 内存泄漏?
ThreadLocal 在线程池场景下最容易泄漏。排查方法:① MAT 找 ThreadLocalMap 大对象 ② 检查代码是否 finally 中 remove() ③ 必要时改用 WeakReference 或 InheritableThreadLocal。
Q9: 堆外内存泄漏怎么排查?
① 开启 NMT(-XX:NativeMemoryTracking=summary)② jcmd <pid> VM.native_memory summary 查看 ③ 检查 Netty ByteBuf 是否 release() ④ 检查 NIO DirectByteBuffer 是否显式释放。
Q10: Arthas 怎么定位线上 CPU 飙高?
① thread -n 1 找到 CPU 最高线程 ② thread <tid> 查看线程栈 ③ trace 命令分析方法耗时 ④ 必要时用 profiler 生成火焰图。
Q11: 接口 RT 周期性抖动怎么排查?
① 开启 GC 日志看是否与 GC 停顿时间吻合 ② 看 Young GC 频率 ③ 看是否有 Full GC ④ 用 Arthas trace 看方法耗时是否和抖动周期一致。
十三、🚀 JVM 进阶优化:逃逸分析与语法糖
除了参数调优,JVM 还有两项"隐藏"机制在默默影响代码性能:
- 逃逸分析:JIT 运行时优化,决定对象能否分配在栈上
- 语法糖:编译期简化,让我们写更简洁的代码
14.1 逃逸分析:对象不一定在堆上
逃逸分析是 JIT 编译器的一项智能分析技术,判断一个 new 出来的对象是否会逃出当前方法/线程的作用范围。
三级逃逸程度
| 逃逸级别 | 对象范围 | 能否栈上分配 | 示例 |
|---|---|---|---|
| 不逃逸 | 只在创建方法内使用 | ✅ 可以 | 方法内的临时对象 |
| 方法逃逸 | 作为返回值或参数逃出方法 | ❌ 不行 | return new User() |
| 线程逃逸 | 被静态变量或共享容器持有 | ❌ 不行 | static User globalUser |
csharp
// 不逃逸:对象只在方法内使用
public void calculate() {
User user = new User(); // 只在方法内部用
user.setAge(20);
int result = user.getAge() + 10;
}
// 方法逃逸:对象作为返回值逃出方法
public User createUser() {
User user = new User();
return user; // 逃出了当前方法
}
// 线程逃逸:对象被静态变量持有
public static User globalUser;
public void init() {
globalUser = new User(); // 能被其他线程访问
}
三大核心优化
- 栈上分配:不逃逸对象直接分配在栈帧,方法结束自动销毁,不用 GC
- 标量替换:把对象拆成成员变量,连对象头都不创建
- 同步消除(锁消除) :对象不逃逸出线程,synchronized 锁直接去掉
csharp
// 栈上分配 + 标量替换示例
public int getAge() {
User user = new User(); // 不逃逸
user.id = 1;
user.age = 20;
return user.age;
}
// JIT 优化后等效逻辑
public int getAge() {
int id = 1; // 标量替换
int age = 20; // 标量替换
return age; // 对象"消失"了
}
typescript
// 同步消除示例:StringBuffer 的锁被消除
public String append() {
StringBuffer sb = new StringBuffer(); // 不逃逸
sb.append("hello"); // append 是 synchronized,但锁会被消除
return sb.toString();
}
// 优化后等效于 StringBuilder
逃逸分析默认开启(JDK 7+),参数
-XX:+DoEscapeAnalysis。注意:大对象栈帧放不下,依然会进堆。
14.2 语法糖:编译器帮你写的代码
语法糖是编译期提供的简洁写法,不会给语言增加新能力,javac 会在编译时脱糖为原始语法。
7 种高频语法糖
| 语法糖 | 糖衣写法 | 脱糖后 |
|---|---|---|
| 泛型 | List<String> |
List,取值时强转 |
| 自动装箱 | Integer num = 100 |
Integer.valueOf(100) |
| 自动拆箱 | int a = num |
num.intValue() |
| 增强 for | for (String s : list) |
迭代器遍历 |
| 枚举 | enum Color { RED } |
继承 Enum 的 final 类 |
| try-with-resources | try (InputStream is = ...) |
finally 中关闭资源 |
| 字符串拼接 | "a" + name |
new StringBuilder().append(...) |
ini
// 泛型类型擦除
List<String> list = new ArrayList<>();
list.add("hello");
String str = list.get(0);
// 脱糖后
List list = new ArrayList();
list.add("hello");
String str = (String) list.get(0); // 强制类型转换
ini
// 自动装箱 / 拆箱
Integer num = 100;
int a = num;
// 脱糖后
Integer num = Integer.valueOf(100);
int a = num.intValue();
go
// 字符串拼接
String result = "Hello, " + name + "!";
// 脱糖后
String result = new StringBuilder()
.append("Hello, ")
.append(name)
.append("!")
.toString();
语法糖避坑
| 坑 | 原因 | 正确做法 |
|---|---|---|
foreach 中 list.remove() |
底层是迭代器,修改结构抛异常 | 用 Iterator.remove() |
| 泛型擦除后类型丢失 | 编译后泛型信息被擦除 | 用 Class 或 TypeToken |
| 自动装箱 == 比较 | -128~127 缓存,之外比较地址 | 用 equals |
循环里字符串 + 拼接 |
每次循环新建 StringBuilder | 循环外用 StringBuilder |
14.3 调优启示
- 逃逸分析是性能优化:让小对象不进堆,减轻 GC 压力;但它是 JIT 行为,不稳定,不能依赖它来设计代码
- 语法糖是开发效率优化 :不是性能优化,用不好反而更慢(如循环里用
+拼接) - 代码写法会影响逃逸分析:尽量缩小对象作用范围,避免不必要的返回和静态持有
十五、面试速记 12 条
| # | 要点 |
|---|---|
| 1 | JVM 调优 = 运行时数据区 + GC 调优 |
| 2 | 堆分年轻代(Eden+S0+S1)和老年代 |
| 3 | 对象晋升 4 种:年龄、大对象、Survivor 满、动态年龄 |
| 4 | JDK 17 选 G1 通用,ZGC 大内存低延迟 |
| 5 | 调优流程:监控 → 定位 → 调优 → 验证 |
| 6 | 监控核心:Full GC、老年代水位、GC 停顿、OOM |
| 7 | -Xms = -Xmx,元空间必须设上限 |
| 8 | 容器环境用 MaxRAMPercentage 感知容器内存 |
| 9 | 先代码优化,再调 JVM 参数 |
| 10 | 生产环境必须监控 + OOM 自动 dump |
| 11 | 逃逸分析让不逃逸对象可能分配在栈上 |
| 12 | 语法糖是编译期简化,不是性能优化 |