漫谈 Java 中八种 OOM(OutOfMemoryError)的排查方法以及解决之道




点击上方蓝字关注灸哥聊技术



说在前面

在开始这对 Java 中八种 OOM 错误类型的详细分析之前,有两个建议/提醒给到大家:

1.对 JVM 内存模型要做一个整体全面的复习,尤其各个区的深度原理和特性应用。

2.理论不经过实践永远都不会成为你的能力,针对这几天我文章的内容,各位看官一定要动手实践哦~

致所有程序员和运维人员

在面对 OOM 错误时,确实可以通用修改各个对应的参数来快速修复 OOM,但是请务必确认好你是推迟还是隐藏了 OOM 的症状。如果应用确实存在内存泄露或者确实加载了一些不合理的类,那你修改配置治标不治本,只是推迟了问题出现的时间罢了,并没有改善任何问题。

java.lang.OutOfMemoryError:Java Heap space

Java 应用程序在启动时会制定需要的内存大小,一般分割成两个不同的区域:

  • Heap space
  • PermGen

这两个区域在 JVM 启动时通过参数 -Xmx 和-XX:MaxPermSize 来设置,如果为主动设置,会使用特定平台的默认值。

当应用程序试图向堆空间添加更多数据,但堆已经没有足够的空间来容纳,就会触发该类型的 OOM,这里所说的足够的空间,是指堆空间设置的限制大小,而非实际的物理内存。

原因分析

造成该类型 OOM 的常见原因一般有三种场景:

  • 应用程序中需要的堆空间超过了 JVM 设置的大小,解决方案可以简单粗暴地扩大堆空间
  • 应用程序在某一阶段是有明确的用户量和数据量限制的,但在某一时刻,用户量或者数据量突然达到一个脉冲峰值,且超过了阈值,那也会触发该异常
  • 错误或者不合理的代码导致应用程序不停消耗堆内存,每次使用有内存泄露风险的功能就会留下一些不能被回收的对象到堆空间中,随着时间推移,泄露的对象就会消耗所有的堆空间,最终触发该异常

代码示例

class OOM {
    static final int SIZE = 2 * 1024 * 1024;
    public static void main(String[] args) {
        int[] arrays = new int[SIZE];   
    }
}

排查方法

  • 使用内存分析工具分系 Heap Dump 文件,找出内存消耗大户
  • 检查代码中是否有大对象或者数据结构不合理使用的情况
  • 优化对象的生命周期管理,确保及时释放不再使用的对象

解决策略

  • 增加 JVM 对内存的大小设置
  • 修复内存泄露,优化内存使用
  • 使用高效的数据结构和算法

java.lang.OutOfMemoryError:GC overhead limit exceeded

当应用程序使用超过 98% 的时间用来 GC 且回收不到 2% 堆内存时,就会抛出该类型的错误,具体表现就是几乎耗尽了所有可用内存,并且多次 GC 也不能清理干净。

原因分析

这个错误是明确告诉你在 GC 上花费了太多时间但却没作用,如果没有这个限制,GC 进程会被重启,100% CPU 用于 GC 处理,就没有 CPU 空余资源去做其他事情了。

代码示例

// 初始化一个 ma p并在无限循环中不停的添加键值对就会抛出该错误
public class Wrapper {
    public static void main(String args[]) throws Exception {
        Map map = System.getProperties();
        Random r = new Random();
        while (true) {
            map.put(r.nextInt(), "value");
        }
    }
}
// 上述代码片段在启动时设置不同的堆空间大小或者不同的 GC 算法
java -Xmx10m -XX:+UseParallelGC Wrapper
// 看到错误
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
    at java.util.Hashtable.rehash(Unknown Source)
    at java.util.Hashtable.addEntry(Unknown Source)
    at java.util.Hashtable.put(Unknown Source)
    at cn.moondev.Wrapper.main(Wrapper.java:12)
// 但如果启动命令换成
java -Xmx100m -XX:+UseConcMarkSweepGC Wrapper
java -Xmx100m -XX:+UseG1GC Wrapper
// 结果
Exception: java.lang.OutOfMemoryError thrown from 
the UncaughtExceptionHandler in thread "main"
// 错误已经被默认的异常处理程序捕获,并且没有任何错误的堆栈信息输出

排查方法

  • 分析 GC 日志,了解 GC 频率和耗时
  • 使用 JVM 监控工具观察 GC 活动
  • 确认应用程序的工作负载是否合理

解决策略

  • 优化 GC 策略和堆配置
  • 减少临时对象的创建,降低 GC 压力
  • 考虑应用程序设计上的优化,如缓存策略

java.lang.OutOfMemoryError:PermGen space

Java 中堆空间是 JVM 管理的最大一块内存空间,在 JVM 启动时是可以指定堆空间的大小,然后堆会被分为两部分:

  • Young
  • Tenured

Young 区又分为三部分:

  • Eden
  • From Survivor
  • To Survivor

该类型错误是因为持久代所在区域的内存耗光。

原因分析

持久代存储的主要是每个类的信息,诸如类加载器引用、运行时常量池、字段数据、方法数据、方法代码、方法字节码等。PermGen 的大小是取决于被加载类的数量和大小,太多的类或者太大的类被加载到持久代的时候,就会引发该类型的错误。

代码示例

// 最简单
import javassist.ClassPool;
public class MicroGenerator {
    public static void main(String[] args) throws Exception {
        for (int i = 0; i < 100_000_000; i++) {
            generate("cn.moondev.User" + i);
        }
    }
 
    public static Class generate(String name) throws Exception {
        ClassPool pool = ClassPool.getDefault();
        return pool.makeClass(name).toClass();
    }
}
// 运行时请设置JVM参数:-XX:MaxPermSize=5m,值越小越好。
// 需要注意的是JDK8已经完全移除持久代空间,取而代之的是元空间(Metaspace),
// 所以示例最好的JDK1.7或者1.6下运行

// 代码在运行时不停的生成类并加载到持久代中,直到撑满持久代内存空间,
// 最后抛出java.lang.OutOfMemoryError:PermGen space。代码中类的生成使用了javassist库

排查方法

  • 检查类加载器是否加载了过多的类
  • 分析应用程序是否使用了大量第三方库
  • 确认是否有大量的动态代理、反射或 CGLIB 使用

解决策略

  • 增加 PermGen 空间大小
  • 减少类加载的数量,比如过滤不必要的类加载
  • 优化代码,减少对反射和动态代理的依赖

OutOfMemoryError 错误在不同应用启动过程中发生时,对应的解决策略又是什么样子的呢?

初始化时的OutOfMemoryError

如果是应用启动时触发该类型的错误,解决方案反而是最简单的,只需要增加 PermGen 区域的大小就行。

Redeploy 时的OutOfMemoryError

这一时期的该类型错误,需要对 dump 文件进行分析,首先找到引用在什么地方被持有,然后在应用中增加一个关闭的 hook。一般此时的问题原因来自于两个方面:

  • 代码问题:及时修改
  • 第三方库问题:找到关闭的接口

运行时的 OutOfMemoryError

运行时的该类型错误发生时,要先检查 GC 是否允许你从 PermGen 区写在类,这是因为 JVM 的标准配置是比较保守的,只要类一旦创建,即使没有实例引用,依旧会保留在内存中,特别是需要动态创建大量的类,但是生命周期又不那么长的时候,允许 JVM 可以卸载类对应用是有很大益处的,配置参数的方式:-XX:+CMSClassUnloadingEnabled。

缺省情况下,该项配置未启用,一旦启用,GC 会对 PermGen 区进行扫描和清理不再使用的类。不过这个配置只在 UseConcMarkSweepGC 的场景生效,其他 GC 算法,这个配置是无效的,需要结合 -XX:+UseConcMarkSweepGC 配合使用。

如果此时你已经对 JVM 可以卸载类确保万无一失,但还是会出现内存溢出的话,就要继续对 dump 文件进行分析,通过工具继续寻找应该卸载但没有被卸载干净的类,然后对这个类进行排查,找到可疑对象,分析相关的代码,直到找到问题的根源。

java.lang.OutOfMemoryError:Metaspace

根据 Java 内存模型,我们可以知道 PermGen 区用于存储类的名称、字段、方法以及字节码、常量池等,但从 Java8 之后,内存模型发生了巨大的变化:引入了 Metaspace 区,删除了 PermGen 区。

Java8 如此改变的原因:

  • 应用所需的 PermGen 区大小很难预测,设置太小冗余触发 OOM,设置过大导致资源浪费
  • 提升 GC 性能,在 HotSpot 中每个垃圾收集器是需要专门的代码来处理存储在 PermGen 区中的类的元数据的。从 PermGen 把类的元数据信息转移到 Metaspace,而 Metaspace 的分配是和 Heap 区有着相同的地址空间,这样 Metaspace 和 Heap 可以无缝管理,简化了 Full GC 的过程
  • 可以进一步进行优化,比如 G1 并发类的卸载

原因分析

Metaspace 大小取决于加载的类的数量以及对应声明的大小,所以引发该类型 OOM 的原因一般都是因为太多的类或者太大的类加载进 Metaspace 区。

代码示例

 public class Metaspace {
    static javassist.ClassPool cp = javassist.ClassPool.getDefault();
 
    public static void main(String[] args) throws Exception{
        for (int i = 0; ; i++) { 
            Class c = cp.makeClass("eu.plumbr.demo.Generated" + i).toClass();
            System.out.println(i);
        }
    }
}
// 程序运行中不停的生成新类,所有的这些类的定义将被加载到Metaspace区,
// 直到空间被完全占用并且抛出java.lang.OutOfMemoryError:Metaspace。
// 当使用-XX:MaxMetaspaceSize = 32m启动时,大约加载30000多个类时就会死机

// 结果
31023
31024
Exception in thread "main" javassist.CannotCompileException: by java.lang.OutOfMemoryError: Metaspace
    at javassist.ClassPool.toClass(ClassPool.java:1170)
    at javassist.ClassPool.toClass(ClassPool.java:1113)
    at javassist.ClassPool.toClass(ClassPool.java:1071)
    at javassist.CtClass.toClass(CtClass.java:1275)
    at cn.moondev.book.Metaspace.main(Metaspace.java:12)
    .....

排查方法

在遇到该类型的错误时,一般按照以下三个步骤进行排查:

  • 分析 Metaspace 的使用情况,确认是否有大量的类元数据
  • 检查是否有大量的动态生成的类或代理类
  • 使用 JVM 监控工具观察 Metaspace 的变化

解决策略

解决该类型错误时,首先是最直接的方案,增加 Metaspace 区的大小,参数 -XX:MaxMetaspaceSize。

另外也可以从以下两个方面去解决和优化该类型错误:

  • 优化类加载机制,减少动态类的生成
  • 修复可能导致类元数据膨胀的代码

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

JVM 中的线程运行是需要对应的空间的,如果有足够多的线程但是没有那么多空间时,就会触发该类型的错误,很明显就是你的应用达到了启动线程数量的极限了。

原因分析

当 JVM 请求创建新线程时,但是系统却无法创建新的 Native 线程,就会 Unable to create new native thread 错误,一般出现改错误会经历一个过程:

1.JVM 中的应用请求创建新线程

2.JVM 向系统申请创建新的 Native 线程

3.系统尝试创建新的 Native 线程,对 Native 线程分配新的内存空间

4.因内存空间耗尽系统拒绝分配内存空间

代码示例

while(true){
    new Thread(new Runnable(){
        public void run() {
            try {
                Thread.sleep(10000000);
            } catch(InterruptedException e) { }        
        }    
    }).start();
}
// 不停地创建并启动新的线程,当代码运行时,很快就达到OS的线程数限制,并抛出该错误

排查方法

可以通过以下三个步骤基本可以确定该类型错误的原因:

  • 检查操作系统的线程限制
  • 分析应用程序的线程使用情况
  • 确认 JVM 的线程栈大小是否足够

解决策略

最直接的解决方案就是在系统级别增加线程数限制来绕过它,当然治标不治本,如果你的应用可以产生一堆线程且出问题了,这时候很大概率是你的应用出了比较严重的代码错误。需要对线程处理相关的代码进行认真仔细的审查,然后针对性优化,尤其是线程使用方式的优化,比如使用线程池等。

java.lang.OutOfMemoryError:Out of swap space

Java 应用在启动时都会指定所需要的内存大小,通过 -Xmx 和其他启动参数指定。在 JVM 请求的总内存大于可用物理内存的情况下,操作系统会将内存中的数据交换到磁盘上去,Out of swap space 表示交换空间也将耗尽,并且由于缺少物理内存和交换空间,再次尝试分配内存也将失败。

原因分析

当应用向 JVM Native heap 请求分配内存失败并且 Native heap 也即将耗尽时,JVM 就会抛出 Out of swap space 错误。该错误消息中包含分配失败的大小和请求失败的原因。

Native Heap Memory 是 JVM 内部使用的 Memory,这部分的 Memory 可以通过 JDK 提供的 JNI 的去访问,这部分 Memory 效率很高,但是管理需要自己去做,如果没有把握最好不要使用,以防出现内存泄露问题。JVM 使用 Native Heap Memory 用来优化代码载入(JTI 代码生成),临时对象空间申请,以及JVM内部的一些操作。

这个问题往往发生在 Java 进程已经开始交换的情况下,现代的 GC 算法已经做得足够好了,当面临由于交换引起的延迟问题时,GC 暂停的时间往往会让大多数应用程序不能容忍。

java.lang.OutOfMemoryError:Out of swap space 一般是由操作系统级别的问题引起的:

  • 操作系统配置的交换空间不足
  • 系统上的另一个进程消耗所有内存资源

还有可能是本地内存泄漏导致应用程序失败,比如应用调用了 Native code 连续分配内存,但却没有被释放。

排查方法

  • 检查操作系统的虚拟内存配置
  • 分析 JVM 的内存使用情况,包括堆内存和非堆内存
  • 确认系统是否有大量的 Page Swapping

解决策略

最简单的方法还是增加系统的虚拟内存或交换空间,不过要注意不同的平台实现的方式是有差异的。

而对于这个问题的唯一真正可行有效的方式是升级机器可以有更多的内存以及优化自己的代码减少内存占用。

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

Java 对应用可以分配的最大数组大小是有限制的。不同平台限制有一定的差异,但通常在 1 到 21 亿个元素之间,遇到 Requested array size exceeds VM limit 错误时,就说明你的代码中有一个超大数组。

原因分析

该错误由 JVM 中的 Native code 抛出。JVM 在为数组分配内存之前,会执行特定于平台的检查,分配的数据结构是否在此平台中是可寻址的。

排查方法

首先就是确认你的代码中是否有请求创建超大数组,然后分析对应代码中是否有不合理的数组大小请求。

解决策略

针对数组大小进行优化,避免创建过大的数组,当然也可以使用其他数据结构,比如 ByteBuffer。

Out of memory: Kill process or sacrifice child

操作系统是建立在进程的概念之上,进程是在内核中作业,其中有一个非常特殊的进程,名叫内存杀手(Out of memory killer)。当内核检测到系统内存不足时,OOM killer 就会被激活,然后选择一个进程杀掉。那哪一个进程会首当其冲呢?选择的算法和想法都很简单那就是谁占用内存最多,谁就被干掉。

当可用虚拟内存消耗到让整个操作系统面临风险时,就会产生 Out of memory:Kill process or sacrifice child 错误。在这种情况下,OOM Killer 会选择流氓进程并杀死它。

原因分析

缺省情况下,Linux 内核允许进程请求比系统中可用内存更多的内存,但大多数进程实际上并没有使用完他们所分配的内存。Linux 内核采用的机制跟宽带运营商差不多,一般情况下都没有问题,但当大多数应用程序都消耗完自己的内存时,麻烦就来了,这些应用程序的内存需求加起来超出了物理内存的容量,内核就必须杀掉一些进程才能腾出空间保障系统正常运行。

代码示例

public static void main(String[] args){
    List<int[]> l = new java.util.ArrayList();
    for (int i = 10000; i < 100000; i++) {
        try {
            l.add(new int[100000000]);
        } catch (Throwable t) {
            t.printStackTrace();
        }
    }
}

Jun  4 07:41:59 plumbr kernel: [70667120.897649] Out of memory: Kill process 29957 (java) score 366 or sacrifice child
Jun  4 07:41:59 plumbr kernel: [70667120.897701] Killed process 29957 (java) total-vm:2532680kB, anon-rss:1416508kB, file-rss:0kB

排查方法

  • 确认操作系统的 OOM Killer 机制是否触发
  • 分析 JVM 进程的内存使用情况
  • 检查是否有其他内存密集型进程在运行

解决策略

解决这个问题最直接有效的方法就是内存升级,当然也可以:

  • 调整 OOM Killer 的配置,避免不必要的进程被杀死
  • 优化 JVM 和应用程序的内存使用,减少内存压力
  • 水平扩展应用,把内存的负载分摊出去

通过上述的排查方法和解决策略,可以有效地诊断和解决 Java 应用程序中的各种 OOM 问题,从而提升应用程序的稳定性和性能。开发者和运维人员应该熟悉这些方法和策略,以便在面对内存危机时能够迅速采取行动。

推荐阅读

  1. 史上最全的 Java 应用程序线上问题排查全手册:从知识体系到实战指南(一):知识体系
  2. 史上最全的 Java 应用程序线上问题排查全手册:从知识体系到实战指南(二):实战指南
  3. 程序员基本功|如何识别和解决 Java 代码中的坏味道
  4. 代码之美:如何为团队制定完美的代码风格后端程序员基本功|接口设计大揭秘:打造完美 API 的黄金法则以及最佳实践(上)
  5. 后端程序员基本功|接口设计大揭秘:打造完美 API 的黄金法则以及最佳实践(中)
  6. 后端程序员基本功|接口设计大揭秘:打造完美 API 的黄金法则以及最佳实践(下)



分享,收藏,点赞,在看一起来

请使用浏览器的分享功能分享到微信等