JVM学习笔记(二) 实战OutOfMemoryError异常

在 JVM 中,除了程序计数器外,虚拟机内存的其他几个运行时区域都有可能发生 OutOfMemoryError 异常。本博客通过若干实例来验证 OOM 发生的场景。写本博客的目的,希望在以后的工作中遇到 OOM 时,能根据异常的信息快速判断出是哪个区域的内存溢出,知道是什么代码引起的,以及该如何处理。本博客代码部分摘自《深入理解Java虚拟机 第二版》

1. Java 堆溢出

Java堆用来存储对象,因此只要不断创建对象,并保证 GC Roots 到对象之间有可达路径来避免垃圾回收机制清楚这些对象,那么当对象数量达到最大堆容量时就会产生 OOM。

限制堆大小为 20M,不可扩展,通过参数 -XX:+HeapDumpOnOutOfMemoryError 让虚拟机在内存溢出时 Dump 当前堆内存快照以便做分析。

/**
 * java堆内存溢出测试
 * VM Args: -Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError
 */
public class HeapOOM {

    static class OOMObject{}

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

运行结果:

java.lang.OutOfMemoryError: Java heap space
Dumping heap to java_pid7164.hprof …
Heap dump file created [27880921 bytes in 0.193 secs]
Exception in thread “main” java.lang.OutOfMemoryError: Java heap space
at java.util.Arrays.copyOf(Arrays.java:2245)
at java.util.Arrays.copyOf(Arrays.java:2219)
at java.util.ArrayList.grow(ArrayList.java:242)
at java.util.ArrayList.ensureExplicitCapacity(ArrayList.java:216)
at java.util.ArrayList.ensureCapacityInternal(ArrayList.java:208)
at java.util.ArrayList.add(ArrayList.java:440)
at com.jvm.oom.HeapOOM.main(HeapOOM.java:17)

堆内存 OOM 是经常会出现的问题,异常信息会进一步提示 Java heap space

要解决这个问题,一般是通过内存分析工具(如Eclipse Memory Analyzer)对 Dump出来的堆存储快照进行分析,到底是内存泄露( Memory Leak ) 还是内存溢出(Memory Overflow)。这里简单介绍下内存溢出和内存泄漏的区别。

  • 内存泄露:指程序中间动态分配了内存,但在程序结束时没有释放这部分内存,从而造成那部分内存不可用的情况,重启计算机或者JVM可以解决,但也有可能再次发生内存泄露,内存泄露和硬件没有关系,它是由软件设计缺陷引起的。

  • 内存溢出:是指程序在申请内存时,没有足够的内存空间供其使用,出现out of memory。

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

在 HotSpot 虚拟机中不区分虚拟机栈和本地方法栈,栈容量只由 -Xss 参数设定。关于虚拟机栈和本地方法栈,在 Java 虚拟机规范中描述了两种异常:

  • 如果线程请求的栈深度大于虚拟机所允许的最大深度,将抛出 StackOverflowError 异常。

  • 如果虚拟机在扩展栈时无法申请到足够的内存空间,则抛出 OutOfMemoryError 异常。

下面测试单线程的情况。

/**
 * 虚拟机栈和本地方法栈内存溢出测试,抛出stackoverflow exception
 * VM ARGS: -Xss128k 减少栈内存容量
 */
public class JavaVMStackSOF {

    private int stackLength = 1;

    public void stackLeak () {
        stackLength++;
        stackLeak();
    }

    public static void main(String[] args) throws Throwable {
        JavaVMStackSOF oom = new JavaVMStackSOF();
        try {
            oom.stackLeak();
        } catch (Throwable e) {
            System.out.println("stack length = " + oom.stackLength);
            throw e;
        }

    }

}

运行结果:
stack length = 11420
Exception in thread “main” java.lang.StackOverflowError
at com.jvm.oom.JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:12)
at com.jvm.oom.JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:13)
at com.jvm.oom.JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:13)
… 后续异常信息略

以上代码在单线程环境下,无论是由于栈帧太大还是虚拟机栈容量太小,当内存无法分配时,抛出的都是 StackOverflowError 异常。
如果测试环境是多线程环境,通过不断建立线程的方式可以产生内存溢出异常,代码如下所示。但是这样产生的 OOM 与栈空间是否足够大不存在任何联系,在这种情况下,为每个线程的栈分配的内存足够大,反而越容易产生OOM 异常。这点不难理解,每个线程分配到的栈容量越大,可以建立的线程数就变少,建立多线程时就越容易把剩下的内存耗尽。这点在开发多线程的应用时要特别注意。如果建立过多线程导致内存溢出,在不能减少线程数或更换64位虚拟机的情况下,只能通过减少最大堆和减少栈容量来换取更多的线程。

/**
 * JVM 虚拟机栈内存溢出测试, 注意在windows平台运行时可能会导致操作系统假死
 * VM Args: -Xss2M -XX:+HeapDumpOnOutOfMemoryError
 */

public class JVMStackOOM {

    private void dontStop() {
        while (true) {}
    }

    public void stackLeakByThread() {
        while (true) {
            Thread thread = new Thread(new Runnable() {

                @Override
                public void run() {
                    dontStop();
                }
            });
            thread.start();
        }
    }

    public static void main(String[] args) {
        JVMStackOOM oom = new JVMStackOOM();
        oom.stackLeakByThread();
    }
}

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

方法区用于存放Class的相关信息,对这个区域的测试,基本思路是运行时产生大量的类去填满方法区,直到溢出。使用CGLib实现。方法区溢出也是一种常见的内存溢出异常,在经常生成大量Class的应用中,需要特别注意类的回收情况,这类场景除了使用了CGLib字节码增强和动态语言外,常见的还有JSP文件的应用(JSP第一次运行时要编译为Java类)、基于OSGI的应用等。

/**
 * 测试JVM方法区内存溢出
 * VM Args: -XX:PermSize=10M -XX:MaxPermSize=10M
 */
public class MethodAreaOOM {

    public static void main(String[] args) {
        while (true) {
            Enhancer enhancer = new Enhancer();
            enhancer.setSuperclass(OOMObject.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();
        }
    }

    static class OOMObject{}
}

运行结果:
Exception in thread “main”
Exception: java.lang.OutOfMemoryError thrown from the UncaughtExceptionHandler in thread “main”

4. 本机直接内存溢出

DirectMemory 容量可通过 -XX:MaxDirectMemorySize 指定,如不指定,则默认与Java堆最大值一样。测试代码使用了 Unsafe 实例进行内存分配。由 DirectMemory 导致的内存溢出,一个明显的特征是在Heap Dump 文件中不会看见明显的异常,如果发现 OOM 之后 Dump 文件很小,而程序直接或间接使用了NIO,那就可以考虑检查一下是不是这方面的原因。

/**
 * 测试本地直接内存溢出
 * VM Args: -Xmx20M -XX:MaxDirectMemorySize=10M
 * @author Administrator
 */
public class DirectMemoryOOM {

    private static final int _1MB = 1024 * 1024;

    public static void main(String[] args) throws Exception {
        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
at sun.misc.Unsafe.allocateMemory(Native Method)
at com.jvm.oom.DirectMemoryOOM.main(DirectMemoryOOM.java:21)

5. 小结

本博客简单介绍了JVM各个区域内存溢出的原因,接下来我会结合实战,使用 Eclipse Memory Analyzer (MAT) 来对 JVM 的内存泄露和内存溢出进行分析。

你可能感兴趣的:(jvm)