Java

CPU 高和 GC 抖同时出现时,先证明谁是因谁是果?

CPU 飙高和 GC 频繁一起出现时,最怕的是一上来就站队。把时间线、线程归属、对象分配速率和回收效果对齐后,才能看清到底是 GC 在推高 CPU,还是业务热点先把 GC 带坏。

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

线上一旦出现 CPU 持续高位,同时 Young GC / Full GC 也开始明显抖动,团队很容易很快分成两派:有人盯着 GC 图怀疑 JVM 顶不住了,有人盯着热点线程认定是业务代码先出问题。

两边都不算错,真正麻烦的是:谁先下结论,谁就可能先走偏。CPU 和 GC 同时异常时,最耗时间的往往不是不会抓栈、不会看指标,而是把因果顺序看反了。

所以这篇文章只聚焦一件事:

当 CPU 高和 GC 抖一起出现时,先把谁领先、谁跟随、谁只是放大器这件事坐实。

这件事不需要玄学,通常把四组证据摆在一起就够了:时间线、线程归属、对象分配速率、以及 GC 回收效果。

先收住争论,再对齐证据

如果现场已经开始争“先查 CPU 还是先查 GC”,我会先把 5 组证据摆在一起:CPU 曲线、YGC / FGC 次数、Heap / Old 区趋势、线程归属,以及业务流量和线程池变化。CPU 和 GC 一起异常时,最容易错的不是命令没跑,而是因果顺序看反;时间线和线程归属没对齐,后面的分析很容易站错边。

先过一遍这张证据表,判断这次是 CPU 先坏,还是 GC 先拖。

你现在看到的现象更像什么下一步
CPU 高和 GC 抖几乎同时出现,不确定谁先把系统拖坏先把时间线和线程归属对齐继续看本文
CPU 明显高,但 GC 只是轻微波动更像 CPU 热点先起来先看 Java CPU 飙高怎么排查:一套线上定位顺序
Full GC 已经频繁、Old 区回不下来更像 GC / 内存专题Full GC 频繁怎么办:先判断是不是内存泄漏
Young GC 很密,但 Full GC 还不突出,吞吐先掉了更像年轻代中间态Young GC 很频繁但没 Full GC,为什么吞吐还是掉得厉害?
服务更像全局冻结或停顿,而不是单纯 CPU 高更像停顿 / Safe Point 这一类问题JVM Safe Point 太久,为什么服务会像“卡死”一样?

哪些时候先判因果,哪些时候直接下钻

如果你现在最大的分歧是“先查 CPU 还是先查 GC”,这篇更适合拿来收住站队:把时间线、线程归属和对象分配拉到同一张图上,看看到底是谁先把系统拖坏。

但有两种情况,不用在这里停太久:

  • 如果你已经确认是 CPU 热点先起来,直接回 Java CPU 飙高怎么排查:一套线上定位顺序 查线程和代码热点。
  • 如果你已经确认是 GC 压力先起来,直接切到 GC / 内存那条线查对象分配和回收效果。
  • 只有在 两边一起异常,团队还在争论先查哪边 时,这篇才最值钱。

这篇要解决的,不是把 CPU 和 GC 所有问题都讲完,而是把“谁是因、谁是果、谁只是放大器”这件事坐实。

一、为什么 CPU 和 GC 一起异常时最容易误判

因为它们本来就会互相放大。

1. GC 可以把 CPU 顶高

例如:

  • 对象分配速率过快
  • 老年代回收压力过大
  • Full GC 频繁
  • GC 线程持续抢占 CPU

这时你看到的是 CPU 高,但更早出现的其实可能是:

  • 堆使用率升高
  • YGC 频率变密
  • Full GC 开始出现
  • GC 停顿和回收耗时逐渐抬升

2. CPU 热点也可以把 GC 带坏

例如:

  • 某段代码高频创建临时对象
  • 异常风暴不断构造栈信息
  • 重试逻辑疯狂序列化 / 反序列化
  • 批量任务把大量对象一次性装进内存

这时先抬头的可能是业务线程 CPU,而 GC 只是跟着对象分配压力一起恶化。

3. 两者也可能都不是起点,而是共同结果

例如:

  • 缓存大面积失效,回源后请求链路变重
  • 下游变慢,线程堆积,重试增加
  • 数据库慢,单次任务拉长,队列堆积,内存和 CPU 一起抖

也就是说,CPU 和 GC 并不一定是彼此的因果,也可能是被更上游的问题一起推高。

所以这类问题最怕的不是复杂,而是你一开始就站错边。

二、第一步别急着抓栈,先把时间线立住

判断因果,第一件事不是命令,而是时间顺序。

你真正要回答的是:

  1. CPU 先开始抬高,还是 GC 指标先变差?
  2. 它们是同一时间一起抬头,还是一个领先另一个几分钟?
  3. 在它们抬头之前,业务流量、接口 RT、线程池、数据库、缓存有没有先变化?

更建议一起看的指标

  • 实例 CPU 使用率
  • load / 上下文切换
  • YGC 次数、FGC 次数、GC pause
  • Heap / Old 区趋势
  • 对象分配速率
  • QPS、RT、错误率
  • 线程池 active、queue、reject
  • 数据库连接池 active、pending
  • 下游调用 RT、超时率

三种最常见的时间形态

1. GC 先抖,CPU 后抬

更像:

  • 内存回收压力先变大
  • GC 线程开始持续抢 CPU
  • 业务线程随后被拖慢

这时更优先怀疑:

  • 对象分配风暴
  • 老年代回不下来
  • Full GC 频繁
  • 本地缓存或长生命周期对象膨胀

2. CPU 先抬,GC 后跟着恶化

更像:

  • 某个热点代码路径先把 CPU 顶高
  • 同时制造大量对象
  • GC 随着分配速率一起被放大

这时更优先怀疑:

  • 死循环或空转
  • 高频重试
  • 日志 / 异常风暴
  • 大对象转换、序列化、聚合

3. 两者几乎同时抬头

更像:

  • 更上游有共同触发因素
  • 比如流量暴涨、缓存失效、批任务启动、最近一次发布引入了更重链路

这时不能只看 JVM,必须把业务链路一起拉进来。

三、第二步:先看“是谁在吃 CPU”,再看“GC 到底回得动吗”

时间线立住后,下一步要把两个方向都拉出证据。

1. 先看 CPU 到底主要被谁吃掉

不要只看实例总 CPU,要继续分层:

  • 是 GC 线程占比明显高
  • 还是业务线程占比更高
  • 是少数热点线程持续高
  • 还是大量线程一起抬升

常见做法仍然是:

top -Hp <pid>
pidstat -t -p <pid> 1 5
jstack <pid>

你这一步真正想回答的是:

  • 高 CPU 线程是不是主要落在 GC ThreadVM Thread 这类 JVM 线程上
  • 还是更多落在业务线程、线程池工作线程、Netty / Tomcat 线程上
  • 多次抓栈后,热点栈位置是否稳定重复

如果主要是 GC 线程高

更像:

  • 回收压力真的已经在抢 CPU
  • GC 至少不是“完全无关的旁观者”

但注意,这也不等于问题就一定是 JVM 参数。GC 线程高,只能说明 GC 很忙,还不能说明 为什么这么忙

如果主要是业务线程高

更像:

  • 业务代码本身有计算热点
  • 或者它在疯狂制造对象
  • GC 是被业务路径带起来的

这种情况下,先直接把锅扣给 GC,通常会偏。

2. 再看 GC 到底是“频繁”还是“无效”

判断 GC 时,不能只看次数,更要看回收效果。

例如:

jstat -gcutil <pid> 1000 10

你重点要看:

  • YGC / FGC 是否突然增多
  • Full GC 后 Old 区是否明显回落
  • 回落之后是不是很快又顶上去
  • 内存基线是不是在持续上移

如果 GC 频繁,但回收后能明显降下来

更像:

  • 对象制造太快
  • 任务窗口制造内存峰值
  • CPU 热点和对象分配风暴一起出现

这类问题未必是传统泄漏,更常见的是任务执行方式或热点路径退化。

如果 GC 频繁,而且回收后也降不下来

更像:

  • 长生命周期对象堆积
  • 缓存膨胀
  • ThreadLocal、静态集合或引用链没释放
  • Full GC 已经在做“低效挣扎”

这时 GC 更可能是受害者,但它已经成了 CPU 高的重要放大器。

四、第三步:把问题归到 3 类典型因果模型里

把线程和回收效果都看过后,现场通常可以收敛到下面三类之一。

模型一:GC 是主因,CPU 是结果

典型特征

  • GC 指标先恶化
  • GC 线程 CPU 占比明显
  • Full GC 或 YGC 增长非常快
  • 回收后内存下降有限,或者下降后很快再冲高
  • 业务线程不一定特别热,但整体 RT、吞吐开始变差

常见真实根因

  • 老年代对象回不下来
  • 缓存 / 集合长期膨胀
  • 批量任务制造了巨大的对象峰值
  • Heap 配置和业务模型不匹配

这时更该继续往哪里查

模型二:业务 CPU 热点是主因,GC 是结果

典型特征

  • 业务线程先热起来
  • 热点线程栈稳定落在业务方法、序列化、计算逻辑、异常构造或重试逻辑上
  • YGC 会跟着变密,但 Full GC 未必先抬
  • 内存回收仍然有效,只是分配速率太快

常见真实根因

  • 大量对象临时创建
  • 高频 JSON 转换、Map / List 复制
  • 批量聚合、报表、导出路径过重
  • 错误重试风暴
  • 日志和异常对象创建过于激进

这时更该继续往哪里查

模型三:更上游的等待链同时把 CPU 和 GC 带坏

典型特征

  • CPU、GC、RT、线程池、连接池一起抬头
  • 单看 JVM 指标无法解释全部现象
  • 热点线程既有业务处理,也有等待和重试痕迹
  • 问题常和流量高峰、缓存失效、数据库慢、发布变更同步出现

常见真实根因

  • 缓存失效后回源链路变重
  • 下游超时导致重试和补偿放大
  • 数据库变慢导致任务堆积,线程、对象和 CPU 一起被拖高
  • 线程池堆积后任务集中释放

这时更该继续往哪里查

五、一个更稳的实战排查顺序

如果线上现在就碰到了“CPU 高 + GC 抖”,我更建议按这个顺序走。

第 1 步:先看时间线

  • CPU 和 GC 谁先抬头
  • RT、QPS、错误率是否同步变化
  • 最近是否有发布、批任务、流量、缓存或数据库事件

第 2 步:拆开 CPU 归属

  • top -Hp
  • pidstat -t
  • 多次 jstack
  • 分清是 GC 线程忙,还是业务线程忙

第 3 步:看 GC 回收效果

  • YGC / FGC 次数
  • Full GC 后 Old 是否下降
  • 内存基线是否持续抬高

第 4 步:看对象分配和业务链路

  • 是否有明显对象制造热点
  • 是否存在批量任务、报表、重试、异常风暴
  • 线程池和连接池是否也在同一时间窗变差

第 5 步:最后再决定主线

  • 更像 GC 先恶化,就走 JVM / 内存方向
  • 更像业务线程先热,就走 CPU / 代码热点方向
  • 更像共同结果,就回到接口、线程池、数据库、缓存链路上

这一步的重点不是“找到唯一理论”,而是先排除最容易走偏的路线。

六、一个典型例子:为什么“先看 GC”会误判

假设某个实例在高峰期突然出现:

  • CPU 从 35% 升到 92%
  • YGC 次数明显增多
  • 接口 RT 抖动
  • 大家第一反应是 GC 出问题

如果你只看 GC 图,很容易直接去调堆、调参数。

但继续往下看:

  1. top -Hp 发现主要高 CPU 线程不是 GC 线程,而是业务线程池
  2. 连续三次 jstack 发现热点栈长期落在 serializeAndBuildResponse
  3. jstat 看到 YGC 变密,但 Full GC 并不突出,而且回收后内存能明显回落
  4. 同一时间窗内某个新接口开始返回超大响应,导致对象创建和序列化成本一起飙升

这时真正的链路就很清楚:

  • 先是业务线程 CPU 高
  • 同时制造大量临时对象
  • YGC 频率被带高
  • 大家看见 GC 抖,误以为 JVM 是起点

问题根因其实不在 GC,而在接口返回模型和对象制造成本。

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

误判 1:看到 GC 次数高,就默认 GC 是根因

GC 次数高只能说明 GC 很忙,不能说明 是谁先把它搞忙的

误判 2:看到 CPU 高,就完全忽略内存趋势

很多业务 CPU 热点最后会演变成对象分配风暴,如果你不看 Old 区和回收效果,就很容易漏掉“CPU 正在把 GC 一起带坏”。

误判 3:只抓一次 jstack 就下结论

单次线程栈只能说明瞬间位置,不能证明持续热点。CPU 和 GC 都是动态问题,证据必须有时间维度。

误判 4:把 JVM 问题和线程池、数据库、下游等待完全拆开看

真实线上里,它们经常就是一条链。线程池堆积、下游超时、数据库慢,都可能让对象积压、重试增加,最后把 CPU 和 GC 一起推高。

误判 5:先调参数,再去定因果

参数调整有时能止血,但如果因果还没分清,后面很容易把现场洗掉。

八、CPU 高和 GC 抖一起出现时,最容易问偏的几个问题

1. 这种现场到底先抓 CPU 还是先抓 GC?

先抓能还原因果的证据,不是先选阵营。通常第一轮就把 CPU 曲线、GC 次数、热点线程、Heap / Old 趋势和业务时间线放在同一个时间窗里。

2. GC 线程 CPU 很高,能不能直接认定 GC 是根因?

还不能。它只能说明 GC 已经非常忙,接下来还得继续问:是分配速率突然上来了,还是老年代对象回不下去,或者更上游链路把 JVM 一起拖进来了。

3. 业务线程 CPU 很高,是不是就可以先不看 GC?

也不行。很多业务热点会顺手制造大量短命对象,GC 很可能只是慢半拍跟着恶化。只看 CPU 热点,不看回收效果,后面还是容易漏掉真正的放大点。

4. YGC 很密但 Full GC 不多,这更像什么?

更像短命对象制造过快,常见在线上序列化、批量聚合、异常风暴、重试放大这些路径。这个时候比调参数更重要的,是先把对象是谁造出来的找出来。

5. 什么时候 Heap Dump 才值得优先抓?

当你已经看到 Full GC 后 Old 区回不下来、内存基线持续上移,或者怀疑长生命周期对象在堆里堆住了,再往 Heap Dump 和 MAT 方向走,收益通常更高。

如果第一轮证据已经开始收敛

可以直接顺着最像的方向继续下钻:

九、如果你准备继续补证据

如果你需要把这次判断继续往下拆,下面这些文章更像下一步该补哪一类证据,而不是单纯给你一串目录。

这篇更接近哪类问题

它属于 JVM 与 Java 性能排查,但重点不是单看 JVM 参数,而是先把 CPU、GC 和业务链路的因果顺序钉住。

想继续把 JVM 这一段查细

如果你开始怀疑 JVM 只是其中一段

手里证据不同,下一步也不同

  1. 先把这篇里的 时间线 -> 线程归属 -> 回收效果 -> 业务链路 走完,别急着先站 CPU 还是 GC。
  2. 如果证据更像 GC 先恶化,就接着看《Full GC 频繁怎么办:先判断是不是内存泄漏》和《Java 内存持续上涨但没有 OOM,通常意味着什么?》。
  3. 如果证据更像业务线程先热,就继续查《Java CPU 飙高怎么排查:一套线上定位顺序》。
  4. 如果你已经看出 JVM 只是链路中间一段,就把线程池、数据库、接口慢这些方向一起带回来核对。

十、最后总结:先把因果顺序钉住,再谈优化动作

CPU 和 GC 一起抖时,最浪费时间的不是不会排查,而是太早选边。

真正能帮现场收敛的,往往只是几件朴素的事:把时间线对齐、把热点线程和 GC 线程拆开看、确认回收到底有没有效果,再把业务流量和上下游变化拉回来一起解释。

先把谁领先、谁跟随、谁只是放大器说清楚,再决定该往 JVM、代码热点,还是更上游调用链继续追。

这一步做扎实以后,CPU 高和 GC 抖就不会再只是两张吓人的图,而会变成一条可验证、可复盘的因果链。