【JVM】OOM实例分析

😀大家好,我是白晨,一个不是很能熬夜😫,但是也想日更的人✈。如果喜欢这篇文章,点个赞👍,关注一下👀白晨吧!你的支持就是我最大的动力!💪💪💪

在这里插入图片描述

OOM实例分析

堆溢出

报错信息

java.lang.OutOfMemoryError: Java heap space

案例模拟

模拟线上环境产生OOM,代码如下:
@RequestMapping("/add")
public void addObject(){
    ArrayList<People> people = new ArrayList<>();
    while (true){
        people.add(new People());
    }
}

@Data
public class People {
    private String name;
    private Integer age;
    private String job;
    private String sex;
}

http://localhost:8080/add发送请求。

JVM参数配置

-XX:+PrintGCDetails -XX:MetaspaceSize=64m 
-XX:+HeapDumpOnOutOfMemoryError  -XX:HeapDumpPath=heap/heapdump.hprof 
-XX:+PrintGCDateStamps -Xms200M  -Xmx200M  -Xloggc:log/gc-oomHeap.log

运行结果

java.lang.OutOfMemoryError: Java heap space
  at java.util.Arrays.copyOf(Arrays.java:3210) ~[na:1.8.0_131]
  at java.util.Arrays.copyOf(Arrays.java:3181) ~[na:1.8.0_131]
  at java.util.ArrayList.grow(ArrayList.java:261) ~[na:1.8.0_131]
  at java.util.ArrayList.ensureExplicitCapacity(ArrayList.java:235) ~[na:1.8.0_131]
  at java.util.ArrayList.ensureCapacityInternal(ArrayList.java:227) ~[na:1.8.0_131]

运行程序得到 heapdump.hprof 文件,如下图所示:

原因及解决方案

  • 原因
    1. 代码中可能存在大对象分配
    2. 可能存在内存泄漏,导致在多次GC之后,还是无法找到一块足够大的内存容纳当前对象。
  • 解决方案
    1. 检查是否存在大对象的分配,最有可能的是大数组分配
    2. 通过jmap命令,把堆内存dump下来,使用MAT等工具分析一下,检查是否存在内存泄漏的问题
    3. 如果没有找到明显的内存泄漏,使用 -Xmx 加大堆内存
    4. 还有一点容易被忽略,检查是否有大量的自定义的 Finalizable 对象,也有可能是框架内部提供的,考虑其存在的必要性

dump文件分析

JvisualVM

通过jvisualvm工具查看,占用最多实例的类是哪个,这样就可以定位到问题所在。

MAT分析

使用MAT工具查看,能找到对应的线程及相应线程中对应实例的位置和代码:

GC日志分析

元空间溢出

元空间存储数据类型

方法区(Method Area)与 Java 堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、即时编译器编译后的代码等数据。虽然Java 虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做 Non-Heap(非堆),目的应该是与 Java 堆区分开来。

Java 虚拟机规范对方法区的限制非常宽松,除了和 Java 堆一样不需要连续的内存和可以选择固定大小或者可扩展外,还可以选择不实现垃圾收集。垃圾收集行为在这个区域是比较少出现的,其内存回收目标主要是针对常量池的回收和对类型的卸载。当方法区无法满足内存分配需求时,将抛出 OutOfMemoryError 异常。

报错信息

java.lang.OutOfMemoryError: Metaspace

案例模拟

案例代码如下:
@RequestMapping("/metaSpaceOom")
public void metaSpaceOom(){
    ClassLoadingMXBean classLoadingMXBean = ManagementFactory.getClassLoadingMXBean();
    while (true){
        Enhancer enhancer = new Enhancer();
        enhancer.setSuperclass(People.class);
        enhancer.setUseCache(false);
        enhancer.setCallback((MethodInterceptor) (o, method, objects, methodProxy) -> {
            System.out.println("我是加强类哦,输出print之前的加强方法");
            return methodProxy.invokeSuper(o,objects);
        });
        People people = (People)enhancer.create();
        people.print();
        System.out.println(people.getClass());
        System.out.println("totalClass:" + classLoadingMXBean.getTotalLoadedClassCount());
        System.out.println("activeClass:" + classLoadingMXBean.getLoadedClassCount());
        System.out.println("unloadedClass:" + classLoadingMXBean.getUnloadedClassCount());
    }
}
 
 
public class People {
    public void print(){
       System.out.println("我是print本人");
    }
}

http://localhost:8080/metaSpaceOom发送请求。

运行结果

我是加强类哦,输出print之前的加强方法
我是print本人
class com.atguiigu.jvmdemo.bean.People$$EnhancerByCGLIB$$6ef22046_10
totalClass:934
activeClass:934
unloadedClass:0
Caused by: java.lang.OutOfMemoryError: Metaspace
  at java.lang.ClassLoader.defineClass1(Native Method)
  at java.lang.ClassLoader.defineClass(ClassLoader.java:763)
  at sun.reflect.GeneratedMethodAccessor1.invoke(Unknown Source)

JVM参数配置

-XX:+PrintGCDetails -XX:MetaspaceSize=60m -XX:MaxMetaspaceSize=60m 
-Xss512K -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=heap/heapdumpMeta.hprof  
-XX:SurvivorRatio=8 -XX:+TraceClassLoading -XX:+TraceClassUnloading 
-XX:+PrintGCDateStamps  -Xms60M  -Xmx60M -Xloggc:log/gc-oomMeta.log

原因及解决方案

JDK8后,元空间替换了永久代,元空间使用的是本地内存。

  • 原因
    1. 运行期间生成了大量的代理类,导致方法区被撑爆,无法卸载
    2. 应用长时间运行,没有重启
    3. 元空间内存设置过小
  • 解决方案

因为该 OOM 原因比较简单,解决方法有如下几种:

1. 检查是否永久代空间或者元空间设置的过小
2. 检查代码中是否存在大量的反射操作
3. dump之后通过MAT检查是否存在大量由于反射生成的代理类

分析及解决

查看监控

metatspace几乎已经被全部占用。

查看GC状态

可以看到,FullGC 非常频繁,而且方法区,占用了59190KB/1024 = 57.8M空间,几乎把整个方法区空间占用,所以得出的结论是方法区空间设置过小,或者存在大量由于反射生成的代理类。

查看GC日志
2025-04-09T00:04:09.052+0800: 109.779: [GC (Metadata GC Threshold) [PSYoungGen: 174K->32K(19968K)] 14926K->14784K(60928K), 0.0013218 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
2025-04-09T00:04:09.054+0800: 109.780: [Full GC (Metadata GC Threshold) [PSYoungGen: 32K->0K(19968K)] [ParOldGen: 14752K->14752K(40960K)] 14784K->14752K(60928K), [Metaspace: 58691K->58691K(1103872K)], 0.0274454 secs] [Times: user=0.17 sys=0.00, real=0.03 secs] 
2025-04-09T00:04:09.081+0800: 109.808: [GC (Last ditch collection) [PSYoungGen: 0K->0K(19968K)] 14752K->14752K(60928K), 0.0009630 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
2025-04-09T00:04:09.082+0800: 109.809: [Full GC (Last ditch collection) [PSYoungGen: 0K->0K(19968K)] [ParOldGen: 14752K->14752K(40960K)] 14752K->14752K(60928K), [Metaspace: 58691K->58691K(1103872K)], 0.0301540 secs] [Times: user=0.17 sys=0.00, real=0.03 secs] 
2025-04-09T00:04:22.476+0800: 123.202: [GC (Metadata GC Threshold) [PSYoungGen: 3683K->384K(19968K)] 18435K->15144K(60928K), 0.0015294 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
2025-04-09T00:04:22.477+0800: 123.203: [Full GC (Metadata GC Threshold) [PSYoungGen: 384K->0K(19968K)] [ParOldGen: 14760K->14896K(40960K)] 15144K->14896K(60928K), [Metaspace: 58761K->58761K(1103872K)], 0.0299402 secs] [Times: user=0.16 sys=0.02, real=0.03 secs] 
2025-04-09T00:04:22.508+0800: 123.233: [GC (Last ditch collection) [PSYoungGen: 0K->0K(19968K)] 14896K->14896K(60928K), 0.0016583 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
2025-04-09T00:04:22.509+0800: 123.235: [Full GC (Last ditch collection) [PSYoungGen: 0K->0K(19968K)] [ParOldGen: 14896K->14751K(40960K)] 14896K->14751K(60928K), [Metaspace: 58761K->58692K(1103872K)], 0.0333369 secs] [Times: user=0.22 sys=0.02, real=0.03 secs] 
2025-04-09T00:04:22.543+0800: 123.269: [GC (Metadata GC Threshold) [PSYoungGen: 229K->320K(19968K)] 14981K->15071K(60928K), 0.0014224 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
2025-04-09T00:04:22.544+0800: 123.271: [Full GC (Metadata GC Threshold) [PSYoungGen: 320K->0K(19968K)] [ParOldGen: 14751K->14789K(40960K)] 15071K->14789K(60928K), [Metaspace: 58692K->58692K(1103872K)], 0.0498304 secs] [Times: user=0.42 sys=0.00, real=0.05 secs] 
2025-04-09T00:04:22.594+0800: 123.321: [GC (Last ditch collection) [PSYoungGen: 0K->0K(19968K)] 14789K->14789K(60928K), 0.0016910 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
2025-04-09T00:04:22.596+0800: 123.322: [Full GC (Last ditch collection) [PSYoungGen: 0K->0K(19968K)] [ParOldGen: 14789K->14773K(40960K)] 14789K->14773K(60928K), [Metaspace: 58692K->58692K(1103872K)], 0.0298989 secs] [Times: user=0.16 sys=0.02, real=0.03 secs] 
2025-04-09T00:04:22.626+0800: 123.352: [GC (Metadata GC Threshold) [PSYoungGen: 0K->0K(19968K)] 14773K->14773K(60928K), 0.0013409 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
2025-04-09T00:04:22.627+0800: 123.354: [Full GC (Metadata GC Threshold) [PSYoungGen: 0K->0K(19968K)] [ParOldGen: 14773K->14765K(40960K)] 14773K->14765K(60928K), [Metaspace: 58692K->58692K(1103872K)], 0.0298311 secs] [Times: user=0.17 sys=0.00, real=0.03 secs] 
2025-04-09T00:04:22.657+0800: 123.384: [GC (Last ditch collection) [PSYoungGen: 0K->0K(19968K)] 14765K->14765K(60928K), 0.0014417 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
2025-04-09T00:04:22.659+0800: 123.385: [Full GC (Last ditch collection) [PSYoungGen: 0K->0K(19968K)] [ParOldGen: 14765K->14765K(40960K)] 14765K->14765K(60928K), [Metaspace: 58692K->58692K(1103872K)], 0.0253914 secs] [Times: user=0.30 sys=0.00, real=0.03 secs] 

可以看到FullGC是由于方法区空间不足引起的,那么我们接下来分析到底是什么数据占用了大量的方法区。

分析dump文件
JvisualVM分析

MAT分析

打开堆文件 heapdumpMeta.hprof:

首先我们先确定是哪里的代码发生了问题,首先可以通过线程来确定,因为在实际生产环境中,有时候是无法确定是哪块代码引起的OOM,那么我们就需要先定位问题线程,然后定位代码,如下图所示。

定位到代码以后,发现有使用到cglib动态代理,那么我们猜想一下问题是不是由于产生了很多代理类,接下来,我们可以通过包看一下我们的类加载情况。

这里发现Method类的实例非常多,查看with outging references

这里发现了很多的People类在调用相关的方法:

由于我们的代码是代理的People类,所以我们直接打开该类所在的包,打开如下图所示:

可以看到确实加载了很多的代理类。

解决方案

那么我们可以想一下解决方案,每次是不是可以只加载一个代理类即可,因为我们的需求其实是没有必要如此加载的,当然如果业务上确实需要加载很多类的话,那么我们就要考虑增大方法区大小了,所以我们这里修改代码如下:

enhancer.setUseCache(true);

enhancer.setUseCache(false),选择为true的话,使用和更新一类具有相同属性生成的类的静态缓存,而不会在同一个类文件还继续被动态加载并视为不同的类,这个其实跟类的equals()和hashCode()有关,它们是与cglib内部的class cache的key相关的。再看程序运行结果如下:

我是加强类哦,输出print之前的加强方法
我是print本人
class com.atguiigu.jvmdemo.bean.People$$EnhancerByCGLIB$$6ef22046
totalClass:6901
activeClass:6901
我是加强类哦,输出print之前的加强方法
我是print本人
class com.atguiigu.jvmdemo.bean.People$$EnhancerByCGLIB$$6ef22046
totalClass:6901
activeClass:6901+

可以看到,几乎不变了,方法区也没有溢出。到此,问题基本解决,再就是把while循环去掉。

GC overhead limit exceeded

案例模拟

示例1
public class OOMTest {
    public static void main(String[] args) {
       int i = 0;
    List<String> list = new ArrayList<>();
    try {
        while (true) {
           list.add(UUID.randomUUID().toString().intern());
           i++;
        }
    } catch (Throwable e) {
        System.out.println("************i: " + i);
        e.printStackTrace();
        throw e;
    }
 
}
JVM配置
-XX:+PrintGCDetails  -XX:+HeapDumpOnOutOfMemoryError 
-XX:HeapDumpPath=heap/dumpExceeded.hprof -XX:+PrintGCDateStamps  
-Xms10M  -Xmx10M -Xloggc:log/gc-oomExceeded.log
报错信息
[Full GC (Ergonomics) [PSYoungGen: 2047K->2047K(2560K)] [ParOldGen: 7110K->7095K(7168K)] 9158K->9143K(9728K), [Metaspace: 3177K->3177K(1056768K)], 0.0479640 secs] [Times: user=0.23 sys=0.01, real=0.05 secs] 
java.lang.OutOfMemoryError: GC overhead limit exceeded
[Full GC (Ergonomics) [PSYoungGen: 2047K->2047K(2560K)] [ParOldGen: 7114K->7096K(7168K)] 9162K->9144K(9728K), [Metaspace: 3198K->3198K(1056768K)], 0.0408506 secs] [Times: user=0.22 sys=0.01, real=0.04 secs] 

通过查看GC日志可以发现,系统在频繁性的做FULL GC,但是却没有回收掉多少空间,那么引起的原因可能是因为内存不足,也可能是存在内存泄漏的情况,接下来我们要根据堆dump文件来具体分析。

示例2

对比前一个代码,这个代码虽然看着在不断增加str,但是不会OOM:

public static void main(String[] args) {
        String str = "";
        Integer i = 1;
        try {
        while (true) {
            i++;
            str += UUID.randomUUID();
        }}catch (Throwable e) {
            System.out.println("************i: " + i);
            e.printStackTrace();
            throw e;
        }
}
JVM配置
-XX:+PrintGCDetails  -XX:+HeapDumpOnOutOfMemoryError 
-XX:HeapDumpPath=heap/dumpHeap1.hprof -XX:+PrintGCDateStamps  
-Xms10M  -Xmx10M -Xloggc:log/gc-oomHeap1.log

代码解析

第一段代码:常量池动态写入机制 该代码通过intern()方法实现运行期字符串常量的池化操作,其核心逻辑为:

  1. 当目标字符串X已存在于字符串常量池时,直接返回池中对象的引用;
  2. 若未检测到相同字符串,则将当前字符串实例存入常量池并返回新引用。

第二段代码:字符串持续性累加操作 该测试案例通过循环结构不断对基础字符串进行追加操作,形成持续增长的数据结构。

关键差异分析: 两个案例虽然表面相似,但内存回收机制存在本质区别。Java Heap Space异常的案例中,每轮循环产生的大量UUID对象大部分可被GC回收,仅有一个核心对象无法释放并持续增长,最终导致渐进式内存耗尽。而GC Overhead异常的案例中,所有生成的字符串均被集合强引用持有,导致内存快速占满并触发JVM的自我保护机制——当连续多次GC回收率低于2%时,强制抛出GC overhead limit exceeded错误。

GC行为诊断: 通过日志观察发现系统频繁执行Full GC操作,但每次仅回收极少量内存空间(ParOldGen区域回收量不足0.02%)。这种异常模式通常指向两种可能性:

  1. 物理内存配置不足导致的正常资源耗尽
  2. 存在隐蔽的内存泄漏问题

为准确判定问题根源,需结合堆内存快照(heap dump)进行对象引用链分析,重点排查常驻内存对象的创建路径和引用保持情况。这种分析方法能有效区分配置性内存不足与代码层面的对象泄漏问题。

分析及解决

定位问题代码块
JvisualVM

这里就定位到了具体的线程中具体出现问题的代码的位置,进而进行优化即可。

MAT分析

通过线程分析如下图所示,可以定位到发生OOM的代码块。

分析dump文件直方图
看到发生OOM是因为进行了死循环,不停的往 ArrayList 存放字符串常量,JDK1.8以后,字符串常量池移到了堆中存储,所以最终导致内存不足发生了OOM。

打开Histogram,可以看到,String类型的字符串占用了大概8M的空间,几乎把堆占满,但是还没有占满,所以这也符合Sun 官方对此的定义:超过98%的时间用来做GC并且回收了不到2%的堆内存时会抛出此异常,本质是一个预判性的异常,抛出该异常时系统没有真正的内存溢出。

解决方案
1. 检查项目中是否有大量的死循环或有使用大内存的代码,优化代码。 2. 添加参数 `-XX:-UseGCOverheadLimit` 禁用这个检查,其实这个参数解决不了内存问题,只是把错误的信息延后,最终出现 java.lang.OutOfMemoryError: Java heap space。 3. dump内存,检查是否存在内存泄漏,如果没有,加大内存。

创建线程

报错信息

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

问题原因

出现这种异常,基本上都是创建了大量的线程导致的

案例模拟

环境:8核linux 机器2G内存

示例代码如下:

public class TestNativeOutOfMemoryError {
    public static void main(String[] args) {
        for (int i = 0; ; i++) {
            System.out.println("i = " + i);
            new Thread(new HoldThread()).start();
        }
    }
}

class HoldThread extends Thread {
    CountDownLatch cdl = new CountDownLatch(1);

    @Override
    public void run() {
        try {
            cdl.await();
        } catch (InterruptedException e) {
        }
    }
}

  • 运行结果
i = 15241
Exception in thread "main" java.lang.OutOfMemoryError: unable to create new native thread
        at java.lang.Thread.start0(Native Method)
        at java.lang.Thread.start(Thread.java:717)
        at TestNativeOutOfMemoryError.main(TestNativeOutOfMemoryError.java:9)

分析及解决

解决方向1

【核心配置参数】 通过 -Xss 参数设定单个线程栈的内存容量。JDK5+版本默认栈容量为1MB(历史版本为256KB),合理调整该值可影响系统线程承载量。

【线程容量计算模型】 系统支持线程数 = (进程最大寻址空间 - JVM堆内存 - 系统保留内存) / 线程栈容量

参数解析:

  • 进程最大寻址空间:操作系统分配给进程的虚拟内存上限
  • JVM堆内存:-Xmx 设定的堆内存容量
  • 系统保留内存:内核运行必需的非JVM内存
  • 线程栈容量:由 -Xss 设定的单线程内存占用量

【内存分配悖论】 JVM堆内存分配与线程容量呈负相关,堆内存越大,可用线程数越少。当系统内存无法满足线程创建需求时,将触发"unable to create new native thread"的OOM异常。

【系统架构差异表现】 32位系统: 严格遵循 (4GB - JVM堆 - 系统保留) / Xss 的计算模型,理论最大线程数约在300-1000区间(实测经验阈值通常在3000-5000)。可通过降低Xss值(建议不低于160k)提升线程容量。

64位系统: 突破32位4GB寻址限制(理论寻址空间达2^64字节),导致:

  1. 实际测试中调整Xss对线程数影响不显著
  2. 线程数达到系统极限时直接引发系统级崩溃
  3. 可用线程数主要受物理内存和系统内核参数制约

【生产环境调优策略】 必要措施:

  1. 优先采用64位系统架构
  2. 严格监控线程生命周期,避免资源泄漏

参数调优组合:

  • 扩容物理内存 → 提升MaxProcessMemory
  • 精简JVM堆 → 降低JVMMemory占用
  • 优化线程栈 → 合理减小Xss值(需考虑栈深度需求)

特别说明: 在32位环境中建议保持Xss≥256k以确保栈深度安全,当需要突破线程数量瓶颈时,可逐步降低Xss至160k-256k区间,同时需配合严格的稳定性测试。

解决方向2

线程总数也受到系统空闲内存和操作系统的限制,检查是否该系统下有此限制:

/proc/sys/kernel/pid_max          系统最大pid值,在大型系统里可适当调大
● /proc/sys/kernel/threads-max     系统允许的最大线程数
● maxuserprocess(ulimit -u)   系统限制某用户下最多可以运行多少进程或线程
● /proc/sys/vm/max_map_count     max_map_count文件包含限制一个进程可以拥有的VMA(虚拟内存区域)的数量。虚拟内存区域是一个连续的虚拟地址空间区域。在进程的生命周期中,每当程序尝试在内存中映射文件,链接到共享内存段,或者分配堆空间的时候,这些区域将被创建。调优这个值将限制进程可拥有VMA的数量。限制一个进程拥有VMA的总数可能导致应用程序出错,因为当进程达到了VMA上限但又只能释放少量的内存给其他的内核进程使用时,操作系统会抛出内存不足的错误。如果你的操作系统在NORMAL区域仅占用少量的内存,那么调低这个值可以帮助释放内存给内核用。

真实测试发现:

当我们增大了threads-max参数时(比如扩大10倍:echo 157010 > /proc/sys/kernel/threads-max),运行相关程序,发现可创建的线程数确实扩大了。但是仍受相关因素的影响,不能保证创建的线程数也扩大对应的倍数。

要想创建更多线程,会发现在执行完程序报错的log日志(自动在当前目录下生成)中有说明:

  • 减少系统内存负载。
  • 增加内存或交换空间。
  • 检查交换空间是否已满。
  • 使用64位Java在64位OS上。
  • 减少Java线程数或线程栈大小。
  • 调整代码缓存大小。
  • 启用核心转储以便进一步分析。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

白晨并不是很能熬夜

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值