Java

Young GC 很频繁但没 Full GC,为什么吞吐还是掉得厉害?

Young GC 已经越跑越密,但还没出现明显 Full GC 时,服务照样可能先掉吞吐。排这类现场,关键不是盯着 Old 区等事故升级,而是对齐对象分配、停顿频率、CPU 归属和业务推进节奏,找出真正把系统拖慢的那一段。

  • Java
  • JVM
  • GC
  • 吞吐下降
  • 性能排查
17 分钟阅读

很多团队看到 GC 图时,注意力都会先落在 Full GC 上。

这很正常,因为 Full GC 更像事故信号:

  • 停顿更重
  • 听起来更危险
  • 常常和 OOM、老年代高位一起出现

但真实线上里,有一类问题更容易被低估:

  • Full GC 并不明显
  • 老年代也未必马上打高
  • 可 Young GC 已经越来越密
  • 接口吞吐、任务推进速度、批处理效率却在持续变差

这类现场最容易冒出一句判断:

既然还没到 Full GC,那应该不算太严重吧?

这句话往往不稳。

因为 吞吐下降并不需要等到 Full GC 才发生。如果年轻代回收已经足够频繁,JVM 即使还没进入真正的老年代危机,也完全可能先在下面几件事上被拖坏:

  • 业务线程不断被短暂停顿切碎
  • 对象分配速率过高,让 CPU 和分配器都很忙
  • 大量短命对象制造无效工作
  • 批任务和高峰流量把年轻代回收频率打到过密

所以这篇文章要回答的,其实是一个很实际的问题:

Young GC 还停留在“频繁但不算失控”的阶段时,为什么接口和任务已经开始掉速?

我的经验是,这种时候别急着把结论写成“参数不合适”或者“还没到大问题”。更有用的做法,是把 GC 频率、对象制造速度、CPU 去向和业务时间线并排看,确认系统到底是被短命对象冲垮、被批量模型打密,还是被过紧的年轻代余量卡住。

如果 Young GC 很频繁,但还没进 Full GC

这张表可以先帮你确认,你碰到的是不是这类“还没 Full GC,系统已经先变钝”的现场。

你现在看到的现象更像什么下一步
Young GC 越来越密,吞吐和 RT 开始变差,但 Full GC 还不明显年轻代中间态沿年轻代这条线把证据补齐
CPU 高和 GC 抖一起出现,不确定谁先把系统拖坏更像 CPU / GC 因果判断CPU 高和 GC 抖同时出现时,先证明谁是因谁是果?
Full GC 已经频繁,Old 区和回收效果都在恶化更像 Full GC 主问题Full GC 频繁怎么办:先判断是不是内存泄漏
老年代没满,却已经频繁 Full GC更像异常触发型 Full GC老年代没有打满却频繁 Full GC,通常意味着什么?
线程池没有明显堆积,但任务整体推进慢也可能是执行链路本身慢线程池队列不长但任务还是慢,常见瓶颈在哪里?

你是不是卡在 Young GC 这一段

这篇重点处理的,是 Young GC 已经明显变密,但问题还没走到 Full GC 主阶段 的那段中间态。

换句话说,我不是想泛讲所有 GC 问题,而是想先帮你回答一个值班现场里很常见的疑问:Full GC 还没闹大,为什么系统已经先变钝了。要是眼前已经是 Old 区回不下来、Full GC 频繁、内存基线抬高,那就别停在这里,直接去看更靠后的 GC / 内存问题。

一、为什么“没有 Full GC”会制造错觉

因为很多人默认 JVM 问题的严重程度大概是这样排序的:

  • 有 Full GC = 很严重
  • 没 Full GC = 还好

这条直觉只对一半。

Full GC 更像什么

更像:

  • 老年代或更重回收阶段已经明显承压
  • 单次停顿和整体回收成本都更高
  • 风险常常逼近 OOM、老对象回不下去或更重的回收退化

频繁 Young GC 更像什么

更像:

  • 年轻代在被对象分配速率持续打满
  • 回收虽然还能做得动,但做得太频繁
  • JVM 花在分配与回收之间来回折腾的时间变多
  • 业务线程被不断打断

所以你不能因为“还没有 Full GC”,就默认吞吐损失不大。

很多服务真正先崩的不是堆,而是:

  • RT 先抖
  • QPS 先掉
  • CPU 先忙起来
  • 批任务先越来越慢

二、第一步先确认:是 GC 真的在拖吞吐,还是两者只是同时出现

看到 Young GC 变密时,不要立刻把锅扣给 JVM。先回答几个更关键的问题:

  1. 吞吐下降和 Young GC 变密,是不是发生在同一时间窗?
  2. Young GC 抬头之前,对象分配速率、流量、批任务、接口返回体有没有变化?
  3. CPU 主要忙在 GC 线程,还是业务线程本身?
  4. 回收后 Heap 能不能明显回落?

更建议一起看的指标

  • YGC 次数和间隔
  • GC pause 时间
  • Heap / Eden 使用趋势
  • 对象分配速率
  • QPS、RT、吞吐
  • CPU 使用率
  • 热点线程归属
  • 线程池 active / queue

这一步真正想证明的不是“GC 多了”,而是:

GC 的变密,是否已经足够频繁到影响业务线程推进速度。

三、为什么 Young GC 很频繁时,吞吐会先掉

即使每次 Young GC 停顿都不算特别长,吞吐还是可能明显下降。

1. 业务线程被高频短暂停顿不断切碎

这类问题特别容易被低估。

因为单次 Young GC 看起来可能只有:

  • 几毫秒
  • 十几毫秒
  • 甚至几十毫秒以内

单看一次并不刺眼。

但如果频率已经变成:

  • 几百毫秒一次
  • 甚至更高频

那业务线程的推进就会变得非常碎。

结果是:

  • 请求处理节奏被切断
  • 批量任务推进不连贯
  • 吞吐下降比单次停顿看起来更明显

2. 对象分配和回收本身就在吃 CPU

Young GC 很频繁,常常意味着另一件事:

  • 短命对象创建得很多
  • 分配器很忙
  • 回收线程也很忙

这时即使 Full GC 没来,CPU 也可能已经在做很多“无效但不得不做”的工作。

典型场景包括:

  • 大量 JSON 序列化 / 反序列化
  • DTO / VO / Map 反复复制
  • 大批量流式转换结果先全量收集
  • 异常对象、日志对象、临时字符串生成过多

3. 年轻代回收频率太高,会放大任务模型本身的问题

比如:

  • 一个接口本来就做了很多对象拼装
  • 一个批任务本来就按大批量加载和组装
  • 一个异步消费线程本来就单次处理太重

在 GC 还不密的时候,它可能只是“有点重”;一旦 YGC 被打密,吞吐就会先明显变差。

也就是说,GC 有时不是唯一根因,但它会把业务模型里的低效放大出来。

四、最常见的 4 类根因:它们都会让 YGC 很密、吞吐先掉

1. 短命对象风暴

这是最常见的一类。

典型特征:

  • Old 区暂时不一定高
  • Full GC 还不突出
  • 但 Eden 很快被打满
  • YGC 间隔越来越短

常见来源:

  • 大量字符串拼接
  • 大量 JSON 对象构造
  • 结果集转多层 DTO / VO
  • stream().map().collect() 链式对象复制过多
  • 高并发下反复创建临时容器对象

这类问题的典型后果不是“立刻 OOM”,而是:

  • CPU 升
  • 吞吐掉
  • RT 抖
  • YGC 变密

2. 批任务、导出、报表把年轻代打穿

固定时间窗口的异常,很常落在这里。

例如:

  • 导出任务先把大量数据查到内存
  • 同步任务按大批次装配对象
  • 定时任务集中处理大量记录
  • 缓存预热一次性塞太多中间对象

这类问题经常表现为:

  • 固定时间窗 YGC 特别密
  • 任务期吞吐明显变差
  • 任务结束后又恢复

如果不先看时间线,很容易误以为 JVM 参数突然“不稳定”。

3. 接口返回模型、序列化链路过重

有些线上退化,不是数据库慢,也不是代码死循环,而是:

  • 接口返回体变大
  • 聚合层对象组装过深
  • 序列化成本和短命对象一起飙升

这时你会看到:

  • 业务线程 CPU 高
  • YGC 也跟着变密
  • Full GC 未必马上明显

这种现场尤其容易和“单纯 CPU 高”混着看。

4. 年轻代容量边界本身偏紧

也有一类问题,确实和容量边界有关。

例如:

  • 高峰对象分配量明显高于原设计
  • 堆整体不算小,但年轻代承接不了当前分配速率
  • 流量结构变了,短命对象比例远高于过去

这时问题不一定是经典代码 bug,但也不能只说“调大点就好”。

更稳的做法仍然是先回答:

  • 现在为什么会制造这么多短命对象
  • 是业务模型变了,还是容量设计没跟上

五、怎么区分“对象制造太快”和“GC 参数真的偏紧”

这是很容易争论的一步。

更像对象制造太快的信号

  • YGC 在业务高峰、批任务、特定接口期间明显变密
  • 业务线程热点落在序列化、对象组装、转换链路
  • 回收后 Heap 仍能明显回落
  • 问题和某个时间窗或流量结构强相关

更像容量边界偏紧的信号

  • 业务模型变化不大,但高峰一到就稳定触发很密的 YGC
  • 调整任务规模或流量后现象立刻改善
  • 对象制造不能说完全异常,但当前分配节奏已经超过年轻代承接能力

即便如此,也不要一上来就只盯参数。因为很多“参数紧”问题,本质上还是业务模型先变重了。

六、一个更稳的排查顺序

如果现在就遇到 “YGC 很频繁但没 Full GC,吞吐却明显掉” 的现场,我更建议按下面顺序走。

第 1 步:先对时间线

  • YGC 是什么时候开始变密的
  • 吞吐和 RT 是不是同时变差
  • 是否和发布、批任务、流量波峰、缓存预热同步出现

第 2 步:看 CPU 归属

  • 是 GC 线程更忙
  • 还是业务线程更热
  • 热点线程是否稳定落在对象创建、序列化、转换或批处理路径

第 3 步:看回收效果

  • YGC 之后 Eden 是否能明显清掉
  • Old 区是否暂时还算稳定
  • 是否存在内存基线持续上移的迹象

第 4 步:回到业务对象制造路径

  • 哪个接口或任务创建对象最多
  • 是否有大结果集、大 JSON、大批量转换
  • 是否存在无意义复制、聚合、临时容器堆积

第 5 步:最后再判断是否需要容量调整

  • 如果对象制造模型本身已经合理,但年轻代承接不了当前高峰,再考虑参数和容量边界
  • 如果对象制造本身就明显过重,先优化路径,再谈参数

七、关键误判:这类问题最容易在哪些地方走偏

误判 1:没有 Full GC,就说明问题不严重

错。

吞吐下降很多时候会先发生在高频 YGC 阶段,而不是等到 Full GC 才开始。

误判 2:YGC 很密,就一定是 JVM 参数问题

不一定。

更常见的根因是对象制造太快、任务模型太重、接口返回和序列化链路过大。

误判 3:只看 GC 图,不看业务时间线

这样最容易漏掉:

  • 定时任务
  • 导出窗口
  • 最近发布
  • 某个大接口上线

误判 4:看到 CPU 高,就完全不看 GC

很多对象制造风暴会同时把业务线程 CPU 和 YGC 一起带高。只站一边都容易偏。

八、FAQ:Young GC 很频繁但没 Full GC 时,最常被问到的几个问题

1. 没有 Full GC,还需要马上处理吗?

如果吞吐、RT、批任务时长已经一起变差,就值得马上处理。

这说明影响已经落到业务侧,不再只是 GC 面板上的数字变化。

2. YGC 很密,一定会继续恶化成 Full GC 吗?

不一定。

有些服务只是被短命对象和批量装配拖得很喘,Old 区未必立刻失守;但这并不妨碍它先把 CPU、RT 和任务吞吐拉坏。

3. 代码里最值得优先翻哪几类位置?

通常先翻这些地方:

  • 返回对象层层转换的接口
  • JSON 编解码很重的链路
  • 大结果集拉平、聚合、导出逻辑
  • 批任务一次吃太多数据的处理段
  • 高频打印异常、临时字符串很多的代码

4. 什么情况下再去碰 JVM 参数更合适?

当你已经确认对象制造路径没有明显浪费,而且高峰阶段依旧频繁把 Eden 打满,这时再去调整年轻代大小、回收参数或容量余量,收益才更稳。

5. 它和 CPU 高是不是经常一起出现?

是。

因为对象创建、回收线程和业务线程抢 CPU 时,常常会互相放大,最后表现成“GC 更密了,业务也更慢了”。

如果现场已经分岔,可以直接跳到这里

如果你接下来更像下面这些情况,可以直接跳到对应文章:

九、顺着这些线索一起看更顺手

如果眼前的现象还没走到 Full GC 主阶段,这篇更适合和下面几篇对照着看:重点是确认年轻代回收频率是不是已经把对象分配、CPU 和业务推进节奏一起拖坏。

同一组 JVM 线索

如果现场还牵着应用侧

我会怎么继续查

  1. 先用这篇把 YGC 频率 -> 对象分配速率 -> 吞吐下降 -> 业务模型 这条主线理顺。
  2. 如果 CPU 也在一起抖,就把 CPU 高和 GC 抖同时出现时,先证明谁是因谁是果? 放在旁边一起对照。
  3. 如果问题已经往 Full GC 方向演化,再接着看 Full GC 频繁怎么办:先判断是不是内存泄漏老年代没有打满却频繁 Full GC,通常意味着什么?
  4. 如果你怀疑 JVM 只是把更上游的任务模型问题放大,再把 线程池队列不长但任务还是慢,常见瓶颈在哪里?接口响应慢怎么排查?后端性能问题定位步骤 串起来看。

十、最后总结:吞吐先掉,不一定要等到 Full GC

YGC 很频繁但没有 Full GC,这种现场最容易被低估。

因为它看起来像“JVM 还没到最危险的时候”,但真实业务里,很多吞吐损失、RT 抖动和批任务退化,恰恰会先发生在这段中间态。

更接近现场的主线是:

  • 先证明 YGC 频率是否已经切碎业务线程推进
  • 再看对象分配是不是过快
  • 再回到接口、任务、序列化和批处理模型
  • 最后才决定容量边界和参数是不是要调

只要顺序不乱,Young GC 频繁就不会再只是“GC 图上有点密”,而会变成一条可以验证、可以复盘、也可以治理的吞吐下降证据链。