JVM(Java虚拟机)详解(JDK7)
1、Java内存区域
运行时数据区域:
- Java 虚拟机在执行Java程序时,定义了若干种程序运行期间会使用到的运行时数据区,其中有一些会随着虚拟机启动而创建,随着虚拟机退出而销毁。另外一些则是与线程一一对应的,这些与线程对应的数据区域会随着线程开始和结束而创建和销毁。
程序计数器(Program Counter Register):
- 是一块内存小,线程私有,且不会发生OOM(OutOfMemoryError)的区域(因为它只是当前线程所执行的字节码的行号指示器)。
- 在Java虚拟机概念模型中(仅仅指概念模型,各类不同的虚拟机可能有更高效的处理方式),字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行指令的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖计数器来完成。
- Java虚拟机可以支持多线程同时执行(可参考《Java 语言规范》第 17 章),是通过轮转时间片的方式让处理器来执行线程的。在任意时刻,一个处理器(对于多核处理器来说是指一个内核)只会处理一个线程中的指令,为了保证线程切换能够回到正确的执行指令,Java虚拟机针对每个线程分配了一个独立的程序计数器,各线程之间的计数器互不干扰,独立存储。
- 如果线程正在执行的方法不是 native的(即Java方法),那程序计数器就保存 Java 虚拟机正在执行的字节码指令的地址,如果该方法是 native 的,那程序计数器的值是 undefined。
- 程序计数器的容量至少应当能保存一个 returnAddress 类型的数据或者一个与平台相关的本地指针的值。
Java虚拟机栈(Java Virtual Machine Stack):
- Java虚拟机栈是线程私有的,它的生命周期与线程一致。
- 虚拟机栈描述的是 Java 方法执行的内存模型:每个方法在执行的同时都会创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法从调用直至执行完成的过程,就对应着一个栈帧从虚拟机栈中入栈到出栈的过程。
- 局部变量表存放了编译期可知的各种基本类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference类型,它不等同于对象本身)和 returnAddress 类型(指向了一条字节码指令的地址)。
- 需要注意的是64位的long以及double类型的数据会占用两个局部变量空间(Slot),其余的数据类型只占一个。局部变量表所需的内存空间在编译期间就已经完成了分配,当进入一个方法时,这个方法需要在帧中分配多大的局部变量空间是已经确认了的,在方法运行期间不会去改变局部变量表的大小。
- Java虚拟机栈有可能发生以下两种异常状况:如果线程请求分配的栈容量(栈深度)超过 Java 虚拟机栈允许的最大容量(深度)时,Java 虚拟机将会抛出一 个StackOverflowError异常;如果Java 虚拟机栈可以动态扩展(目前大部分虚拟机都支持动态扩展),并且扩展的动作已经尝试过,但是目前无法申请到足够的内存去完成扩展,或者在建立新的线程时没有足够的内存去创建对应的虚拟机栈,那 Java 虚拟机将会抛出一个 OutOfMemoryError异常。
本地方法栈(Native Method Stack):
- 本地方法栈与Java虚拟机栈是非常类似的,他们的主要区别是Java虚拟机栈是服务于Java方法(字节码)的,而本地方法栈则是服务于Native方法的。
- 与Java虚拟机栈一样,本地方法栈同样会出现StackOverflowError异常和OutOfMemoryError异常。
Java堆(Java Heap):
- 在 Java 虚拟机中,堆(Heap)是可供各条线程共享的运行时内存区域,也是供所有类实例和数组对象分配内存的区域,一般而言堆(Heap)占用的内存空间也是最大的。
- Java 堆在虚拟机启动的时候就被创建,它存储了被自动内存管理系统(Automatic Storage Management System,也即是常说的“Garbage Collector(垃圾收集器)”)所管理的各种对象,这些受管理的对象无需,也无法显式地被销毁。所以一般我们也称Java堆为GC堆(Garbage Collected Heap)。
- 如果实际所需的堆超过了自动内存管理系统能提供的最大容量(或者说堆中没有内存来完成实例分配,且无法扩展),那 Java 虚拟机将会抛出一个 OutOfMemoryError异常。
方法区(Method Area):
- 在 Java 虚拟机中,方法区(Method Area)是可供各条线程共享的运行时内存区域。
- 它存储了每一个类的结构信息,例如运行时常量池(Runtime Constant Pool)、字段和方法数据、构造函数和普通方法的字节码内容、还包括一些在类、实例、接口初始化时用到的特殊方法(即存储着已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据)。
- 如果方法区的内存空间不能满足内存分配请求,那 Java 虚拟机将抛出一个 OutOfMemoryError异常。
运行时常量池(Runtime Constant Pool):
- 运行时常量池是方法区(Method Area)的一部分。
- 它是每一个类或接口的常量池(Constant_Pool)的运行时表示形式,它包括了若干种不同的常量:从编译期可知的数值字面量到必须运行期解析后才能获得的方法或字段引用。
- 一般情况下,除了保存Class文件中描述的符号引用外,还会把翻译过来的直接引用也保存在运行时常量池中。
- 运行时常量池相对于Class文件常量池的另一个特征是具备动态性,即运行期间也可将新的常量放入池中,比如String.intern()方法。
- 当创建类或接口的时候,如果构造运行时常量池所需要的内存空间超过了方法区所能提供的最大值,那 Java 虚拟机将会抛出一个 OutOfMemoryError 异常。
直接内存(Direct Memory):
- 直接内存并不属于Java虚拟机运行时内存区域,也不是Java虚拟机规范中定义的内存区域。
- 在这里提出来的主要原因是因为Java在1.4版本引入了NIO类,是一种基于通道(Channel)和缓存区(Buffer)的IO操作,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆中的DirectByteBuffer对象作为这块内存的引用进行操作。
- 在某些情况下,通过减少Java堆和Native堆来回复制数据的操作,从而提高一些性能。
- 因为这块内存也是实际存在的,所以受硬件总内存影响,如果需要对虚拟机内存进行扩容操作,需要考虑虚拟机内存加直接内存超过物理内存限制所引起的 OutOfMemoryError异常。
2、HotSpot虚拟机对象探究
对象的创建(以普通Java对象new指令为例):
- 当虚拟机遇到一条new指令时,首先会去常量池中检查是否存在这个对象的引用,并且检查该引用所代表的类是否已经被加载,解析和初始化过,如果没有,则先执行类加载。
- 当类加载检查通过之后,虚拟机将会为新生对象分配内存(对象所需的内存大小在类加载完成之后就已经确认了,为对象分配空间就是在Java堆上划分出一块内存来。一般有两种分配方式,根据内存规整程度分为:指针碰撞(规整)以及空闲列表(不规整))。
- 针对并发情况下对象内存分配冲突解决方案有:一是同步处理,二是使用本地线程分配缓冲(Thread Local Allocation Buffer,TLAB)(虚拟机是否使用本地线程分配缓冲可通过-XX:+/-UseTLAB参数来设定)。
- 内存分配完毕之后,虚拟机会将对应的内存空间初始化为零值(不包括对象头)。接着会对这个对象进行必要的设置,例如此对象是哪个类的实例,如何查找类的元数据信息,对象的哈希码,对象的GC分代年龄等等,这些信息都放在了对象头中。
- 执行完new指令之后还需要执行<init>方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算是完全创建好了。
对象的内存布局:
- 在HotSpot虚拟机中,对象在内存中存储的布局可以分为三块区域:对象头(Header),实例数据(Instance Data)以及对齐填充(Padding)。
- 对象头(Header)主要包含两部分信息:第一部分用于存储对象自身的运行时数据(如哈希码(HashCode),GC分代年龄,锁状态标志,线程持有的锁,偏向线程ID,偏向时间戳等,这部分数据长度在32位或者64位的虚拟机中分别为32bit以及64bit,官方称之为“Mark Word”);第二部分是类型指针,即对象指向它的类元数据的指针,虚拟机可以通过这个指针来确定该对象属于哪个类的实例(并不是所有的虚拟机实现都需要在对象数据上保留类型指针,即查找对象的元数据信息并不一定要经过对象本身),如果对象是一个Java数组,那么对象头还需要记录该数组长度,因为普通的Java对象可以通过元数据信息确认对象大小,而数组却不行。
- 实例数据(Instance Data)存储着对象真正有效的信息,即各种类型的字段,无论是继承父类还是子类自定义。
- 对齐填充(Padding)不是必然存在的,也没有特殊的含义,仅仅起到占位符的作用,保证对象大小是某个字节的整数倍(HotSpot VM 的自动内存管理系统要求对象起始地址必须为8字节的整数倍)。
对象的访问定位:
- 建立对象的目的是为了使用该对象,Java程序是通过栈上的reference指针来操作堆上的具体对象的。
- 目前主流的访问方式有句柄和直接指针两种。
句柄访问定位:
- 使用句柄的话,Java堆将会划分出一块内存来作为句柄池,reference中存放的也就是对象的句柄地址(句柄中包含了对象实例数据和类型数据各自的地址)。
- 句柄的优势在于其稳定性更高,如果对象频繁的发生移动(GC操作时移动对象),那么只会改变句柄中的实例数据的指针,reference本身不需要改变。
- 如下图所示:
直接指针访问定位:
- 使用直接指针,那么reference将直接指向对象对应的地址(Java堆布局时需要考虑如何存放访问类型数据的相关信息)。
- 直接指针的优势在于其速度更快,因为减少了一次指针定位的操作。如果对象访问频繁,那么使用直接访问将会提高相当不错的效率。
- 如下图所示: