Java中OOM异常的情况总结

在《Java虚拟机规范》中规定,除了程序计数器外,虚拟机内存的其他几个运行时区域都有发生OutOfMemoryError异常的可能。即会出现如下OOM异常:

  • Java堆溢出
    Java堆用于存储对象实例,只要是不断地创建对象,并且保证GC Roots到对象之间有可达路径来避免垃圾回收机制来清除这些对象,那么随着对象的增加,总容量触及最大堆的容量限制后就会产生内存溢出异常。
  • 虚拟机栈和本地方法栈溢出
    1)如果线程请求的栈深度大于虚拟机所允许的最大深度,将抛出StackOverflowError异常。
    2)如果虚拟机的栈内存允许动态扩展,当扩展栈容量无法申请到足够多的内存时,将抛出OutOfMemoryError异常。
  • 方法区和运行时常量池溢出
    从JDK8中完全使用元空间来代替永久代。(具体见下文)
  • 本地直接内存溢出
    直接内存(Direct Memory)的容量可通过-XX:MaxDirectMemorySize参数来指定,如果不指定则默认和Java堆最大值一致。

1 Java堆溢出

编写测试用例,用static修饰一个类保证GC机制清除不了,然后在循环体中一直创建对象,例子如下:

import java.util.ArrayList;
import java.util.List;

public class HeapOOM {
    static class OOMObject {
    }

    public static void main(String[] args) {
        List<OOMObject> list = new ArrayList<>();
        while (true) {
            list.add(new OOMObject());
        }
    }
}

设置虚拟机启动参数:将堆的最小值-Xms和堆的最大值-Xmx参数设置为一样避免堆自动扩展,通过-XX:+HeapDumpOnOutOfMemeoryError可以让虚拟机在内存溢出异常的时间Dump出当前的内存堆转储快照以便事后分析。

Java Service Wrapper 服务错误日志 某java服务出现了oom_内存溢出

运行上段代码会出现如下异常:

Java Service Wrapper 服务错误日志 某java服务出现了oom_内存溢出_02

Java堆内存的OOM异常时实际应用中最常见的内存溢出情况。出现Java堆内存溢出时,异常堆栈信息“java.lang.OutOfMemoryError”会跟进一步提示“Java heap space”。

要解决这个内存区域的异常,常规的处理方法是首先通过内存映像分析工具对Dump出来的堆转储快照进行分析。(如IDEA中的插件Jprofiler,网上下载低版本的Jprofile,如9.2.1,然后找一个注册码注册,然后在idea下载jprofiler插件选择下载路径bin下的exe集成即可。)

  1. 第一步首先应该确认内存中导致OOM的对象是否是必要的,也就是要先分清楚到底是出现内存泄露还是内存溢出。查看上述代码运行的内存情况:

Java Service Wrapper 服务错误日志 某java服务出现了oom_jvm_03

  • 如果是内存泄露,可进一步通过工具查看泄露对象到GC Roots的引用链,找到泄露对象是通过怎样的引用路径、与哪些GC Roots相关联,才导致垃圾收集器无法回收它们,根据泄露对象的类型信息以及它到GC Roots引用链的信息,一般可以比较准确的定位到这些对象创建的位置,进而找到产生内存泄露的代码的具体位置。
  • 如果不是内存泄露,换句话说内存中的对象确实是必须存活的,那就应当检查Java虚拟机的堆参数设置,与机器的内存相比是否还有向上调整的空间。

2 虚拟机栈和本地方法栈溢出

由于HotSpot虚拟机中并不区分虚拟机栈和本地方法栈,因此对于Hotspot来说,-Xoss参数(设置本地方法栈大小)虽然存在,但实际上没有任何效果,栈容量只能由-Xss参数来设定。关于虚拟机栈和本地方法栈,在《Java虚拟机规范》中描述了两种异常:

  1. 如果线程请求的栈深度大于虚拟机所允许的最大深度,将抛出StackOverflowError异常。
  2. 如果虚拟机的栈内存允许动态扩展,当扩展栈容量无法申请到足够的内存时,将抛出OutOfMemoryError异常。

3 方法区和运行时常量池溢出

String::intern()是一个本地方法,它的作用是如果字符串常量池中已经包含了一个等于此String对象的字符串,如果有的话则返回它的引用,如果没有的话在把此对象添加到常量池中,并且返回此String对象的引用。(注:这个是1.6之前的版本)在1.6之前的虚拟机中,常量池是分配在永久代中的,我们可以通过-XX:PermSize和-XX:MaxPerSize限制永久代的大小。首先用jdk1.6测试如下代码:

import java.util.HashSet;
import java.util.Set;

/**
 * Created by Yinlu on 2021/7/8
 */
public class RuntimeConstantPoolOOM {

    public static void main(String[] args) {
        // 使用Set保持常量池引用,避免FUll GC回收常量池行为
        Set<String> set = new HashSet<String>();

        // 在short范围内足以让6M的PerSize产生OOM了
        short i = 0;
        while (true) {
            set.add(String.valueOf(i++).intern());
        }
    }
}

虚拟机参数如下设置

Java Service Wrapper 服务错误日志 某java服务出现了oom_java_04

结果如下:说明运行时常量池的确是属于方法区的。(1.6)

Java Service Wrapper 服务错误日志 某java服务出现了oom_java_05

但是,如果换成更高的版本(9)就不会出现上面的结果了,而是不支持的虚拟机参数设置:

Java Service Wrapper 服务错误日志 某java服务出现了oom_Java_06

4 本机直接内存溢出

具体查看《深入理解JVM虚拟机》