根据《Java虚拟机规范SE7》的规定,Java虚拟机包括:程序计数器、虚拟机栈、本地方法栈、方法区、堆共5个运行时数据区。如下图:
程序计数器是一块较小的内存空间,可以看做是当前程序所执行字节码的行号指示器。
特点:
虚拟机栈描述的的是Java方法执行的内存模型,每个方法在执行的同时都会创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、返回地址等信息。每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。
特点:
局部变量表是一组变量值存储空间,用于存放编译期可知的各种基本数据类型、对象引用、返回地址。局部变量表内部的最小单元为变量槽(Slot),每个slot都应该能存放一个 boolean、 byte、char、short、Reference 等类型的数据,允许 Slot 的长度可以随着处理器、操作系统或虚拟机的实现不同而发生变化。对于64位的数据类型(long,double),虚拟机会为其分配2个连续的Slot空间(线程私有数据,无论读写2个连续的slot是否为原子操作,都不存在线程安全问题)。
如果执行的是实例方法,则 slot 0 存储的是方法所属对象的引用,在方法中可以通过关键字 this 来访问。局部变量表中的 Slot 是可以重用的,如果一个局部变量过了其作用域,那么在其作用域之后申明的新的局部变量就很有可能会复用过期局部变量的槽位,从而达到节省资源的目的。
操作数栈是一个先入后出栈,用来保存在方法运行过程中,根据字节码指令,向栈中压入或提取数据,及入栈(push)、出栈(pop)。每一个操作数栈都会拥有一个明确的栈深度用于存储数值,其所需的最大深度在编译期就定义好了,保存在方法的code属性中,为max_stack的值。
在概念模型中,两个栈帧作为不同方法的虚拟机栈的元素,是完全相互独立的。但在大多数虚拟机的实现里都会做一些优化处理,令两个栈帧出现一部分重叠。让下面栈帧的部分操作数栈与上面栈帧的部分局部变量表重叠在一起,这样在进行方法调用时就可以共用一部分数据,无须进行额外的参数复制传递。
每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用。包含这个引用的目的就是为了支持当前方法的代码能够实现动态链接(Dynamic Linking),比如:invokedynamic指令。
在Java源文件被编译成字节码文件时,所有方法的引用作为符号引用(Symbolic Refernce)保存在class文件常量池中,动态链接的作用就是为了将这些方法的符号引用转化为方法的直接引用。
在JVM中,将符号引用转换为调用方法的直接引用与方法的绑定机制相关:
存放该方法调用者的PC寄存器的值。 一般来说,方法正常退出时,调用者的 PC 计数器的值可以作为返回地址,栈帧中很可能会保存这个计数器值。而方法异常退出时,返回地址是通过异常处理器确定的,栈帧中一般不会保存这部分信息。
本质上,方法的退出就是当前栈帧出栈的过程。此时,需要恢复上层方法的局部变量表、操作数栈、将返回值压入调用者栈帧的操作数栈、设置PC寄存器值等,让调用者方法继续执行下去。
本地方法栈与虚拟机发挥的所有非常相似,区别为虚拟机栈为执行Java方法服务,而本地方法栈为为虚拟机使用到的Native方法服务。HotSpot虚拟机在实现的时候就直接将虚拟栈和和本地方法栈合二为一。
特点:
Java堆是Java虚拟机用来存放对象实例的空间。堆在逻辑上分为新生代(Young Generation)、老年代(Old Generation)、永久代(Perm Generation)。其中新生代细分为Eden空间、From Survivor空间、To Survivor空间。JDK 1.8 后方法区(HotSpot的永久代)被彻底移除,取而代之是元空间(Meta Space),元空间使用的是直接内存。如下图:
大部分情况,对象都会首先在Eden区域分配,在一次新生代垃圾回收后,如果对象还存活,则会进入s0或者s1,并且对象的年龄还会加1(Eden区->Survivor区后对象的初始年龄变为1),当它的年龄增加到一定程度(默认为15岁),就会被晋升到老年代中。对象晋升到老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 来设置。
特点:
方法区用来存储已经被虚拟机加载的类信息、常量、静态变量、及时编译器编译后的代码等数据。需要说明的是,HotSpot虚拟机用永久代来实现方法区。
特点:
直接内存并不是虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域。在JDK1.4中加入了NIO类,引入了一种基于通道(Channel)与缓冲区(Buffer)的I/O方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆中的DirectByteBuffer对象作为这块内存的引用进行操作。
特点:
当虚拟机遇到一条new指令时,首先会检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并检查这个符号引用所代表的类是否被加载、解析和初始化过。如果没有,必须先执行相应类的加载过程,然后为对象分配内存,分配算法主要有两种:
选择哪种分配方式由Java堆是否规整决定,而Java堆是否规整又由所采用的垃圾回收器是否带有压缩整理功能(Compact)决定。一般在使用带有 Compact 过程的垃圾回收器时,系统采用指针碰撞算法;而使用CMS这种基于 Mark-Sweep 算法的垃圾回收器时,通常使用空闲列表。
划分空间时需要考虑线程安全问题,有可能正在给A对象分配内存,指针还没来得及修改,对象B同时使用了原来的指针来分配内存,解决这种问题有2种方案:
对象内存分配完后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),如果使用了TLAB,这一工作过程也可以提前至TLAB分配时进行。这一操作保证了对象实例的属性在Java代码中可以不赋值就直接使用,程序能访问到这些属性的数据类型所对应的零值。
上面的工作完成后,从虚拟机角度来看,一个新的对象已经产生,但是从Java程序角度来看,对象的创建才刚刚开始,此时
Java虚拟机规范定义了对象类型的数据在内存中的存储格式,一个对象由:对象头、实例数据、对齐填充3部分组成,如下图:
对象头的数据包含3个部分:
其中 Mark world 是对象头中非常关键的一部分,其在JVM中结构如下:
实例数据重要存储对象的字段数据信信息,包括父类的字段信息。JVM为了内存对齐会进行字段重排。JVM的选项 -XX:FieldsAllocationStyle 有3个可选值:
无论何种排序都需要遵循以下规则:
需要说明的是:
JVM堆中所有对象分配的内存字节数必须是8N,如果对象头和实例数据的总大小不满足要求,则需要通过对齐数据来填满。另外填充数据可能在实例数据末尾,也可能穿插在实例数据个属性之间。
在JVM 1.8版本后,虚拟机默认开启逃逸分析和标量替换特性,如果对象不会被当前线程以外的线程访问到,则优化为在线程栈上分配,从而减轻 GC 压力。
可以使用 -XX:+/-DoEscapeAnalysis 打开或关闭逃逸分析,使用 -XX:+/-EliminateAllocations来打开或关闭标量替换。
Java程序需要通过栈上的 reference 数据来操作堆上的具体对象。虚拟机规范只规定了 reference 类型是一个指向对象的引用,并没有定义引用如何去定位、访问堆中的对象的具体位置,各种虚拟机实现有所不同,目前主流的访问方式有2种,分别是句柄访问和直接指针访问
句柄访问方式会在 Java堆中划分一块内存来作为句柄池,reference中存的就是对象的句柄地址,而句柄中则包含了对象的实例数据和类型数据各自的具体地址信息,如下图:
直接指针访问,就需要在Java堆对象的布局中考虑如何放置访问类型数据的相关信息,而reference中存储的直接就是对象的地址,如下图:
总结:
局部变量表的访问是通过方法栈的基址 + 偏移量 这种方式实现的。每个变量的偏移量在编译阶段就确定了,即偏移量在具体生成的指令中是写死的,不需要变量名称。
对象的成员变量是通过对象的基址 + 偏移量这种方式实现。虽然这个偏移量也是在编译阶段就确定了,但是Java类是动态加载的,也就是说其它类的方法中调用这个成员变量的时候是无法确定偏移量的,所以生成的指令里只能用成员变量名代替,真正执行时根据加载类的信息,将成员变量名称替换成偏移量在访问,即解析、链接的过程。