带你学习如何排查和解决OOM

带你学习如何排查和解决OOM

在面试的过程中,可能有的小伙伴会被问到:你有遇到过OOM吗?你是如何解决的?

当然,在日常的开发中,我们可能永远也遇不到OOM,哈哈哈,当然,万一碰到了呢,所以大家还是有必要来学习一下OOM以及如何排查。

通过阅读这篇文章,我将带领大家解决以下两个问题:

  • 哪些内存区域会出现OOM?
  • 如何排查和解决OOM?

下面就让我们一起来学习吧~

在《Java虚拟机规范》的规定里,除了程序计数器外,虚拟机内存的其他几个运行时区域都有发生OutOfMemoryError(下文称OOM)异常的可能。

现在我们解决了第一个问题,除了程序计数器外,虚拟机内存的其他几个运行时区域都会产生OOM异常。那么我们就一起来学习当特定的区域出现了OOM的话应该如何排查和分析。

1. Java堆区溢出

Java堆用于储存对象实例,我们只要不断地创建对象,并且保证GC Roots到对象之间有可达路径来避免垃圾回收机制清除这些对象,那么随着对象数量的增加,总容量触及最大堆的容量限制后就会产生内存溢出异常

比如下面这段测试的代码:

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

/**
* @Author zal
* @Date 2024/01/25 21:06
* @Description: VM option: -Xms20m -Xmx20m
* @Version: 1.0
*/
public class Main {

static class OOMObject { }

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

输出结果为:

Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
at java.base/java.util.Arrays.copyOf(Arrays.java:3512)
at java.base/java.util.Arrays.copyOf(Arrays.java:3481)
at java.base/java.util.ArrayList.grow(ArrayList.java:237)
at java.base/java.util.ArrayList.grow(ArrayList.java:244)
at java.base/java.util.ArrayList.add(ArrayList.java:454)
at java.base/java.util.ArrayList.add(ArrayList.java:467)
at com.example.test.Main.main(Main.java:21)

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

如何排查和解决堆区OOM?

要解决堆区内存的异常,常规的处理方法首先是通过内存映像分析工具(如Eclipse Memory Analyzer)对Dump出来的堆转储快照进行分析。

  • 首先确认内存中导致OOM的对象是否是必要的,也就是要先分清楚是内存泄漏(Memory Leak)还是内存溢出(Memory Overflow)。
    • 如果是内存泄漏,可进一步通过工具查看泄漏对象到GC Roots的引用链,找到泄漏对象是通过怎样的引用路径、与哪些GC Roots相关联,才导致垃圾收集器无法回收它们,根据泄漏对象的类型信息以及它到GC Roots引用链的信息,一般可以比较准确地定位到这些对象创建的位置,进而找出产生内存泄漏的代码的具体位置。
    • 如果是内存溢出,那就应当检查Java虚拟机的堆参数(-Xmx与-Xms)设置,与机器的内存相比,查看是否还有向上调整的空间。再从代码上检查是否存在某些对象声明周期过长、持有状态过长、存储结构设计不合理等情况,尽量减少程序运行期的内存消耗。

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

关于虚拟机栈和本地方法栈,在《Java虚拟机规范》中描述了两种异常:

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

我们知道这部分内存是线程私有的,每个线程都需要分配一块内存,所以当线程很多时就会发生内存溢出,下面来分析一下这句话背后的原理:

① 内存容量=堆内存+方法区内存+程序计数器内存(可忽略)+栈内存(虚拟机栈和本地方法栈);

② 因为栈容量在编译器就可知,且一旦分配在运行期就不会改变,在栈容量一定的情况下,每个虚拟机栈分配到的栈容量越大,可以创建的线程数就越少

当线程过多时,就会导致栈容量不足,从而发生内存溢出

如何排查和解决栈中的OOM呢?

① 出现StackOverflowError异常时,会有明确错误堆栈可供分析,相对而言比较容易定位到问题所在。

② 出现OutOfMemoryError异常时:

  • 可以首先考虑是否可以减少线程数,对线程数进行适当调整;
  • 如果不能减少线程数,就只能通过减小最大堆内存容量最大栈容量来解决(–Xmx和–Xss)。

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

由于运行时常量池是方法区的一部分,所以这两个区域的溢出测试可以放到一起进行。

方法区的主要职责是用于存放类型的相关信息,如类名、访问修饰符、常量池、字段描述、方法描述等。如果运行时产生大量的类去填满方法区,就能出现内存溢出异常。

一个类如果要被垃圾收集器回收,要达成的条件是比较苛刻的。在经常运行时生成大量动态类的应用场景里,就应该特别关注这些类的回收状况。这里就涉及到如何动态产生大量类的方法,一般有如下两种:

  • 使用反射机制或动态代理
  • 使用CGLib直接操作字节码

除了以上两个直接产生大量类之外,还有如下的场景:

  • 大量JSP或动态产生JSP文件的应用(JSP第一次运行时需要编译为Java类)
  • 基于OSGi的应用(即使是同一个类文件,被不同的加载器加载也会视为不同的类)等。

这里我们使用通过动态代理产生大量类来测试方法区溢出,测试代码如下:

/**
* @Author zal
* @Date 2024/01/25 21:06
* @Description: VM option: -XX:PermSize=10M -XX:MaxPermSize=10M
* @Version: 1.0
*/
public class Main {
static class OOMObject {
}

public static void main(String[] args) {

while (true) {
Enhancer enhancer = new Enhancer();
enhancer.setSuperclass(OOMObject.class);
enhancer.setUseCache(false);
enhancer.setCallback(new MethodInterceptor() {
public Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) throws Throwable {
return proxy.invokeSuper(obj, args);
}
});
enhancer.create();
}
}

}

输出结果为:

Exception in thread "main" java.lang.OutOfMemoryError: Metaspace

Exception: java.lang.OutOfMemoryError thrown from the UncaughtExceptionHandler in thread "main"

如何解决方法区内存溢出?

JDK8之后元空间替代了永久代,在默认设置下,正常的动态创建新类型的测试用例已经很难再迫使虚拟机产生方法区的溢出异常了。

不过还是可以通过一些参数的设置来预防OOM:

  • -XX:MaxMetaspaceSize设置元空间最大值,默认是-1,即不限制,或者说只受限于本地内存大小。
  • -XX:MetaspaceSize指定元空间的初始空间大小,以字节为单位,达到该值就会触发垃圾收集进行类型卸载,同时收集器会对该值进行调整:如果释放了大量的空间,就适当降低该值;如果释放了很少的空间,那么在不超过-XX:MaxMetaspaceSize(如果设置了的话)的情况下,适当提高该值。
  • -XX:MinMetaspaceFreeRatio作用是在垃圾收集之后控制最小的元空间剩余容量的百分比,可减少因为元空间不足导致的垃圾收集的频率。
  • 类似的还有-XX:Max-MetaspaceFreeRatio,用于控制最大的元空间剩余容量的百分比

4. 直接内存溢出

直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是《Java虚拟机规范》中定义的内存区域。但是这部分内存也被频繁地使用。

在JDK 1.4中新加入了NIO(New Input/Output)类,引入了一种基于通道(Channel)与缓冲区(Buffer)的I/O方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆里面的DirectByteBuffer对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在Java堆和Native堆中来回复制数据。

直接内存(Direct Memory)的容量大小可通过-XX:MaxDirectMemorySize参数来指定,如果不去指定,则默认与Java堆最大值(由-Xmx指定)一致。

下面我们使用使用unsafe分配本机内存,来模拟OOM,测试代码如下:

/**
* @Author zal
* @Date 2024/01/25 21:06
* @Description: VM option: -Xmx20M -XX:MaxDirectMemorySize=10M
* @Version: 1.0
*/
public class Main {

private static final int _1MB = 1024 * 1024;

public static void main(String[] args) throws IllegalAccessException {
Field unsafeField = Unsafe.class.getDeclaredFields()[0];
unsafeField.setAccessible(true);
Unsafe unsafe = (Unsafe) unsafeField.get(null);
while (true) {
unsafe.allocateMemory(_1MB);
}
}
}

输出结果为:

Exception in thread "main" java.lang.OutOfMemoryError: Unable to allocate 1048576 bytes
at java.base/jdk.internal.misc.Unsafe.allocateMemory(Unsafe.java:632)
at jdk.unsupported/sun.misc.Unsafe.allocateMemory(Unsafe.java:462)
at com.example.test.Main.main(Main.java:24)

如何排查和解决直接内存溢出的OOM?

由直接内存导致的内存溢出,**一个明显的特征是在Heap Dump文件中不会看见有什么明显的异常情况,如果发现内存溢出之后产生的Dump文件很小,而程序中又直接或间接使用了DirectMemory**(典型的间接使用就是NIO),那就可以考虑重点检查一下直接内存方面的原因了。

直接内存溢出可通过调节参数-XX:MaxDirectMemorysize调整内存区域,默认值和Java堆内存大小一样。