Java

Full GC 频繁怎么办:从回收效果分清泄漏和分配压力

Full GC 一频繁,最常见的错误动作就是先调 JVM 参数。但参数只能改表现,不能替你判断根因;先看回收效果、趋势和对象变化,才知道到底是泄漏、晋升过快,还是业务模型把堆顶住了。

  • Java
  • JVM
  • GC
  • 性能排查
19 分钟阅读

线上一旦开始频繁 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. 是偶发,还是持续发生?
  2. 是某个固定时间窗口出现,还是全天都在发生?
  3. 是发布后出现,还是服务跑一段时间后才出现?
  4. 它有没有明显影响业务指标?

这四个维度,几乎直接决定后面的排查方向。

几种常见形态,含义差别很大

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
  • 调整新生代比例
  • 换收集器
  • 先扩容机器

这些动作都可能在某些场景下有效,但如果根因是泄漏或长生命周期对象错误持有,那么它们最多只是把爆炸时间往后推。

所以更稳的排查顺序应该是:

  1. 先确认是否真的频繁,并且已经影响业务
  2. 先看 Full GC 后内存能不能回下来
  3. 优先判断是不是内存泄漏 / 长期持有对象问题
  4. 如果不像泄漏,再判断是不是分配过快、批任务冲击、堆配置不匹配
  5. 最后再决定是否抓 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 问题,还是已经拖到应用层,可以按现场缺的证据继续补:

十二、最后总结:先判断是不是内存泄漏,再谈参数

Full GC 频繁最怕的不是难,而是乱。

一乱,团队就很容易直接做这些动作:

  • 先调参数
  • 先扩容
  • 先换 GC
  • 先猜某个 JVM 配置有问题

但更稳的做法应该是:

  1. 先看它是否真的影响业务
  2. 先看趋势和回收效果
  3. 先判断是不是内存泄漏
  4. 再区分对象晋升过快、批任务冲击、缓存膨胀或容量问题
  5. 最后才决定要不要深入 dump 或调参数

只要这个顺序立住,Full GC 问题就不会再显得那么玄学。它会重新变回一个可以拆证据、做判断、逐步收敛的工程问题。