案例。没看过GC日志就改JVM参数,就像没看病就乱吃药。 今天聊三个真实案例:堆内存改太大导致GC停顿几十秒、改太小频繁Full GC、元空间泄漏被当成堆内存问题瞎调。
一、典型“瞎调”场景
场景:感觉系统慢,上来就改堆内存
bash
# 常见“经验主义”调参
-Xms8g -Xmx8g -Xmn4g -XX:SurvivorRatio=8
结果:
- 老年代8G,一次Full GC几十秒
- 系统直接卡死
- 还不如不改
为什么?——没分析就直接调
正确的调优流程应该是:
text
1. 观察现状 → 2. 分析GC日志 → 3. 定位问题 → 4. 针对性调整 → 5. 验证效果
今天按这个流程,带你走一遍。
二、第一步:学会看GC日志
2.1 开启GC日志(必做)
JDK 8:
bash
-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
-XX:+PrintGCTimeStamps
-Xloggc:/path/to/gc.log
-XX:+UseGCLogFileRotation
-XX:NumberOfGCLogFiles=5
-XX:GCLogFileSize=20M
JDK 9+:
bash
-Xlog:gc*:file=/path/to/gc.log:time,uptime,level,tags:filecount=5,filesize=20M
2.2 看懂GC日志的关键指标
一条Young GC日志:
text
2024-01-15T10:30:25.123+0800: 120.456: [GC (Allocation Failure)
[PSYoungGen: 524288K->87345K(611840K)] 524288K->123456K(2015232K),
0.0234567 secs] [Times: user=0.06 sys=0.01, real=0.02 secs]
拆解:
| 字段 | 含义 | 健康值 |
|---|---|---|
PSYoungGen: 524288K->87345K | Young区:回收前→回收后 | 存活率<10% |
(611840K) | Young区总大小 | - |
524288K->123456K(2015232K) | 堆整体:回收前→回收后 | - |
0.0234567 secs | GC耗时 | <50ms |
real=0.02 secs | 实际停顿时间 | 越小越好 |
一条Full GC日志:
text
2024-01-15T10:35:12.456+0800: 420.789: [Full GC (Metadata GC Threshold)
[PSYoungGen: 0K->0K(139776K)]
[ParOldGen: 1023456K->1023456K(1398272K)] 1023456K->1023456K(1538048K),
[Metaspace: 98765K->98765K(1099776K)], 0.8765432 secs]
[Times: user=0.89 sys=0.01, real=0.88 secs]
关键信号:
- Full GC > 1秒 → 有问题
- Full GC后老年代占用不降 → 内存泄漏
- Metadata GC Threshold → 元空间不够或类加载泄漏
三、案例1:堆内存改太大,GC停顿几十秒
现象
系统TP99从50ms飙升到5秒,监控看到GC停顿长达20-30秒。
GC日志分析
bash
2024-01-15T10:30:25.123+0800: 120.456: [GC (Allocation Failure)
[PSYoungGen: 1048576K->1024000K(1258304K)]
1048576K->1024000K(8192000K), 0.5234567 secs] # Young GC耗时500ms
2024-01-15T10:30:45.123+0800: 140.456: [Full GC (Ergonomics)
[PSYoungGen: 1024000K->0K(1258304K)]
[ParOldGen: 6144000K->6144000K(6933696K)]
7168000K->6144000K(8192000K), 25.6789012 secs] # Full GC耗时25秒!
问题定位
- 堆内存8G,老年代近7G
- 每次Full GC需要扫描7G内存,耗时25秒
- 系统响应超时,服务被判定死亡
根本原因
堆内存不是越大越好。 GC扫描时间与堆内存大小成正比。
解决方案
bash
# 调整前
-Xms8g -Xmx8g
# 调整后:根据对象生命周期分析
-Xms4g -Xmx4g -Xmn1.5g -XX:SurvivorRatio=8
-XX:MaxGCPauseMillis=100 # 设置目标停顿时间
堆内存选择原则:
| 场景 | 建议堆大小 | 原因 |
|---|---|---|
| 响应优先(互联网) | 2-4G | GC停顿可控 |
| 吞吐优先(批处理) | 4-8G | 可以忍受较长GC |
| 大内存系统 | 8G+ 用G1GC | 分区回收,停顿可控 |
记住:4G以上的堆,建议用G1GC代替ParallelGC。
四、案例2:堆内存改太小,频繁Full GC
现象
系统CPU持续30%,QPS正常但RT升高,监控看到每分钟好几次Full GC。
GC日志分析
bash
# 1分钟内的日志
2024-01-15T10:30:25.123+0800: 120.456: [Full GC (Allocation Failure)
[PSYoungGen: 102400K->1024K(153600K)]
[ParOldGen: 307200K->307200K(409600K)]
409600K->308224K(563200K), 0.5234567 secs]
2024-01-15T10:31:05.456+0800: 160.789: [Full GC (Allocation Failure)
[PSYoungGen: 102400K->1024K(153600K)]
[ParOldGen: 307200K->307200K(409600K)]
409600K->308224K(563200K), 0.5345678 secs]
# 老年代几乎不降,说明内存一直满着
问题定位
- 堆内存512M,老年代400M,每次Full GC后老年代还是400M
- 说明业务内存需求超过400M
- 每次Young GC升到老年代的对象,老年代装不下
根本原因
堆内存小于应用程序的实际内存需求,导致频繁Full GC。
解决方案
bash
# 1. 先分析真实内存需求
# 用jstat查看内存占用趋势
jstat -gc <pid> 1000 10
# 2. 根据分析结果调整
-Xms2g -Xmx2g -Xmn768m -XX:SurvivorRatio=8
-XX:MetaspaceSize=256m -XX:MaxMetaspaceSize=256m
# 3. 观察晋升情况
-XX:+PrintTenuringDistribution # 查看对象年龄分布
堆内存大小经验公式:
text
年轻代 = 堆内存的 1/3 ~ 1/4
老年代 = 堆内存的 2/3 ~ 3/4
如果老年代增长很快 → 加大年轻代
如果Young GC频繁 → 加大年轻代
如果Full GC频繁 → 加大老年代或整体堆
五、案例3:元空间泄漏,被当成堆内存问题
现象
系统运行几天后OOM,报错:java.lang.OutOfMemoryError: Metaspace
GC日志分析
bash
# 发现频繁的Metadata GC
2024-01-15T10:30:25.123+0800: 120.456: [Full GC (Metadata GC Threshold)
[PSYoungGen: 1024K->0K(153600K)]
[ParOldGen: 1024K->1024K(409600K)]
[Metaspace: 98765K->98765K(1099776K)], 0.8765432 secs]
# 元空间满了但释放不掉
# 多次GC后Metaspace占用只增不减
问题定位
使用jcmd查看类加载情况:
bash
jcmd <pid> VM.classloader_stats
发现:
ClassLoader实例数量异常多(几十万个)- 大量动态生成的类没有被卸载
根本原因
元空间泄漏:动态生成类(Groovy脚本、CGLIB代理、热部署)的ClassLoader无法被GC回收。
常见原因:
- 使用Groovy动态脚本,但脚本ClassLoader未释放
- 热部署应用,旧的ClassLoader未卸载
- 框架(如Mockito)动态生成类过多
解决方案
bash
# 1. 临时方案:加大元空间(治标)
-XX:MetaspaceSize=256m -XX:MaxMetaspaceSize=512m
# 2. 根本方案:找到泄漏点
# 添加参数打印类加载器统计
-XX:+TraceClassLoading -XX:+TraceClassUnloading
# 3. 如果是Groovy泄漏
// 使用GroovyClassLoader后记得close
groovyClassLoader.close()
# 4. 如果是热部署
# 确保自定义ClassLoader能被回收(没有外部引用)
六、GC调优常用参数速查
常用GC日志参数
| 参数 | 作用 |
|---|---|
-XX:+PrintGCDetails | 打印详细GC日志 |
-XX:+PrintGCDateStamps | 打印日期时间 |
-Xloggc:/path/gc.log | 输出到文件 |
-XX:+PrintHeapAtGC | GC前后打印堆信息 |
-XX:+PrintTenuringDistribution | 打印对象年龄分布 |
-XX:+PrintReferenceGC | 打印引用处理信息 |
GC收集器选择
| 场景 | 推荐GC | 参数 |
|---|---|---|
| 响应优先(4G以下) | G1GC | -XX:+UseG1GC |
| 响应优先(4G以上) | G1GC / ZGC | -XX:+UseZGC |
| 吞吐优先 | ParallelGC | -XX:+UseParallelGC |
| 大内存低延迟 | Shenandoah | -XX:+UseShenandoahGC |
G1GC常用参数(推荐)
bash
-Xms4g -Xmx4g
-XX:+UseG1GC
-XX:MaxGCPauseMillis=100 # 目标停顿时间
-XX:G1HeapRegionSize=16m # 分区大小
-XX:InitiatingHeapOccupancyPercent=45 # 触发并发GC的堆占用
-XX:G1ReservePercent=10 # 预留空间
七、JVM调优的正确流程
text
第1步:开启GC日志(必须)
↓
第2步:系统运行一段时间,收集日志
↓
第3步:分析GC日志,找问题类型
├── Young GC频繁 → 年轻代太小
├── Young GC耗时太长 → 年轻代太大或垃圾多
├── Full GC频繁 → 内存泄漏或老年代太小
├── Full GC耗时太长 → 堆太大或收集器不合适
└── Metadata GC频繁 → 元空间泄漏或太小
↓
第4步:调整对应参数
↓
第5步:观察效果,重复2-4步
↓
第6步:压测验证,确定最终参数
八、一句话避坑口诀
text
调优先看GC日志,别凭感觉乱改参数。
堆内存不是越大越好,4G以上用G1GC。
Full GC频繁看老年代,Young GC频繁看年轻代。
元空间泄漏查ClassLoader,不要当堆内存问题调。
九、互动一下
你因为JVM调优翻过车吗?
有没有“改完参数更差了”的经历?
评论区聊聊👇
下期预告: 避坑6——数据库索引“我以为走了索引”(隐式转换、函数操作、不等号、OR条件导致索引失效)
我是小李,9年Java,产假中持续输出。点个赞,收藏防丢❤️