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

2020年3月22日 | 作者 Siran | 9000字 | 阅读大约需要18分钟
归档于 JVM | 标签 #JVM

运行时数据区域

Java 虚拟机在执行Java 程序的过程中会把它所管理的内存划分为若干个不同的数据区域。这些区域有各自的用途,以及创建和销毁的时间,有的区域随着虚拟机进程的启动一直存在, 有些区域则是依赖于用户线程的启动和结束而建立和销毁,根据《Java虚拟机规范》的规定,Java 虚拟机所管理的内存将会包括以下几个运行时区域。

如下图所示


程序计数器

程序计数器(Program Counter Register) 线程私有的,它可以看作是当前线程所执行的字节码的行号指示器。 主要工作就是通过改变这个计数器的值来选举下一条需要执行的字节码指令, 它是程序控制流的指示器、分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。

  • 如果线程正在执行一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址
  • 如果正在执行的是本地方法(Native)方法,这个计数器值则为空(Undefined)。
  • 在Java虚拟机规范中,是唯一一个不会发生OOME情况的区域。

以代码和字节码为例子

public void test(){
        int a = 0;
        System.out.println("123");
    }

//对应的字节码
public void test();
    Code:
       0: iconst_0
       1: istore_1
       2: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
       5: ldc           #3                  // String 123
       7: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
      10: return

在字节码中 0,1,2,5,7,10这些数字就是由程序计数器来保存的,代表着节码指令的行号,旁边的则是字节码的具体操作


Java虚拟机栈

和程序计数器一样,Java虚拟机栈(Java Virtual Machine Stack) 也是线程私有的,它的生命周期与线程相同。

虚拟机栈描述的是Java方法执行的线程模型:每个方法被执行的时候,Java虚拟机都会创建一个栈帧(Stack Frame) 用于存储局部变量表、操作数栈、动态连接、方法出口等信息。

每一个方法的调用直至执行完毕的过程,就对应着一个栈帧在虚拟机中从入栈到出栈的过程。

    LocalVariableTable:
      Start  Length  Slot  Name   Signature
          0      11     0  this   Lcn/sirann/aotuMemoryManager/memoryArea/MemoryAreaTest;
          2       9     1     a   I

局部变量表

  • 对应程序计数器中的test()方法,可以发现除了有基本类型a 以外还有一个 this 也就是说 this 会被隐式的传入方法中所以我们可以在方法里直接使用 this
  • 可以存放基本数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference 类型,指向对象起始地址的引用指针,也可能是代表对象的句柄)。
  • 这些数据类型在局部变量表中以槽(slot)来表示,其中64位的longdouble类型的数据会占用两个变量槽,其余的只占用一个。
  • 局部变量表在编译期间就完全分配,当进入一个方法时,这个方法需要在栈帧中分配多少局部变量空间都完全确定。运行期间不会改变局部变量表的大小。
  • 在虚拟机栈中有两类异常情况
    • 如果线程请求的栈深度大于虚拟机所允许的深度,抛出StackOverflowError异常。
    • 如果Java虚拟机栈容量可以动态扩展(Hotspot是不可以动态扩展的,所以只要线程申请栈空间成功了就不会有OOME,但是如果申请失败就会出现OOME),当栈扩展时无法申请到足够的内存会抛出OOME异常

本地方法栈

本地方法栈(Native Method Stacks) 与 虚拟机栈基本一样,主要的区别在于:

  • 虚拟机栈是为虚拟机执行Java方法(字节码)服务。
  • 本地方法栈则是为本地(Native)方法服务。

Java堆

Java堆是共享区域,也是在内存区域中最重要的一块,Java几乎所有的对象实例都会在这里分配内存。所以堆也是垃圾回收器重点照顾对象。

  • Java堆可以处于物理上不连续的内存空间中,但是逻辑上它应该被视为连续的,就像我们用磁盘空间去存储文件一样,并不要求每个文件都连续存放的,但是取得时候是一个完整的文件。
  • Java堆即可以被实现成固定大小,也可以通过参数-Xmx和-Xms来设定。
  • 如果Java堆中没有内存完成实例分配,并且堆也无法在扩展,则会抛出OOME

方法区

方法区(Method Area) 与 Java堆一样是共享区域它用于存放已经被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。

  • 在Jdk8以前,使用永久代来实现方法区。这样使得HotSpot 的垃圾收集器能够像管理Java堆一样来管理这片内存。
  • Jdk8废弃了永久代的概念,把原本放在永久代的字符串常量池、静态变量、类型数据全部移到元空间中(Meta-space)
  • 如果方法区无法满足新的内存分配需求时,将抛出OOME

运行时常量池

运行时常量池(Runtime Constant Pool)方法区的一部分。

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

  • 运行时常量池具有动态性,并非在编译器期间生成的常量能进入运行时常量池,运行期间也可以把新的常量放入池中。比如String.intern()方法

intern方法会判断常量池是否有相同的常量如果有,那么直接引用,如果没有则创建。


直接内存

直接内存(Direct Memory) 并不是虚拟机运行时数据区的一部分,它是堆外内存

  • NIO 引入了一种基于通道(Channel) 与缓冲区(Buffer) 的I/O方法,它可以直接使用Native方法直接分配堆外内存,然后通过存储在Java堆中的DirectByteBuffer对象作为这块内存的引用进行操作。
  • 可以避免在Java堆与Native堆之间来回复制数据。
  • 不受Java堆大小的限制,受限于本机总内存,超过本机总内存也会发生OOME

HotSpot 虚拟机对象探秘


对象的创建

我们创建对象基本都是使用new关键字,比如下面的代码:

Test test = new Test();

当虚拟机遇到一条字节码new指令时,首先讲去检查这个指令的参数是否在常量池中定位到一个类的符号引用, 并检查这个符号因引用代表的类是否已经被加载了、解析和初始化过。如果没有,那就必须执行相应的类加载过程。 在类加载检查通过后,虚拟机将为新生成的对象分配内存(加载后可以确定所需的内存大小)。

对象的内存分配实际上就是在Java堆上找到一块足够大的空间给对象实例,如果发现不够那就进行GC,如果GC后还是不够那就抛出OOME

Java堆内存的分配方式有两种:

  • 指针碰撞:Java堆中的内存是绝对规整的,所有使用过的都被放在一边,空闲的内存放在另一边。中间放着一个指针作为分界点的指示器,分配内存仅仅是把那个指针向空闲空间方向挪动一段与对象大小相等的距离
  • 空闲列表:维护一个列表记录哪些内存是可用的,分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录。

使用哪种分配方式取决于垃圾回收器是否带有空间压缩整理的能力比如Serial、ParNew是拥有此能力的;而CMS这种是基于清除(Sweep) 无法使用指针碰撞。

思考一个问题:对象创建在虚拟机中是非常频繁的行为,即时仅仅修改一个指针所指向的位置,在并发情况下也并不是线程安全的。可能出现在给对象A分配内存,指针还没来得及修改, 对象B又同时使用了原来的指针来分配内存的情况。

可以通过以下两种方式来解决

  • 对分配内存空间的动作进行同步处理(虚拟机采用CAS来保证更新操作的原子性)
  • 每个线程在Java堆中预先分配一小块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer,TLAB),哪个线程需要分配内存,就在那个线程的本地缓冲区中分配。只有在本地缓冲区用完了,分配新的缓存区才需要同步锁定。虚拟机是否使用TLAB,可以通过-XX:+/-UseTLAB参数设定。

内存分配完成之后,虚拟机必须将分配到的内存空间(但不包括对象头)都初始化为零值,如果使用了TLAB的话,这一项工作也可以提前至TLAB分配时顺便进行。这步操作保证了对象的实例字段在Java代码中可以不赋初始值就直接使用,使程序能访问到这些字段的数据类型所对应的零值。

Java虚拟机还要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码(实际上对象的哈希码会延后到真正调用Object::hashCode()方法时才计算)、对象的GC分代年龄等信息。这些信息存放在对象的对象头(ObjectHeader)之中,根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。

在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了。但是从Java程序的视角看来,对象创建才刚刚开始——构造函数,即Class文件中的<init>()方法还没有执行,所有的字段都为默认的零值,对象需要的其他资源和状态信息也还没有按照预定的意图构造好。一般来说(由字节码流中new指令后面是否跟随invokespecial指令所决定,Java编译器会在遇到new关键字的地方同时生成这两条字节码指令,但如果直接通过其他方式产生的则不一定如此),new指令之后会接着执行<init>()方法,按照程序员的意愿对对象进行初始化,这样一个真正可用的对象才算完全被构造出来。


对象的内存布局

在HotSpot虚拟机里,对象在堆内存中的存储布局可以划分为三个部分:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)

  • HotSpot虚拟机对象的对象头部分包括两类信息:

    • 第一类是用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,这部分数据的长度在32位和64位的虚拟机(未开启压缩指针)中分别为32个比特和64个比特,官方称它为“Mark Word”。
    • 另一类是类型指针,即对象指向它的类型元数据的指针,Java虚拟机通过这个指针来确定该对象是哪个类的实例
  • 实例数据部分是对象真正存储的有效信息:

    • 即我们在程序代码里面所定义的各种类型的字段内容,无论是从父类继承下来的,还是在子类中定义的字段都必须记录起来。
    • 这部分的存储顺序会受到虚拟机分配策略参数(-XX:FieldsAllocationStyle参数)和字段在Java源码中定义顺序的影响。HotSpot虚拟机默认的分配顺序为longs/doubles、ints、shorts/chars、bytes/booleans、oops(Ordinary Object Pointers,OOPs)
    • 从以上默认的分配策略中可以看到,相同宽度的字段总是被分配到一起存放,在满足这个前提条件的情况下,在父类中定义的变量会出现在子类之前。如果HotSpot虚拟机的+XX:CompactFields参数值为true(默认就为true),那子类之中较窄的变量也允许插入父类变量的空隙之中,以节省出一点点空间。
  • 对象的第三部分是对齐填充:

    • 它仅仅起着占位符的作用。由于HotSpot虚拟机的自动内存管理系统要求对象起始地址必须是8字节的整数倍,换句话说就是任何对象的大小都必须是8字节的整数倍。对象头部分已经被精心设计成正好是8字节的倍数(1倍或者2倍),因此,如果对象实例数据部分没有对齐的话,就需要通过对齐填充来补全。

对象的访问定位

创建对象自然是为了后续使用该对象,我们的Java程序会通过栈上的reference数据来操作堆上的具体对象。由于reference类型在《Java虚拟机规范》里面只规定了它是一个指向对象的引用,并没有定义这个引用应该通过什么方式去定位、访问到堆中对象的具体位置,所以对象访问方式也是由虚拟机实现而定的

主流的访问方式主要有使用句柄直接指针两种

  • 句柄访问:Java堆中将可能会划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自具体的地址信息
  • 直接指针访问:Java堆中对象的内存布局就必须考虑如何放置访问类型数据的相关信息,reference中存储的直接就是对象地址,如果只是访问对象本身的话,就不需要多一次间接访问的开销。

这两种对象访问方式各有优势:

使用句柄来访问的最大好处就是reference中存储的是稳定句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而reference本身不需要被修改。

使用直接指针来访问最大的好处就是速度更快它节省了一次指针定位的时间开销,由于对象访问在Java中非常频繁,因此这类开销积少成多也是一项极为可观的执行成本,就HotSpot而言,它主要使用第二种方式进行对象访问


OutOfMemoryError 异常

《Java虚拟机规范》的规定里,除了程序计数器外,虚拟机内存的其他几个运行时区域都有发生OOM异常的可能


Java堆溢出

Java堆用于储存对象实例,我们只要不断地创建对象,并且保证GC Roots到对象之间有可达路径来避免垃圾回收机制清除这些对象,那么随着对象数量的增加,总容量触及最大堆的容量限制后就会产生内存溢出异常。

//-Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError
public class HeapOOM {
    static class OOMObject{

    }

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

运行结果:

java.lang.OutOfMemoryError: Java heap space
Dumping heap to java_pid17035.hprof ...
Heap dump file created [27845055 bytes in 0.098 secs]

Java堆内存的OutOfMemoryError异常是实际应用中最常见的内存溢出异常情况。出现Java堆内存溢出时,异常堆栈信息“java.lang.OutOfMemoryError”会跟随进一步提示“Java heap space”。


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

由于HotSpot虚拟机中并不区分虚拟机栈和本对象的内存布局 地方法栈,因此对于HotSpot来说,-Xoss参数(设置本地方法栈大小)虽然存在,但实际上是没有任何效果的,栈容量只能由-Xss参数来设定。

关于虚拟机栈和本地方法栈,在《Java虚拟机规范》中描述了两种异常:

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

  • 如果虚拟机的栈内存允许动态扩展,当扩展栈容量无法申请到足够的内存时,将抛出OutOfMemoryError异常。

《Java虚拟机规范》明确允许Java虚拟机实现自行选择是否支持栈的动态扩展,而HotSpot虚拟机的选择是不支持扩展所以除非在创建线程申请内存时就因无法获得足够内存而出现OutOfMemoryError异常,否则在线程运行时是不会因为扩展而导致内存溢出的,只会因为栈容量无法容纳新的栈帧而导致StackOverflowError异常

  • 使用-Xss参数减少栈内存容量。结果:抛出StackOverflowError异常,异常出现时输出的堆栈深度相应缩小。
//-Xss160k
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:771
Exception in thread "main" java.lang.StackOverflowError
	at cn.sirann.aotuMemoryManager.oom.JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:12)

  • 创建线程导致OOM
//-Xss2M
public class JavaVMStackOOM {
    private void dontStop(){
        while (true){
            
        }
    }

    public void stackLeakByThread(){
        while (true){
            new Thread(()->{
                dontStop();
            }).start();
        }
    }
    public static void main(String[] args) throws Throwable {
        JavaVMStackOOM oom = new JavaVMStackOOM();
        oom.stackLeakByThread();
    }
}

输出结果:

Exception in thread "main" java.lang.OutOfMemoryError: unable to create native thread

出现StackOverflowError异常时,会有明确错误堆栈可供分析,相对而言比较容易定位到问题所在。

如果使用HotSpot虚拟机默认参数,栈深度在大多数情况下(因为每个方法压入栈的帧大小并不是一样的,所以只能说大多数情况下) 到达1000~2000是完全没有问题,对于正常的方法调用(包括不能做尾递归优化的递归调用),这个深度应该完全够用了。

但是,如果是建立过多线程导致的内存溢出,在不能减少线程数量或者更换64位虚拟机的情况下,就只能通过减少最大堆和减少栈容量来换取更多的线程。这种通过“减少内存”的手段来解决内存溢出的方式,如果没有这方面处理经验,一般比较难以想到。也是由于这种问题较为隐蔽,从JDK 7起,以上提示信息中“unable to create nativethread”后面,虚拟机会特别注明原因可能是“possibly out of memory or process/resourcelimits reached”。


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

由于运行时常量池是方法区的一部分,所以这两个区域的溢出测试可以放到一起进行。前面曾经提到HotSpot从JDK 7开始逐步“去永久代”的计划, 并在JDK 8中完全使用元空间来代替永久代,在此我们就以测试代码来观察一下,使用“永久代”还是“元空间”来实现方法区,对程序有什么实际的影响。

String::intern()是一个本地方法,它的作用是如果字符串常量池中已经包含一个等于此String对象的字符串,则返回代表池中这个字符串的String对象的引用;否则,会将此String对象包含的字符串添加到常量池中,并且返回此String对象的引用。 在JDK 6或更早之前的HotSpot虚拟机中,常量池都是分配在永久代中,我们可以通过-XX:PermSize-XX:MaxPermSize限制永久代的大小,即可间接限制其中常量池的容量,具体实现如代码清单2-7所示,请读者测试时首先以JDK 6来运行代码。

从运行结果中可以看到,运行时常量池溢出时,在OutOfMemoryError异常后面跟随的提示信息是“PermGen space”,说明运行时常量池的确是属于方法区(即JDK 6的HotSpot虚拟机中的永久代)的一部分。

而使用JDK 7或更高版本的JDK来运行这段程序并不会得到相同的结果,无论是在JDK 7中继续使用-XX:MaxPermSize参数或者在JDK 8及以上版本使用-XX:MaxMeta-spaceSize参数把方法区容量同样限制在6MB,也都不会重现JDK 6中的溢出异常,循环将一直进行下去,永不停歇。

出现这种变化,是因为自JDK 7起,原本存放在永久代的字符串常量池被移至Java堆之中,所以在JDK 7及以上版本,限制方法区的容量对该测试用例来说是毫无意义的。这时候使用-Xmx参数限制最大堆到6MB就能够看到以下两种运行结果之一,具体取决于哪里的对象分配时产生了溢出:

关于这个字符串常量池的实现在哪里出现问题,还可以引申出一些更有意思的影响,看如下的代码:

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);
    }
}
  • 这段代码在JDK 6中运行,会得到两个false,而在JDK 7中运行,会得到一个true和一个false。产生差异的原因是,在JDK 6中,intern()方法会把首次遇到的字符串实例复制到永久代的字符串常量池中存储,返回的也是永久代里面这个字符串实例的引用,而由StringBuilder创建的字符串对象实例在Java堆上,所以必然不可能是同一个引用,结果将返回false。

  • JDK 7(以及部分其他虚拟机,例如JRockit)的intern()方法实现就不需要再拷贝字符串的实例到永久代了,既然字符串常量池已经移到Java堆中,那只需要在常量池里记录一下首次出现的实例引用即可,因此intern()返回的引用和由StringBuilder创建的那个字符串实例就是同一个。而对str2比较返回false,这是因为“java”这个字符串在执行String-Builder.toString()之前就已经出现过了,字符串常量池中已经有它的引用,不符合intern()方法要求“首次遇到”的原则,“计算机软件”这个字符串则是首次出现的,因此结果返回true。

我们再来看看方法区的其他部分的内容,方法区的主要职责是用于存放类型的相关信息,如类名、访问修饰符、常量池、字段描述、方法描述等。 对于这部分区域的测试,基本的思路是运行时产生大量的类去填满方法区,直到溢出为止

虽然直接使用Java SE API也可以动态产生类(如反射时的GeneratedConstructorAccessor和动态代理等),但在本次实验中操作起来比较麻烦。 在下面的代码中借助了CGLib直接操作字节码运行时生成了大量的动态类

类似这样的代码在当前的很多主流框架,如Spring、Hibernate对类进行增强时,都会使用到CGLib这类字节码技术,当增强的类越多,就需要越大的方法区以保证动态生成的新类型可以载入内存

另外,很多运行于Java虚拟机上的动态语言(例如Groovy等)通常都会持续创建新类型来支撑语言的动态性

借助CGLib使得方法区出现内存溢出异常:

//-XX:PermSize=10M -XX:MaxPermSize=10M
public class JavaMethodAreaOOM {
    static class OOMObject{

    }
    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();
        }
    }
}

在JDK 7中的运行结果:

Caused by: java.lang.OutOfMemoryError: PermGen space

方法区溢出也是一种常见的内存溢出异常,一个类如果要被垃圾收集器回收,要达成的条件是比较苛刻的。

在经常运行时生成大量动态类的应用场景里,就应该特别关注这些类的回收状况。这类场景除了之前提到的程序使用了CGLib字节码增强和动态语言外, 常见的还有:大量JSP或动态产生JSP文件的应用(JSP第一次运行时需要编译为Java类)、基于OSGi的应用(即使是同一个类文件,被不同的加载器加载也会视为不同的类)等

在JDK 8以后,永久代便完全退出了历史舞台,元空间作为其替代者登场。在默认设置下,前面列举的那些正常的动态创建新类型的测试用例已经很难再迫使虚拟机产生方法区的溢出异常了。

HotSpot还提供了一些参数作为元空间的防御措施,主要包括:

  • -XX:MaxMetaspaceSize:设置元空间最大值,默认是-1,即不限制,或者说只受限于本地内存大小。

  • -XX:MetaspaceSize:指定元空间的初始空间大小,以字节为单位,达到该值就会触发垃圾收集进行类型卸载,同时收集器会对该值进行调整:如果释放了大量的空间,就适当降低该值;如果释放了很少的空间,那么在不超过-XX:MaxMetaspaceSize(如果设置了的话)的情况下,适当提高该值。

  • -XX:MinMetaspaceFreeRatio:作用是在垃圾收集之后控制最小的元空间剩余容量的百分比,可减少因为元空间不足导致的垃圾收集的频率。

  • -XX:Max-MetaspaceFreeRatio,用于控制最大的元空间剩余容量的百分比。


本机直接内存溢出

直接内存(Direct Memory)的容量大小可通过-XX:MaxDirectMemorySize参数来指定,如果不去指定,则默认与Java堆最大值(由-Xmx指定)一致。

//-Xmx20M -XX:MaxDirectMemorySize=10M
public class DirectMemoryOOM {
    private static final int _1MB = 1024 * 1024;

    public static void main(String[] args) throws IllegalAccessException {
        Field declaredField = Unsafe.class.getDeclaredFields()[0];
        declaredField.setAccessible(true);
        Unsafe unsafe = (Unsafe) declaredField.get(null);
        while (true){
            unsafe.allocateMemory(_1MB);
        }
    }
}

输出结果:

Exception in thread "main" java.lang.OutOfMemoryError
     at sun.misc.Unsfe.allocateMemory(Native Method)
     at org.fenixsoft.oom.DMOOM.main(DMOOM.java:20)

参考

The Java® Virtual Machine Specification

《深入理解Java虚拟机 第三版》