JVM 线上频繁 Full GC,到底是泄漏、流量问题还是参数问题?
JVM Full GC 问题在线上怎么排查?一次讲清 GC 日志、内存泄漏、对象晋升与排障路径
大家好,我是一名有 4 年工作经验的 Java 后端开发。
最近在系统整理高并发业务场景下的一些核心设计问题,准备沉淀成一个系列。
前面几篇我写了秒杀库存扣减、缓存一致性、MQ 幂等消费、支付超时库存回补、热点 Key 治理、分布式锁、本地消息表、限流降级熔断、MySQL 慢 SQL,这一篇继续聊一个线上排障里非常高频、也特别容易让人慌的话题:JVM Full GC。
🦅个人主页
🐼
文章目录
一、前言
很多 Java 后端第一次碰到线上 Full GC,现场通常都很像这样:
- 接口 RT 突然飙升
- 应用 CPU 打高
- Pod 开始频繁重启
- 监控里堆内存一路上涨
- Full GC 次数突然变多
- OOM 预警开始响
这时候大家最容易做的事通常有两种:
- 先把堆调大
- 重启服务顶一下
这两种手段有时候能暂时缓解,但如果根因没有找到,问题很快还会再来。
而且真正麻烦的地方在于,Full GC 并不一定都代表“内存泄漏”。
真实线上场景里,导致 Full GC 的原因可能有很多:
- 老年代对象积压
- 对象晋升过快
- 大对象分配太频繁
- 缓存没有边界
- ThreadLocal 没清理
- MQ 消息堆积导致对象常驻
- 元空间持续增长
- 显式
System.gc() - 容器内存和 JVM 参数不匹配
也就是说:
Full GC 只是现象,不是结论。
真正线上排障最重要的,不是背 GC 名词,而是:
如何判断它到底是泄漏、配置问题、流量问题还是代码对象生命周期出了问题。
这篇文章就结合一个典型业务场景,把 JVM Full GC 的定位和治理思路系统讲透。
二、业务场景
先假设这样一个场景。
2.1 场景设定
电商系统里的订单服务,在大促期间负责:
- 创建订单
- 查询订单详情
- 处理支付回调
- 发送订单事件消息
服务技术栈大致如下:
- Spring Boot
- JDK 17
- G1 GC
- Redis
- MySQL
- RocketMQ
部署环境:
- Kubernetes
- 单实例内存限制
6G - JVM 堆设置
4G - 实例数
6
2.2 高峰特征
在活动高峰期,这个服务有几个明显特点:
- 峰值 QPS 高
- JSON 序列化对象很多
- 订单事件消息量大
- 本地缓存和线程池对象较多
- 查询和写入同时很频繁
2.3 业务要求
这个场景下,通常需要满足下面这些要求:
- 高峰期接口 RT 稳定
- Full GC 不能频繁触发
- 单次 Full GC 停顿时间不能过长
- 服务不能因 OOM 频繁重启
- 排障时要尽量减少对线上影响
- 问题定位后要能快速复盘和治理
三、问题现象
很多线上 Full GC 问题,最初的外在表现并不是“JVM 报错”,而是业务先出问题。
3.1 接口 RT 周期性飙升
比如监控里会看到:
- 平时 RT
50ms - 每隔几分钟突然升到
2s - 然后又恢复
这类现象很多时候就是:
- Stop The World 停顿
- Full GC 期间业务线程全部暂停
3.2 堆内存用量越来越高,Full GC 后也降不下来
这是最典型也最危险的信号之一。
比如你会看到:
- Old 区一路上涨
- Full GC 后只回收了一点点
- 下一轮很快又打满
这通常意味着:
- 长生命周期对象太多
- 或者确实存在内存泄漏
3.3 Full GC 次数明显增多
正常情况下,Full GC 不应该在短时间内频繁发生。
如果你看到:
- 几分钟一次
- 几十秒一次
- 甚至几秒一次
那基本就已经是明确异常信号了。
3.4 应用 CPU 很高,但吞吐反而下降
很多人会误以为 CPU 高就一定是业务请求多。
但真实场景里,也可能是:
- GC 线程在大量工作
- 对象分配与回收极其频繁
- 服务在“忙着自救”
3.5 服务被容器 OOM Kill
在 Kubernetes 场景下,经常还会出现一种更迷惑的现象:
- Java 堆看起来还没完全打满
- 但 Pod 已经被杀了
这往往说明问题可能不只在 Java Heap,还要考虑:
- Metaspace
- Direct Memory
- 线程栈
- 本地内存
四、原理分析
Full GC 真正要解决的,不是“调几个 JVM 参数”,而是:
分清楚到底是对象活得太久、对象分配太猛、GC 配置不合理,还是根本就是内存泄漏。
4.1 Full GC 到底意味着什么?
简单理解,Full GC 通常表示:
- 老年代回收
- 可能伴随整个堆更大范围回收
- Stop The World 时间通常比 Minor GC 更长
它最大的业务影响不是“JVM 做了一次清理”,而是:
- 业务线程会暂停
- 请求 RT 直接抖动
- 高峰期容易形成堆积
4.2 导致 Full GC 的常见原因有哪些?
线上最常见的根因通常有这些:
- 老年代占用持续升高
- 对象晋升速度过快
- 大对象 / 大数组分配频繁
- 本地缓存没有大小边界
- ThreadLocal 未清理
- 静态集合长期持有对象
- 消息消费不及时导致对象积压
- 元空间持续膨胀
- 显式触发
System.gc() - 容器内存限制与 JVM 参数不匹配
4.3 为什么 Full GC 不一定等于内存泄漏?
这是线上排障里最容易误判的地方。
比如下面几种情况,都会让你看到 Full GC 很频繁:
- 某个接口短时间创建了大量大对象
- 高峰流量把 Old 区很快顶满
- 参数配置导致对象晋升过快
- 某批任务集中执行,短时间堆占用异常
这些问题未必是“对象回不去”,也可能只是:
- 一段时间内对象来得太快
所以判断内存泄漏,最关键的一步通常是:
看 Full GC 之后,堆内存能不能明显回落。
如果 Full GC 后依然回落很少,而且一轮比一轮高,就更像泄漏或长生命周期对象积压。
4.4 为什么要先分清“堆问题”和“非堆问题”?
因为有些“看起来像 GC 问题”的现场,根因并不在 Heap。
比如:
- Metaspace 打满
- Direct Memory 使用过高
- 线程数过多导致本地内存耗尽
- Netty / ByteBuffer 占用过大
所以真正排查时,不能只盯着 Heap 曲线看。
五、常见排查手段对比
下面把几种常见的排查工具和方式放在一起看一下。
| 手段 | 主要作用 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| GC 日志 | 看 GC 频率、停顿、回收前后内存 | 成本低,线上首选 | 不能直接看到对象链路 | 首先使用 |
jstat |
快速看堆区变化和 GC 次数 | 轻量、即时 | 信息相对粗粒度 | 线上快速判断 |
jcmd |
看 JVM 运行信息、类直方图、Heap Dump | 官方工具,信息全 | 部分操作有开销 | 线上排查常用 |
| Heap Dump + MAT | 找泄漏对象、Retained Size | 分析最深入 | 生成和分析成本高 | 怀疑泄漏时 |
| Arthas | 在线诊断类、线程、内存、方法调用 | 很适合线上临时排查 | 需要操作经验 | Java 服务排障 |
| JFR | 低开销事件分析 | 视角全、适合性能排查 | 学习门槛略高 | 中高级排查 |
如果是大多数线上 Full GC 问题,我更推荐:
先看监控和 GC 日志,再用
jstat/jcmd快速确认方向;只有怀疑泄漏时,再谨慎做 Heap Dump。
这个顺序非常重要。
六、推荐排查思路
这里给一版更贴近线上落地的排障路径。
6.1 第一步:先看是不是“持续性问题”
先看监控里这些指标:
- Heap 使用率
- Old 区占用趋势
- Full GC 次数
- 单次 Full GC 停顿时间
- 接口 RT 抖动节奏
如果你发现:
- Full GC 后堆明显回落
- 高峰过去后恢复正常
那更像:
- 流量峰值放大
- 对象分配过猛
如果你发现:
- Full GC 后回落很少
- 一轮比一轮更高
那就要重点怀疑:
- 内存泄漏
- 长生命周期对象未释放
6.2 第二步:看 GC 日志,先确认 GC 原因
JDK 11+/17+ 常用 GC 日志参数可以这样开:
-Xlog:gc*,safepoint:file=/data/logs/gc.log:time,uptime,level,tags
看 GC 日志时,我一般会重点关注:
- Full GC 触发频率
- 回收前后堆大小变化
- 停顿时间
- 触发原因
比如你要看的是:
- 是老年代占满了
- 还是 Humongous Object 过多
- 还是元空间问题
- 还是显式 GC
6.3 第三步:用 jstat 快速确认现场趋势
例如:
jstat -gcutil <pid> 1000 20
这个命令可以快速看到:
- Eden、Survivor、Old 区使用率
- YGC / FGC 次数
- 每类 GC 累计时间
如果 Old 区长期接近满载,并且 FGC 持续增长,就基本确认方向了。
6.4 第四步:再判断是不是锁死在某类对象上
可以先看类直方图:
jcmd <pid> GC.class_histogram
这个命令很适合先判断:
- 哪类对象数量特别多
- 哪类对象占用内存特别大
常见异常对象包括:
HashMap$Nodebyte[]char[]String- 业务 DTO / VO
- MQ 消息包装对象
如果你看到某类业务对象占比异常高,就要开始回到代码里找:
- 谁在持有它
- 为什么生命周期这么长
6.5 第五步:怀疑泄漏时,再做 Heap Dump
如果监控、日志、类直方图都指向:
- Full GC 回收不掉
- 某些对象持续增长
那就可以进一步做 Heap Dump。
例如:
jcmd <pid> GC.heap_dump /data/dump/order-service.hprof
然后再用 MAT 去分析:
- Dominator Tree
- Leak Suspects
- Retained Size
- 引用链路
这一层最关键的是找:
真正谁在“持有”这些对象不放。
6.6 第六步:不要忘了回到业务代码和流量模型
工具只能告诉你“对象在增长”,但最后还是要回到业务本身。
比如常见根因通常是这些:
- 本地缓存没有上限
- ThreadLocal 没有
remove - 消费者积压对象长期堆在内存
- 批量查询一次加载太多数据
- 大 JSON 一次性反序列化太多
- 线程池队列里堆了大量任务对象
也就是说:
线上排障的最后一步,往往不是继续调 JVM,而是改业务对象生命周期。
七、落地代码与实战示例
下面给一版比较贴近实际项目思路的内容。
7.1 启动参数里先把 GC 日志准备好
如果你连 GC 日志都没有,线上排查会非常被动。
JDK 17 常见配置:
-Xms4g
-Xmx4g
-XX:+UseG1GC
-Xlog:gc*,safepoint:file=/data/logs/gc.log:time,uptime,level,tags
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/data/dump
这里几个重点:
-Xms和-Xmx尽量一致,减少运行时扩缩容扰动- 提前配置 GC 日志
- 提前配置 OOM 自动导出 Dump
7.2 线上快速排查命令
我在线上一般会先用这几条命令快速定方向:
查看 GC 概览:
jstat -gcutil <pid> 1000 10
查看堆信息:
jcmd <pid> GC.heap_info
查看类直方图:
jcmd <pid> GC.class_histogram
查看 JVM 参数:
jcmd <pid> VM.flags
这些命令的优点是:
- 成本相对可控
- 足够快速
- 适合线上先判断方向
7.3 一个典型问题:本地缓存没有上限
比如代码里写了这样的缓存:
private static final Map<Long, OrderDTO> ORDER_CACHE = new ConcurrentHashMap<>();
public OrderDTO getOrder(Long orderId) {
return ORDER_CACHE.computeIfAbsent(orderId, this::queryOrderFromDb);
}
这段代码的问题很明显:
- 没有过期时间
- 没有容量上限
- 热点期间缓存对象只增不减
在高并发场景下,这种代码非常容易把对象长期留在老年代里。
更合理的做法通常是:
private final Cache<Long, OrderDTO> orderCache = Caffeine.newBuilder()
.maximumSize(50_000)
.expireAfterWrite(Duration.ofMinutes(10))
.build();
public OrderDTO getOrder(Long orderId) {
return orderCache.get(orderId, this::queryOrderFromDb);
}
7.4 一个典型问题:ThreadLocal 没清理
比如:
private static final ThreadLocal<Map<String, Object>> LOCAL_CONTEXT = new ThreadLocal<>();
public void process() {
Map<String, Object> ctx = new HashMap<>();
ctx.put("order", loadBigOrderInfo());
LOCAL_CONTEXT.set(ctx);
}
如果线程来自线程池,而你又没有清理:
LOCAL_CONTEXT.remove();
那这些对象就可能被线程长期持有。
更合理的做法是:
public void process() {
try {
Map<String, Object> ctx = new HashMap<>();
ctx.put("order", loadBigOrderInfo());
LOCAL_CONTEXT.set(ctx);
doBusiness();
} finally {
LOCAL_CONTEXT.remove();
}
}
7.5 一个典型问题:批量加载过大对象
比如一次查很多订单,还顺手查了大字段:
select *
from order_info
where create_time >= #{startTime}
limit 50000
如果再在 Java 里全部组装成复杂对象树,就很容易:
- 一次性创建大量对象
- Survivor / Old 区压力上升
- Full GC 提前发生
更稳妥的方式通常是:
- 分批查询
- 只查必要字段
- 流式处理
- 避免一次性组装超大对象集合
7.6 Arthas 在线排查也很好用
如果现场允许,我也会结合 Arthas 看:
dashboardmemorythreadheapdump
例如:
dashboard
memory
thread -n 10
Arthas 的优势在于:
- 不用重启应用
- 更适合线上临时诊断
- 能把线程、内存、方法执行情况串起来看
八、为什么很多项目调了 JVM 参数,Full GC 还是没解决?
这也是线上非常常见的情况。
8.1 只加堆,不改代码对象生命周期
这相当于只是把问题往后拖。
如果根因是缓存无边界、ThreadLocal 泄漏、对象积压,再大的堆最终也会被吃满。
8.2 把所有 Full GC 都当成“GC 参数不合理”
GC 参数当然会影响表现,但很多根因其实在业务代码和流量模型上。
8.3 只看 Heap,不看容器内存
在容器环境里,如果忽略:
- Direct Memory
- Metaspace
- 线程栈
那很容易出现“堆看起来还行,但 Pod 已经被杀”。
8.4 Heap Dump 一上来就做,反而影响现场
Dump 很有用,但它不是第一步。
如果服务已经很脆弱,贸然 Dump 可能进一步放大风险。
8.5 只解决 Full GC,不解决请求放大
有些系统 Full GC 的根因其实是:
- 下游慢
- 请求重试
- 消息堆积
- 批处理堆积
所以只调 JVM,不治理上游流量和代码模型,问题还是会反复出现。
九、压测与监控怎么写,文章才更像做过项目的人写的?
JVM Full GC 这类文章,如果只讲概念,不讲线上指标和排障顺序,很容易显得“懂 GC 名词,但没排过线上问题”。
所以建议补上压测和线上观测视角。
9.1 压测场景示例
这里给一个适合写进文章的测试场景:
场景配置:
- 订单服务峰值 QPS:
4000 - JVM Heap:
4G - GC:G1
- 消息消费高峰:每分钟
20 万 - 本地缓存开启
- 部分接口会返回较大 JSON
对比方案:
- 方案 A:缓存无上限 + 大批量处理
- 方案 B:增加堆内存,但代码不改
- 方案 C:缓存加边界 + 分批处理 + ThreadLocal 清理 + 参数优化
9.2 压测结果示例
| 指标 | 原始方案 | 只加堆 | 代码治理 + 参数优化 |
|---|---|---|---|
| Full GC 次数/小时 | 36 | 18 | 2 |
| 单次 Full GC 最长停顿 | 4.2s | 3.1s | 420ms |
| Heap 高峰占用 | 持续逼近上限 | 较高 | 稳定 |
| 接口 TP99 | 2800ms | 1900ms | 260ms |
| 服务稳定性 | 差 | 一般 | 好 |
从结果上可以看出:
- 单纯加堆只能缓解,不一定治根
- 真正有效的手段通常是业务对象治理 + 分配模型优化 + 必要参数调整
说明:以上压测数据为示例写法,实际结果需要结合对象模型、JDK 版本、GC 类型、容器资源和流量特征综合评估。
9.3 线上建议重点监控哪些指标?
如果你准备真正治理 Full GC,至少建议监控这些指标:
- Heap 使用率
- Old 区占用变化
- Full GC 次数
- 单次 Full GC 停顿时间
- GC 总耗时占比
- Metaspace 使用率
- Direct Memory 使用情况
- Pod 重启次数
- 接口 TP95、TP99
- 线程池队列堆积长度
这些指标一旦写进文章里,会明显更像真实线上排障经验总结。
十、面试中怎么回答这个问题?
如果面试官问你:
JVM Full GC 在线上一般怎么排查?
你可以这样回答。
10.1 回答思路
第一,我会先看监控,确认 Full GC 是偶发还是持续,以及 Full GC 后堆内存能不能明显回落。如果回落很少并且一轮比一轮高,我会重点怀疑内存泄漏或长生命周期对象积压。
第二,我会结合 GC 日志去看 Full GC 的频率、停顿时间和触发原因,先判断是老年代问题、Humongous Object 问题、元空间问题,还是显式 GC。
第三,我会用 jstat、jcmd 这类轻量工具在线上快速确认方向,比如看 Old 区变化、类直方图、堆信息,先定位是哪些对象占用异常。
第四,如果监控和类直方图都指向对象持续增长,我才会进一步做 Heap Dump,用 MAT 去看 Dominator Tree 和引用链路,确认到底是谁在持有这些对象。
第五,最后一定要回到业务代码和流量模型里看根因,比如本地缓存无边界、ThreadLocal 没清理、批量处理过大、消息堆积、线程池任务对象积压等。很多时候根因不在 JVM 参数,而在对象生命周期设计。
10.2 面试官更想听到什么?
面试官真正想听的,通常不是你会不会背 Eden、Survivor、Old 的概念,而是你有没有这些意识:
- 你知道 Full GC 不一定等于内存泄漏
- 你知道先看回收后内存是否回落
- 你知道 GC 日志和
jstat/jcmd是第一梯队工具 - 你知道 Dump 不是一上来就做
- 你知道 Heap 和非堆内存都要看
- 你知道最后要回到代码对象生命周期
如果你能把这些点讲清楚,面试官会明显觉得你排过真实线上问题,而不只是学过 JVM 理论。
十一、总结
JVM Full GC 这个问题,真正难的不是“记住多少 GC 名词”,而是如何在高并发线上环境里,快速判断问题方向、尽量低风险排查、最后把根因收敛到具体对象和具体代码。
如果只记一句结论,我觉得可以记住这句:
线上 Full GC 排查最关键的是“先看趋势,再看 GC 日志,再用轻量工具定方向,最后再决定是否 Dump,并回到业务对象生命周期治理”。
这套思路通常比“先调堆、先重启、先加参数”更稳,也更接近真实线上排障方式。
十二、后续准备继续写的内容
如果这篇你觉得还可以,后面这个系列我准备继续写:
- ThreadPool 线程池参数到底怎么配才靠谱?
- 一次真实线上接口 RT 飙升的排查复盘
- Redis 大 Key 和热 Key 怎么分别治理?
- 高并发系统的链路追踪和可观测性怎么落地?
- Arthas 在线上排障到底怎么用?
如果你也在做高并发相关业务,欢迎交流。
十三、结尾
如果你觉得这篇文章对你有帮助,欢迎点赞、收藏、关注。
后面我会继续输出一些偏实战的 Java 后端文章。
我是一个正在持续沉淀高并发与后端工程实践的 Java 开发,
也欢迎大家一起讨论更好的实现方案。
更多推荐




所有评论(0)