一、OOM 问题概述

在 Java 应用的生产环境中,OutOfMemoryError(简称 OOM)是最令开发和运维人员头疼的问题之一。它的出现往往意味着应用已经走到了崩溃的边缘:服务响应变慢、请求失败率飙升,甚至整个进程被操作系统强制终止。与普通的业务异常不同,OOM 通常不是单次请求的错误,而是系统级资源耗尽的结果,它会影响整个 JVM 实例内运行的所有线程和任务。

理解 OOM 的关键在于理解 JVM 的内存管理机制。Java 语言通过自动内存管理(Automatic Memory Management)把开发者从手动申请和释放内存的负担中解放出来,但这份便利的代价是:一旦内存使用失控,排查难度会显著上升。因为内存的分配和回收由垃圾收集器(Garbage Collector,GC)自动完成,开发者往往无法直观地看到"内存究竟被谁占用了",也无法像 C/C++ 那样通过检查代码里的 malloc/free 配对来定位问题。

OOM 的本质是 JVM 在尝试分配内存时,发现即使经过垃圾回收,仍然没有足够的空间满足新的分配需求,于是抛出 OutOfMemoryError。这里有一个重要的事实需要澄清:OOM 不等于内存泄漏。内存泄漏(Memory Leak)只是导致 OOM 的众多原因之一,除此之外,内存配置过小、峰值流量过高、创建了超出预期的超大对象、元空间或直接内存使用过度、线程数过多导致线程栈耗尽等,都可能导致 OOM。

从工程实践的角度看,排查 OOM 的完整链路通常包含四个阶段:发现与确认(观察监控指标、确认 OOM 类型)、现场保留(开启堆转储、记录 GC 日志)、数据分析(使用工具分析堆转储文件、定位占用内存的对象)、修复与验证(修改代码或参数、灰度验证、回归监控)。本文将围绕这四个阶段展开,系统性地介绍 JVM 内存结构、各类 OOM 的产生原因、常用排查工具以及经过实战验证的解决方案。

在正式进入主题之前,我们还需要明确两个基本概念。第一,OOM 是 Error 而非 Exception,它继承自 VirtualMachineError,属于严重错误,理论上不应该被业务代码捕获和处理。虽然在极少数场景下有人会尝试 catch Throwable 来维持服务运行,但这通常是不被推荐的做法,因为一旦内存状态已经损坏,继续运行可能带来更不可控的后果。第二,OOM 的抛出位置与问题的根源位置往往不一致。内存分配失败的代码可能只是"压垮骆驼的最后一根稻草",真正吞噬内存的代码可能在完全不相干的地方。这就要求排查者必须具备全局视角,而不是盯着堆栈顶部那几行代码不放。

二、JVM 内存模型详解

要深入排查 OOM,首先必须对 JVM 的内存布局有清晰的认识。根据《Java 虚拟机规范》以及 HotSpot 虚拟机的实际实现,JVM 运行时的内存可以划分为若干个区域,每个区域都有其特定的用途、容量限制和溢出表现。下图呈现了 HotSpot JVM 内存区域的全貌:

flowchart TB
    subgraph JVM["JVM 运行时数据区"]
        direction TB
        subgraph ThreadShared["线程共享区"]
            Heap["堆 Heap,由 -Xmx 控制"]
            Meta["元空间 Metaspace,由 -XX:MaxMetaspaceSize 控制"]
            Direct["直接内存 Direct Memory,受 -XX:MaxDirectMemorySize 限制"]
        end
        subgraph ThreadPrivate["线程私有区"]
            PC["程序计数器 PC Register"]
            Stack["虚拟机栈 VM Stack,由 -Xss 控制"]
            Native["本地方法栈 Native Method Stack"]
        end
    end
    Heap --> Young["新生代 Young Gen,由 -Xmn 控制"]
    Young --> Eden["Eden 区"]
    Young --> Survivor["Survivor 区 S0/S1"]
    Heap --> Old["老年代 Old Gen"]
    Direct --> NIO["NIO ByteBuffer 等"]

从上到下,我们逐一分析每个区域的特点。首先是程序计数器(Program Counter Register),它是一块非常小的内存区域,每个线程独享一份,用于记录当前线程正在执行的字节码指令地址。它是 JVM 中唯一不会发生 OutOfMemoryError 的区域,因此我们在排查 OOM 时通常不会关注它。

其次是虚拟机栈(VM Stack),它同样是线程私有的。每个 Java 方法在执行时都会创建一个栈帧(Stack Frame),用于存储局部变量表、操作数栈、动态链接和方法出口等信息。当线程请求的栈深度超过虚拟机允许的最大深度时,会抛出 StackOverflowError;当栈空间无法继续扩展以满足新栈帧的申请时,会抛出 OutOfMemoryError。虚拟机栈的大小由 -Xss 参数控制,默认通常在 512KB 到 1MB 之间。

重点理解:线程栈的大小直接影响 JVM 能创建的最大线程数量。如果一个线程默认占用 1MB 栈空间,那么仅堆外内存(包括线程栈)就可能限制线程数量。当线程数量过多时,即使堆内存还有大量剩余,也可能因为无法为新的线程分配栈空间而抛出 OutOfMemoryError,错误信息类似 unable to create new native thread。这一点在后面的章节会详细展开。

再次是本地方法栈(Native Method Stack),它与虚拟机栈功能类似,只是服务于 Native 方法。在 HotSpot 实现中,虚拟机栈和本地方法栈是合并在一起的,所以通常不单独讨论。

接下来是 JVM 内存中最重要的区域——(Heap)。堆是 JVM 中最大的一块内存,它在 JVM 启动时创建,被所有线程共享,几乎所有的对象实例和数组都分配在这里。堆的大小由 -Xms(初始大小)和 -Xmx(最大大小)参数控制。堆内部又划分为新生代(Young Generation)和老年代(Old Generation)。新生代进一步划分为 Eden 区和两个 Survivor 区(S0、S1),默认比例是 8:1:1。新创建的对象首先分配在 Eden 区,经过多次 Minor GC 仍存活的对象会晋升到老年代。大对象可能会直接分配在老年代,具体行为受 -XX:PretenureSizeThreshold 参数影响。关于堆的垃圾回收细节属于 GC 调优的范畴,本文在关注 OOM 时,只需要明确:绝大多数导致 OOM 的"元凶"都藏在堆里

然后是元空间(Metaspace)。在 JDK 8 之前,类的元数据存储在永久代(PermGen)中,永久代是堆的一部分,大小由 -XX:PermSize-XX:MaxPermSize 控制。从 JDK 8 开始,永久代被元空间取代。元空间并不在堆内,而是使用本地内存(Native Memory)来存储类的元信息,包括类的名称、字段、方法、常量池、注解等。元空间的大小由 -XX:MetaspaceSize(初始触发 GC 的阈值)和 -XX:MaxMetaspaceSize(最大大小)控制。如果应用动态加载大量类(例如使用 CGLIB、动态代理、Groovy 脚本、JSP 等),而没有及时卸载,元空间就可能被耗尽,抛出 OutOfMemoryError: Metaspace

最后是直接内存(Direct Memory)。直接内存并不是 JVM 规范中定义的内存区域,但它对 OOM 排查至关重要。NIO 的 ByteBuffer.allocateDirect() 方法会分配直接内存,它的分配不受 -Xmx 限制,而是受 -XX:MaxDirectMemorySize 参数限制(默认等于 -Xmx 的值)。直接内存的回收不像堆回收那样受 GC 完全控制,它依赖 DirectByteBuffer 对象的 Cleaner 机制在对象被 GC 回收时释放。因此,如果大量 DirectByteBuffer 对象因为某些原因没有被及时回收,直接内存就可能溢出,抛出 OutOfMemoryError: Direct buffer memory

除了上述区域,Java 进程实际占用的内存还包括 JVM 自身的运行开销(如编译器、GC 数据结构)、代码缓存(Code Cache,由 -XX:ReservedCodeCacheSize 控制)、类加载器的内部结构、以及各种本地库(Native Library)占用的内存。因此,我们经常观察到一个现象:Java 进程的 RSS(Resident Set Size)远大于 -Xmx 配置的值。这一点在容器化部署中尤其重要,如果容器内存限制设置得太紧,即使堆没有溢出,整个进程也可能被 OOM Killer 杀死,而排查者往往只盯着 JVM 内部数据,忽略了操作系统层面的内存限制。

为了加深理解,下面通过一个简单的示例代码来演示不同内存区域的分配:

import java.nio.ByteBuffer;
import java.util.ArrayList;
import java.util.List;
public class MemoryRegionDemo {
public static void main(String[] args) {
// 1. 堆内存:普通对象分配在堆上,由 -Xmx 控制
List<byte[]> heapData = new ArrayList<>();
for (int i = 0; i < 10; i++) {
heapData.add(new byte[1024 * 1024]); // 每次分配 1MB
}
System.out.println("堆内存分配完成,已分配:" + heapData.size() + "MB");
    // 2. 直接内存:DirectByteBuffer 分配在本地内存,不受 -Xmx 限制
    ByteBuffer directBuffer = ByteBuffer.allocateDirect(1024 * 1024);
    System.out.println("直接内存分配完成");
// 3. 线程栈:每个线程独享栈空间,由 -Xss 控制
Thread thread = new Thread(() -&amp;gt; {
    byte[] localData = new byte[10 * 1024];
    System.out.println("线程栈空间使用中,线程名:" + Thread.currentThread().getName());
});
thread.start();
// 4. 元空间:类元数据存储位置,动态生成类会占用元空间
System.out.println("JVM 内存区域演示完毕");
}
}

总结一下,JVM 内存区域可以概括为"堆内"和"堆外"两大部分。堆内主要存储对象实例,由 -Xms-Xmx 控制;堆外包括元空间、直接内存、线程栈、代码缓存等,它们共同构成了 Java 进程的真实内存占用。很多经验不足的开发者只关注堆,却忽略了堆外内存,导致在排查 OOM 时"找不到问题",明明堆使用率不高,进程却被杀死了。牢记这张内存地图,是高效排查 OOM 的第一步。

三、常见 OOM 类型及其原因分析

3.1 OutOfMemoryError: Java heap space

这是最常见的一种 OOM。它的错误信息完整形式是:

Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
        at com.example.MemoryTest.main(MemoryTest.java:15)

触发条件非常直接:堆中没有足够的空间来分配新的对象,而且即使进行了 Full GC,仍然无法释放足够的空间。可能的原因包括:

  • 堆配置过小:业务需要的内存大于 -Xmx 设置的值。
  • 内存泄漏:某些对象被无意中持有强引用,导致 GC 无法回收。
  • 超大对象分配:一次性申请超出堆可用空间的数组或对象。
  • 流量洪峰:瞬时流量激增,产生了大量存活对象。
  • 缓存无界增长:使用了无界缓存(如静态 Map)且没有淘汰策略。

下面是一段必然触发堆溢出的示例代码:

import java.util.ArrayList;
import java.util.List;
public class HeapSpaceOOM {
static class BigObject {
private byte[] data = new byte[1024 * 1024]; // 每个对象 1MB
}
public static void main(String[] args) {
    List&lt;BigObject&gt; list = new ArrayList&lt;&gt;();
    while (true) {
        list.add(new BigObject()); // 不断持有引用,对象无法被 GC 回收
    }
}
}

使用 -Xmx256m 运行上述代码,很快会抛出 OutOfMemoryError: Java heap space。这是最典型的内存泄漏场景:list 是静态存活对象,它持有的引用阻止了所有 BigObject 被回收。

3.2 OutOfMemoryError: GC overhead limit exceeded

这个错误的完整信息是:

java.lang.OutOfMemoryError: GC overhead limit exceeded

它的含义是:GC 花费了过多的时间却只回收了极少的堆空间。在 HotSpot 中,触发条件默认是:在连续多次 GC 中,JVM 花费了超过 98% 的时间用于垃圾回收,却只回收了不到 2% 的堆内存。这个默认阈值可以通过 -XX:GCTimeLimit-XX:GCHeapFreeLimit 调整,但一般不推荐修改。

这个错误通常意味着堆已经几乎被填满,并且 GC 无法有效回收任何对象,系统陷入了"几乎每个时间片都在做 GC,但 GC 又几乎回收不了任何东西"的恶性循环。它本质上是"堆空间不足"的一种变体,只不过 JVM 选择了一种更早暴露问题的方式——避免应用在极低吞吐量下苟延残喘。

可以通过 -XX:-UseGCOverheadLimit 关闭这个检查,但这样做通常只是把问题延后,最终还是会触发 Java heap space。正确的做法是排查为什么 GC 回收不掉对象,而非回避这个警告。

3.3 OutOfMemoryError: Metaspace

错误信息通常如下:

java.lang.OutOfMemoryError: Metaspace

触发原因是元空间不足以容纳新加载类的元数据。常见场景包括:

  • 动态生成类的框架滥用:如 CGLIB、Javassist、Spring AOP 大量创建动态代理类。
  • Groovy/JS 等脚本语言引擎:每次执行脚本都会生成新的类。
  • JSP 反复编译:每次请求都编译生成新的 Class。
  • 频繁热部署:应用反复重新部署但旧的类加载器没有被释放,导致类元数据堆积。

下面是一段模拟元空间溢出的代码:

import net.sf.cglib.proxy.Enhancer;
import net.sf.cglib.proxy.MethodInterceptor;
import net.sf.cglib.proxy.MethodProxy;
import java.lang.reflect.Method;
public class MetaspaceOOM {
static class SampleClass {
public void doSomething() {
// 普通业务方法
}
}
public static void main(String[] args) {
    int count = 0;
    while (true) {
        Enhancer enhancer = new Enhancer();
        enhancer.setSuperclass(SampleClass.class);
        enhancer.setUseCache(false); // 关闭缓存,每次都生成新类
        enhancer.setCallback(new MethodInterceptor() {
            @Override
            public Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) throws Throwable {
                return proxy.invokeSuper(obj, args);
            }
        });
        enhancer.create(); // 动态生成新的子类并加载
        System.out.println("已生成第 " + (++count) + " 个动态类");
    }
}
}

使用 -XX:MaxMetaspaceSize=64m 运行上述代码,很快会抛出 OutOfMemoryError: Metaspace。这个示例揭示了动态代理类的一个关键问题:即使每次生成的类内容相同,如果没有设置缓存,也会无限制地创建新的类元数据。

3.4 OutOfMemoryError: Direct buffer memory

错误信息如下:

java.lang.OutOfMemoryError: Direct buffer memory

这个错误表示直接内存(堆外内存)不足。当我们调用 ByteBuffer.allocateDirect() 时,如果已分配的容量超过了 -XX:MaxDirectMemorySize 的限制,且本次分配请求无法满足,就会抛出此错误。直接内存的回收依赖 GC 回收 DirectByteBuffer 对象后触发 Cleaner 清理,因此如果堆内没有足够的 GC 压力,直接内存可能迟迟得不到释放。

常见场景包括:NIO 网络通信中大量使用 DirectByteBuffer、自定义的堆外缓存、依赖 Netty 等服务但未正确释放 ByteBuf 等。

import java.nio.ByteBuffer;
import java.util.ArrayList;
import java.util.List;
public class DirectMemoryOOM {
public static void main(String[] args) {
List<ByteBuffer> buffers = new ArrayList<>();
while (true) {
ByteBuffer buffer = ByteBuffer.allocateDirect(1024 * 1024); // 每次分配 1MB 直接内存
buffers.add(buffer);
}
}
}

使用 -XX:MaxDirectMemorySize=128m 运行即可触发。值得注意的是,如果我们在每次循环后不持有 buffer 的引用,而只是分配并丢弃,可能会发现直接内存的占用呈现"锯齿状":因为 GC 会周期性地回收 DirectByteBuffer 对象并释放直接内存,但如果释放速度跟不上分配速度,依然会溢出。

3.5 OutOfMemoryError: unable to create new native thread

错误信息如下:

java.lang.OutOfMemoryError: unable to create new native thread

这个错误说明JVM 无法继续创建新的线程。可能的原因有两类:

  • 操作系统层面限制:进程可创建的最大线程数达到上限(受 ulimit -u/proc/sys/kernel/threads-max 影响)。
  • 内存不足:每个线程需要为栈分配内存(默认 1MB 左右),当进程或系统内存不足以支撑新线程的栈空间时,创建会失败。

下面的代码会无限创建线程,直到系统资源耗尽:

public class ThreadOOM {
    public static void main(String[] args) {
        int count = 0;
        while (true) {
            Thread thread = new Thread(() -> {
                try {
                    Thread.sleep(Long.MAX_VALUE); // 线程保持存活
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                }
            });
            thread.start();
            System.out.println("已创建线程数:" + (++count));
        }
    }
}

这类问题的根源通常是代码中使用了无限制的线程池、或者对每次请求都创建新线程而没有复用、又或者线程池的核心线程数设置过大而任务执行缓慢导致线程堆积。

3.6 OutOfMemoryError: Requested array size exceeds VM limit

错误信息如下:

java.lang.OutOfMemoryError: Requested array size exceeds VM limit

这个错误表示应用尝试创建一个超过 JVM 允许的最大数组大小的数组。在 HotSpot 中,数组的大小限制取决于元素类型和平台,但通常非常接近 Integer.MAX_VALUE。如果代码尝试分配一个超大数组(例如数组长度接近或超过整型上限),或者由于计算错误导致数组长度异常膨胀,就会触发此错误。

public class ArraySizeOOM {
    public static void main(String[] args) {
        // 尝试分配一个接近 Integer.MAX_VALUE 长度的数组
        int[] hugeArray = new int[Integer.MAX_VALUE - 2];
    }
}

这类问题通常是编程错误导致的,例如数组的 size 计算溢出变成了负数、或者外部输入的数据量异常导致数组长度计算失控。

3.7 其他 OOM 类型

除了上述常见类型,还有一些在特定场景下才会出现的 OOM,例如:

  • OutOfMemoryError: Out of swap space:操作系统交换空间不足,通常发生在 Native 代码或 GC 需要分配内存时。
  • OutOfMemoryError: Compressed class space:压缩类空间不足,发生在启用压缩类指针(-XX:+UseCompressedClassPointers)时,类元数据中的一部分存放在独立的压缩类空间中。
  • OutOfMemoryError: Map failed / Commit failed:底层内存映射或提交失败,通常与操作系统内存、ulimit 设置或 huge page 有关。

对于这些相对罕见的类型,排查思路与前面类似:识别出错区域、确认内存限制、分析占用来源。

四、OOM 排查工具全景

工欲善其事,必先利其器。排查 OOM 离不开一系列命令行工具和图形化工具。本节按照"信息收集类""堆分析类""在线诊断类"三个维度,系统介绍 Java 生态中最常用、最有效的排查工具。

4.1 基础命令行工具

JDK 自带了多个命令行诊断工具,它们位于 JAVA_HOME/bin 目录下,无需额外安装,是所有排查工作的基础。

jps(Java Virtual Machine Process Status Tool)用于列出当前系统中的 Java 进程及其进程号(PID)。它是后续所有工具操作的第一步,因为没有 PID 就无法定位目标进程。

jps -l
# 输出示例:
# 12345 com.example.MyApplication
# 67890 sun.tools.jps.Jps

jstat(JVM Statistics Monitoring Tool)用于实时监控 JVM 的运行状态,包括类加载、垃圾回收、内存使用等。下面是查看堆内存各区域使用情况和 GC 统计的常用命令:

# 每 1000ms 打印一次进程 12345 的 GC 统计,共打印 10 次
jstat -gcutil 12345 1000 10
# 输出列:S0 S1 E O M CCS YGC YGCT FGC FGCT GCT
# 分别代表:Survivor0使用率、Survivor1使用率、Eden使用率、Old使用率、Metaspace使用率、压缩类空间使用率、
# Young GC次数、Young GC耗时、Full GC次数、Full GC耗时、总GC耗时
# 查看堆空间容量与使用量(单位:KB)
jstat -gc 12345 1000 5

通过持续观察 O(老年代使用率)和 FGC(Full GC 次数),可以快速判断堆是否处于不健康状态。如果 O 一直维持在 90% 以上且 FGC 频繁增长,说明堆很可能已经接近溢出边缘。

jmap(Memory Map)用于查看堆内存的详细信息并生成堆转储文件(Heap Dump)。堆转储是离线分析 OOM 最核心的数据来源。

# 查看堆的概要信息
jmap -heap 12345
# 生成堆转储文件
jmap -dump:format=b,file=/tmp/heapdump.hprof 12345

在生产环境中,直接在运行中的进程上执行 jmap -dump 可能会导致应用短暂停顿(尤其在使用某些 GC 算法时)。更推荐的方式是在应用启动时加上如下参数,让 JVM 在发生 OOM 时自动转储堆:

-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp/heapdump.hprof

jstack(Stack Trace)用于生成线程快照(Thread Dump),可以查看所有线程的当前状态、调用栈和锁等待情况。在排查 OOM 中,它主要用于分析线程数过多、死锁以及定位占用 CPU 或阻塞的线程。

jstack -l 12345 > /tmp/threaddump.txt

jcmd是 JDK 7 之后推出的"全能型"诊断命令,它集成了 jmap、jstack 等多个工具的功能。

# 列出指定进程支持的所有诊断命令
jcmd 12345 help
# 生成堆转储(等价于 jmap -dump)
jcmd 12345 GC.heap_dump /tmp/heapdump.hprof
# 打印线程堆栈(等价于 jstack)
jcmd 12345 Thread.print

jinfo用于查看和动态修改 JVM 的运行参数。在排查 OOM 时,可以用它确认某个参数是否真正生效,或者动态开启某些诊断开关。

# 查看所有 JVM 参数
jinfo -flags 12345

4.2 图形化监控工具

JConsoleJVisualVM是 JDK 自带的图形化监控工具。它们可以直观地展示堆内存曲线、线程数量、类加载数、CPU 使用率等指标,适合在开发环境和预发环境进行实时观察。

JVisualVM 还支持安装插件(如 Visual GC),用于可视化 GC 过程和内存区域的动态变化。通过观察堆内存曲线的"锯齿"形态,可以大致判断 GC 是否健康:正常的曲线应该是周期性上升后陡降,如果曲线一路攀升且从不回落,说明可能存在内存泄漏。

对于 JDK 9 及更高版本,JVisualVM 已从 JDK 中移除,需要到官方仓库单独下载使用。部分团队也会选择开源的 Arthas 或商业 APM 工具(如 SkyWalking、Pinpoint、Dynatrace 等)作为替代。

4.3 堆转储分析工具

当 OOM 发生后,分析堆转储文件(.hprof)是定位问题的关键环节。业界最常用的两个工具是 Eclipse MAT(Memory Analyzer Tool)JProfiler

Eclipse MAT 是开源的堆分析工具,功能强大,能够处理数 GB 甚至更大的堆转储文件。它提供了多种分析报告,包括:

  • Leak Suspects Report(泄漏嫌疑报告):自动分析最可能造成内存泄漏的对象和引用链。
  • Histogram(直方图):按类统计对象数量和占用的内存大小,快速找出"大头"。
  • Dominator Tree(支配树):展示对象的支配关系,帮助理解一个对象"到底留住了多少内存"。
  • Path to GC Roots(GC 根路径):追踪一个对象到 GC Roots 的引用链,是定位"对象为什么没被回收"的终极手段。
# MAT 的常用分析流程:
# 1. 打开 .hprof 文件
# 2. 运行 Leak Suspects Report
# 3. 查看 Histogram,按 Retained Heap 排序
# 4. 对可疑对象执行 Path to GC Roots,选择排除弱引用
# 5. 定位到具体代码行

需要提醒的是,分析大型堆转储文件非常消耗内存。一般建议 MAT 的运行环境内存至少是堆转储文件大小的 1.5 到 2 倍,并且可以通过修改 MemoryAnalyzer.ini 中的 -Xmx 参数来调整。

如果堆转储文件大到本地无法分析,也可以使用 MAT 的命令行模式或 jhat(JDK 自带的堆分析工具,通过 HTTP 提供服务)进行初筛,但 jhat 的性能较差,不推荐用于生产级分析。

4.4 在线诊断工具 Arthas

Arthas 是阿里巴巴开源的 Java 在线诊断工具,它最大的优势是无需重启应用、无需修改代码,可以在运行中的进程上执行各种诊断命令,特别适合生产环境的临时排查。

# 启动 Arthas 并附着到目标进程
java -jar arthas-boot.jar 12345

Arthas 中与内存排查相关的常用命令有:

  • dashboard:实时展示内存、GC、线程、CPU 等多维度的仪表盘,类似一个轻量版的 JConsole。
  • heapdump:在线上生成堆转储文件,等价于 jmap -dump。
  • thread:查看线程状态,可以找出线程数最多的状态和阻塞原因。
  • memory:查看 JVM 内存区域的实时使用情况。
  • classloader:查看类加载器的层次结构和加载的类数量,帮助定位元空间问题。
  • loggersysprop:查看日志级别和系统属性,用于辅助分析。
# 在 Arthas 控制台中查看内存概况
memory
生成堆转储
heapdump /tmp/arthas-heapdump.hprof

Arthas 的另一个强大功能是可以动态反编译观察方法调用(watch/trace),在排查某些"运行时才出现"的内存异常时非常实用。不过需要注意的是,Arthas 本身也会占用一定的内存和 CPU,在生产环境使用时要谨慎,并且用完后及时关闭。

五、JVM 关键内存参数详解

合理配置 JVM 参数是预防 OOM 的第一道防线。很多生产事故的根源,就是参数配置不合理——要么太小,要么太大,要么遗漏了关键选项。本节梳理与内存管理相关的核心参数。

5.1 堆内存参数

参数含义默认值
-Xms堆内存初始大小物理内存的 1/64
-Xmx堆内存最大大小物理内存的 1/4
-Xmn新生代大小堆大小的 1/3 左右
-XX:NewRatio老年代与新生代的比例2(即老年代占 2/3)
-XX:SurvivorRatioEden 区与单个 Survivor 区的比例8
-XX:NewSize / -XX:MaxNewSize新生代的初始/最大大小由 NewRatio 推导

关于堆参数,有几点重要建议:

  • -Xms 与 -Xmx 建议设置为相同值:避免 JVM 在运行过程中动态扩容堆,减少因扩容导致的停顿和性能抖动。
  • -Xmx 不宜超过物理内存的 60%-70%:必须为线程栈、元空间、直接内存、操作系统和其他进程预留足够空间,尤其是在容器环境中。
  • 新生代大小需要根据对象生命周期合理设置:如果应用产生大量短命对象,适当增大新生代可以减少对象过早晋升到老年代的概率,从而降低 Full GC 频率。

5.2 元空间参数

参数含义默认值
-XX:MetaspaceSize元空间初始触发 Full GC 的阈值约 20MB
-XX:MaxMetaspaceSize元空间最大大小无限制(受物理内存约束)
-XX:MinMetaspaceFreeRatioGC 后元空间最小空闲比例40
-XX:MaxMetaspaceFreeRatioGC 后元空间最大空闲比例70

强烈建议显式设置 -XX:MaxMetaspaceSize。如果不设置,元空间理论上可以一直增长到耗尽系统内存,这在容器环境中尤其危险,因为元空间的增长不受 -Xmx 约束,可能导致容器被 OOM Killer 杀掉。对于使用动态代理、脚本引擎的应用,MetaspaceSize 也可以适当调大,以减少元空间扩容触发的 Full GC 次数。

5.3 直接内存参数

参数含义默认值
-XX:MaxDirectMemorySize最大直接内存大小等于 -Xmx 的值

对于大量使用 NIO 的应用(如网关、消息中间件、网络框架),建议显式设置该参数。如果 Netty 等框架报出 Direct buffer memory 错误,通常需要增大这个值,或者在代码层面检查 ByteBuf 是否正确释放。

5.4 线程栈参数

参数含义默认值
-Xss每个线程的栈大小因平台而异,常见 512KB 到 1MB

如果应用需要创建大量线程(例如高并发的 Servlet 线程池),可以适当调小 -Xss 以降低单线程内存占用,从而在同样的内存下支持更多线程。但注意,-Xss 过小可能导致 StackOverflowError。反之,如果遇到 unable to create new native thread,可以尝试调小 -Xss 或检查操作系统线程数限制。

5.5 关键诊断参数

以下是排查 OOM 时"保命"级别的参数配置,强烈建议所有生产应用默认开启:

-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/data/logs/heapdump.hprof
-XX:+ExitOnOutOfMemoryError
-Xloggc:/data/logs/gc.log
-XX:+UseGCLogFileRotation
-XX:NumberOfGCLogFiles=10
-XX:GCLogFileSize=100M

各参数含义:

  • -XX:+HeapDumpOnOutOfMemoryError:发生 OOM 时自动生成堆转储文件,这是事后分析的关键现场。
  • -XX:HeapDumpPath:指定堆转储文件的输出路径,避免默认路径空间不足。
  • -XX:+ExitOnOutOfMemoryError:发生 OOM 后自动退出进程,配合容器编排(如 K8s 的自动重启)使用,可以避免进程处于半死不活的状态。是否开启取决于业务容忍度,有些场景宁愿自动重启也不愿长时间不可用。
  • GC 日志相关参数(JDK 8 使用 -Xloggc,JDK 9+ 使用 -Xlog:gc*):记录每次 GC 的详细信息,帮助判断 GC 是否健康以及 OOM 发生前的内存变化趋势。

对于 JDK 9 及更高版本,GC 日志的配置方式发生了变化,使用统一的 -Xlog 参数:

-Xlog:gc*,gc+heap=debug:file=/data/logs/gc.log:time,uptime,level,tags:filecount=10,filesize=100M

一个典型的、面向生产环境的内存参数配置示例如下(以一个 4 核 8GB 内存的应用为例):

-Xms4g -Xmx4g \
-Xmn2g \
-Xss512k \
-XX:MetaspaceSize=256m \
-XX:MaxMetaspaceSize=512m \
-XX:MaxDirectMemorySize=1g \
-XX:SurvivorRatio=8 \
-XX:+HeapDumpOnOutOfMemoryError \
-XX:HeapDumpPath=/data/logs/heapdump.hprof \
-XX:+ExitOnOutOfMemoryError \
-Xloggc:/data/logs/gc.log \
-XX:+UseGCLogFileRotation \
-XX:NumberOfGCLogFiles=10 \
-XX:GCLogFileSize=100M

六、OOM 排查方法论

拥有一堆工具不等于会排查问题。面对一个"正在发生"的 OOM,有章法地推进能够显著缩短定位时间。本节总结一套经过大量实践验证的排查流程。

6.1 第一步:确认 OOM 类型与发生时机

拿到问题后,首先要回答三个问题:

  • 什么时候发生的?是应用启动时就挂,还是运行一段时间后才挂?是某个固定时间点(如定时任务触发时),还是流量高峰时?
  • 错误信息是什么?Java heap space 还是 Metaspace 还是 unable to create new native thread?错误类型直接决定了排查方向。
  • 是否影响了服务?是进程直接退出,还是进程还活着但请求大量超时?

这些信息的来源包括应用日志、监控告警、GC 日志、容器事件等。在容器化环境中,还应该检查容器是否被 OOM Killer 杀死,这可以从 dmesg 或 K8s 的 Pod 事件中看出端倪。

6.2 第二步:观察运行时指标

如果进程还活着,立即使用工具观察"实时画像":

# 1. 确认进程和 PID
jps -l
2. 观察内存与 GC 趋势
jstat -gcutil <PID> 1000 20
3. 查看线程数量
jstack <PID> | grep "java.lang.Thread.State" | wc -l
4. 查看堆概要
jmap -heap <PID>

这一步的目标是快速判断问题区域。例如:老年代使用率接近 100% 说明堆有问题;Metaspace 使用率接近上限说明类加载有问题;线程数异常高说明线程管理有问题。

6.3 第三步:保留现场

如果判断堆是问题所在(最常见的情况),应立即生成堆转储。如果应用已经配置了 -XX:+HeapDumpOnOutOfMemoryError,那么 OOM 发生时堆转储已经自动生成了;否则需要手动执行 jmap -dump 或 Arthas 的 heapdump 命令。同时,保存 GC 日志、线程快照和应用日志,这些信息的关联分析往往能揭示问题的全貌。

# 手动生成堆转储
jmap -dump:format=b,file=/data/dump/heap_$(date +%Y%m%d%H%M%S).hprof <PID>
保存线程快照
jstack -l <PID> > thread_$(date +%Y%m%d%H%M%S).txt

需要特别提醒:如果进程已经反复 OOM 且极度不稳定,应优先保留现场,而不是反复重启尝试。很多事故的教训是:运维为了快速恢复服务而重启了进程,结果堆转储没生成,导致后来无法定位根因。

6.4 第四步:离线分析堆转储

将堆转储文件拷贝到分析机器(本地或专用分析服务器),使用 MAT 打开,按照以下顺序分析:

  1. 运行 Leak Suspects Report:让工具先给出自动化的泄漏判断,通常能快速锁定可疑对象。
  2. 查看 Histogram 并按 Retained Heap 排序:找出占用内存最大的类,判断是否存在不合理的对象数量或单对象体积。
  3. 对可疑对象执行 Path to GC Roots:沿着引用链追踪到 GC Roots,定位真正"拽住"这些对象的根对象和代码位置。
  4. 结合业务代码分析:确认这些对象是否应该存活,是缓存配置不当、集合类未清理、监听器未注销、还是线程池任务堆积。

在分析过程中,有几个高频出现的"罪魁祸首"值得优先关注:大字符串、大数组、集合类(尤其是 HashMap 和 ArrayList)、缓存对象、线程池中的任务队列、以及各种自定义的静态集合。

6.5 第五步:修复、验证与复盘

定位到根因后,制定修复方案。修复方式大致分为三类:

  • 代码修复:消除内存泄漏、限制缓存大小、及时关闭资源、使用对象池等。
  • 参数调整:增大堆、元空间、直接内存或线程栈配置。
  • 架构优化:将大对象转存到磁盘或外部缓存(如 Redis)、分批处理数据、异步化等。

修复后必须在灰度环境验证,观察内存曲线是否回归正常,再进行全量发布。最后,组织一次简短复盘,把 OOM 的类型、根因、修复方案沉淀到知识库,并补齐监控告警阈值,让同样的错误不再"重演一次才被发现"。

七、实战案例:堆内存泄漏排查全过程

理论讲得再多,不如一个完整的案例来得直观。本节通过一个模拟真实场景的案例,完整走一遍堆 OOM 的排查流程。

7.1 案例背景

某电商系统的订单查询服务近期频繁出现接口超时,随后整个服务不可用,应用日志中出现了 OutOfMemoryError: Java heap space。该服务部署在 4 核 8GB 的虚拟机上,JVM 参数为 -Xmx4g,未开启堆转储。故障恢复后,团队决定彻底排查根因。

7.2 现场观察

首先检查监控系统,发现故障发生前 30 分钟内,JVM 老年代使用率从 50% 直线上升到 100%,Full GC 频率从平均每小时 1 次激增到每分钟多次,但 Full GC 后老年代使用率几乎没有下降。这个特征强烈提示存在内存泄漏:有大量对象被持续持有,GC 无法回收。

随后查看 GC 日志,确认了同样的趋势——Full GC 频繁、回收效果极差。初步结论:这是一起典型的堆内存泄漏导致的 OOM。

7.3 保留现场与生成堆转储

由于未开启 -XX:+HeapDumpOnOutOfMemoryError,此次只能手动生成。故障再次出现时,运维迅速执行:

jmap -dump:format=b,file=/data/dump/order_service.hprof 29418

生成的堆转储文件约 3.7GB。同时保存了线程快照和 GC 日志。

7.4 使用 MAT 分析堆转储

在分析机器上使用 MAT 打开堆转储文件,首先运行 Leak Suspects Report。报告迅速给出了三个泄漏嫌疑,排在第一位的是一个名为 OrderQueryCache 的类,它关联的 HashMap 实例占用了约 2.8GB 的堆内存,占整个堆的 70% 以上。

进一步查看 Histogram,按 Retained Heap 排序,结果如下(示例数据):

Class Name                                    | Objects      | Shallow Heap | Retained Heap
-------------------------------------------------------------------------------------
java.util.HashMap$Node                        | 12,532,000   | 501.3 MB     | 2.81 GB
com.example.cache.OrderQueryCache             | 1            | 16 B         | 2.81 GB
java.lang.String                              | 25,064,000   | 1.0 GB       | 2.20 GB
com.example.model.OrderDetail                 | 2,100,000    | 638.4 MB     | 1.90 GB

可以看到,OrderQueryCache 这个对象虽然自身只有 16 字节,但它通过一个巨大的 HashMap 间接持有了 2.81GB 的内存。这正是支配树的核心价值——一个看似不起眼的单例对象,却可能"支配"着海量内存。

7.5 追踪 GC Roots 定位根因

OrderQueryCache 执行 Path to GC Roots > exclude weak references,得到的引用链类似:

OrderQueryCache@0x7f8e2d4a0010
  - cacheMap : java.util.HashMap
      - table : HashMap$Node[131072]
          - [56789] : HashMap$Node
              - value : OrderDetail
                  - items : java.util.ArrayList
                      - elementData : Object[]
                          - [0] : OrderItem
                              - ...

沿着这条链找到代码位置后,发现根因是:OrderQueryCache 是一个基于本地 HashMap 实现的订单查询缓存,只实现了 put 操作,没有实现任何淘汰策略(如 LRU、TTL)。每次用户查询订单时,结果都会被缓存到这个 Map 中,而缓存键是订单号,键的多样性导致 Map 无限膨胀。随着订单量增长,缓存最终撑爆了堆内存。

// 问题代码(简化示例)
@Component
public class OrderQueryCache {
    private final Map<String, OrderDetail> cacheMap = new HashMap<>();
public OrderDetail getOrLoad(String orderId) {
    OrderDetail cached = cacheMap.get(orderId);
    if (cached != null) {
        return cached;
    }
    OrderDetail fresh = loadFromDatabase(orderId);
    cacheMap.put(orderId, fresh); // 无淘汰策略,无限增长
    return fresh;
}
}

7.6 修复方案

修复的核心是给缓存加上明确的容量上限和淘汰策略。根据业务特点(订单查询结果相对稳定、热点集中在近期订单),团队选择了基于 Caffeine 的本地缓存替代手写 HashMap,配置最大容量和过期时间:

import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import java.time.Duration;
@Component
public class OrderQueryCache {
private final Cache<String, OrderDetail> cache = Caffeine.newBuilder()
.maximumSize(10_000)                       // 最大缓存 1 万条
.expireAfterWrite(Duration.ofMinutes(30))  // 写入 30 分钟后过期
.recordStats()
.build();
public OrderDetail getOrLoad(String orderId) {
    return cache.get(orderId, this::loadFromDatabase);
}
private OrderDetail loadFromDatabase(String orderId) {
// 从数据库加载订单详情
return orderRepository.findByOrderId(orderId);
}
}

同时补充了如下措施:

  • 开启 -XX:+HeapDumpOnOutOfMemoryError 和 GC 日志,保证下次故障有现场可查。
  • 接入 JVM 内存监控,设置老年代使用率超过 85% 持续 5 分钟的告警。
  • 对缓存命中率、淘汰率进行可视化监控,验证缓存策略是否合理。

7.7 验证结果

修复发布到灰度环境后,进行了一轮压测。老年代使用率稳定在 40%-60% 之间,Full GC 频率恢复到每小时几次的正常水平,缓存命中率达到 92%。全量发布后持续观察一周,未再出现 OOM。这个案例充分说明:内存泄漏的根因往往不在报错的那一行代码,而在一个被忽略的"设计缺陷"里

八、实战案例:元空间溢出排查

8.1 案例背景

某报表平台使用 Groovy 脚本实现报表的动态计算逻辑,允许用户上传自定义脚本。系统上线一段时间后,频繁出现 OutOfMemoryError: Metaspace,每次故障后需要重启才能恢复,但过几个小时又再次出现。

8.2 现象与初步判断

观察 jstat -gcutil 的输出,发现 Metaspace 使用率(M 列)持续上涨,最终触发 Full GC 后依然降不下来,直至溢出。由于该系统使用了 Groovy 动态脚本,初步怀疑是脚本引擎反复编译生成新类,但类没有被卸载

8.3 验证猜想

在 Arthas 中使用 classloader 命令查看类加载器信息:

classloader
# 输出中可以看到多个 GroovyClassLoader 实例,每个实例加载了大量类

进一步查看 JVM 类加载统计:

jstat -class <PID> 1000 10
# 输出:Loaded  Bytes  Unloaded  Bytes     Time
#       152300  291M   120       0.2M      32.5

可以看到 Loaded(累计加载类数)在持续增长,而 Unloaded(累计卸载类数)几乎停滞。这证实了猜想:每次执行 Groovy 脚本都会生成新的 Class,但这些 Class 从未被卸载,元空间被持续消耗直至溢出。

8.4 根因分析

深入代码发现,业务代码每次执行脚本时都创建了一个全新的 GroovyClassLoader 和脚本类实例:

public Object executeScript(String scriptContent) {
    GroovyClassLoader loader = new GroovyClassLoader(); // 每次都新建
    Class<?> scriptClass = loader.parseClass(scriptContent);
    Script script = (Script) scriptClass.getDeclaredConstructor().newInstance();
    return script.run();
}

这里存在的问题是:虽然每次创建了新的 GroovyClassLoader,但没有任何地方显式关闭它,而且每次 parseClass 都生成了新的类定义。随着脚本执行次数的增加,元空间中堆积了大量不会被使用的类元数据。

8.5 解决方案

修复思路是:复用 GroovyClassLoader,并对脚本编译结果做缓存。相同内容的脚本只编译一次,避免重复生成类。对于确实需要动态编译不同脚本的场景,也要确保在任务结束后关闭 ClassLoader 或控制缓存上限。

import groovy.lang.GroovyClassLoader;
import groovy.lang.Script;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
public class GroovyScriptEngine {
private final GroovyClassLoader loader = new GroovyClassLoader();
private final Map<String, Class<?>> scriptCache = new ConcurrentHashMap<>();
public Object executeScript(String scriptContent) throws Exception {
    Class&lt;?&gt; scriptClass = scriptCache.computeIfAbsent(
            scriptContent, loader::parseClass);
    Script script = (Script) scriptClass.getDeclaredConstructor().newInstance();
    return script.run();
}
}

同时,将 MaxMetaspaceSize 从默认的无限制改为显式值(如 512m),避免元空间异常增长拖垮整个进程。修复后,Unloaded 的增速恢复正常,元空间使用率稳定在 200m 以下,问题彻底解决。

经验总结:凡是使用动态脚本、动态代理、字节码增强技术的应用,都要特别关注类加载和类卸载的平衡。必要时要设置 MaxMetaspaceSize 兜底,并监控 Loaded/Unloaded 的比例。

九、实战案例:直接内存溢出排查

9.1 案例背景

某消息推送网关基于 Netty 开发,负责将消息推送给海量客户端。在一次大促活动中,网关出现 OutOfMemoryError: Direct buffer memory,导致推送链路中断。奇怪的是,监控显示堆内存使用率一直正常,团队一时困惑。

9.2 现象分析

堆内存使用率正常但抛出 Direct buffer memory,说明问题出在堆外,而非堆内。这类问题的排查不能只盯着 -Xmx,而要关注 -XX:MaxDirectMemorySize 以及本地内存的整体占用。

通过 jcmd 查看本地内存的汇总信息:

jcmd <PID> VM.native_memory summary

输出中 InternalOther 部分的 Native Memory 占用异常高,结合业务特征,推断直接内存被大量使用而未及时释放。

9.3 根因定位

排查代码后发现,网关在处理消息时使用了 ByteBuf(Netty 的缓冲区),但在某些异常路径下,ByteBuf 没有被正确 release。例如以下代码:

public void handleMessage(ChannelHandlerContext ctx, ByteBuf msg) {
    try {
        // 业务处理
        process(msg);
    } catch (Exception e) {
        log.error("处理消息失败", e);
        // 注意:这里没有调用 ReferenceCountUtil.release(msg)
    }
}

当业务处理抛出异常时,msg 的引用计数没有减一,导致底层直接内存缓冲区无法被释放。大促期间消息量激增,异常也相应增多,泄漏速度超过回收速度,最终耗尽直接内存。

9.4 解决方案

修复的关键是确保 ByteBuf 在所有路径上都得到正确释放。使用 Netty 提供的 ReferenceCountUtil.release() 或者利用 SimpleChannelInboundHandler(它会自动释放消息)来管理引用计数:

public void handleMessage(ChannelHandlerContext ctx, ByteBuf msg) {
    boolean released = false;
    try {
        process(msg);
    } catch (Exception e) {
        log.error("处理消息失败", e);
    } finally {
        if (!released) {
            ReferenceCountUtil.release(msg); // 确保释放
            released = true;
        }
    }
}

更优雅的方式是继承 SimpleChannelInboundHandler<ByteBuf>,它会自动处理释放逻辑。此外,团队还在代码评审规范中增加了"所有 ByteBuf 必须明确释放"的检查项,并通过 Netty 自带的 ResourceLeakDetector 在测试环境提前发现泄漏。

# 在测试环境开启泄漏检测(最高级别)
-Dio.netty.leakDetection.level=paranoid

修复后,直接内存占用恢复到正常水平,大促期间再未出现类似问题。这个案例提醒我们:排查 OOM 时不要被"堆正常"的表象迷惑,堆外内存的使用同样需要严格监控和管理

十、实战案例:线程数过多导致 OOM

10.1 案例背景

某文件批量处理服务需要并发上传大量文件,原代码为每个文件启动一个独立线程。随着业务增长,单批处理的文件数量从数百增加到数万,服务开始出现 OutOfMemoryError: unable to create new native thread

10.2 问题分析

这个错误的直接原因是线程创建失败,而线程创建失败又分两个层次:一是操作系统对进程可用线程数有限制;二是每个线程的栈空间占用内存,线程过多导致内存耗尽。

使用 jstack 查看线程状态:

jstack -l <PID> | grep "java.lang.Thread.State" | sort | uniq -c

发现存在数万个处于 RUNNABLE 或 BLOCKED 状态的线程。再查看系统层面的线程限制:

# 查看进程级线程限制
ulimit -u
查看系统全局线程上限
cat /proc/sys/kernel/threads-max

根因清晰了:代码为每个文件创建一个新线程,线程既没有复用也没有数量上限,当单批文件达到数万时,线程数量触顶,新线程创建失败。

10.3 解决方案

修复的方式是引入线程池来复用线程,并设置合理的并发上限。线程池的大小应根据机器的 CPU 核数和任务的 I/O 特性来确定,而不是盲目追求"线程越多越快"。

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
public class BatchFileUploader {
// IO 密集型任务:线程数可以适当多于 CPU 核数
private static final int MAX_POOL_SIZE = 200;
private final ExecutorService executor = Executors.newFixedThreadPool(MAX_POOL_SIZE);
public void uploadBatch(List&lt;File&gt; files) throws InterruptedException {
    for (File file : files) {
        executor.submit(() -&gt; uploadFile(file));
    }
    executor.shutdown();
    executor.awaitTermination(1, TimeUnit.HOURS);
}
private void uploadFile(File file) {
// 单文件上传逻辑
}
}

如果业务确实存在非常高的并发需求,还可以考虑使用协程风格的异步编程(如 CompletableFuture、响应式框架)或引入外部队列削峰,但核心原则不变:线程是昂贵资源,必须受控使用

十一、内存泄漏的常见模式与防治

内存泄漏是 OOM 最常见也最隐蔽的成因。了解常见的泄漏模式,有助于在代码评审阶段就提前识别风险。

11.1 静态集合类持有对象引用

这是最经典的内存泄漏模式。静态变量的生命周期与 JVM 相同,任何被静态集合持有的对象都无法被 GC 回收。

public class OrderMonitor {
    // 静态 Map 会一直持有所有订单
    private static final Map<String, Order> ALL_ORDERS = new HashMap<>();
public void addOrder(Order order) {
    ALL_ORDERS.put(order.getId(), order); // 从不移除,内存持续增长
}
}

防治方法:明确集合的生命周期,对长期存活的集合设置容量上限,或者在对象不再需要时主动移除。

11.2 监听器/回调未注销

当一个对象注册为另一个长生命周期对象(如 ApplicationContext、EventBus、观察者模式中的主题)的监听器后,如果没有在恰当的时候注销,它就会一直被持有。

public class EventSource {
    private final List<EventListener> listeners = new ArrayList<>();
public void register(EventListener listener) {
    listeners.add(listener);
}
// 缺少 unregister 方法,listener 永远无法被移除
}

防治方法:使用弱引用(WeakReference)维护监听器列表,或者提供成对的注册/注销接口,并确保调用方在组件销毁时调用注销。

11.3 内部类持有外部类引用

非静态内部类(包括匿名内部类)会隐式持有外部类实例的引用。如果一个内部类实例的生命周期比外部类更长,就会导致外部类无法被回收。

public class ActivityManager {
    private byte[] bigData = new byte[10 * 1024 * 1024]; // 10MB
public void scheduleTask() {
    new Thread(new Runnable() { // 匿名内部类持有 ActivityManager 的引用
        @Override
        public void run() {
            try {
                Thread.sleep(Long.MAX_VALUE);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        }
    }).start();
}
}

防治方法:尽可能使用静态内部类,或在不需要时手动断开引用。

11.4 ThreadLocal 使用不当

ThreadLocal 的值在线程存活期间不会被回收,如果线程是线程池中的常驻线程,且 ThreadLocal 没有及时 remove,就会导致对象长期滞留。

public class RequestContextHolder {
    private static final ThreadLocal<Map<String, Object>> CONTEXT = new ThreadLocal<>();
public static void setContext(Map&lt;String, Object&gt; context) {
    CONTEXT.set(context);
}
// 缺少 clear 方法,常驻线程会一直持有 context
}

防治方法:在 finally 块中调用 ThreadLocal.remove(),确保在线程归还线程池前清理数据。

11.5 未关闭的资源

数据库连接、文件流、网络连接等资源如果未正确关闭,不仅会泄漏连接,还可能持有大量缓冲区内存。

public void readFile(String path) throws IOException {
    FileInputStream fis = new FileInputStream(path);
    // 没有关闭 fis,文件句柄和内部缓冲区都会泄漏
}

防治方法:使用 try-with-resources 语法,确保资源自动关闭。

public void readFile(String path) throws IOException {
    try (FileInputStream fis = new FileInputStream(path)) {
        // 处理文件
    }
}

11.6 缓存设计不当

缓存是一把双刃剑:合理使用能大幅提升性能,滥用则会成为内存杀手。典型问题包括:缓存无上限、缓存键无限增长、缓存值过大且不清理。

防治方法:使用成熟的缓存框架(如 Caffeine、Guava Cache、Redis),配置最大容量、过期时间和淘汰策略,并监控缓存命中率和内存占用。

十二、OOM 的预防措施与最佳实践

与其在故障发生后焦头烂额地排查,不如在日常开发中就把 OOM 的风险降到最低。本节从代码、架构、参数、监控四个维度总结预防之道。

12.1 代码层面

  • 避免在循环中拼接大字符串:应该使用 StringBuilder 或 StringBuffer。
  • 批量处理大数据时使用分页或流式处理:不要一次性把海量数据加载到内存。
  • 合理设置集合初始容量:既要避免频繁扩容,也要避免初始容量过大浪费内存。
  • 及时释放不再使用的引用:特别是在长生命周期对象中。
  • 谨慎使用 finalize 和大量弱引用/软引用:它们的行为有时不符合直觉。
  • 代码评审中增加内存安全相关的检查项:如静态集合是否有清理机制、资源是否关闭、线程池是否有上限等。

12.2 架构层面

  • 缓存外置:对于大容量、高并发的缓存需求,优先考虑 Redis 等外部缓存,而不是无限增大本地堆。
  • 削峰填谷:通过消息队列等中间件平滑流量,避免瞬时洪峰把堆打爆。
  • 异步化和背压:在数据生产速度与消费速度不匹配时,引入背压机制防止内存无限堆积。
  • 对象池化:对创建成本高、复用率高的对象(如连接、大缓冲区)使用对象池。

12.3 参数与部署层面

  • 容器内存限制与 JVM 参数对齐:容器限制的内存不能等于 -Xmx,必须为堆外内存预留 20%-30% 的余量。
  • 显式设置 MaxMetaspaceSize:防止元空间无限增长拖垮进程。
  • 开启堆转储和 GC 日志:为可能的故障保留现场。
  • 定期做容量评估:随着业务增长,内存需求也会增长,参数不是设置一次就永远不变的。

12.4 监控与告警层面

  • 监控堆各区域使用率:Eden、Survivor、Old、Metaspace 的实时占用。
  • 监控 GC 频率和耗时:Full GC 频繁或耗时过长是内存问题的前兆。
  • 监控线程数:线程数异常增长往往先于 OOM 出现。
  • 监控直接内存使用:对 NIO 应用尤为重要。
  • 设置合理的告警阈值:例如老年代使用率超过 85% 持续 5 分钟告警,Full GC 每小时超过 10 次告警。

一个完整的"内存健康告警"配置示例(以 Prometheus + JVM Exporter 为例)如下:

groups:
  - name: jvm_memory_alerts
    rules:
      - alert: JVMHeapUsageTooHigh
        expr: sum(jvm_memory_used_bytes{area="heap"}) / sum(jvm_memory_max_bytes{area="heap"}) > 0.85
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "JVM 堆内存使用率超过 85%"
  - alert: JVMFullGCOverheadTooHigh
    expr: increase(jvm_gc_pause_seconds_count{action="end of major GC"}[1h]) &gt; 10
    for: 5m
    labels:
      severity: critical
    annotations:
      summary: "JVM Full GC 频率过高"</code></pre>

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐