☰
CPU飙高、内存泄漏、GC频繁?Java线上问题排查实战思路
2026/10/5 3:43:30 网站建设 项目流程

前两天有个同事在群里喊:服务 CPU 飙到 300%,GC 日志刷得比流水还快,内存看着也在一路涨。我第一反应是让他先别动,把现场留好,别急着重启。这种“CPU 高、内存泄漏、GC 频繁”三兄弟同时现身的戏码,在 Java 后端服务里几乎每个月都要碰上一回。很多人看到这三个关键词就慌了,有人先去翻 heap dump,有人盯着 GC 日志发呆,还有的直接 kill -9 重启,结果第二天又原样复现。今天我不讲那些花哨的工具链,就按我这几年的实战顺序,把从接到告警到定位根因的完整思路、命令和踩过的坑一次说清楚。无论是刚接手 Java 服务排查的新人,还是被线上问题搞到头大的运维/开发,这套思路应该都能帮你少走弯路。

1. 先理清 CPU、内存、GC 到底是谁在拖谁

1.1 内存泄漏先“喂饱”老年代,再把 GC 拖入泥潭

Java 的堆内存被分成新生代和老年代,新生代里朝生夕灭的对象一般活不过 Young GC,而老年代里存的是长期存活的对象。如果代码里有一个全局 Map 不断塞数据但从不删除,或者线程池里某个 ThreadLocal 被长期持有,这些对象就会一点一点往老年代堆积。老年代里明明已经有很多“垃圾”却因为引用链没断掉,Full GC 时清理不掉,堆占用只涨不跌。GC 判定的依据是“对象是否还从 GC Roots 可达”,而不是“开发者是否还在用”——只要引用还在,对象就不会被回收,这就是内存泄漏最常见的内核。

当老年代占用越来越高、Young GC 一直把存活对象晋升到老年代时,JVM 必须加快 Full GC 的频率来尝试腾出空间。Full GC 要扫描整个堆,还要做压缩,这个过程极其消耗 CPU。所以内存泄漏并不是“内存悄悄没了”那么简单,它会逼着 GC 不停地干活,最终表现为 CPU 占用率居高不下。很多人在排查时只看到 CPU 高,忽略了底层的内存堆积,导致方向跑偏。

1.2 GC 高峰反过来吃掉 CPU,形成恶性循环

GC 本身是需要线程来执行的,尤其是 Full GC,无论是 Parallel Old 还是 CMS 或 G1,在标记、清理、压缩阶段都会占用多个 CPU 核心。当 GC 频率升高后,CPU 被 GC 线程吃掉一大块,业务线程的执行时间被压缩,用户的请求变慢,吞吐量下降。更麻烦的是,业务线程一旦处理不过来,请求积压,又会创建更多对象,进一步加剧 GC 压力,最终形成一个“请求慢 → 对象堆积 → GC 更频繁 → 请求更慢”的死亡螺旋。

我们排查时一定要先确定 CPU 的消耗来自哪类线程。如果是 GC 线程(比如 VM Thread)占大头,那问题基本锁定在堆内存分配和 GC 行为上;如果是业务线程占大头,那要优先查业务代码里的死循环、正则回溯、序列化等热路径。这两种场景的后续动作完全不同,所以第一步不是背命令,而是先判断 CPU 花在谁身上。

1.3 一场事故可能同时踩中多个坑

实战里经常发现 CPU 高和内存泄漏都不是单一原因。比如最近一次排查,我先看到某个业务线程 CPU 很高,jstack 后发现它在循环做字符串截取和 JSON 序列化,大量产生中间对象。这些对象瞬时涌入新生代,Young GC 变得非常频繁,老年代也因此被不断晋升的对象塞满,结果 CPU 高和 Full GC 高同时出现。如果不先看线程栈,只做堆 dump,很可能会误判成“纯粹的内存泄漏”,而实际上代码层面的 CPU 热区才是导火索。所以下面这套排查路径,必须是“线程 → GC → 堆”三个视角轮流切,而不是闷头只查一个维度。

2. 进场之前,先把现场保护起来

2.1 故障现场“五件套”采集顺序比什么都重要

很多工程师接到告警后的第一反应是“重启试试”。一旦重启,堆内存态势、GC 日志、线程栈这些关键现场全部丢失,下次复现不知道要等多久。正确做法是先按顺序采集一组快照,保证任何一个时间点都能还原状态。我的固定顺序是:

  1. uptime和top:看系统平均负载和整体 CPU/内存占用,确认是不是整机资源问题。
  2. top -Hp <pid>:按线程维度看 CPU 占用,拿到最烧 CPU 的那批线程 ID。
  3. jstat -gcutil <pid> 1000:连续打印几秒的 GC 情况,记录 Young GC 和 Full GC 的频率、老年代占用百分比。
  4. jstack <pid> > jstack_$(date +%Y%m%d_%H%M%S).txt:保存当前所有线程栈,注意多执行几次(间隔 30 秒),因为线程可能在切换。
  5. jmap -dump:format=b,file=heap_$(date +%Y%m%d_%H%M%S).hprof <pid>:保存堆快照。如果服务还很脆弱,这个操作要放到最后,并且确认磁盘空间足够。

这里有个容易被忽略的细节:jstat -gcutil采样至少持续 15 秒以上才看得出趋势,只看一次输出没有意义。另外,如果服务已经进入濒危状态,jmap dump可能触发 GC 线程暂停,反而让服务雪上加霜,这时候宁可先放弃 dump,也要把jstack拿到手。

2.2 能不上重工具就尽量别上,避免二次伤害

线上查问题时,最忌讳的是把平时压测才会用的工具一股脑怼到生产环境。例如jmap -histo:live <pid>会强迫 JVM 先做一次 Full GC,这个行为在某些高并发、大堆场景下相当于主动制造一次长时间暂停,比原来的故障还要致命。我会优先用无侵入或低侵入的手段:jstack、jstat、jcmd的只读命令,以及top -Hp。如果必须看对象分布,我通常用jcmd <pid> GC.class_histogram,它虽然也可能有开销,但比jmap -histo:live温和一些。

还有一个经验:配置了-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=...的话,等故障再次自然触发 OOM 也能拿到 dump,不一定非要手动去抓。总之,保护的优先级是“先留活口,再抓证据”。如果服务进程已经处于 hung 状态、任何命令都卡住,那另说;只要进程还活着,就按序采集。最小化影响的同时最大化信息量,才是线上排查的第一原则。

2.3 确认应用版本、启动参数和最近变更

现场采集完了,别急着分析,先把应用的 JVM 参数、版本、最近发布的代码 commit 调出来看一眼。很多看似玄学的内存泄漏其实是“升级了 JDK 版本”或者“改了一个依赖版本”带进来的。我就遇到过一例:某模块对应的 JVM 从 JDK 8 换到 JDK 11,G1GC 的行为变化导致 Full GC 变频繁,问题根本不在业务代码。启动参数里的-Xmx、-Xms、-XX:MaxMetaspaceSize、-Xloggc路径都要弄清楚,不然分析 GC 日志时连“堆为什么会顶到上限”都看不明白。这个步骤花不了几分钟,但能省下后面大量瞎猜的时间。

3. CPU 高,先揪出最烧 CPU 的线程

3.1 top 加 jstack 是永不过时的基本功

当top显示 Java 进程 CPU 占用极高时,第一步不是jmap,而是用top -Hp <pid>进入线程视图。这一步会列出该进程下所有线程的 CPU 占用排名,拿其中最靠前的那几个线程 ID,记下来,它们此刻就是嫌疑犯。注意top -Hp里的 PID 是十进制的线程号,而jstack输出中的线程标识是十六进制,要先用printf "%x\n" <线程号>转换一下,再到jstack输出里搜nid=对应的线程栈。

举个例子,假设top -Hp 12345显示线程 22333 占用了 65% CPU,执行printf "0x%x\n" 22333得到0x573d,然后grep -A 30 "0x573d" jstack.txt就能看到这个线程究竟在做什么。如果是 GC 线程,你会看到VM Thread或者G1 Main之类的栈;如果是业务线程,你会直接看到你的代码类名、方法名和行号。

3.2 常见的高 CPU “热代码”模式

空转循环。while (true)里没有 await / 阻塞操作,纯粹在做重复计算;或者poll()空轮询。这类代码在 jstack 里通常表现为 RUNNABLE 状态,堆栈底部是Thread.run(),但上层会看到你自己的循环逻辑。正则表达式回溯。一个长字符串匹配一个复杂正则,会出现大量回溯计算,线程栈会卡在java.util.regex相关类。序列化反射。频繁调用反射、动态代理时,CPU 占用也不低。日志爆炸。在循环里打 info/debug 日志,字符串拼接和 I/O 会把 CPU 吃光,这类问题 jstack 里往往能看到log4j或logback的调用栈。

还有一种情况是线程看起来在等待,但 GC 线程仍然高——这时的 CPU 消耗其实不在业务线程里,直接在 GC 线程上体现。所以不要只盯着业务线程,top -Hp里常见的一类线程名是VM Thread,也就是 JVM 的后台线程,专门负责执行 GC。当它排在最前面,就说明 GC 本身已经成了系统重心,赶紧转向下一节。

3.3 用 Arthas 快速拿到“最烧脑”线程

如果线上环境允许,我强烈推荐用 Arthas 的thread -n 3命令,一条命令就打印出 CPU 占用最高的 3 个线程的栈,并且会自动过滤掉 GC 等不需要关注的线程(也可以加参数指定)。它的原理也是读取 JMX 和线程栈,但输出比top + jstack人肉拼接方便得多。遇到“CPU 高但 jstack 抓不到根因”的场景,我通常会跑thread --state RUNNABLE -n 5,把反复活跃的线程栈拍下来,再结合采样多次的结果对比。另外要注意,Arthas attach 到进程本身也会带来少量开销,在 CPU 已经爆满时建议先抓 top 前三排,再决定要不要上 Arthas。

3.4 当高 CPU 线程是 GC 线程时别急着优化代码

有些人看到 GC 线程占用高,第一反应是去调 JVM 参数,比如堆大小、垃圾回收器。但参数调整只是缓解,真正要找到“为什么 GC 停不下来”。如果老年代持续增长,就要怀疑内存泄漏;如果老年代没涨,但 GC 频率也高,可能是对象分配速率太高,也就是业务代码在大量创建短生命周期对象。这时候需要回到 GC 日志和堆占用曲线去判断,而不是盲目改参数。改参数属于治标,即使一时平稳,泄漏的根因还在,过几天又会卷土重来。

4. 用 GC 日志反向定位内存压力来源

4.1 先学会看 GC 日志里的几行字

拿到 GC 日志,很多人会被一长串格式吓到。其实核心就是几个数字:GC 类型、发生位置、GC 前后各区域容量、耗时。以比较常见的-XX:+PrintGCDetails输出为例,你会看到类似:

[GC (Allocation Failure) [PSYoungGen: 51200K->10239K(51200K)] 51200K->12000K(131072K), 0.0123456 secs] [Full GC (Ergonomics) [PSYoungGen: 10239K->0K(51200K)] [ParOldGen: 90000K->89900K(79872K)] 100239K->89900K(131072K), [Metaspace: ...], 0.6789012 secs]

关键看三个位置:第一个是 Young GC 后存活对象的大小,第二个是 Full GC 后老年代的大小,第三个是耗时。如果每次 Full GC 结束后老年代还是高的(比如每次都释放不到 1%),那基本就是老年代里存在大量不可回收对象——内存泄漏的典型信号。如果 Full GC 能回收大部分,但过一会儿又涨回来,说明存在慢泄漏或者缓存没有被做限流。

4.2 jstat 看趋势,而不是看单点

jstat -gcutil <pid> 2000每两秒打印一次,会给出E(Eden)、O(Old)、M(Metaspace)、FGC(Full GC 次数)、FGCT(Full GC 累计耗时)等。我一般至少连续看 30 秒,把数据记录下来,画个简单趋势图:老年代百分比是阶梯式上升、抛物线上升,还是水平波动。

一次线上告警,我看到老年代从 40% 升到 95%,中间出现了 10 多次 Full GC,且每次 Full GC 后老年代只回落 2~3 个百分点,这种就是很明确的泄漏。还有一种情况是年轻代 GC 非常频繁、Eden 每次都清空后还是不断分配,老年代还算平稳,这更像是“分配速率过高”而不是泄漏。两者解决方向不一样:前者要去找对象被谁引用住不放;后者要去查代码里是不是在循环里造大对象、或者序列化 buffer 没复用。

4.3 Full GC 次数多不等于内存泄漏

接上一个点引申一下,很多人只要看见 FGC 多就喊“泄漏”,其实不对。曾经有个服务在促销活动期间 Young GC 每秒几十次,Full GC 每分钟也有一次,但堆占用始终稳定在 30%~60% 之间,最终定位是接口里一个Arrays.asList转ArrayList后疯狂扩容,把对象分配速率拉高了几十倍。这种问题的核心是 CPU 和 GC 被“高速分配—回收”拖住,而不是内存“只进不出”。判断方法很简单:观察 Full GC 后老年代是否明显下降,如果每次都能回到基线,那内存没有泄漏,只是分配压力大。

4.4 活用-XX:+PrintGCDetails -XX:+PrintGCDateStamps留下历史

我习惯在启动参数里永久加上 GC 日志配置,并且按天切分。例如-Xloggc:/data/logs/gc-%t.log -XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+PrintTenuringDistribution。这样出问题时不需要临时去改配置,直接翻当天的 GC 日志,还能对比几天前的情况。PrintTenuringDistribution可以看到对象晋升阈值和分布,在定位“对象过早晋升老年代”时非常有用。很多时候我们排查慢,就是因为线上没有有效的 GC 日志,只能靠 jstat 抓几秒的实时数据,信息远远不够。提前把观测能力建好,远比事后抢救省力。

5. 内存泄漏定位:heap dump 分析三招制敌

5.1 什么时机 dump 才不白费

dump 堆快照的关键不是“抓多少次”,而是“在什么状态下抓”。最理想的时机是:老年代已经涨到 80% 以上,而且连续多次 Full GC 后依然不降。在那一刻,堆里积压的基本就是疑似泄漏对象。如果一开始老年代占有率还不高,dump 出来的文件里大多是正常业务对象,很难看出谁在泄漏。所以我会先通过jstat确认时机,再把 dump 任务排上。

另外,建议隔一段时间采样两份 dump:一份是低峰期或刚 GC 完时,一份是高峰期或老年代高位时。用 MAT 对比两份 dump 中“增长量最大”的对象,往往就是泄漏源头。我自己在项目中会直接用jmap -dump:format=b,file=heap1.hprof抓第一份,过两小时再抓第二份,然后交给 MAT 的“Compare Retained Set”功能看增长主体。这个方法比单独看一份 dump 准确得多。

5.2 MAT 看 Shallow Heap 与 Retained Heap

Eclipse MAT 打开 heap dump 后,我一般不走“Leak Suspects”一步到位,而是先看Histogram把所有对象按数量排序,点掉 byte[] 和 char[] 这种基础数组之后,再按 Retained Heap 排序。Retained Heap 代表“如果这个对象被回收,能释放多少内存”,是查找大对象的直接指标。定位到 Candidate 类之后,右键Merge Shortest Paths to GC Roots,选择exclude weak references,就能看到是谁通过强引用一路把它扣在堆里。这条路径上出现的静态集合、缓存容器或单例对象,基本就是泄漏的关键锚点。

举个真实例子:之前有个服务的堆里出现了大量org.apache.http.impl.conn.PoolingHttpClientConnectionManager对象,Retained Heap 极大。沿着 GC Root 路径一看,发现是一个自定义的 HTTP 工具类把每次创建的连接管理器都塞进了 static ThreadLocal 里,线程长期存活,连接管理器也一直没被 replace。替换成复用单例后,Full GC 立刻消失,CPU 也掉了下来。

5.3 常见的泄漏模式和反直觉陷阱

ThreadLocal 泄漏:ThreadLocal 的 key 是弱引用,value 是强引用,当线程长期存活(比如线程池里的线程),value 就无法被回收。所以在 web 应用中,如果使用 ThreadLocal 保存用户上下文又没 remove,就会造成老年代增长。静态集合无界增长:最经典的缓存 Map,只往里面 put,没有淘汰机制。监听器未注销:注册了监听器却没在对象销毁时注销,导致被观察者反向持有观察者。连接/流未关闭:数据库连接、输入流、Socket 连接开着不关,显式内存一次又一次分配。

要注意的反直觉点是:某些看似“内存泄漏”的其实是“堆外内存”或“元空间”问题,居中的堆 dump 可能显示堆内占用正常。字节缓冲DirectByteBuffer、线程栈、Metaspace 的类加载器泄漏都不在常规 heap dump 里。所以如果 dump 里堆内不高,但 RSS(常驻内存)一直涨,我会立刻转向pmap -x <pid>和 NMT(Native Memory Tracking)去排查堆外部分。

6. 堆内没问题时,别忽略堆外和线程开销

6.1 堆外内存泄漏:DirectByteBuffer、网络缓冲和 NMT

JVM 进程占用的内存除了 Java 堆,还有 Metaspace、线程栈、Code Cache、JIT 编译产物、DirectByteBuffer 等。Netty 这类高性能框架经常用堆外内存做零拷贝。如果代码里分配了 DirectBuffer 但没有释放,堆外内存会缓慢上升,这个时候 GC 不一定异常,但进程的 RSS 会持续增长,最后被操作系统 OOM Kill。排查手段是在启动参数里加上-XX:NativeMemoryTracking=summary,然后通过jcmd <pid> VM.native_memory summary查看各区域占用,定位是哪一个 Area 在上涨。

6.2 线程数量失控也是内存和 CPU 双杀

线程池开得太大、每次请求都 new Thread,线程数量会消耗大量内存(每个线程默认栈大小 1MB),也会加剧上下文切换,CPU 高、内存也高。用jstack文件里统计线程数量非常快:grep "java.lang.Thread.State" jstack.txt | wc -l,正常服务几十个线程,如果成千上万,那除了堆内分析,还得看看是不是线程池配置不合理或线程卡在不释放的锁上。线程暴增往往和底层连接池/阻塞调用有关,比如 HTTP client 没有设置超时,请求一直堵着,线程越堆越多。

6.3 CPU 高但一切 JVM 指标都正常,看看非 Java 线程

还有一种很少被注意的情况:JVM 里除了 GC 线程,还有 RMI 线程、JIT 编译线程(C1 CompilerThread/C2 CompilerThread)等。如果业务代码做大量热点优化,JIT 线程 CPU 会变高,这种情况一般不会持续太久。但如果持续高,可以看看是不是有外部监控工具在通过 JMX 频繁拉指标。另外,进程 CPU 高可能是 JVM 之外的东西:服务部署在同一台机器上的其他脚本、容器 CPU 限制、真机超卖等。用top先看整个系统负载,再用pidstat区分进程,能避免把锅扣在 Java 上。

7. 实战排查速查表和避坑记录

7.1 一个快速定位的“小抄”

现象优先排查方向常用命令
CPU 高,但 GC 线程不高业务线程死循环/正则/序列化/日志top -Hp+jstack/thread -n
CPU 高,VM Thread 或 GC 线程占大头GC 频繁、老年代压力大jstat -gcutil、GC 日志
Young GC 极频繁,老年代平稳对象分配速率过高,代码或 buffer 反复分配jstat、分配内存统计(jcmd或 profiler)
Full GC 多,且 GC 后老年代不降内存泄漏,检查 ThreadLocal/静态集合/连接dump + MAT
堆内正常,但 RSS 持续涨堆外内存、Native 缓存、线程数pmap、NMT、jstack统计线程数
老年代缓慢上涨,GC 能降但回不去基线慢泄漏(缓存未淘汰、监听器未注销)两份 dump 对比

7.2 生产中踩过最值的几个坑

第一,不要在 CPU 已经 100% 的时候用jmap -histo:live/jmap -dump:live。这类命令会触发 Full GC,在“GC 频繁”的现场等于给服务再补一刀。如果一定要抓 dump,我倾向完全不触发 GC 的普通 dump,宁可包含一些垃圾对象,也不要把服务搞挂。第二,抓完立即重启而不是先分析。很多问题换台机器、拉一段新流量就消失了,过几天又回来,等于天天救火。第三,GC 日志文件写到根目录导致磁盘满。排查问题时顺手在-Xloggc指定到一个有保留策略的目录,避免 GC 文件占用大量磁盘。第四,忽视堆初始化大小。如果-Xms过小,JVM 会频繁扩容/收缩堆,也会带来额外 CPU 开销,看起来像 GC 问题。

7.3 一套适合日常巡检的配置建议

与其等问题爆发,不如在启动参数里把观测做到位。我会在绝大多数 Java 服务里固定加上这些参数:-Xloggc:/data/logs/gc-%t.log -XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+PrintTenuringDistribution -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/data/logs/。同时,用/usr/bin/time -v或容器监控来记录 RSS 增长曲线,用 Prometheus 的jvm_gc_*指标做告警。如果内存使用在 7 天内持续上升且从不回落,基本可以判定存在慢泄漏,就算现在 CPU 不告警,也值得提前排查。

结尾:个人经验谈

排查这一类问题,我最深的感觉是“顺序比操作更重要”。很多人报警后急着做高级分析,结果连 CPU 消耗在 GC 线程还是业务线程都没分清,后面做的所有步骤都在绕路。CPU 高、内存泄漏、GC 频繁这三个关键词放在一起时,一般都有清晰的因果链路:先确认线程视图,再读 GC 趋势,最后用堆 dump 收敛到具体对象。每一步都用简单的命令去验证,而不是凭感觉猜,通常两小时内能找到根因。

最后分享一个小技巧:拿到jstack后不要只看一次,同一个高 CPU 线程前后隔 5 秒抓两份栈,如果两次栈都停在同一个业务方法,那基本就是那个方法在死循环或狂转;如果栈在 GC 相关方法上,那问题就更靠近堆和 GC 策略。这个“双份栈对比”的方法简单到没有任何门槛,但在实战中一键确认了很多次方向。希望这篇记录能让你下次遇到“CPU 高、内存泄漏、GC 频繁”时,心里有底,手上有招。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询