Java 与 C++ 之间有一堵由内存动态分配和垃圾收集技术所围成的 “高墙”,墙外面的人想进去,墙里面的人却想出来。——《深入理解Java虚拟机:JVM高级特性与最佳时实践(第二版)》周志明
Java 虚拟机作为运行 Java 程序抽象出来的计算机,具有内存管理的能力,像内存分配、垃圾回收等这些相关的内存管理问题,Java 虚拟机都会帮我们解决,所以作为一个 Java 程序员要比 C++ 程序员幸福,但是内存方面一旦出现问题,如果对虚拟机怎样使用内存不了解,就很难排查错误。
这段时间看周志明先生的《深入理解Java虚拟机:JVM高级特性与最佳时实践(第二版)》,下面就对 Java 虚拟机对内存的管理做一个系统的整理,本篇文章是该专题的第三篇。
虽然说有 Java 虚拟机帮助我们管理内存,但是在管理过程中仍然有内存异常的发生。除了前面内存划分中说到的程序计数器外,其他区域都有发生 OutOfMemoryError 异常的可能。
我们可以给 Java 虚拟机设置参数来模拟这些异常的发生,不同的 Java 虚拟机运行结果可能也不同,这里使用的是 Oracle 公司的 JDK。
特别说明:下面如果没有特殊说明,默认使用的是 JDK8。
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 的参数。
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 结果如下:
Java堆异常运行结果.jpg
从上图中可以看到 Java堆区域(Java heap space)出现了 OutOfMemoryError 的异常,并且在我们指定的文件夹生成了内存快照文件。在使用 MAT 内存分析器工具之前,我们还要知道内存泄露和内存溢出的区别,我在前面没有将 OutOfMemoryError 异常翻译成内存泄露异常或内存溢出异常,而是使用原本的英文,内存泄露和内存溢出只是导致出现异常的原因,该事件的结果才是产生 OutOfMemoryError 异常。
内存泄露和内存溢出的区别:
知道了内存泄露和内存溢出的区别,我们再来用 MAT 工具分析内存快照,首先调出 MAT 视图,然后在 “File” 选项中选择 “Open Heap Dump” 打开内存快照文件。
调出MAT视图.jpg
打开内存快照文件.jpg
打开后快照文件后可以清晰的看出内存异常的可能出现问题的地方(Problem Suspect)。
内存快照.jpg
点击 “Details” 可以查看具体的细节。
具体细节.jpg
可以看到 OOMObject 占用的内存空间很大,可以查看该对象是否有到 GC roots 的引用链,导致垃圾收集器无法回收对象占用的内存空间,由于是内存空间被占用无法回收,所以 OutOfMemoryError 异常产生的原因是内存泄露。
查看泄露对象到GCRoots的引用链.gif
在 HotSpot 虚拟机中并不区分 Java 虚拟机栈和本地方法栈,栈的容量可以通过 -Xss 参数来设定。
在 Java 虚拟机规范中描述了两种栈会出现的异常:
栈的深度是由栈的内存空间决定的,请求的栈越深,也即是已使用的栈的空间越大,所以上面 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 异常。
栈异常结果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高级特性与最佳时实践(第二版)》,这里给出理论结果,也可以在虚拟机系统中尝试运行此代码,但也可能会出现外部系统假死的情况,读者可以自己尝试。
栈异常结果2.jpg
方法区中有运行时常量池,如果向常量池中添加大量的内容,也可以导致方法区内存异常,可以通过 -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 中,发现垃圾回收器会不断的回收常量池的旧常量所占用的内存,以便新的常量可以进入,从而避免了常量池内存异常的发生。
方法区常量池内存异常.jpg
方法区用于存放类的相关信息,如类名,访问修饰符,常量池,字段描述,方法描述等。使方法区内存异常的大致思路是产生大量的类填满方法区,直到方法区内存溢出。由于实验操作起来比较麻烦,直接操作字节码文件来动态的生成大量的类,所以这里也是使用书中的运行结果。
方法区内存异常.jpg
直接内存的大小可以通过 -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 编译运行该程序,运行结果如下:
直接内存异常.jpg