Full GC 频繁怎么办:从回收效果分清泄漏和分配压力
Full GC 一频繁,最常见的错误动作就是先调 JVM 参数。但参数只能改表现,不能替你判断根因;先看回收效果、趋势和对象变化,才知道到底是泄漏、晋升过快,还是业务模型把堆顶住了。
线上一旦开始频繁 Full GC,监控群里几乎总会立刻冒出几个熟悉判断:是不是堆太小了、是不是该换 GC、要不要先把参数调大一点。
这些动作不一定完全错,但如果在没判断问题类型之前就直接调参数,往往只是把问题往后拖,而不是把问题解决掉。
因为 Full GC 频繁不是一个单一故障现象。它背后可能是完全不同的几类原因:
- 真正的内存泄漏
- 对象创建过快,晋升压力过大
- 批任务 / 报表 / 导出制造短时内存峰值
- 本地缓存或集合持续膨胀
- 堆配置与业务负载不匹配
- 某些时候甚至是对 GC 现象的误读
频繁 Full GC 先别急着翻参数表。更有用的做法,是先看它到底有没有伤到业务、回收后内存能不能下来,再决定该往泄漏、分配压力,还是堆配置去查。
如果这波 Full GC 还伴随 CPU 抖动,可以连着看 Java 服务 CPU 高怎么排查:一套更稳的线上定位顺序。如果接口 RT 也开始变差,再补 接口响应慢怎么排查?后端 API 变慢与超时的定位步骤,把 JVM 现象和业务侧症状对起来。
Full GC 已经变密时,它到底伤到了哪里
先把眼前现象按形态拆开,后面更容易判断该往哪条线继续查。
| 你现在看到的现象 | 更像什么 | 下一步 |
|---|---|---|
| Full GC 从偶发变成持续,而且越来越密 | 泄漏、长生命周期对象、缓存膨胀 | 先看下文的回收效果和泄漏信号 |
| Full GC 常在固定时段触发 | 批任务、报表、导出、同步窗口 | 重点对时间窗、批任务和流量峰值 |
| CPU 高同时伴随 GC 次数明显增多 | JVM 压力和 CPU 分支已经交叉 | 先把 GC 和 CPU 时间线对齐,再联动 CPU 页 |
| Full GC 后内存能回落很多,但很快又顶上去 | 对象创建过快、批量任务、峰值分配压力 | 重点查对象制造速率和批量任务 |
| 内存高但几乎不发生 Full GC,问题更像别的等待 | 不一定先走 GC 分支 | 先回接口慢那条排查线 |
| 已经接近 OOM,需要保现场和 dump | 需要进入更下游的 dump / OOM 分支 | 看完本文后接 OOM / Heap Dump 页 |
一、别只盯 GC 次数,先确认它到底影响到了什么
如果你现在手上只有一屏 JVM 监控,我通常会先对 3 组证据:Full GC 前后的 Old 区回落幅度、业务 RT / 错误率是不是同步抖动,以及问题是否总在固定时间窗出现。我见过不少“GC 很频繁”的误判,最后根本不是 JVM 本身先坏,而是批任务、缓存加载或对象创建风暴把 GC 现象放大了。把这三组证据对齐,后面才知道该往泄漏、分配过快还是业务窗口去查。
不是所有 Full GC 都等于事故。
真正值得警惕的,不只是 GC 次数,而是它是否已经开始影响业务:
- 接口 RT 明显抖动
- 错误率升高
- 实例偶发假死或超时
- CPU 随 GC 一起波动
- 线程池堆积、吞吐下降
- 服务运行一段时间后越来越不稳定
所以第一步不要先问“GC 多不多”,而是先问四个更有价值的问题:
- 是偶发,还是持续发生?
- 是某个固定时间窗口出现,还是全天都在发生?
- 是发布后出现,还是服务跑一段时间后才出现?
- 它有没有明显影响业务指标?
这四个维度,几乎直接决定后面的排查方向。
几种常见形态,含义差别很大
1. 偶发 Full GC,但回收后很快恢复
更像:
- 短时流量高峰
- 某个接口瞬时制造大量对象
- 一次性批任务带来的临时内存压力
2. 周期性 Full GC,总在固定时间窗口出现
更像:
- 定时任务
- 报表导出
- 数据同步 / 批处理
- 缓存预热或批量刷新
3. 持续性 Full GC,而且越来越密
更像:
- 内存泄漏
- 长生命周期对象持续膨胀
- 缓存 / 集合只增不减
- 老年代对象根本回不下来
4. 发布后很快出现
更像:
- 新代码引入大对象 / 长引用链
- 缓存逻辑变化
- DTO 转换、序列化、批量查询路径退化
- JVM 参数变更和业务负载不匹配
所以 Full GC 的第一判断,不是技术细节,而是时间形态 + 业务影响。
二、先看哪些指标:趋势比一张截图更重要
真正有价值的证据,通常来自这几类信息的组合。
1. 堆使用率趋势
重点看:
- 堆总使用量是不是持续抬高
- Old 区是不是越来越高
- Full GC 后是不是明显回落
- 回落之后是稳定一段时间,还是很快又顶上去
2. Full GC 的回收效果
这是最关键的判断点之一。
你真正要关心的是:
- Full GC 之后,老年代有没有明显下降
- 下降幅度是大还是很小
- 几次 Full GC 之后,内存基线是不是还在往上抬
一个非常实用的经验判断是:
如果 Full GC 之后老年代能明显回落,说明 JVM 还“回收得动”;如果回收后还是高位横盘,甚至越来越高,就要优先怀疑泄漏或长期持有对象问题。
3. Young GC 与 Full GC 的关系
还要看:
- Young GC 是否先明显增多
- 对象是不是快速晋升到老年代
- Full GC 是在一波 Young GC 之后被迫触发,还是老年代本身已经长期顶住
这能帮你区分:
- 是短命对象制造太快
- 还是老对象根本清不掉
4. 业务指标是否同步恶化
结合看:
- RT
- 错误率
- 吞吐
- 线程池队列
- CPU
- 下游调用耗时
因为有时候你看到的是“GC 很多”,但真正拖垮服务的,可能是更上游的业务路径:比如报表任务、慢 SQL、缓存预热、批量 JSON 组装。
三、排查顺序要立住:先分泄漏和分配压力,再决定要不要调参数
这是这篇文章最核心的结论。
很多人看到 Full GC 频繁,第一反应是:
- 增大 Xmx
- 调整新生代比例
- 换收集器
- 先扩容机器
这些动作都可能在某些场景下有效,但如果根因是泄漏或长生命周期对象错误持有,那么它们最多只是把爆炸时间往后推。
所以更稳的排查顺序应该是:
- 先确认是否真的频繁,并且已经影响业务
- 先看 Full GC 后内存能不能回下来
- 优先判断是不是内存泄漏 / 长期持有对象问题
- 如果不像泄漏,再判断是不是分配过快、批任务冲击、堆配置不匹配
- 最后再决定是否抓 dump、是否调参数
这一步之所以重要,是因为泄漏和非泄漏问题,后面的处理动作完全不同。
四、怎么优先判断是不是内存泄漏
内存泄漏不一定会立刻 OOM,但非常常见的早期表现就是:
- Full GC 越来越频繁
- 老年代基线越来越高
- 回收之后还是降不下来
- 服务运行越久越危险
最典型的泄漏信号
如果你同时看到下面几条,就该把“是不是内存泄漏”放到最高优先级:
- Full GC 后 Old 区下降不明显
- 每次回收后,下一轮触发越来越快
- 内存基线随时间持续抬升
- 问题和流量高峰不完全同步,低峰也回不去
- 堆中某类对象长期占大头
常见泄漏来源
线上项目里更常见的,不是“JVM 回收器失效”,而是代码层的错误持有:
- 静态
Map/List持续累积数据 - 本地缓存没有上限,只增不减
ThreadLocal用完没清理- 监听器、回调、注册表对象未注销
- 单例 Bean 持有大量业务对象
- 某些上下文对象生命周期被无意拉长
什么时候应该进一步做 dump 分析
如果你已经看到:
- Full GC 后内存明显回不去
- 问题持续恶化
- 监控只能告诉你“回收无效”,但无法告诉你“谁占住了内存”
那就不要只停留在 GC 日志层面了,应该进入堆转储分析。
因为 GC 日志回答的是:
- GC 怎么发生
- 频率如何
- 回收是否有效
而 dump 才能回答:
- 到底是谁占住了内存
- 哪类对象活得太久
- 引用链最后卡在谁手里
五、如果不是内存泄漏,最常见的还有哪几类原因
很多 Full GC 问题最后并不是“经典泄漏”,而是下面这些场景。
1. 对象创建过快,导致晋升压力过大
典型场景:
- 大量 JSON 反序列化 / 序列化
- 一个请求里反复创建中间 DTO
- 大列表多次
stream/map/collect - 大批量组装复杂对象树
这类问题常见表现:
- Young GC 会先变多
- 晋升到老年代的速度变快
- Full GC 看起来频繁,但回收后又能降一些
- 问题更容易出现在高峰流量或高耗时接口上
这种情况下,重点不是找“泄漏对象”,而是回到对象创建路径:
- 是否一次请求创建了太多临时对象
- 是否能分页、分段、流式处理
- 是否有大量无意义的对象复制和转换
2. 批任务 / 报表 / 导出制造了内存峰值
这类在线上非常常见,而且特别容易误判成“JVM 配置不合理”。
典型场景:
- 一次性查几十万行到内存
- 报表导出先把结果全部拼完再写出
- 批量同步时整批数据全装进集合
- 上传和解析大文件时一次性占用大块内存
它的特点通常是:
- 问题和固定时间点强相关
- 平时服务没事,一跑任务就抖
- Full GC 更像被任务窗口触发
- 回收后能降一些,但任务期间反复触发
这里最有效的优化通常不是继续加堆,而是:
- 分页
- 分批
- 流式处理
- 控制单次批量规模
3. 本地缓存或集合持续膨胀
很多 Full GC 的根因,最后会落到缓存设计。
比如:
- 本地缓存没设上限
- 淘汰策略不合理
- 按用户维度缓存大对象
- 预热之后没有清理
- 某些集合随着运行时间持续增长
这种问题处在“泄漏”和“非泄漏”的中间地带:
- 从 JVM 角度看,对象是可达的,不一定叫技术意义上的泄漏
- 但从工程效果看,它和泄漏一样会让老年代越来越难受
所以这里不要纠结术语,重点是:
这些对象是不是长期占着内存,并且没有清晰边界。
4. 堆配置与业务负载不匹配
这类问题确实存在,但它应该在前面几类排除之后再考虑。
更像配置问题时,常见特征是:
- 流量一上来就紧张
- Full GC 多发生在峰值时段
- 回收后内存还是能明显下降
- 没有特别突出的可疑对象画像
这时可能需要考虑:
- 堆是否过小
- 新生代 / 老年代比例是否不合理
- 当前实例规格是否承接不了业务峰值
但注意:配置问题往往是放大器,不一定是根因本身。
六、GC 日志、监控、dump 到底该怎么配合看
很多人会问:到底该先看日志,还是先抓 dump?
更稳妥的顺序通常是:
1. 先看监控和趋势
回答:
- 问题从什么时候开始
- 是偶发、周期性,还是持续性
- 是否伴随业务指标恶化
2. 再看 GC 日志
回答:
- Full GC 触发频率
- 停顿时间
- 回收前后各代内存变化
- 回收有没有效果
3. 最后在必要时做 dump
回答:
- 谁在占用内存
- 哪条引用链没有释放
- 哪类对象画像最可疑
这三者的边界要分清
- 监控:告诉你问题何时开始、影响多大、趋势如何
- GC 日志:告诉你 JVM 怎样回收、回收是否有效
- dump:告诉你到底是谁活着不该活
如果顺序反了,比如一上来就抓 dump,很容易成本高、信息多,但方向并不清晰。
七、Full GC 场景里最容易出现的误判
1. 一看到 Full GC 就先调大堆
有时会缓解,但如果是泄漏、缓存膨胀或长引用链问题,只是晚点炸。
2. 看到 CPU 高,就默认 GC 是根因
CPU 和 GC 会互相影响。
- 可能是 GC 导致 CPU 高
- 也可能是对象创建风暴先把 CPU、内存一起推高
- 还可能是批任务 / 慢接口造成对象分配压力,GC 只是后果之一
所以不要看到 CPU 高 + Full GC 多,就直接把锅全甩给 JVM 参数。
3. 只看某一个时刻的截图,不看趋势
GC 问题最怕只看瞬时值。
一张高内存截图,只能说明“此刻内存高”; 真正有价值的是:
- 回收后是否下降
- 下降幅度如何
- 基线是不是越来越高
4. 把“对象很多”直接等同于“内存泄漏”
对象多不一定泄漏。
可能只是:
- 短时流量高
- 单次任务太重
- 临时对象制造太快
- 堆配置偏小
泄漏的关键不是“对象多”,而是:
本来应该释放的对象,没有被释放。
5. 只盯 JVM,不回到业务行为
很多 GC 问题最终根因都不在 JVM 参数,而在业务处理方式:
- 一次性查太多
- 大对象缓存太久
- 批量任务太贪心
- 对象复制链条太长
JVM 只是把业务设计的问题放大出来了。
八、一个更接近实战的判断顺序
如果你线上现在就遇到了 Full GC 频繁,可以按下面这条顺序走:
第 1 步:先确认问题形态
- 偶发还是持续
- 固定时间点还是全天持续
- 发布后出现还是跑久了才出现
- 是否已经影响 RT、错误率、吞吐
第 2 步:先看回收效果
重点只问一件事:
- Full GC 后,老年代能不能明显回下来?
如果回不下来,优先怀疑:
- 内存泄漏
- 长生命周期对象
- 缓存 / 集合持续膨胀
如果能回下来,但很快又顶上去,优先怀疑:
- 对象创建过快
- 批任务冲击
- 峰值负载下堆配置偏紧
第 3 步:结合业务时间点判断场景
- 是否总在报表、导出、同步时触发
- 是否与某个新接口、新功能上线时间重合
- 是否只有高峰期明显
第 4 步:必要时进入 dump
当你已经基本确认“回收无效,老对象回不去”时,再去抓 dump,性价比最高。
第 5 步:最后才决定是否调参数
- 如果是容量问题,可以调参数
- 如果是对象生命周期和代码结构问题,先修代码
- 如果是批任务问题,先改处理方式
九、FAQ:几个最常见的问题
1. Full GC 频繁是否一定意味着内存泄漏?
不一定。
它也可能是:
- 对象创建过快
- 批任务制造瞬时内存峰值
- 大对象分配过猛
- 堆配置太小
但如果 Full GC 后内存长期回不下来,就必须优先怀疑泄漏或长期持有对象问题。
2. 直接把堆调大,是不是最简单的解法?
不是。
如果只是容量略紧,调大堆可能有帮助;但如果根因是泄漏、缓存无边界、批任务设计不合理,调大堆只是延后暴露时间,还可能让停顿更重。
3. 没有 OOM,但 Full GC 已经很频繁,这说明什么?
这通常说明问题还处在“还能撑住,但已经明显异常”的阶段。
这时候最有价值,因为:
- 现场还在
- 证据还完整
- 还没发展到彻底 OOM
很多真正的内存问题,都会在 OOM 之前先经历一段 Full GC 越来越频繁的阶段。
4. 什么时候应该优先看 GC 日志,什么时候应该直接抓 dump?
默认先看 GC 日志和趋势,因为它们能最快回答“回收是否有效”。如果已经看到 Full GC 后老年代长期降不下来,而且问题持续恶化,再抓 dump 的性价比最高。
5. 什么时候必须抓 dump?
当你已经看到以下信号时,抓 dump 往往绕不过去:
- Full GC 后老年代长期降不下来
- 内存基线持续上升
- 问题越来越重
- 需要定位到底是哪类对象、哪条引用链在占内存
十、止血动作和长期治理,不是一回事
短期止血可以做什么
- 临时限流或摘流量
- 暂停批任务 / 导出 / 同步任务
- 回滚最近变更
- 必要时扩容实例,先把业务撑住
- 在确定风险后,再抓现场信息
长期治理应该回到哪里
- 缩短对象生命周期
- 限制本地缓存和集合边界
- 重构批量处理方式,改成分页 / 分批 / 流式
- 减少无意义的对象复制与中间对象
- 让 JVM 参数调整基于真实监控和压测,而不是靠猜
真正的目标不是“让 GC 日志好看一点”,而是让业务对象模型、数据规模和实例容量真正匹配。
十一、如果不只是一条 GC 线,再补哪些证据
如果你已经确认 Full GC 频繁,但还没判断它是单点 JVM 问题,还是已经拖到应用层,可以按现场缺的证据继续补:
- 想先排除 CPU 分支是不是一起出问题:
Java 服务 CPU 高怎么排查:一套更稳的线上定位顺序 - 接口 RT、超时和 GC 抖动一起出现:
接口响应慢怎么排查?后端 API 变慢与超时的定位步骤 - 已经怀疑内存泄漏,准备看 dump 和 MAT:
Java 内存泄漏怎么定位?从 Heap Dump 到 MAT 实战 - 现场已经接近 OOM,需要先保住证据:
Java 服务 OOM 了怎么排查?一条常用思路讲清楚 - 想把 jstack / jmap / jstat 的分工放回同一组证据里:
线上问题排查时,jstack、jmap、jstat 分别怎么看 - 线程池、慢接口也一起恶化,怀疑 JVM 问题已经外溢到应用层:
线程池打满以后,应该先查队列、拒绝策略还是慢任务?
十二、最后总结:先判断是不是内存泄漏,再谈参数
Full GC 频繁最怕的不是难,而是乱。
一乱,团队就很容易直接做这些动作:
- 先调参数
- 先扩容
- 先换 GC
- 先猜某个 JVM 配置有问题
但更稳的做法应该是:
- 先看它是否真的影响业务
- 先看趋势和回收效果
- 先判断是不是内存泄漏
- 再区分对象晋升过快、批任务冲击、缓存膨胀或容量问题
- 最后才决定要不要深入 dump 或调参数
只要这个顺序立住,Full GC 问题就不会再显得那么玄学。它会重新变回一个可以拆证据、做判断、逐步收敛的工程问题。