JVM内存溢出与GC日志排查:从OOM分类到工具链实战

发布时间:2026/10/29 3:41:22
JVM内存溢出与GC日志排查:从OOM分类到工具链实战

简介:《深入理解Java虚拟机:JVM高级特性与最佳实践(第3版)》PDF电子书,面向具备Java基础、希望系统掌握JVM原理与性能调优的开发者及面试备考人群。压缩包内含1个PDF文件,大小28.65MB,内容对应周志明第3版,覆盖Java技术体系、运行时数据区域、对象创建与内存布局等基础主题。书中结合OutOfMemoryError实战讲解堆、栈、方法区及直接内存溢出,系统梳理引用计数、可达性分析与分代收集理论,并比较Serial、CMS、G1、ZGC等收集器的适用场景。监控排错部分介绍jps、jstat、jmap、jstack及JConsole、VisualVM等工具的使用思路,调优章节通过大内存部署、集群同步、堆外内存等案例演示问题定位与优化过程。已有747人学习,适合用作JVM知识体系搭建、故障排查参考与性能调优实战手册。

1. Java heap space 报错:JVM 内存问题为什么总在半夜找上门

凌晨两点被告警叫醒,日志里躺着一行java.lang.OutOfMemoryError: Java heap space,这类 JVM 内存报错几乎每个上过生产的人都遇到过。它让人头疼的地方不在于报错本身,而在于同一段代码在测试环境跑得好好的,换个数据量就炸;重启能撑两小时,改个-Xmx又能撑一天。堆、元空间、直接内存、线程栈,四块地方哪个溢出的表现都能长成「内存不够」,但处置方式完全不同,照着别人的参数抄一遍往往只是把问题推迟了。下面按「先分清是哪块内存炸了、再看 GC 日志和工具链、最后落到参数和代码」的顺序,把 JVM 排错里最容易翻车的几处拆开讲。

2. OOM 报错分四类:先定位是哪块内存溢出了

看到 OutOfMemoryError 就条件反射去加-Xmx,是新手最常见的误判。JVM 的运行时数据区域在书里被切成程序计数器、虚拟机栈、本地方法栈、堆、方法区(JDK 8 之后是元空间)、运行时常量池、直接内存,每一块都有自己独立的溢出条件,报错文案也不同。先把文案认全,排查方向就少走一半弯路。

2.1 堆溢出:Java heap space 与 GC overhead limit exceeded

堆溢出的触发条件只有两个:要么存活对象真的超过了堆上限,要么对象回收速度跟不上分配速度。日志里Java heap space是堆分配失败,GC overhead limit exceeded则是另一个面孔——超过 98% 的时间在做 GC 却只回收了不到 2% 的堆,虚拟机干脆提前抛错,本质仍然是堆不够或有泄漏,别被名字骗了。

真正有用的动作不是重启,而是在下次炸之前把现场留下来。启动参数里加上「事后取证」的两行:

# -Xms 与 -Xmx 设成一样,避免堆伸缩带来的额外 GC 和抖动 # HeapDumpPath 指向独立磁盘分区,hprof 体积接近堆上限,别写进容器临时目录 java -Xms4g -Xmx4g \ -XX:+HeapDumpOnOutOfMemoryError \ -XX:HeapDumpPath=/data/dump/heap-%p.hprof \ -Xlog:gc*:file=/data/logs/gc-%t.log:time,uptime:filecount=5,filesize=50M \ -jar app.jar

-XX:+HeapDumpOnOutOfMemoryError是排障中最有价值的开销,OOM 瞬间自动导出快照,不用赌能不能复现。%p是进程号占位符,防止同名覆盖。-Xlog:gc*是 JDK 9 以后的统一日志写法,JDK 8 要用另一套参数,混用会直接启动失败,后面第 5 章会细说。

对于已经在跑的进程,手工抓快照优先用jcmd:

# jcmd 走 attach 机制,不强制 Full GC,对大堆更友好 jcmd 12345 GC.heap_dump /data/dump/heap-manual.hprof # 老版本 JDK 只有 jmap 可用 # 注意 :live 会先触发一次 Full GC 再遍历堆,大堆上可能造成秒级停顿 jmap -dump:format=b,file=/data/dump/heap.hprof 12345

拿到 hprof 之后,用堆分析工具打开,先看 Leak Suspects 给出的可疑对象,再顺着支配树(Dominator Tree)往下找持有者,最后看 GC Roots 到泄漏对象的引用链。判断标准很朴素:如果某个业务对象的实例数量随请求量线性增长却从不下降,那就是集合类没清理;如果数量稳定但每个都巨大,那是单条数据太大或缓存没有上限。

2.2 栈溢出:StackOverflowError 和 unable to create new native thread

这两个报错经常被混为一谈,其实一个是单线程栈帧太深,一个是线程总数太多。StackOverflowError多半来自没有终止条件的递归、JSON 序列化时的循环引用、正则表达式的灾难性回溯。用一段最小代码就能量出当前配置下的深度:

public class StackDepth { private static int depth = 0; public static void main(String[] args) { try { recurse(); } catch (StackOverflowError e) { // 捕获后打印深度,用来反推单帧大小与 -Xss 的关系 System.out.println("栈深度约 " + depth); } } static void recurse() { depth++; recurse(); } }

配上java -Xss256k StackDepth跑一遍,再换成-Xss1m跑一遍,深度大致按倍数变化,说明单帧大小基本固定。这里有个反直觉的点:把-Xss调大确实能让递归更深,但每个线程占用的栈内存也变大了,同样物理内存下能创建的线程数反而变少,unable to create new native thread往往就是这么被「优化」出来的。

排查线程数上限的路径很短:

# 当前进程的线程总数 cat /proc/12345/status | grep Threads # 系统级与用户级线程数限制 ulimit -u # 线程池无界、每请求 new Thread,都是线程数暴涨的常见来源 jstack 12345 | grep -c '^"'

如果线程数逼近ulimit -u或容器 pids limit,要么收敛线程池(有界队列 + 合理的 maxPoolSize),要么把-Xss从默认的 1M 降到 256k~512k。但降栈只是腾地方,根因还是创建线程的地方没有节制。

2.3 元空间溢出:Metaspace 与类加载器泄漏

java.lang.OutOfMemoryError: Metaspace这个报错在业务代码里很少见,一旦出现,八成和动态生成类有关。常见来源包括 CGLIB/ByteBuddy 生成的代理类、反射调用产生的 MethodAccessor 膨胀、脚本引擎反复编译同一段表达式,以及热部署场景下每次 reload 都新建一个类加载器、旧的却因为被引用而无法卸载。

定位哪个类加载器在膨胀,用类加载统计:

# 查看每个类加载器加载的类数量与占用,按块大小倒序 jcmd 12345 GC.class_stats | sort -k3 -nr | head -20 # 老版本 JDK 用这个 jmap -clstats 12345

如果看到某个自定义加载器的类数量持续上涨,基本可以锁定热部署或动态代理泄漏。参数上要给它封顶:

# MetaspaceSize 是首次触发 Metaspace GC 的阈值,不是初始大小 # 设得太小会导致启动期频繁 Full GC;MaxMetaspaceSize 不设则只受本机内存限制 -XX:MetaspaceSize=256m -XX:MaxMetaspaceSize=512m

很多团队只记得设-Xmx,元空间放任不管,结果容器内存缓慢上涨、堆却平平整整,最后被内核干掉,连一句 Metaspace 的报错都没留下——因为进程根本没机会打印。

2.4 直接内存溢出:Direct buffer memory 与堆外泄漏

java.lang.OutOfMemoryError: Direct buffer memory的现场通常是 NIO 的ByteBuffer.allocateDirect、池化的网络缓冲区,或者Unsafe.allocateMemory。这里有个坑中坑:-XX:MaxDirectMemorySize不设时默认约等于-Xmx,但Unsafe.allocateMemory的分配并不受这个参数约束,只受物理内存和容器 limit 限制,所以你会看到「堆没满、直接内存参数也没超,进程却没了」。

排查手段是打开 Native Memory Tracking:

# NMT 有 5%~10% 的性能开销,只在排障窗口开,别长期挂在生产上 java -XX:NativeMemoryTracking=summary -jar app.jar # 分区域看:Java Heap / Class / Thread / Code / GC / Internal / Other jcmd 12345 VM.native_memory summary

另一个高频诱因是-XX:+DisableExplicitGC。这个参数会把System.gc()变成空操作,而直接内存的回收恰恰依赖 Full GC 触发 Cleaner,于是堆外内存迟迟不释放,日志里也看不到任何 OOM。书里专门用一个堆外内存的调优案例讲过这个连锁反应:堆参数看着完全正常,问题出在堆之外。遇到这类现象,先把DisableExplicitGC摘掉验证,再决定是用池化复用还是显式调用 Cleaner。

3. Full GC 频繁与长停顿:GC 日志怎么开、数字怎么读

内存溢出的另一面是「没溢出但卡」。接口 P99 从 80ms 涨到 2s,监控上 CPU 不高、QPS 不高,就是周期性地整站卡顿几秒。这类问题不看 GC 日志基本靠猜,而 GC 日志的第一道门槛是参数写对——不同大版本之间,日志参数是两套体系。

3.1 开 GC 日志:JDK 8 与 JDK 9+ 的参数不能混用

# JDK 8 及以前 -XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:/data/logs/gc-%t.log \ -XX:+UseGCLogFileRotation -XX:NumberOfGCLogFiles=5 -XX:GCLogFileSize=50M
# JDK 9 及以后,统一日志框架 -Xlog:gc*:file=/data/logs/gc-%t.log:time,uptime,level,tags:filecount=5,filesize=50M

-Xlog的语法是-Xlog:<标签>:<输出>:<装饰器>:<轮转策略>。gc*表示打开所有 gc 相关标签,包含分代细节和收集器阶段;time,uptime分别打墙钟时间和进程启动后的相对时间,比对业务日志时用前者、算停顿间隔时用后者;filecount与filesize控制轮转,避免日志把磁盘写满。

这里必须强调:-XX:+PrintGCDetails在 JDK 9 之后是被移除而不是被弃用,升级 JDK 后照抄旧启动脚本,会得到Unrecognized VM option 'PrintGCDetails',应用直接起不来。这类问题在灰度发布时最容易漏,因为测试环境的启动脚本往往和线上不是同一份。

3.2 从一行日志判断收集器、停顿与回收效果

一段典型的 G1 日志长这样:

[2024-05-11T02:13:41.882+0800][info][gc] GC(128) Pause Young (Normal) (G1 Evacuation Pause) 3821M->1204M(4096M) 23.451ms [2024-05-11T02:13:52.117+0800][info][gc] GC(129) Pause Full (G1 Compaction Pause) 3890M->2100M(4096M) 1876.332ms

3821M->1204M(4096M)这三个数字要连起来读:回收前占用、回收后占用、堆总容量。第一次 Young GC 后还剩 1204M,说明存活对象占了三成,偏高;第二次 Full GC 之后仍然有 2100M,回收比只有 46%,这就不像正常的朝生夕死了,更像大量对象被长期持有。最后那个1876.332ms是本次停顿,对在线服务来说接近两秒的 STW 已经足以打穿上游的超时阈值。

几个可以直接套用的判据:老年代占用呈阶梯上升且 Full GC 后不回落,指向泄漏;占用呈锯齿状反复起落,是正常的新生代回收;Young GC 频率极高但单次很短,通常是 Eden 太小或者分配速率过大;对象晋升年龄普遍偏低,要去看 Survivor 空间比例和动态年龄判定。CMS 时代还有一个标志性日志concurrent mode failure,出现它意味着并发清理期间老年代被填满,退化成单线程 Full GC,停顿会从几十毫秒跳到几秒,这时候调-XX:CMSInitiatingOccupancyFraction提前触发才有意义。

3.3 收集器怎么选:从堆大小和停顿目标倒推

选收集器不是选先进,是选匹配。堆 100M 的客户端程序用 Serial 完全够用且开销最小;几 GB 的在线服务在 G1 成熟之后基本没有留在 CMS 的理由;几十 GB 的超大堆、又不能接受百毫秒级停顿时,才轮到 ZGC 这类低延迟收集器上场。

收集器典型堆规模停顿特征适用场景
Serial / Serial Old百 MB 级单线程 STW,停顿与堆成正比客户端、命令行工具、容器小规格边车
Parallel Scavenge / Old数 GB多线程 STW,追求吞吐批处理、离线计算、可接受秒级停顿
CMS数 GB并发标记,怕碎片与并发失败老系统维护,新项目不建议
G16GB~数十 GB可设停顿目标,分区回收主流在线服务默认选择
ZGC超大堆亚毫秒到毫秒级大内存、低延迟敏感型服务

G1 的起手式参数:

# MaxGCPauseMillis 是软目标不是保证,设得过小会让 Young 区被压得很小、GC 反而更频繁 # IHOP 表示老年代占用到 45% 就启动并发标记周期,比默认 45 更适合晋升快的应用 -XX:+UseG1GC -XX:MaxGCPauseMillis=200 \ -XX:G1HeapRegionSize=8m \ -XX:InitiatingHeapOccupancyPercent=45 \ -XX:+ParallelRefProcEnabled

G1HeapRegionSize建议按堆大小除以 2048 取 2 的幂,堆 16G 时 8m 比较合适;ParallelRefProcEnabled让软引用、弱引用的清理并行化,对引用多的框架有明显收益。参数调完之后一定要回头用日志验证,而不是调完就当解决了。

3.4 用一段代码验证分配与晋升规则

参数背后的规则是可以自己跑出来的。下面这段代码配合小堆,能同时观察到「对象优先在 Eden 分配」和「大对象直接进入老年代」:

// -Xms20m -Xmx20m -Xmn10m -XX:SurvivorRatio=8 -XX:+PrintGCDetails public class AllocationDemo { private static final int _1MB = 1024 * 1024; public static void main(String[] args) { byte[] a1 = new byte[2 * _1MB]; byte[] a2 = new byte[2 * _1MB]; byte[] a3 = new byte[2 * _1MB]; // 第四块 4MB,超过阈值直接进老年代,不经过 Eden byte[] a4 = new byte[4 * _1MB]; } }

Eden 按-Xmn10m和SurvivorRatio=8算出是 8MB,前三块 2MB 刚好填满,第四块 4MB 放不下会触发一次 Young GC,而如果打开-XX:PretenureSizeThreshold=3m,它会绕过新生代直接进老年代。注意这个参数只对 Serial 和 Parallel 生效,G1 不认,这也是很多人照着旧文章调参却看不到效果的原因。把这段代码在本地跑通,再去读线上日志,晋升行为和 GC 次数就对得上了。

4. 排查工具链:jps、jstat、jmap、jstack 的上场顺序

工具不熟的时候,最常见的做法是上来就jstack,抓一大坨线程栈翻半天,最后什么也没看出来。工具是有顺序的:先确认进程和目标,再看趋势判断是泄漏还是抖动,然后才决定要不要付代价去 dump 或抓栈。顺序反了,不仅浪费时间,还可能给本来只是慢的应用补上一刀。

4.1 jps 与 jstat:三十秒内判断是泄漏还是抖动

# -l 显示主类全名,-v 显示 JVM 参数,-m 显示 main 参数 jps -lvm # 每 1 秒采样一次,共 10 次,看各区域使用率百分比 jstat -gcutil 12345 1000 10

-gcutil的输出列里,E是 Eden、O是老年代、M是元空间,都是百分比;YGC/YGCT是年轻代回收次数与累计耗时,FGC/FGCT是 Full GC 的。判断逻辑很直接:如果O单调上升、FGC同步增长且FGCT累计时间肉眼可见地涨,那是泄漏或老年代容量不足;如果O在固定区间内来回锯齿、FGC长期为 0,只是YGC很密,那是分配速率大或新生代太小,方向完全不同。

需要看绝对容量时换jstat -gc,它能给出各区域的当前容量、已用容量和本次回收耗时,配合公式估算晋升速率。这一步的价值在于:花三十秒就能把「要不要 dump」这个决策做掉,而不是凭感觉。

4.2 jmap 与堆快照:什么时候该 dump,什么时候千万别 dump

jmap -histo是最轻的对象直方图,用来粗看谁占得多:

# 不带 :live 只扫一遍,包含已死对象,读数偏大但基本不停顿 jmap -histo 12345 | head -30 # 带 :live 会先触发一次 Full GC,得到的是存活对象真实占用,但会 STW jmap -histo:live 12345 | head -30

这两条命令的差别在生产上非常关键。前者可以随手用,后者等于主动制造一次 Full GC,堆大的时候停顿能到秒级。同样地,heap dump 在超大堆上耗时几十秒,写入磁盘的 IO 还会挤占业务,一定要在低峰期做,或者干脆依赖HeapDumpOnOutOfMemoryError的自动导出。

快照拿到之后,分析顺序是「先看总量再看增量,先看支配树再看引用链」。总量对比两次不同时间的 dump 能立刻区分泄漏和正常波动;支配树能告诉你「回收掉这一个对象能释放多少内存」,比按类统计更贴近真实收益;最后顺着 shortest path to GC Roots 找到是谁在持有,通常就是某个静态 Map、某个未关闭的监听器、某个 ThreadLocal 没 remove。

4.3 jstack:CPU 打满与线程卡死的定位套路

CPU 100% 的标准动作是「线程号转十六进制再回查」:

# 1. 按 CPU 倒序列出进程内的线程 top -Hp 12345 -b -n 1 | head -20 # 2. 把十进制线程号转成十六进制,jstack 里的 nid 是这个格式 printf '%x\n' 12389 # 3. 抓栈并定位那个 nid(假设得到 306d) jstack -l 12345 > /tmp/stack-$(date +%s).txt grep -A 30 'nid=0x306d' /tmp/stack-$(date +%s).txt

-l会额外打印锁的附加信息,包括Locked ownable synchronizers。要点是抓三次、间隔五秒,看同一个 nid 是否反复出现在 RUNNABLE 且栈顶落在同一个方法上——一次快照可能只是恰好路过。

死锁和锁竞争要分开看。jstack -l会在末尾自动打印Found one Java-level deadlock,那是真正的循环等待;而大量线程处于BLOCKED状态、卡在同一个waiting to lock <0x...>地址上,那是热点锁竞争,通常是 synchronized 方法里塞了远程调用或者日志 IO。两者的处置方式完全不同:前者要打破循环依赖或统一加锁顺序,后者要把临界区缩小或换成分段锁。看到线程数量多就下「死锁」结论,是很容易翻车的一步。

4.4 JFR 与可视化工具:低开销的持续观测

线上问题往往不可复现,最有价值的其实是「出问题那几十秒的连续记录」。飞行记录器就是干这个的:

# 启动即录,采样配置用 profile 更细,默认 duration 到期自动落盘 java -XX:StartFlightRecording=duration=120s,filename=/tmp/app.jfr,settings=profile -jar app.jar # 对已在运行的进程追加录制 jcmd 12345 JFR.start duration=60s filename=/tmp/rec.jfr

开销通常在 1%~2%,能同时看到 GC 事件、线程状态迁移、锁等待、异常抛出和方法采样,相当于一个常开的黑匣子。用配套的可视化工具打开 jfr 文件,时间轴上一眼就能把「接口变慢」和「Full GC」对齐。相比之下,JConsole 和 VisualVM 更适合短时观察,长期挂在生产上要注意 attach 带来的额外开销。

5. 避坑清单:JVM 排查里最容易翻车的五个动作

有些坑不是知识不够,是手太快。下面五条都是事后复盘时最容易拍大腿的,每条都能对上一种具体的日志或现象。

5.1 坑一:容器里 -Xmx 顶到 limit,进程被 OOMKilled

现象是 Pod 反复重启、退出码 137、JVM 侧既没有 OutOfMemoryError 也没有 heap dump,日志干净得可疑。原因很朴素:容器 limit 设 1000Mi,-Xmx也设 1000m,但 JVM 的内存不止堆——元空间、线程栈、JIT 代码缓存、GC 自身的辅助结构、直接内存加起来还要几百兆,超了 cgroup 限制就被内核直接杀掉,JVM 连打印的机会都没有。解决办法是给堆留出余量,用百分比而不是绝对值:

# 堆最多用到容器 limit 的 70%,其余留给元空间、线程栈、代码缓存与直接内存 java -XX:MaxRAMPercentage=70.0 \ -XX:MaxMetaspaceSize=256m \ -XX:MaxDirectMemorySize=256m \ -jar app.jar

MaxRAMPercentage依赖容器感知,JDK 8u191 之后默认开启,更老的版本要手动加-XX:+UseContainerSupport,否则 JVM 会把宿主机内存当成可用内存,参数再漂亮也没用。

5.2 坑二:大堆上直接 jmap -dump:live 把应用卡成假死

现象是执行 dump 之后,接口 P99 从 80ms 涨到数秒,监控上出现一条明显的长停顿,持续时间正好等于 dump 耗时。原因是-dump:live会先做一次 Full GC 标记存活对象再遍历写出,堆到 16G 以上,几十秒很正常。正确姿势是优先用jcmd GC.heap_dump,它不强制 Full GC;实在需要存活快照就避开高峰、先摘流量。另外,千万别在 dump 失败时上jmap -F,强制 attach 得到的是不完整甚至损坏的快照,分析结论会把你带到更远的地方。

5.3 坑三:GC 日志参数照抄旧版本,应用直接起不来

现象是升级 JDK 后启动脚本报Unrecognized VM option 'PrintGCDetails',一行参数卡住整次发布。原因是 JDK 9 起的统一日志框架把旧参数移除了,不是弃用而是移除。解决是把-XX:+PrintGCDetails-Xloggc这一套整体替换为-Xlog:gc*:file=...,并且不要新旧混用,混用时的行为在不同小版本上并不一致,排查起来更痛苦。

5.4 坑四:jstack 说没有死锁,其实是锁竞争

现象是jstack -l没有输出Found one Java-level deadlock,但接口超时率上升、吞吐掉了一半。原因是死锁要求循环等待,而大量线程BLOCKED在同一个监视器地址上是热点锁竞争,工具不会把它标成死锁。定位方法是统计:

# 有多少线程卡在 BLOCKED jstack 12345 | grep -c 'java.lang.Thread.State: BLOCKED' # 看是卡在同一个锁地址上,还是分散在多个锁上 jstack 12345 | grep 'waiting to lock' | sort | uniq -c | sort -rn | head

如果几十个线程的 nid 各不相同但waiting to lock后面是同一个0x开头的地址,结论就明确了:把这段临界区里耗时的部分(远程调用、日志刷盘、序列化)挪出去。

5.5 坑五:元空间不设上限,容器内存悄悄上涨

现象是堆使用率平稳、GC 也正常,容器内存却一天天往上涨,最终被 OOMKilled 或者报 Metaspace 溢出。原因是动态生成类持续增加而MaxMetaspaceSize默认不限;更隐蔽的情况是热部署或自定义类加载器没有释放,旧加载器连同它加载的类一起常驻。解决是给它封顶并加监控告警,同时用jcmd GC.class_stats找出膨胀的加载器。这一类问题的验证方式很直接:重启后元空间归零、随后又沿同一斜率上涨,基本可以确认是类加载器泄漏而非业务数据。

6. 三分钟定位:我的 JVM 故障处置清单

真出事的时候没人有空翻文档,所以我习惯把判断压成一张表,从上往下走,每一步都只回答一个问题:是不是这类问题,下一步做什么。

现象第一条命令重点看什么下一步
应用挂了,日志有 OOMgrep OutOfMemoryError 日志报错后缀是 heap、Metaspace 还是 Direct按第 2 章对应小节处置
接口周期性变慢jstat -gcutil PID 1000 10O 是否单调涨、FGC 是否同步增长涨则查泄漏,不涨则查新生代与分配速率
CPU 打满top -Hp PID+printf '%x'同一 nid 是否反复在栈顶抓三次 jstack 对齐
内存缓慢上涨但堆平稳jcmd PID VM.native_memory summaryClass、Thread、Internal 哪个在涨对应查元空间、线程数、直接内存
启动即失败看启动日志首行是否为 Unrecognized VM option按 JDK 版本替换参数
容器反复重启无日志kubectl describe/ 退出码是否 137(被 SIGKILL)核对 limit 与 -Xmx 比例

走这张表有两个前提,值得单独强调。第一是参数先于工具:HeapDumpOnOutOfMemoryError、-Xlog:gc*、MaxRAMPercentage这三行应该在服务上线时就写进启动脚本,而不是等出事再补,因为很多故障只有一次现场,错过了就只能靠推演。第二是一次只改一个变量:调完-Xmx又顺手换了收集器,问题如果没解决,你连是哪一步起了反作用都说不清;调完参数必须用日志里的 Young GC 次数、Full GC 次数和平均停顿三项指标做前后对比,没有对比数据的「优化」只是换了个玄学。

还有个小习惯:所有 dump 文件、GC 日志、jstack 输出都按「进程名-时间戳-类型」命名并集中存放,事后复盘时能把同一时刻的堆、线程、GC 三条线对齐看。我踩过最亏的一次,是线上卡顿三天,最后发现线程栈和 GC 日志的时间对不上——日志被轮转覆盖了一半,等于看了个残卷。

从那以后我每次上线新服务,都会先把堆转储、GC 日志、容器内存比例这三件事过一遍,再跑一轮压测看日志读数是否和预期一致。这十分钟的功夫,比半夜被叫起来强太多。希望帮到你。

本文还有配套的精品资源,点击获取