1. 概述
对于Java程序员,在虚拟机自动内存管理机制的帮助下,不再需要为每个new操作去写配对的delete/free代码,不容易出现内存泄漏和内存溢出问题。
2. 运行时数据区域
Java虚拟机在执行Java程序的过程中会把它管理的内存划分为若干个不同的数据区域。这些区域都有各自的用途,以及创建和销毁的时间,有的区域随着虚拟机进程的启动而存在,有些区域则依赖用户线程的启动和结束而建立和销毁。根据《Java虚拟机规范(Java SE 7版)》的规定,Java虚拟机所管理的内存将会包括以下几个运行时数据区域:程序计数器,Java虚拟机栈,本地方法栈,Java堆,方法区。
下图为各个区域以及进一步细化图:
2.1 程序计数器
程序计数器(Program Counter Register)是一块较小的内存空间,可以看做当前线程所执行的字节码的行号指示器。字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。
由于Java虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器都只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,每个线程之间计数器互不影响,独立存储。
备注:
(a)如果线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是Native方法,这个计数器的值则为空。
(b)此内存区域是唯一在Java虚拟机规范中没有规定任何OOM情况的区域。
2.2 Java虚拟机栈
Java Virtual Machine Stacks,也是线程私有的,它的生命周期与线程相同。
虚拟机栈描述的是Java方法执行的内存模型(非native方法)。
每个方法在执行的同时都会创建一个栈帧用于存储局部变量表,操作数栈,动态链接,方法出口等信息。
每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程,当方法被调用则入栈,一旦完成调用则出栈。所有的栈帧都出栈后,线程就结束了。
局部变量表存放了编译器可知的各种基本数据类型、对象引用、returnAddress类型。局部变量表所需的内存空间在编译器完成分配。当进入一个方法时,这个方法需要在帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。
基本类型:boolean, byte, char, short, int, float, long, double,其中long和double占用2个局部变量空间slot其余的占用1个;
对象引用:reference类型,它不等同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置;
returnAddress类型:指向了一条字节码指令的地址;
在Java虚拟机规范中,对这个区域规定了两种异常:线程请求的栈深度大于虚拟机所允许的深度,抛出StackOverflowError异常;如果虚拟机栈可以动态扩展(目前大部分的Java虚拟机都可动态扩展,只不过Java虚拟机规范中也允许固定长度的虚拟机栈),如果扩展时无法申请到足够的内存,抛出OutOfMemoryError异常。
2.3 本地方法栈
Native Method Stack与虚拟机栈的作用非常相似,区别是:虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则为虚拟机使用到的Native方法。
备注:HotSpot直接把本地方法栈和虚拟机栈合二为一。本地方法栈区域也会抛出StackOverflowError和OutOfMemoryError异常。
2.4 Java堆
Java Heap是Java虚拟机所管理的内存中最大的一块。Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此区域的唯一目的就是存放对象实例(Java虚拟机规范中的描述时:所有的对象实例以及数组都要在堆上分配)。
Java堆是GC的主要区域,因此很多时候也被称为GC堆。
从内存分配的角度来看,线程共享的Java堆中可能划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer, TLAB)
从内存回收的角度来看,由于现在收集器基本都采用分代收集算法,所以Java堆中还可以细分为:新生代和老年代,在细致一点的有Eden空间,From Survivor空间,To Survivor空间等。
备注:有OOM异常
2.5 方法区
Method Area是各个线程共享内存区域,用于存储已被虚拟机加载的类信息,常量,静态变量,即时编译器编译后的代码等数据。这个区域的内存回收目标主要是针对常量池的回收和对类型的卸载。
在HtoSpot虚拟机中该区域叫永久代,即方法区是虚拟机规范,而永久代是HotSpot实现的方法区。
绝大部分Java 程序员应该都见过 “java.lang.OutOfMemoryError: PermGen space “这个异常。这里的 “PermGen space”其实指的就是方法区(永久代)。不过方法区和“PermGen space”又有着本质的区别。前者是 JVM 的规范,而后者则是JVM 规范的一种实现,并且只有 HotSpot 才有 “PermGen space”,而对于其他类型的虚拟机,如JRockit(Oracle)、J9(IBM)并没有“PermGen space”。
注:从JDK7开始永久代的移除工作,存储在永久代的一部分数据已经转移到了Java Heap或者是Native Heap。但永久代仍然存在于JDK7,并没有完全的移除:符号引用(Symbols)转移到了native heap;字面量(interned strings)转移到了java heap;类的静态变量(class statics)转移到了java heap。
备注:有OOM异常
2.6 补充
2.6.1 运行时常量池和Class文件常量池
Runtime Constant Pool是方法区的一部分。Class文件中除了有类的版本,字段,方法,接口等描述信息,还有一项信息是常量池,用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。
运行时常量池相对于Class文件常量池的另外一个重要特征是具备动态性。
备注:有OOM异常
2.6.2 直接内存
Direct Memory,并不是虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域。
在jdk1.4加入的NIO类,引入了一种基于通道(Chanel)与缓冲区(Buffer)的IO方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆中的DirectByteBuffer对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在Java堆和Native堆中来回复制数据。
备注:本机直接内存的分配不会受到Java堆大小的限制,受到本机总内存和处理器寻址空间的限制,有OOM异常。
3.1 内存划分
根据JVM规范,内存可分为:虚拟机栈,本地方法栈,堆,方法区,程序计数器五个部分。
但是各种虚拟机HotSpot,JRockit实现却与JVM不尽相同。
HotSpot主要有:虚拟机栈,堆,程序计数器,Metaspace,直接内存四个部分。
如下为JDK8的内存划分:
其中,元空间(Metaspace)的本质和永久代类似,都是对JVM规范中方法区的实现。
不过元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。
因此,默认情况下,元空间的大小仅受本地内存限制,但可以通过以下参数来指定元空间的大小:
-XX:MetaspaceSize,初始空间大小,达到该值就会触发垃圾收集进行类型卸载,同时GC会对该值进行调整:如果释放了大量的空间,就适当降低该值;如果释放了很少的空间,那么在不超过MaxMetaspaceSize时,适当提高该值。
-XX:MaxMetaspaceSize,最大空间,默认是没有限制的。
3.2 对象的创建、内存布局、访问定位
基于虚拟机HotSpot深入探讨Java堆中对象分配、布局和访问的全过程。
3.2.1 对象的创建
(1)虚拟机遇到new指令后,首先检查new指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否被加载,解析和初始化过。如果没有,那必须先执行类的加载过程;
(2)类加载检查通过之后,虚拟机将为新生对象分配内存(对象所需内存的大小在类加载完成后可完全确定);
(3)内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),这一步操作保证了对象的实例字段在Java代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值;
(4)对对象进行必要的设置,例如这个对象是哪个类的实例,如果才能找到类的元数据信息,对象的哈希码,对象的GC分代年龄等信息(这些对象存放在对象的对象头Object Header中)
(5)执行init方法,把对象按照程序员的意愿进行初始化。
有指针碰撞和空闲列表两种分配方式:选择哪种分配方式由Java堆是否规整决定,而Java堆是否规整又由所采用的GC是否带有压缩整理功能决定;因此,在使用Serial、ParNew等带Compact过程的收集器时,系统采用的分配算法时指针碰撞,而使用CMS这种基于Mark-Sweep算法的收集器时,通常采用空闲列表。
此外还有对象创建的并发问题:
(1)堆分配内存空间的动作进行同步处理—采用CAS配上失败重试的方式保证更新操作的原子性;
(2)把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在Java堆中预先分配一小块内存,称为本地线程分配缓存(Thread Local Allocation Buffer,TLAB)
注:指针碰撞:内存是规整的,空闲列表:内存不是规整的
3.2.2 对象的内存布局
在HotSpot虚拟机中,对象在内存中存储的布局可以分为3块区域:对象头、实例数据、对齐填充。
如图:
- 对象头(存储在堆)包括两部分:
(1)存储对象自身的运行时数据,如哈希码,GC分代年龄,锁状态标识,线程持有的锁,偏向线程ID,偏向时间戳等,官方称为Mark Word。Mark Word被设计成一个非固定的数据结构以便在极小的空间内存储尽量多的信息,它会根据对象的状态复用自己的存储空间。
存储内容 | 标志位 | 状态 |
---|---|---|
对象哈希码,对象分代年龄 | 01 | 未锁定 |
指向锁记录的指针 | 00 | 轻量级锁定 |
指向重量级锁的指针 | 10 | 膨胀(重量级锁定) |
空,不需要记录信息 | 11 | GC标记 |
偏向线程ID,偏向时间戳,对象分代年龄 | 01 | 可偏向 |
(2)类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
实例数据部分是对象真正存储的有效信息,也是在程序代码中所定义的各种类型的字段内容。
对齐填充并不是必然存在的,也没有特别的含义。HotSpot自动内存管理系统要求对象起始地址必须是8字节的倍数,也就是说对象的大小必须是8字节的整数倍。而对象头部分正好是8字节的倍数,因此,当对象实例数据不符没有对齐时,就需要通过对齐填充来补全。
3.2.3 对象的访问定位
Java程序需要通过栈上的reference数据来操作堆上的具体对象。由于reference类型在Java虚拟机规范中规定了一个指向对象的引用,并没有定义这个引用应该通过何种方式去定位,访问堆中的对象的具体位置,所以对象访问方式也是取决于虚拟机实现而言的。目前主流的访问方式有使用句柄和直接指针两种。
(1)句柄访问
Java堆中将会划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据和类型数据各自的具体地址信息。
(2)直接指针,reference中存储的是对象地址,而对象实例的对象头中有类型指针,指向类型数据。
两种对象访问各有优势,使用句柄来访问的最大好处是reference中存储的是稳定的句柄地址,对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而reference本身不需要修改。
直接指针最大好处就是速度更快,节省了一次指针定位的时间开销。
HotSpot使用了直接指针方式进行对象访问。
备注:reference应该指向堆中Java对象的内存地址,而对象的内存布局里面包括:对象头和对象数据,对象头里面有对象类型指针。
OOM:OutOfMemoryError异常
在Java虚拟机规范中,除了程序计数器外,虚拟机内存的其他几个运行时区域都有发生OOM异常的可能。
下面的代码都是基于JDK1.8,因此对堆,虚拟机栈,Metaspace和直接内存做验证和分析。
4.1 堆OOM
import java.util.ArrayList;
import java.util.List;
/**
* VM -Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError
*/
public class HeapOOM {
public static void main(String[] args) {
List<HeapOOM> list = new ArrayList<HeapOOM>();
while(true)
list.add(new HeapOOM());
}
}
-Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError
Java堆的大小为20MB,不可扩展;通过参数-XX:+HeapDumpOnOutOfMemoryError可以让虚拟机在出现内存溢出异常时Dump出当前的内存堆转储快照。
运行结果:
4.2 虚拟机栈OOM
HotSpot虚拟机中并不区分虚拟机栈和本地方法栈,统一是虚拟机栈。
栈容量只由-Xss参数设定。
在Java虚拟机规范中描述了2种异常:
(1)如果线程请求的栈深度大于虚拟机所允许的最大深度,将抛出StackOverflowError异常;
(2)如果虚拟机在扩展栈时无法申请到足够的内存空间,则抛出OOM。
/**
* VM -Xss108k
*/
public class JavaVMStackSOF {
private int stackLength = 1;
public void stackLeak() {
stackLength++;
stackLeak();
}
public static void main(String[] args) {
JavaVMStackSOF oom = new JavaVMStackSOF();
try {
oom.stackLeak();
} catch (Throwable t) {
System.out.println(oom.stackLength);
throw t;
}
}
}
-Xss指定栈内存容量。
运行结果:
实验结果表明:在单个线程下,无论是由于栈帧太大还是虚拟机栈容量太小,当内存无法分配的时候,虚拟机抛出的都是StackOverflowError异常。
4.3 元空间OOM
4.4 直接内存OOM
深入理解Java虚拟机:JVM高级特性与最佳实践(第二版)