第2章—Java内存区域与内存溢出异常
2.1 概述
总结:本章将从概念上介绍 Java 虚拟机内存的各个区域,讲解这些区域的作用、服务对象以及其中可能产生的问题。
2.2 运行时数据区域
Java 虚拟机在执行 Java 程序的过程中会把它所管理的内存划分为若干个不同的数据区域,这些区域都有各自的用途以及创建和销毁时间
2.2.1 程序计数器(线程私有)
由于Java虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,在任意一个确定的时刻,一个处理器都只会执行一条线程中的指令。因此,为了线程切换后能回复到正确的执行位置,每条线程都需要有一个独立的程序计数器。
如果线程正在执行的是一个 Java 方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址; 如果线程正在执行的是 Native 方法,这个计数器值则为空。
可能出现的异常:此内存区域是唯一一个在 Java 虚拟机规范中没有规定任何 OutOfMemoryError 情况的区域
2.2.2 Java 虚拟机栈(线程私有)
Java 虚拟机栈描述的是Java方法执行的内存模型,每个方法在执行的同时都会创建一个栈帧用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。
局部变量表存放了编译期可知的各种数据类型、对象引用和 returnAddress 类型,且它所需的内存空间在编译期间完成分配,在方法运行期间不会改变局部变量表的大小。
可能出现的异常:1.线程请求的栈深度大于虚拟机所允许的深度,StackOverflowError
2.如果虚拟机栈可以动态扩展,但扩展时无法申请到足够的内存,OutOfMemoryError
2.2.3 本地方法栈(线程私有)
本地方法栈与虚拟机栈的作用类似,区别是虚拟机栈为虚拟机执行 Java 方法服务,本地方法栈则为虚拟机执行Native方法服务。Sun Hotspot 直接将本地方法栈和虚拟机栈合二为一。
可能出现的异常:StackOverflowError、OutOfMemoryError
2.2.4 Java 堆(线程共享)
所有对象实例以及数组都要在堆上分配,Java 堆在虚拟机启动时创建,此内存的唯一目的就是存放对象实例,Java 堆可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可。
可能出现的异常:当在堆中没有内存可以完成实例分配,并且堆也无法再扩展时,OutOfMemoryError。
2.2.5 方法区(线程共享)
方法区用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据,这区域的内存回收目标主要是针对常量池的回收和对类型的卸载。
可能出现的异常:当方法区无法满足内存分配需求时,OutOfMemoryError
2.2.6 运行时常量池
运行时常量池是方法区的一部分。Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项就是常量池,用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。
一般来说,除了保存Class文件中描述的符号引用外,还会把翻译出来的直接引用也存储在运行时常量池中。
运行时常量池相对于Class文件常量池的另外一个重要特征是具备动态性,运行期间也可能将新的常量放入池中(如 String 类的 intern() 方法)
可能出现的异常:当常量池无法再申请到内存时会抛出OutOfMemoryError
2.2.7 直接内存
直接内存并不是虚拟机运行时数据区的一部分,也不是虚拟机规范中定义的内存区域。Jdk1.4中新加入了NIO类,引入了一种基于通道与缓冲区的I/O方式,它可以使用 Native 函数库直接分配对外内存,然后通过一个存储在 Java 堆中的DirectByteBuffer对象作为这块内存的引用进行操作,避免了在Java堆和Native堆中来回复制数据,在一些场景中显著提高性能。
直接内存的分配不会受到Java堆大小的限制,但是,既然是内存,肯定会受到本机总内存大小和处理器寻址空间的限制。
服务器管理员在配置虚拟机参数时,会根据实际内存设置 -Xmx 等参数信息,但经常忽略直接内存,使得各个内存区域总和大于物理内存限制,从而导致动态扩展时出现OutOfMemoryError异常
2.3 HotSpot 虚拟机对象探秘
根据上述知识,我们已经了解到虚拟机内存划分为哪几块以及这些块中都分别存放哪些内容,再来了解虚拟机内存中数据的其他细节,例如它们是如何创建、如何布局以及如何访问的,下面将深入探讨 HotSpot 虚拟机在 Java 堆中对象创建、布局和访问的全过程。
2.3.1 对象的创建
在语言层面上,创建对象(例如克隆和反序列化)通常仅仅是一个new 关键字而已,而在虚拟机中,对象(普通对象,不包括数组和Class对象等)的创建又是怎样的一个过程呢?
1.虚拟机遇到一条new指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,必须先执行相应的类加载过程。
2.在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需的内存大小在类加载完成后便可完全确定,为对象分配空间的任务等同于把一块确定大小的内存从Java堆中划分出来(两种方式:1.指针碰撞; 2.空闲列表),选择哪种分配方式由Java堆是否规整决定,而Java堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定,因此,使用Serial、ParNew等待Compact 过程的收集器时系统采用指针碰撞分配算法,而使用CMS 这种基于Mark-Sweep算法的收集器时,通常使用空闲列表。为确保内存分配时的线程安全,通常使用两种解决方案:一种是对分配内存空间的动作进行同步处理——实际上虚拟机采用CAS 配上失败重试的方式保证更新操作的原子性;另一种是把内存分配的动作按照线程划分在不同的空间之中,即每个线程在Java 堆中预先分配一小块内存,称为本地线程分配缓冲(TLAB),哪个线程要分配内存,就在哪个线程的TLAB上分配,只有TLAB用完并分配新的TLAB时,才需要同步锁定。
3.内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值,如果使用TLAB,这一工作可以提前至TLAB分配时进行。这一步操作保证了对象的实例字段在Java 代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。
4.接下来,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例,如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息。这些信息存放在对象的对象头之中。
5.在上面工作都完成之后,从虚拟机的角度来看,一个新对象已经产生,但从Java程序的角度来看,对象的创建才刚刚开始。一般来说,执行new 指令之后会接着执行<init>方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来。
2.3.2 对象的内存布局
在HotSpot 虚拟机中,对象在内存中存储的布局可以分为 3 块区域:对象头、实例数据和对齐填充。
1.HotSpot 虚拟机的对象头包括两部分信息:
第一部分用于存储对象自身的运行时数据(如哈希码、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等),官方称为“Mark Word”。Mark Word被设计成一个非固定的数据结构以便在极小的空间内存储尽量多的信息,它会根据对象的状态复用自己的存储空间
第二部分是类型指针(并不是所有虚拟机都有),即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例(句柄和直接指针)。另外,如果对象是一个Java 数组,那在对象头中还必须有一块用于记录数组长度的数据,因为虚拟机可以通过普通Java 对象的元数据信息确定Java 对象的大小,但是从数组的元数据中却无法确定数组的大小。
2.实例数据部分是对象真正存储的有效信息,也是在程序代码中定义的各种类型的字段内容。无论是从父类继承下来的,还是在子类中定义的,都需要记录下来。这部分的存储顺序会受到虚拟机分配策略参数和字段在Java源码中定义的顺序的影响。HotSpot 虚拟机默认的分配策略是相同宽度的字段总是被分配到一起,满足这个前提条件下,在父类中定义的变量会出现在子类之前。
3.对齐填充部分并不是必然存在的,它仅仅起着占位符的作用。由于HotSpot 虚拟机的自动内存管理系统要求对象起始地址必须是8字节的整数倍,因此,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。
2.3.3 对象的访问定位
建立对象是为了使用对象,Java 程序需要通过栈上的 reference 数据来操作堆上的具体对象。目前主流的访问方式有使用句柄和直接指针两种。
1.如果使用句柄访问的话,那么 Java 堆中将会划分出一块内存来作为句柄池,reference 中存储的就是对象的句柄池地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息。
2.如果使用直接指针访问,那么Java 堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,而 reference 中存储的直接就是对象地址。
这两种对象访问方式各有优势,使用句柄来访问的最大好处就是 reference 中存储的是稳定的句柄地址;使用直接指针访问方式的最大好处就是速度更快,它节省了一次指针定位的时间开销。
Sun HotSpot 使用的是第二种方式进行对象访问。
2.4 实战: OutOfMemoryError 异常
在Java 虚拟机规范的描述中,除了程序计数器外,虚拟机内存的其他几个运行时数据区都有发生OutOfMemoryError(OOM)异常的可能。
本节主要内容有两个:
第一,通过代码验证Java 虚拟机规范中描述的各个运行时数据区存储的内容;
第二,在遇到实际的内存溢出异常时,能根据异常的信息快速判断是哪个区域的内存溢出,知道什么样的代码可能会导致这些区域内存溢出,以及出现这些异常后该如何处理。
2.4.1 Java 堆溢出(产生大量对象)
Java 堆用来存储对象实例,只要不断地创建对象,并且保证 GC Roots 到对象之间有可达路径来避免垃圾回收机制清楚这些对象,那么在对象数量达到最大堆的容量限制后就会产生内存溢出异常。
将堆的最小值 -Xms 参数与最大值 -Xmx参数设置为一样即可避免堆自动扩展。
Java 堆内存的 OOM 异常是实际应用中常见的内存溢出异常情况。当出现 Java 堆内存溢出时,异常信息栈信息 “java.lang.OutOfMemoryError" 会跟着进一步提示 "Java heap space" 。
要解决这个区域的异常,一般的手段是先通过内存映像分析工具对Dump出来的堆转储快照进行分析,重点是确认内存中的对象是否是必要的,也就是分清楚到底出现了内存泄漏还是内存溢出。如果是内存泄漏,可进一步通过工具查看泄漏对象到 GC Roots 的引用链;如果不存在泄漏,就是内存中的对象都必须存活着,那就应当检查虚拟机参数与机器物理内存对比看是否可以调大,从代码上检查是否存在某些对象生命周期过长、持有状态时间过长的情况,尝试减少程序运行期的内存消耗。
2.4.2 虚拟机栈和本地方法栈溢出(递归方法)
由于HotSpot 虚拟机并不区分虚拟机栈和本地方法栈,所以两种情况一并说明。
1.线程请求的栈深度大于虚拟机所允许的深度,StackOverflowError
2.如果虚拟机栈可以动态扩展,但扩展时无法申请到足够的内存,OutOfMemoryError
结论:
在单线程情况下,无论是由于栈帧太大还是虚拟机栈容量太小,当内存无法分配的时候,虚拟机抛出的都是StackOverflowError异常
在多线程情况下,每个线程的栈分配的内存越大,越容易产生内存溢出异常
2.4.3 方法区和运行时常量池溢出(产生大量类)
方法区用于存放Class的相关信息,如类名、访问修饰符、常量池、字段描述、方法描述等。对于这个区域的测试,基本思路是运行大量的类去填满方法区,直到溢出。
方法区溢出也是一种常见的内存溢出异常,当出现 Java 方法区内存溢出时,异常信息栈信息 “java.lang.OutOfMemoryError" 会跟着进一步提示 "PermGen space" ,一个类要被垃圾收集器回收掉,判定条件是比较苛刻的。
2.4.4 本机直接内存溢出
DirectMemory容量可通过 -XX: MaxDirectMemorySize 指定,如果不指定,则默认与 Java 堆最大值一样。
由 DirectMemory 导致的内存溢出的一个很明显的特征是在Heap Dump文件中不会看见明显的异常,如果发现OOM后Dump文件很小,而程序中又直接或间接使用了NIO,那就可以考虑是否是DirectMemory 导致。
2.5 本章总结
通过本章的学习,我们明白了虚拟机中的内存是如何划分的,哪部分区域、什么样的代码和操作可能导致内存溢出异常。虽然Java 有垃圾收集机制,但内存溢出异常离我们仍然并不遥远,本章知识说明了各个区域出现内存溢出异常的原因。