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

运行时数据区域

我们看一下Java虚拟机运行时数据区:

Java虚拟机运行时数据区

程序计数器

程序计数器:是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。每个线程都有自己的独立的程序计数器。

如果线程正在执行的是Java方法,那么这个计数器的值就是正在执行的虚拟机字节码指令的地址;如果正在执行的是Native方法,这个计数器值为空(undefined)。此内存区域是唯一一个在Java虚拟机规范中没有规定任何OutOfMemoryError情况的区域。

Java虚拟机栈

线程私有的,它的生命周期与线程相同。虚拟机栈描述的是Java方法执行的内存模型:每个方法执行的同时都会创建一个栈帧用于存储局部变量表、操作数栈、动态链接、方法出口等信息。

局部变量表存放了编译期可知的各种基本数据类型(boolean、byte、char、short、int、float、long、double)、对象引用和returnAddress类型(指向了一条字节码指令的地址)。

其中64位长度的long和double类型的数据会占用2个局部变量空间(slot),其余的数据类型占1个。局部变量表所需的内存空间在编译期间分配完成,当进入一个方法时,这个方法需要在帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。

如果线程请求栈的深度大于虚拟机所允许的深度,将抛出StackOverflowError异常;无法申请到内存抛出OutOfMemoryError异常。

本地方法栈

本地方法栈与虚拟机栈所发挥的作用是非常相似的,它们之间的区别不过是虚拟机栈为虚拟机执行java方法,而本地栈则为虚拟机使用到的Native方法服务。

Java堆

Java堆是线程共享的,在虚拟机启动时创建。此区域的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存。

Java堆是垃圾收集器管理的主要区域,因此很多时候也被称作“GC堆”。由于现在收集器基本都采用分代收集算法,所以Java堆中还可以细分为:新生代和老年代;再细致一点的有Eden空间、From Survivor空间、To Survivor空间等。

在实现时,既可以实现成固定大小的,也可以是可扩展的,不过当前主流的虚拟机都是按照可扩展来实现的(通过-Xmx和-Xms控制)。

方法区(永久代)

线程共享,用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。

这区域的内存回收目标主要是针对常量池的回收和对类型的卸载!

运行时常量池

他是方法区的一部分,Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息就是常量池,用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。

直接内存

直接内存不是虚拟机运行时数据区的一部分。但是这部分内存也被频繁地使用,而且也可能导致OutOfMemoryError异常出现。

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

HotSpot虚拟机对象探秘

对象的创建

虚拟机遇到一个new指令时,首先去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已经被加载、解析和初始化过。如果没有,那必须先执行相应的类加载过程。接下来JVM将为新生对象分配内存。

如果Java堆是规整的(所有用过的内存在一边,未使用的在另一边,维护麻烦),那么将使用“指针碰撞”的分配方式,否则使用“空闲列表”的分配方式。Java堆是否规整由采用的垃圾收集器是否带有压缩整理功能决定。

但是内存的分配是同步的,如果一个线程刚分配一个对象内存,但是还没有修改指针所指向的位置,那么另一个线程分配对象的时候可能就出错了。解决方法有两个,一是对分配内存空间的动作进行同步处理(CAS方式)。另一种是把内存分配的动作按照线程划分在不同的空间进行,每个线程在java堆中预分配一小块内存,称为本地线程分配缓冲(TLAB)。只有TLAB用完并分配新的TLAB时,才需要同步。JVM是否开启TLAB功能,可通过-XX:+/-UseTLAB参数来设定。

内存分配完之后,初始化零值(不包括对象头),如果使用TLAB,这一工作过程也可以提前至TLAB分配时进行。

接下来,JVM对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息。这些信息存放在对象的对象头中,根据JVM当前运行状态不同,如是否启用偏向锁等,对象头会有不同的设置方式。

执行完new指令后接着执行方法,把对象按照程序员的意愿进行初始化,这样一个对象就初始化完成了。

对象的内存布局

在HotSpot虚拟机中,对象在内存中存储的布局可以分为3块区域:对象头、实例数据和对齐填充。

HotSpot虚拟机的对象头包括两部分信息,第一部分用于存储对象自身的运行时数据(哈希码、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,这部分数据的存储官方称为Mark Word),另一部分是类型指针(即对象指向它的类元数据的指针,JVM通过这个指针来确定这个对象是哪个类的实例)。

HotSpot虚拟机对象头Mark Word
存储内容 标志位 状态
对象哈希码、对象分代年龄 01 未锁定
指向锁记录的指针 00 轻量级锁定
指向重量级锁的指针 10 膨胀(重量级锁定)
空,不需要记录信息 11 GC标记
偏向线程ID、偏向时间戳、对象分代年龄 01 可偏向

如果对象是一个Java数组,那在对象头中还必须有一块用于记录数组长度的数据。

接下来的实例数据是对象真正存储的有效信息,也是在程序代码中所定义的各种类型的字段内容,在父类中定义的变量会出现在子类之前,如果CompactFields参数值为true,那么子类中较窄的变量也可能会插入到父类变量的空隙之中。

第三部分对齐填充并不是必然存在的,也没有特别的含义,它仅仅起着占位符的作用。不满8个字节的时候占位。

对象的访问定位

我们的Java程序需要通过栈上的Reference数据来操作堆上的具体对象。Reference访问对象的方式目前主流的有两种:句柄和直接指针。

  • 如果直接使用句柄访问,java堆中将会划分出一块内存来作为句柄池,reference中存储的是对象的句柄地址,而句柄中包含了对象数据与类型数据各自的具体地址信息,如下图所示。

vern通过句柄访问对象

  • 如果使用直接指针访问,那么java堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,而reference中存储的直接就是对象地址,如下图所示。

vern通过直接指针访问对象

这两种对象访问方式各有优势,使用句柄来访问的最大好处是reference中存储的是稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据指针,而reference本身不需要修改。

使用直接指针访问方式的最大好处就是速度更快,它节省了一次指针定位的时间开销。HotSpot虚拟机使用的是直接指针访问的方式。句柄来访问的情况也十分常见。

OutOfMemoryError异常

主要是为了学习之前学的各种内存区域的内容,还有就是以后遇到内存错误的时候,能够根据异常的信息快速判读是哪个区域的内存溢出,知道是什么样的代码可能会导致这些区域内存溢出,以及出现这些异常后,该如何处置。

Java堆溢出

我们在JVM参数中加入:

-verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8

这里堆的大小固定为20M且不可扩展,也可以通过参数-XX:+HeapDumpOnOutOfMemoryError可以让虚拟机在出现内存溢出异常时,Dump当前的内存堆转储快照以便事后进行分析。如果你不想等到发生崩溃性的错误时才获得堆转储文件,也可以通过设置如下 JVM 参数来按需获取堆转储文件。-XX:+HeapDumpOnCtrlBreak

List<Object> list = new ArrayList<Object>();
for(int i=0; i< 100000000; i++) {
    HelloBean helloBean1 = new HelloBean();
    list.add(helloBean1);
}

这段代码运行会抛出内存溢出的问题,并产生java_pid17824.hprof内存镜像文件,然后通过分析工具打开这个文件查看(比如Eclipse自带的Eclipse Memory Analyzer,这是一个插件需要安装)。重点是确认内存中的对象是否是必要的,也就是要先分清楚到底是出现了内存泄漏还是内存溢出的问题。

Eclipse Memory Analyzer工具

如果是内存泄漏,可进一步通过工具查看泄漏对象到GC Roots的引用链。于是就能找到泄露对象是通过怎样的路径与GC Roots相关联并导致垃圾收集器无法自动回收它们的。掌握了泄露对象的类型信息及GC Roots引用链的信息,就可以比较准确地定位出泄露代码的位置。

如果不存在泄露,换句话说,就是内存中的对象确实都还必须存活着,那就应当检查虚拟机的堆参数(-Xmx与-Xms),与机器物理内存对比看是否还可以调大,从代码上检查是否存在某些对象生命期过长、持有状态时间过长的情况,尝试减少程序运行期的内存消耗。

https://www.ibm.com/developerworks/cn/opensource/os-cn-ecl-ma/

http://blog.csdn.net/aaa2832/article/details/19419679

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

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

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

这两种异常其实存在着一些互相重叠的地方。实验结果表明:在单个线程下,无论是由于栈帧太大还是虚拟机栈容量太小,当内存无法分配的时候,虚拟机抛出的都是StackOverflowError异常。如果测试时不限于单线程,通过不断地建立线程的方式倒是可以产生内存溢出异常。

如果是建立过多线程导致内存溢出,在不能减少线程数或者更换64位虚拟机的情况下,就只能通过减少最大堆和减少栈容量来换取更多的线程。

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

由于运行时常量池是方法区的一部分,因此这两个区域的溢出测试就放在一起进行。前面提到JDK1.7开始逐步“去永久代”的事情,在此就以测试代码观察一下这件事对程序的实际影响。

String.intern()是一个Native方法,他的作用是:如果字符串常量池中已经包含一个等于此String常量的字符串,则返回代表池中这个字符串的String对象;否则,将此String对象包含的字符串添加到常量池中,并且返回此String对象的引用。在JDK1.6及之前的版本中,由于常量池分配在永久代内,我们可以通过-XX:PermSize和-XX:MaxPermSize限制方法区大小,从而间接限制其中的常量池的容量。

这意味着重复调用String.intern()在JDK1.6之前的版本中会抛出方法区(PermGen space)OutOfMemoryError,而在JDK1.7中,不会出现。

方法区用于存放Class的相关信息,如类名、访问修饰符、常量池、字段描述、方法描述等。对于这些区域的测试,基本的思路是运行时产生大量的类去填满方法区,直到溢出。虽然直接使用Java SE API也可以动态产生类(如反射时的GeneratedConstructorAccessor和动态代理等),但在本次实验中操作起来比较麻烦。在下面的代码中,笔者借助CGLib直接操作字节码运行时生成了大量的动态类。

值得特别注意的是,我们在这个例子中模拟的场景并非纯粹是一个实验,这样的应用经常会出现在实际应用中:当前的很多主流框架,如Spring、Hibernate,在对类进行增强时,都会使用到CGLib这类字节码技术,增强的类越多,就需要越大的方法区来保证动态生成的Class可以加载入内存。另外,JVM上的动态语言(如Groovy)通常都会持续创建类来实现动态语言的动态性,随着这类语言的流行,这类溢出也会越来越多。

public class JavaMethodAreaOOM {
    public static void main(final String[] args) {
        while(true) {
            Enhancer enhancer = new Enhancer();
            enhancer.setSuperclass(OOMObject.class);
            enhancer.setUseCache(false);
            enhancer.setCallback(new MethodInterceptor() {
                @Override
                public Object intercept(Object o, Method method, Object[] objects, MethodProxy methodProxy) throws Throwable {
                    return methodProxy.invokeSuper(o, args);
                }
            });
            enhancer.create();
        }
    }

    static class OOMObject {
    }
}

方法区溢出也是一种常见的内存溢出异常,一个雷要被垃圾器回收掉,判定条件是比较严苛的。在经常动态产生大量Class的应用中,需要特别注意类的回收状况。这类场景除了上面提到的程序使用了CGLib字节码增强和动态语言之外,常见的还有:大量JSP或者动态产生JSP文件的应用、基于OSGi的应用等。

本机直接内存溢出

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

/**
 * VM Args: -Xmx20M -XX:MaxDirectMemorySize=10M
 */
public class DirectMemoryOOM {
    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);
        }
    }
}

由DirectMemory导致的内存溢出,一个明显的特征是在Heap Dump文件中不会看见明显的异常,如果读者发现OOM之后Dump文件很小,而程序中又直接或者间接使用了NIO,那就可以考虑检查一下是不是这方面的原因。

results matching ""

    No results matching ""