
半夜十一点手机一连弹出五六条告警Full GC 次数超过阈值、老年代占用 98%、接口 RT 持续飙红。打开监控一看GC 日志里密密麻麻全是连续的老年代回收每次回收完占用不下来像一个只进不出的蓄水池。处理这种 JVM 内存区域爆掉的现场是每个 Java 后端工程师迟早要面对的功课。这个系列的上篇讲了 JVM 内存区域的划分堆、栈、元空间、直接内存各自管什么本篇重点放在另一面当这些区域装不下了溢出异常是怎么暴露出来的用什么思路排查哪些参数能救急哪些坑不能踩。内容适合正在做 Java 服务端开发、遇到过 OOM 但不知道从哪儿下手的人也适合准备面试时想系统梳理一遍 JVM 考点的人。我尽量把这些年踩过的坑和沉淀下来的排查套路揉成能直接用的东西。1. 先认清四种“溢”的现场Java 进程报 OutOfMemoryError实际上是一族异常不是只有一种。不同内存区域溢出时给的报错关键词完全不一样排查方向也天差地别。很多人一看到 OOM 就急着加 -Xmx这个习惯非常危险——如果根本是元空间或直接内存爆了加堆内存不但没用还可能掩盖真实问题。1.1 堆溢出最常见也最好认的一类堆溢出的报错是java.lang.OutOfMemoryError: Java heap space这也是绝大多数 Java 开发者第一次遇到的 OOM。堆是最容易满的地方因为几乎所有对象都在这里分配。堆溢出的基本面有两种一是真的内存泄漏对象被无意识地持有垃圾回收不掉二是对象太多但生命周期本来就不该这么长比如并发高峰期瞬间产生海量请求对象属于峰值压力超过了堆容量上限。有一个很重要的误区需要说清楚不是所有堆溢出都是泄漏。我见过不少团队一看到 heap space 就怀疑代码里有什么东西没释放查了一周发现其实是某个大促活动运营配置了错误的容量请求量翻了十倍堆就是装不下。所以在定位之前先确认一句话这个现象是持续渐进式的还是突发到达峰值式的前者倾向泄漏后者倾向容量规划问题。写一段最典型的泄漏代码做演示。一个没有覆盖 equals/hashCode 的 Key 类被放进 HashMap每次请求都 new 一个 Key永远也找不到已有条目于是数据只增不减public class LeakDemo { private static final MapKey, Value CACHE new HashMap(); public static void main(String[] args) throws Exception { for (int i 0; ; i) { CACHE.put(new Key(i), new Value(i)); if (i % 1000 0) { System.out.println(size CACHE.size()); } TimeUnit.MILLISECONDS.sleep(1); } } static class Key { private final int id; Key(int id) { this.id id; } // 没有重写 equals() 和 hashCode() } static class Value { private final int data; Value(int data) { this.data data; } } }跑不了多久就会Java heap space。这个例子放在这里有教学价值泄漏很多时候不是“忘了赋 null”而是容器类从逻辑上就留不住应该被回收的东西。1.2 栈溢出递归之外还有哪些坑栈溢出的报错是java.lang.StackOverflowError注意它是个 Error 而不是 OutOfMemoryError但本质也是内存区域装不下——准确的说是某个线程的虚拟机栈深度超过了容量上限。绝大多数人第一反应是“递归太深了”这没错但只答对了一半。栈帧大小由两部分决定局部变量表和操作数栈。一个方法里塞了十几个大数组局部变量它的栈帧就比空方法大得多同样的栈容量能压的调用层数就浅得多。所以压栈溢出不一定是递归问题也可能是一个“方法特别重”的方法在正常业务路径里被反复调用每次调用栈帧巨大。栈深度和 -Xss 参数直接相关这个参数指定每个线程的栈容量。64 位 HotSpot 默认通常是 1MB如果每个栈帧大约占 1KB2KB那么大约能压到几百层上下。真实业务里方法栈帧更大几百层看起来不小但框架的调用链很嵌套例如 MyBatis 映射、Spring AOP 多层代理再加业务递归很容易在低配的 -Xss 设置下触顶。我曾经在排查一个“偶发 StackOverflowError”问题时发现业务代码里根本没有什么深层递归倒是在一个工具类里有个方法用 stream 反复 self-call加上 Spring 代理导致调用链格外长一压测就挂。把那个方法的 stream 改成简单循环问题直接消失。栈优化的优先级永远是先减少无谓的调用层级而不是无脑调大 -Xss。1.3 元空间溢出类加载失控的信号元空间溢出的报错是java.lang.OutOfMemoryError: Metaspace。JDK 8 之后方法区挪到了本地内存叫 Metaspace。它的特点是默认没有上限受物理内存限制但如果你显式设置了-XX:MaxMetaspaceSize超过就报这个错。元空间里存的是类元信息。正常情况下类被加载后当类加载器和类都不再被引用时这些元信息可以卸载。但一旦类加载器本身被意外持有它加载的所有类就都没法回收元空间使用量就会像爬山一样只涨不跌。最常见的高发群体是热部署场景、动态代理大量生成代理类的框架CGLIB、ByteBuddy、以及各种 Groovy 脚本、动态 SQL 解析类库。我曾经排查过一个支付系统每次发布版本都会生成一批 CGLIB 代理类而项目用了一个自定义的类加载器管理插件。类加载器被静态变量引用没释放连续发布几次之后 Metaspace 直接爆掉。最后是用jmap -clstats看到了几百个存活类加载器才定位到的。1.4 直接内存溢出被遗忘的一块直接内存溢出的报错是java.lang.OutOfMemoryError: Direct buffer memory。这是 JVM 里最“隐蔽”的一类因为它在堆外常规堆 dump 里完全看不到它的占用。直接内存由-XX:MaxDirectMemorySize控制默认值约等于堆上限。NIO、Netty 这类库用堆外内存做缓冲区是为了减少堆内对象拷贝和 GC 压力。代价是一旦有代码重复申请 DirectByteBuffer 而没有释放堆内存看起来一点问题没有Full GC 也不频繁但进程整体内存占用一路走高最后 Native 内存不够用。这类问题排查难度大的原因在于MAT 分析堆 dump 时看不到问题的核心你需要在监控里同时看usedDirectMemory这类指标或者用 JFRJava Flight Recorder的 Native Memory Tracking 来辅助。Netty 的缓冲池如果配置不合理也有可能在无泄漏的情况下高频创建堆外缓冲区只是生命周期太短导致碎片的堆积。遇到 Direct buffer memory 时第一反应不应该是“又是谁的 ByteBuffer 没 release”而是先去看 NMT 报告里 native memory 各分类的走势。2. 一次真实堆溢出排查的全过程实录理论讲再多不如走一遍完整排查。我挑一个典型的堆内存泄漏案例按实际操作顺序还原过程包括当时为什么先看 GC 日志为什么用特定命令以及每一步得到的判断依据。2.1 案发现场GC 日志里面写了什么那是一个用户积分服务功能很简单每天批量对账后更新用户等级。上线后第三周开始告警特征很明显每天早上对账跑完后老年代占用就开始缓慢爬坡到了晚上也不回落第二天接着涨。手里第一份证据是全量 GC 日志。片段大概是这样的[Full GC (Allocation Failure) 2.4G-1.8G(2.5G), 3.2130 secs] [Full GC (Allocation Failure) 2.4G-1.9G(2.5G), 3.7150 secs] [Full GC (Ergonomics) 2.5G-1.9G(2.5G), 4.1020 secs]注意一个细节每次 Full GC 都回收了几百 MB但回收后老年代占用依然在 1.8G 以上而且下一次 Full GC 很快又出现。这说明对象不是完全不可达而是被某种集合长期引用GC 能清掉一部分临时对象但核心泄漏对象动不了。第二个关键观察告警出现的时间点每天都在同一时段和业务的对账任务高度吻合。于是思路收敛到对账任务创建了什么数据被放到了某个存活周期很长的结构里。2.2 三板斧jstat 观察、jmap 抓堆、MAT 定位第一步用 jstat 看内存池的分配和回收走势jstat -gcutil pid 1000 10输出里重点看两列O老年代占用比例和FGCFull GC 次数。老年代占用百分比随 FGC 次数同步攀升基本坐实了老年代内存只增不减的判断。第二步抓堆 dump。这里有个注意事项jmap -dump会触发 Stop The World线上服务在高峰期是不能乱抓的。当时选了低峰期执行jmap -dump:live,formatb,file/data/dump/heap-20231012.hprof pid加live参数的意思是只 dump 存活对象文件会小很多分析时干扰也少。但也要知道加了live会强制触发一次 Full GC顺序是先 GC再 dump所以 dump 出来的内容代表“GC 之后还活着的对象”正好适合找泄漏根源。第三步把 heap-20231012.hprof 塞进 MATEclipse Memory Analyzer。分析顺序建议固定先看Leak Suspects 报告再进Dominator Tree看大对象持有关系最后查GC Roots。当时 Leak Suspects 直接给了一个结论某个 ArrayList 的 Total Heap Size 占到了堆的 68%Retained Heap 巨大。顺着 Dominator Tree 一路展开很快就找到对象持有链TaskExecutor → 静态 ConcurrentHashMap → ArrayList → 对账明细对象。代码里是一个静态的“最近一次对账结果缓存”只写不淘汰每天往里塞十几万条明细没有任何容量上限。这条链肉眼可见地清晰不需要任何玄学。2.3 修复与验证不是改完参数就结束当时的修复方案很简单把静态缓存改成带过期时间的 Caffeine 本地缓存单 key 存储汇总结构而不是明细列表并且对内存使用加了监控告警。但真正值得讲的不是修复本身而是验证方法。我习惯用三个步骤确认“修透了”第一代码修复后重启观察连续七天的老年代曲线确认斜率归零第二用压测工具把对账数据量拉到线上峰值的两倍跑完看 Full GC 频率是否回归正常第三把 JVM 参数里-XX:HeapDumpOnOutOfMemoryError打开万一再溢出至少能自动留下现场不用等人工抓取。加了这个参数之后JVM 在第一次 OOM 时会把堆现场自动 dump 出来并打印文件位置这对事后追查非常重要。3. 参数与工具链先弄懂怎么调再谈调到多少很多资料喜欢直接给“标准答案”比如“堆默认设置成物理内存的一半”。这种说法误导性很强。JVM 参数的作用是约束行为边界没有一套参数能适配所有业务。我把自己常用到的参数整理成了一份速查表每个参数的适用场景和注意事项都写在旁边。3.1 常用 JVM 内存参数速查表参数作用建议与提醒-Xms/-Xmx堆初始大小 / 堆最大大小生产环境建议设成相同值避免运行期堆扩容时出现额外停顿-Xss单线程栈容量默认 1MB 左右。不要为了省内存粗暴调低到 128k 以下容易引发栈溢出-XX:MaxMetaspaceSize元空间上限必须设。不设等于让元空间挤压本机其他进程的内存-XX:MaxDirectMemorySize直接内存上限用了 Netty 就必须关注默认值等于堆上限对堆外占用心里要有数-XX:HeapDumpOnOutOfMemoryErrorOOM 时自动 dump 堆强烈建议开启配合下面的 -XX:HeapDumpPath 指定目录-XX:HeapDumpPathdump 文件保存路径指向有足够磁盘空间的目录文件名会自动带 pid-XX:PrintGCDetails打印 GC 明细日志老版本 JVM 常用现在的容器化环境建议改用统一日志参数有一个很多人忽略的点-Xmx的配置不能只看应用本身。一台机器上跑的还有 GC 线程、JIT 编译器、元空间、直接内存、线程栈这些全在进程地址空间里。如果堆上限设得几乎等于容器内存上限那么堆外那些开销会先扛不住。反过来说若-Xmx设得太小高峰期对象分配不下去也会触发频繁 Full GC。合理范围取决于业务对象的存活率没有统一数值。我的习惯是先压测再观察监控里的堆占用快照把-Xmx设在稳定高水位峰的 1.5~2 倍左右留出峰谷缓冲。3.2 内存泄露查看工具横向对比工具选型上我按处理阶段把它们分成三类线上快速观测、现场快照抓取、离线深度分析。工具定位优势短板jstat线上实时观测JDK 自带、零安装、命令轻量只有数值看不到对象明细jmap现场抓取dump 堆快照、clstats 看类加载器dump 时会 STW要注意时机jhat离线分析JDK 自带能看对象直方图交互老式、大 dump 吃力现在用得很少JConsole / VisualVM本地开发观测图形界面直观适合本机调试线上环境一般不开放 JMX 端口MAT离线深度分析Leak Suspects 和 Dominator Tree 极好用对超大 dump 文件内存要求高Arthas线上综合诊断dashboard、heapdump、thread 命令都很实用还能在容器里用生产环境用它需要审慎评估权限管控Alibaba Arthas 里的heapdump命令可以直接导出堆快照thread -n 3能瞬间看到最忙的三个线程这两个命令在容器化环境特别香因为很多云原生环境里根本没有jmap的安装条件。我现在的线上排查习惯是先用 Arthas 的dashboard看内存池占用与 GC 次数迅速锁定大致区域再决定是否需要 dump 做深度分析而不是一上来就抓 dump。3.3 一段常用的命令行排查流程下面这段命令序列是我在 Linux 服务器上排查内存问题时习惯先跑的几条顺序是经过实际验证的。先确认进程和参数再看 GC 走势最后决定是否抓 dump。# 确认进程与启动参数重点看 Xmx、Xss、MetaspaceSize ps -ef | grep java jcmd pid VM.flags | tr \n | grep -E Xmx|Xss|MaxMetaspace|MaxDirect # 观察 GC 与内存池走势每 1 秒输出一次共 10 次 jstat -gcutil pid 1000 10 # 查看类加载器统计定位 Metaspace 泄漏 jmap -clstats pid # 抓堆现场低峰期操作会触发 Full GC jmap -dump:live,formatb,file/data/dump/heap.hprof pid这套命令解决不了所有问题但覆盖了堆、元空间两类最常见的现场。直接内存问题则要看进程的 Native 内存单靠 jmap 不够需要开启 NMT-XX:NativeMemoryTrackingsummary再jcmd pid VM.native_memory summary看分类统计。NMT 本身有少量性能开销线上开启前需要评估。4. 面试官想听到的JVM 内存与溢出的关键认知JVM 相关的面试题基本绕不开内存区域、GC、溢出异常、调优这几个话题。这里我不去背题而是把高频考点背后真正需要建立的几个认知讲透。4.1 内存区域和溢出异常的对应关系很多面试问题是“每个内存区域都什么时候会溢出”。这张对应关系表可以作为答题骨架内存区域溢出异常典型触发场景排查入口堆Java heap space内存泄漏、峰值对象过多堆 dump MAT虚拟机栈/本地方法栈StackOverflowError递归无出口、调用链过深、栈帧过大线程 dump检查调用栈元空间Metaspace动态生成类、类加载器泄漏jmap -clstats统计类加载器直接内存Direct buffer memoryNIO/Netty 堆外缓冲区泄漏或碎片化NMT 或 JFR 的 native memory 走势这个表里有个细节值得展开为什么栈溢出是 StackOverflowError 而不是 OutOfMemoryError因为栈空间在创建线程时就已经确定请求的 depth 超出容量时 JVM 直接认为“栈深度计算错误”而不是“内存不足”。如果创建线程本身都开不了新栈那就变成Unable to create new native thread这类报错实际是操作系统线程资源耗尽属于另一套排查思路经常和 ulimit 限制、线程数失控挂钩。4.2 “内存泄漏”和“内存溢出”到底有什么区别这个经典考点的标准答法大家都会背泄漏是对象无法被回收溢出是真的装不下了。但面试官更想听的是二者之间的因果关系。打个比方内存好比一个水桶水位持续上涨到溢出来叫溢出。水位上涨的原因有两种可能桶底有个洞水一边漏进来一边渗下去但进水速度大于漏水速度桶最终还是会满——这是泄漏加高速进水另一种是桶本身没洞但水管拧到了最大冲洗量超过了桶的容量一样会溢出来——这是纯峰值压力。排查的时候如果只确认“溢出了”很难知道是哪种原因。所以正确姿势是先用 GC 日志和堆 dump 判断对象存活情况如果 GC 后占用仍然很高倾向泄漏如果 GC 后占用能大幅下降但很快又满倾向容量不足。顺带说一下 JRE 和 JVM 的关系。这个知识点虽然基础但经常被混在“启动报错”里考。JRE 是 Java 运行时环境里面包含了 JVM 实现、核心类库和其他运行时组件JVM 是 JRE 的核心执行引擎。启动 Java 程序时先通过启动器找到 JRE 里的 JVM再让 JVM 加载并执行字节码。如果启动器找不到可用的 JVM就会看到no suitable jvm was found to start the application。这个报错在 Windows 双击打包应用时很常见多数和 JAVA_HOME 未配置、32 位程序找了 64 位 JRE、或者注册表信息缺失有关并不是代码层面的问题。4.3 调优面试题的底层逻辑先量化再动手JVM 调优的面试题经常问“你的 JVM 参数怎么设置的”。如果直接回答“Xmx 设了 4g”基本暴露了没有体系。面试官真正想看的是你面对一个具体性能问题时怎么建立判断路径。我常用的调优路径只有四步第一量化现状通过 GC 日志和监控拿到 Full GC 频率、单次停顿时间、各代占用曲线第二定位卡点是分配速率过高、晋升过快、还是存在泄漏第三针对卡点做最小干预一次只改一个参数第四用压测验证并对比调优前后数据。这个闭环比任何具体参数都重要。举个实际例子某服务 GC 停顿平均 300ms但线上 P99 要求低于 200ms。GC 日志显示新生代 Minor GC 频率不算高但每次进入老年代的对象量很大导致老年代快速膨胀触发 Full GC。这种情况下盲目调大堆只能推迟问题正确的干预方向其实是调整晋升阈值-XX:MaxTenuringThreshold相关和 Survivor 区大小让更多对象在新生代被回收而不是一股脑往老年代送。调优不是比拼参数调得多花哨而是比拼你对分配和回收链路的理解。5. 高频问题速查与避坑经验最后这部分是我这几年的问题清单专门挑那些“平时没人写、出了事才发现”的细节。我把它整理成速查表的样式方便遇到问题时直接对号入座。5.1 常见问题定位对照表现象可能原因第一排查动作老年代占用持续上涨FGC 后不回落堆内对象泄漏抓堆 dump看 Dominator Tree 的 Retained HeapMinor GC 后大量对象进入老年代FGC 频繁晋升阈值太小或 Survivor 区不足调整 survivor 比例和晋升阈值别急着加堆MetaspaceOOM重启后能恢复类加载器泄漏或动态类生成失控jmap -clstats统计存活加载器数量进程内存占用远超堆上限直接内存或元空间在堆外增长开启 NMT看 native memory 分类走势频繁Unable to create new native thread线程数超过系统限制查 ulimit、线程池是否无限创建、容器 pid 限制启动时No suitable JVM was found启动器找不到 JRE 里的 JVM检查 JAVA_HOME、Path、32/64 位匹配5.2 我踩过的几个坑说出来让你少走弯路第一个坑是线上抓 dump 的时机。有一次我没看监控直接用jmap -dump:live抓一个高负载服务本来就快 Full GC 了再触发一次 GC 加 dump 写盘服务直接僵住两分钟。从那以后我抓 dump 前必看当前的 FGC 频率和 CPU 水位非紧急情况一律低峰期处理。紧急情况也优先用-XX:HeapDumpOnOutOfMemoryError配合自动 dump让 OOM 发生时系统自己保留现场。第二个坑是分析 dump 时把注意力全放在“大对象”上。MAT 的 Histogram 按占用排序排在最前面的往往是某个基础类型的数组如果你只盯着它看可能半天看不出业务问题。正确做法是直接看 GC Roots 的引用链注意力放在“谁通过什么路径持有了这些数据”。大对象本身不是问题持有它的那条链才是问题。第三个坑是元空间溢出时重启就能恢复导致很多团队一直靠重启续命直到发布频繁之后彻底没法启动。我当时遇到的那个支付系统就是这样连续几个版本发布后某台机器直接 Metaspace OOM 起不来。解决思路不是调大-XX:MaxMetaspaceSize而是找到持有了类加载器的静态引用。这里检查的范围很具体看有没有自定义 ClassLoader 被放进了缓存、有没有框架动态生成大量代理类、有没有临时目录加载过不常用的 jar。元空间这类问题重启只能掩盖不会修复。第四个坑和命令工具有关新版 JDK从 9 开始已经移除了独立的 jhat 工具老博客里的 jhat 用法有一部分已经过时。另外jmap -dump里的live参数到底加不加要分清楚场景——排查泄漏时加live抓存活对象更聚焦但如果你怀疑的是“堆内存里有很多本应死掉的临时对象没有被回收”不加live抓全量 dump 反而能看到更多细节。最后分享一个我自己的小习惯每次调整内存参数之后我不只看压测数据还看连续一周的监控曲线。内存问题有滞后性压测那十分钟跑得好不代表三天后内存不涨。把老年代占用曲线、FGC 频率、进程 RSS 三条曲线拉出来放一起对比比任何压测报表都更接近真相。这套“先看区域再抓现场最后修根因”的思路帮我解决过的线上问题不计其数也希望你在下一次 OOM 告警来临之前已经准备好了。