Java虚拟机内存管理(三)—内存异常

Java 与 C++ 之间有一堵由内存动态分配和垃圾收集技术所围成的 “高墙”,墙外面的人想进去,墙里面的人却想出来。——《深入理解Java虚拟机:JVM高级特性与最佳时实践(第二版)》周志明

Java 虚拟机作为运行 Java 程序抽象出来的计算机,具有内存管理的能力,像内存分配、垃圾回收等这些相关的内存管理问题,Java 虚拟机都会帮我们解决,所以作为一个 Java 程序员要比 C++ 程序员幸福,但是内存方面一旦出现问题,如果对虚拟机怎样使用内存不了解,就很难排查错误。

这段时间看周志明先生的《深入理解Java虚拟机:JVM高级特性与最佳时实践(第二版)》,下面就对 Java 虚拟机对内存的管理做一个系统的整理,本篇文章是该专题的第三篇。

3、内存异常

虽然说有 Java 虚拟机帮助我们管理内存,但是在管理过程中仍然有内存异常的发生。除了前面内存划分中说到的程序计数器外,其他区域都有发生 OutOfMemoryError 异常的可能。

我们可以给 Java 虚拟机设置参数来模拟这些异常的发生,不同的 Java 虚拟机运行结果可能也不同,这里使用的是 Oracle 公司的 JDK。

特别说明:下面如果没有特殊说明,默认使用的是 JDK8。

3.1 Java 堆内存异常

Java 堆是用于存储对象实例的,所以只要不断的创建对象把 Java 堆区域填满,并且还要保证牢记垃圾回收机制不能清除这些对象,就可以模拟出 Java 堆内存的异常。

模拟程序代码如下:

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

// 模拟 Java 堆内存异常
public class HeapOOM {
    // 声明类内部静态类,生命周期和外部类 HeapOOM 一样长,使垃圾收集器无法回收这些对象占用的内存空间
    static class OOMObject{
        
    }
    public static void main(String[] args) {
        List<OOMObject> list = new ArrayList<>();
        // 死循环不断生成对象,并添加到 list 中, 直到占满 Java堆内存
        while(true) {
            list.add(new OOMObject());
        }
    }
}

这里使用 MAT 内存分析器插件来对内存异常进行分析,IDE 使用免费的 Eclipse,当然 IDEA 也可以安装,Eclipse种的安装教程可以参看这篇文章《mat之一--eclipse安装Memory Analyzer》

在 Debug 的配置页面,设置 JVM 的参数。

5763525-43eee4c983e0d37f.jpg
Debug设置.jpg

JVM Debug 参数:

-verbose:gc -Xms20M -Xmx20M

-XX:+PrintGCDetails

-XX:+HeapDumpOnOutOfMemoryError

-XX:HeapDumpPath=D:\CodeWorkspace\Java\Dump

-Xms、-Xmx、-Xmn 后面分别是 Java 堆的最小值、Java 堆的最大值都是 20M,-XX后面可以添加一些额外的设置,PrintGCDetails 是打印出垃圾收集的详细信息,HeapDumpOnOutOfMemoryError 是发生OutOfMemoryError 异常时记录内存快照,HeapDumpPath后面是存放内存快照的文件夹位置。

Debug 结果如下:

5763525-14bd7c6ebc820f5f.jpg
Java堆异常运行结果.jpg

从上图中可以看到 Java堆区域(Java heap space)出现了 OutOfMemoryError 的异常,并且在我们指定的文件夹生成了内存快照文件。在使用 MAT 内存分析器工具之前,我们还要知道内存泄露和内存溢出的区别,我在前面没有将 OutOfMemoryError 异常翻译成内存泄露异常或内存溢出异常,而是使用原本的英文,内存泄露和内存溢出只是导致出现异常的原因,该事件的结果才是产生 OutOfMemoryError 异常。

内存泄露和内存溢出的区别:

  • 内存泄露是指程序在申请内存后,无法释放已申请的内存空间,内存泄露会导致内存资源耗光,通俗的说就是对象占着内存空间不归还给系统。
  • 内存溢出是指程序申请内存使用时,发现内存空间并不够使用,很常见的例子就是在存一个大数时超过了该数据类型的最大值,通俗的是说就是程序在借内存空间时发现无法满足自己的要求。

知道了内存泄露和内存溢出的区别,我们再来用 MAT 工具分析内存快照,首先调出 MAT 视图,然后在 “File” 选项中选择 “Open Heap Dump” 打开内存快照文件。

5763525-e058acf46e99d002.jpg
调出MAT视图.jpg
5763525-cb937184da1cc6af.jpg
打开内存快照文件.jpg

打开后快照文件后可以清晰的看出内存异常的可能出现问题的地方(Problem Suspect)。

5763525-194b0ad55fdeb3ea.jpg
内存快照.jpg

点击 “Details” 可以查看具体的细节。

5763525-ffab3b9b59dfcfa9.jpg
具体细节.jpg

可以看到 OOMObject 占用的内存空间很大,可以查看该对象是否有到 GC roots 的引用链,导致垃圾收集器无法回收对象占用的内存空间,由于是内存空间被占用无法回收,所以 OutOfMemoryError 异常产生的原因是内存泄露。

5763525-d2611c3a57c382b6.gif
查看泄露对象到GCRoots的引用链.gif

3.2 栈内存异常

在 HotSpot 虚拟机中并不区分 Java 虚拟机栈和本地方法栈,栈的容量可以通过 -Xss 参数来设定。

在 Java 虚拟机规范中描述了两种栈会出现的异常:

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

栈的深度是由栈的内存空间决定的,请求的栈越深,也即是已使用的栈的空间越大,所以上面 Java 虚拟机规范中的两种异常是有重叠之处的,一种异常也可能会导致另外一种异常的发生,到底是栈的内存空间太小引起的内存异常还是已使用的栈的内存空间太大引起的内存异常?

减少栈内存的容量和定义大量的局部变量来增加栈帧中局部变量表的长度,理论上都是可以产生 StackOverflowError 异常,也可以产生 OutOfMemoryError 异常的。

但是下面的代码只能产生 StackOverflowError 异常。

// 栈 StackOverflowError 异常
public class JVMStackSOF {
    private int stackLength = 1;
    // 递归函数
    public void stackLeak() {
        stackLength++;
        stackLeak();
    }
    public static void main(String[] args) {
        JVMStackSOF stackSOF = new JVMStackSOF();
        try {
            stackSOF.stackLeak();
        } catch (Throwable e) {
            System.out.println("Stack Length:" + stackSOF.stackLength);
            throw e;
        }
    }
}

Debug 的参数为:-verbose:gc -Xss128k -XX:+PrintGCDetails -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=D:\CodeWorkspace\Java\Dump

Debug 结果如下,只产生了 StackOverflowError 异常。

5763525-adf9c04227d4ee59.jpg
栈异常结果1.jpg

而在多线程环境中测试,可以才模拟出 OutOfMemoryError 异常。

特别提醒:此代码运行时会导致系统假死,具有一定的风险性,请在运行前保存好其他文件。

代码如下:

// 栈 OutOfMemoryError 异常
public class JVMStackOOM {
    private void dontStop() {
        while(true) {
            
        }
    }
    // !危险代码请勿随便尝试
    public void stackLeakByTread() {
        // 死循环不断创建线程
        while(true) {
            Thread thread = new Thread(new Runnable() {
                
                @Override
                public void run() {
                    dontStop();
                }
            });
            thread.start();
        }
    }
    
    public static void main(String[] args) {
        JVMStackOOM stackOOM = new JVMStackOOM();
        stackOOM.stackLeakByTread();
    }
}

由于在做这项危险的测试时,系统死掉了,所以笔者并没有得出实际结果,根据《深入理解Java虚拟机:JVM高级特性与最佳时实践(第二版)》,这里给出理论结果,也可以在虚拟机系统中尝试运行此代码,但也可能会出现外部系统假死的情况,读者可以自己尝试。

5763525-a971ebe9de9a3b50.jpg
栈异常结果2.jpg

3.3 方法区内存异常

方法区中有运行时常量池,如果向常量池中添加大量的内容,也可以导致方法区内存异常,可以通过 -XX:Permsize 和 -XX:MaxPermSize 来限制方法区的大小,进而限制常量池的容量。常量池在编译期可以放入常量了,在运行时也可以再添加新的常量,不存在内存被占用无法回收,所以这里的异常不是内存泄露导致的,而是内存溢出。

代码如下:

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

// 模拟方法区中的常量池内存溢出
public class RuntimeConstantPoolOOM {
    public static void main(String[] args) {
        List<String> list = new ArrayList<String>();
        int i = 0;
        while(true) {
            list.add(String.valueOf(i++).intern());
        }
    }
}

经过实际测试,发现 JDK6 会出现下面内存异常的情况,而在 JDK7 和 JDK8 中,发现垃圾回收器会不断的回收常量池的旧常量所占用的内存,以便新的常量可以进入,从而避免了常量池内存异常的发生。

5763525-4cfffbb892fd64a9.jpg
方法区常量池内存异常.jpg

方法区用于存放类的相关信息,如类名,访问修饰符,常量池,字段描述,方法描述等。使方法区内存异常的大致思路是产生大量的类填满方法区,直到方法区内存溢出。由于实验操作起来比较麻烦,直接操作字节码文件来动态的生成大量的类,所以这里也是使用书中的运行结果。

5763525-3e6d0ae905d932f9.jpg
方法区内存异常.jpg

3.4 直接内存异常

直接内存的大小可以通过 -XX:MaxDirectMemorySize 来指定,如果不指定默认是和 Java 堆的最大值(-Xmx)一样,可以通过使用 Unsafe 类来申请内存,由于该类的使用有限制,只有引导类的加载器才会返回对象实例,所以只能通过反射来获取 Unsafe 类的实例,但是在 Eclipse 中导入该类的包会报错,解决方案见参考文章。

参考文章:

eclipse中解决import sun.misc.Unsafe报错的方法

代码如下:

import java.lang.reflect.Field;
import sun.misc.Unsafe;

// 模拟直接内存异常
public class DirectMemoryOOM {
    private static final int _1MB = 1024 * 1024;
    public static void main(String[] args) throws IllegalArgumentException, IllegalAccessException {
        Field unsafeField = Unsafe.class.getDeclaredFields()[0];
        unsafeField.setAccessible(true);
        Unsafe unsafe = (Unsafe)unsafeField.get(null);
        while(true) {
            unsafe.allocateMemory(_1MB); // 申请内存
        }
    }
}

Debug 参数:-verbose:gc -Xmx20M -XX:MaxDirectMemorySize=10M -XX:+PrintGCDetails

由于在 Eclipse 中使用 JDK6 和 JDK7 运行该程序时会直接闪退,无法得到输出的异常,所以直接在控制台中使用 JDK8 编译运行该程序,运行结果如下:

5763525-618471d6ea5cdb64.jpg
直接内存异常.jpg

小结:模拟内存异常是一件危险的事情,所以务必在测试前保存好各种文件,以免造成文件内容丢失。
觉得文章还不错,可以关注 编程心路 微信公众号,在编程的路上,我们一起成长。

5763525-5e80ed57b0e58441.png
编程心路
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值