Java内存区域与内存溢出异常

  《深入理解Java虚拟机》第2章读书笔记与实验记录。

1、对象创建内存分配方式

  • 指针碰撞: Java堆内存绝对规整,使用指针作为分界点指示器
  • 空闲列表: 已使用的内存的空闲内存相互交错,虚拟机需要维护一个列表,记录哪些内存可用

2、对象内存布局

HotSpot虚拟机中对象内存布局可以分为三块区域:对象头、实例数据、对齐数据

3、对象访问定位

  • 句柄访问:Java堆划分出一块内存区域作为句柄池,句柄中包含了实例数据和类型数据的具体地址信息,reference则存储的是对象的句柄地址,如下图所示:
    通过句柄访问对象.png

优势:reference中存储的是稳定的句柄地址,在对象被移动(垃圾回收时移动对象时普遍的行为)时只改变句柄中实例数据指针,reference无需更改。

  • 直接指针访问:reference中存储的直接是对象地址,Java堆中对象的布局必须考虑如何存放访问类型数据相关信息,如下图所示:
    通过直接指针访问对象.png

    优势:访问速度快,节省了一次指针定位的开销,由于对象的访问在java中非常频繁,因此这类开销极少成多后也是一项非常可观的执行成本。Sun HotSpot也是使用该种方式进行对象访问的。

4、实战:OutOfMemoryError异常

4.1Java堆溢出

虚拟机参数配置: -Xms20M -Xmx20M -XX:+HeapDumpOnOutOfMemoryError

代码清单:Java堆内存溢出异常测试

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_pid10816.hprof ...
Heap dump file created [28348501 bytes in 0.080 secs]
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
    at java.util.Arrays.copyOf(Arrays.java:3210)
    at java.util.Arrays.copyOf(Arrays.java:3181)
    at java.util.ArrayList.grow(ArrayList.java:265)
    at java.util.ArrayList.ensureExplicitCapacity(ArrayList.java:239)
    at java.util.ArrayList.ensureCapacityInternal(ArrayList.java:231)
    at java.util.ArrayList.add(ArrayList.java:462)
    at com.mpoom.jvm.HeapOOM.main(HeapOOM.java:21)

通过内存映像分析工具(如Eclipse Memory Analyzer)对Dump出来的堆转储快照进行分析:


使用Eclipse Memory Analyzer打开的堆转储快照文件.png
4.2虚拟机栈和本地方法栈溢出

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

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

虚拟机参数配置:-Xss128K

代码清单:虚拟机栈和本地方法栈OOM测试(仅作为第1点测试程序)

public class JavaVMStackSOF {
    private int stackLength = 1;

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

运行结果:

stack length:19211
Exception in thread "main" java.lang.StackOverflowError
    at com.mpoom.jvm.JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:13)
    at com.mpoom.jvm.JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:13)
    at com.mpoom.jvm.JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:13)
    at com.mpoom.jvm.JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:13)
    at com.mpoom.jvm.JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:13)
......

  实验结果表明:在单个线程下,无论是由于栈帧太大还是由于虚拟机容量太小,当内存无法分配的时候,虚拟机都抛出的是StackOverflowError异常。

如果测试不局限于单线程,通过不断建立线程倒是可以产生内存溢出异常

虚拟机参数配置:-Xss2M

代码清单: 创建线程导致内存溢出异常

public class JavaVMStackOOM {

    private void dontStop() {
        while(true) {}
    }
    public void stackLeakByThread() {
        while(true) {
            Thread thread = new Thread(new Runnable() {
                public void run() {
                    dontStop();
                }
            });
            thread.start();
        }
    }
    public static void main(String[] args) {
        JavaVMStackOOM oom = new JavaVMStackOOM();
        oom.stackLeakByThread();
    }
}

注意:在windows平台上运行上述代码有风险,会导致操作系统假死(做好重启的准备— _ —)。因为在windows在平台的虚拟机中,Java的线程是映射到操作系统的内核线程上的。
本次实验由于系统假死,没有查看到,下面的实验结果是从别处获取的:

Exception in thread"main"java.lang.OutOfMemoryError:unable to create new native thread
4.3方法区和运行时常量池溢出

  由于运行时常量池属于方法区的与部分,两个区域的溢出测试放在一起。

4.3.1运行时常量池溢出

  String.intern()是一个Native方法,它的作用是:如果字符串常量池中已经包含一个等
于此String对象的字符串,则返回代表池中这个字符串的String对象;否则,将此String对象包
含的字符串添加到常量池中,并且返回此String对象的引用。

虚拟机参数配置:-XX:PermSize=10M-XX:MaxPermSize=10M
JDK:1.6

代码清单:运行时常量池导致的内存溢出异常

public class RuntimeConstantPoolOOM {
    public static void main(String[] args) {
        // 使用List保持着常量池引用, 避免Full GC回收常量池行为
        List list = new ArrayList();
        int i = 0;
        while(true) {
            list.add(String.valueOf(i++).intern());
        }

    }
}

运行结果

Exception in thread "main" java.lang.OutOfMemoryError: PermGen space
    at java.lang.String.intern(Native Method)
    at com.mpoom.jvm.RuntimeConstantPoolOOM.main(RuntimeConstantPoolOOM.java:18)

从运行结果中可以看到,运行时常量池溢出,在OutOfMemoryError后面跟随的提示信息是“PermGen space”,说明运行时常量池属于方法区(HotSpot虚拟机中的永久代)的一部分。
更改JDK版本为1.8,代码中的while循环将一直执行下去。关于这个字符串常量池的实现问题,还可以引申出一个更有意思的影响,代码如下:

JDK:1.8

代码清单:String.intern()返回引用的测试

public class RuntimeConstantPoolOOM {
    public static void main(String[] args) {
        String str1=new StringBuilder("计算机").append("软件").toString();
        System.out.println(str1.intern()==str1);

        String str2=new StringBuilder("ja").append("va").toString();
        System.out.println(str2.intern()==str2);
    }
}

运行结果

true
false

这段代码在JDK 1.6中运行,会得到两个false,而在JDK 1.7中运行,会得到一个true和一个false。 产生差异的原因是:在JDK 1.6中,intern()方法会把首次遇到的字符串实例复制到永久代中,返回的也是永久代这个字符串实例的引用,而由StringBuilder创建的字符串实例在Java堆上,所以必然不是同一个引用,将返回false。 而JDK 1.7(以及部分其他虚拟机,例如JRockit)的intern()实现不会再复制实例,只是在常量池中记录首次出现的实例引用,因此intern()返回的引用和由StringBuilder创建的那个字符串实例是同一个。 对str2比较返回false是因为“java”这个字符串在执行StringBuilder.toString()之前已经出现过,字符串常量池中已经有它的引用了,不符合“首次出现”的原则,而“计算机软件”这个字符串则是首次出现的,因此返回true。

4.3.2方法区溢出

  方法区用于存放Class的相关信息,如类名、 访问修饰符、 常量池、 字段描述、 方法描等。 对于这些区域的测试,基本的思路是运行时产生大量的类去填满方法区,直到溢出。这里借助CGLib[1]直接操作字节码运行时生成了大量的动态类。

虚拟机参数配置:-XX:PermSize=10M-XX:MaxPermSize=10M
JDK:1.6

Maven依赖


     cglib
     cglib
     2.1_3

代码清单:借助CGLib使方法区出现内存溢出异常

public class JavaMethodAreaOOM {
    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();
        }
    }

    static class OOMObject {
    }
}

运行结果

Caused by: java.lang.OutOfMemoryError: PermGen space
    at java.lang.ClassLoader.defineClass1(Native Method)
    at java.lang.ClassLoader.defineClassCond(ClassLoader.java:631)
    at java.lang.ClassLoader.defineClass(ClassLoader.java:615)
    ... 8 more

方法区溢出也是一种常见的内存溢出异常,一个类要被垃圾收集器回收掉,判定条件是比较苛刻的。 在经常动态生成大量Class的应用中,需要特别注意类的回收状况。
注意:上述在JDK1.6环境下实验的,JDK1.8下while循环将一直运行下去。

4.4本机直接内存溢出

  DirectMemory容量可通过-XX:MaxDirectMemorySize指定,如果不指定,则默认与Java堆最大值(-Xmx指定)一样。下面代码越过了DirectByteBuffer类,直接通过反射获取Unsafe实例进行内存分配(Unsafe类的getUnsafe()方法限制了只有引导类加载器才会返回实例,也就是设计者希望只有rt.jar中的类才能使用Unsafe的功能)。 因为,虽然使用DirectByteBuffer分配内存也会抛出内存溢出异常,但它抛出异常时并没有真正向操作系统申请分配内存,而是通过计算得知内存无法分配,于是手动抛出异常,真正申请分配内存的方法是unsafe.allocateMemory()。

虚拟机参数配置:-Xmx20M -XX:MaxDirectMemorySize=10M
JDK:1.8

代码清单:使用unsafe分配本机内存

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.mpoom.jvm.DirectMemoryOOM.main(DirectMemoryOOM.java:21)

总结:本章主要学习了虚拟机的内存是如何划分的,哪部分区域、什么样的代码和操作会导致内存溢出异常。

你可能感兴趣的:(Java内存区域与内存溢出异常)