运行时数据区域
程序计数器(Program Counter Register)
程序计数器是一块很小的内存空间,可以看做是当前线程所执行的字节码的行号指示器,存储着当前执行的字节码指令,通过改变这里的值来进行指令的调用。
在多线程中每个线程执行的命令都不相同且互不影响,所以程序计数器是线程私有的,这类内存区域“线程私有”的内存。
如果线程正在执行的是一个Java方法,这个计数器存储的是正在执行的虚拟机字节码指令的地址;如果执行的本地方法(Native方法),则为空(Unfefined)。
Java虚拟机栈(Java Virtual Machine Stacks)
Java虚拟机栈是描述Java方法执行的内存模型,所以跟程序计数器一样也是线程私有的。每个方法在执行的同时都会创建一个栈帧(Stack Frame)用于存储局部变量、操作数栈、动态链接、方法出口等信息。每一个方法的调用直至执行完成的过程,就对应着一个栈帧从入栈到出栈的过程。
如果方法在执行时需要调用另一个方法,则将另一个方法压入栈顶,执行栈顶方法,执行完毕之后将返回值返回到调用者那里。
栈帧(Stack Frame)
每一个栈帧都包括了局部变量表、操作数栈、动态链接、方法返回地址和一些其它信息。在将Java代码编译成Class文件的时候就已经确定了局部变量表的大小和操作数栈的深度,并且写入到了方法表中Code属性中。
对于执行引擎来说,在活动线程中,只有位于栈顶的栈帧才是有效的,称为当前栈帧(Current Stack Frame),与这个栈帧相关联的方法称为当前方法(Current Method)。
局部变量表(Local Variable Table)
局部变量表是一组变量值存储空间,用于存放方法参数和方法内部定义的变量。在Java程序编译成Class文件的时候,会将局部变量表需要分配的大小写入Class文件中方法表里的Code属性的max_locals数据项中。
局部变量表的容量以变量槽(Variable Slot)为最小单位,每个Slot都可以存放一个boolean、byte、char、int、float、reference或returnAddress类型的数据,其中reference类型表示对一个对象实例的引用。
虚拟机通过索引定位的方式使用局部变量表,索引值的范围是从0开始到局部变量表最大容量,如果执行的是实例方法,那局部变量表中第0位Slot默认是用于存储方法所属对象实例的引用。
为了尽可能的节省栈帧空间,局部变量表中的Slot是可以重用的。由于方法体中的变量并不一定都会覆盖整个方法体,有的变量会在方法中途失去作用,这个时候该变量所占用的Slot就会被新的变量所占据。就跟坐火车一样,同一个位置在不同路段总会有人上上下下。
操作数栈(Operand Stack)
操作数栈也成为操作栈,它是一个后入先出(Last In First Out)栈,如名字所说,就是用来进行一些指令操作的。通局部变量一样,操作数栈的最大深度也是在Java程序编译成Class文件的时候就已经确定了的,其最大深度会被写入Class文件中的方法表里的Code属性的max_stacks数据项中。
当一个方法刚刚开始执行的时候,这个方法的操作数栈是空的,在方法的执行过程中会有各种字节码指令往操作数栈中写入和提取内容,也就是出栈/入栈操作。
动态链接(Dynamic Linking)
每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态链接。在上一篇Java Class文件结构中说到Class文件中的常量池中存有大量的符号引用,字节码中的方法调用指令就一常量池中指向方法的符号引用作为参数。这些符号引用一部分会在类加载阶段或第一次使用的时候就转化为直接引用,这种转化成为静态解析。另外一部分将在每一次运行期间转化为直接引用,这部分成为动态链接。关于这两部分会在以后详解。
方法返回地址
当一个方法开始执行后,只有两种方式可以退出这个方法。第一种方式是执行引擎遇到任意一个方法返回的字节码指令。另一种方式是在方法执行过程中出现异常。
无论是何种方式退出,在方法退出之后都需要返回到方法被调用的位置,程序才能继续执行。方法退出就是将当前栈帧出栈,所以需要恢复上层方法的局部变量表和操作数栈,把返回值(如果有)压入调用者栈帧的操作数栈中,调整PC计数器的指令值指向方法调用指令后面的一条指令。
本地方法栈(Native Method Stack)
与Java虚拟机栈作用相似,区别为Java虚拟机栈执行Java方法,本地方法栈执行本地方法(Native Method)。
java堆(Java Heap)
对大多数应用老说,Java堆是Java虚拟机所管理的内存中最大的一块。Java堆是被所有线程共享的一块区域,在虚拟机启动的时候创建,此区域唯一的目的就是存放对象实例,几乎所有对象实例都在这里分配内存。
Java堆是垃圾收集器管理的主要区域,因此很多时候也被称作“GC堆”(Garbage Collected Heap)。从内存回收的角度看,由于现在收集器基本上都采用分代收集算法,所以Java堆还可以分为:新生代和老年代;再细分一下,新生代还可以分为:Eden空间、From Survivor空间和To Survivor空间。
方法区(Method Area)
方法区与Java堆一样,也是线程共享区域,用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
对于HotSpot虚拟机来说,方法区还被称为“永久代(Permanent Generation)”。
方法区中有一块区域叫做运行时常量池(Runtime Constant Pool),上一篇文章Java Class文件结构中讲到的Class文件中的常量池就是被加载到这里的。
字节码指令表
- invokevirtual指令用于调用对象的实例方法,根据对象的实际类型进行分派。
- invokeinterface指令用于调用接口方法,它会在运行时搜索一个实现了这个接口方法的对象,找出适合的方法进行调用。
- invokespecial指令用于调用一些需要特殊处理的实例方法,包括实例初始化方法、私有方法和父类方法。
- invokestatic用于调用静态方法。
- invokedynamic指令用于在运行时动态解析出调用点限定符所引用的方法,并执行该方法,前面四条调用指令的分派逻辑都固化在Java虚拟机内部,而invokedynamic指令的分派逻辑由用户所设定的引导方法决定的。
更多的指令请Google搜索或者阅读书籍,这些指令够阅读下面的例子了。
放上上次的Java代码和它的Class文件,应该能够阅读了。
package com.overridere.six;
public class Test {
public static void main(String[] args) {
TestClass tc = new TestClass();
System.out.println(tc.print());
}
}
javap命令解析的Class文件:
public class com.overridere.six.TestClass
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Class #2 // com/overridere/six/TestClass
#2 = Utf8 com/overridere/six/TestClass
#3 = Class #4 // java/lang/Object
#4 = Utf8 java/lang/Object
#5 = Utf8 m
#6 = Utf8 I
#7 = Utf8 <init>
#8 = Utf8 ()V
#9 = Utf8 Code
#10 = Methodref #3.#11 // java/lang/Object."<init>":()V
#11 = NameAndType #7:#8 // "<init>":()V
#12 = Utf8 LineNumberTable
#13 = Utf8 LocalVariableTable
#14 = Utf8 this
#15 = Utf8 Lcom/overridere/six/TestClass;
#16 = Utf8 print
#17 = Utf8 ()I
#18 = Fieldref #1.#19 // com/overridere/six/TestClass.m:I
#19 = NameAndType #5:#6 // m:I
#20 = Utf8 SourceFile
#21 = Utf8 TestClass.java
{
public com.overridere.six.TestClass();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #10 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 3: 0
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this Lcom/overridere/six/TestClass;
public int print();
descriptor: ()I
flags: ACC_PUBLIC
Code:
stack=2, locals=1, args_size=1
0: aload_0
1: getfield #18 // Field m:I
4: iconst_1
5: iadd
6: ireturn
LineNumberTable:
line 8: 0
LocalVariableTable:
Start Length Slot Name Signature
0 7 0 this Lcom/overridere/six/TestClass;
}
HotSpot虚拟机对象
对象的创建
堆内存的分配方式:
指针碰撞
假设Java堆中内存是绝对规整的,所有用过的内存都放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲那边挪动一段对对象大小相等的距离,这种分配方式称为“指针碰撞(Bump the Pointer)”。
空闲列表
如果Java堆中的内存并不是规整的,已使用的内存和空闲的内存相互交错,虚拟机就会维护一个列表,记录上哪些内存块是可用的,再分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式称为“空闲列表(Free List)”。
内存分配的并发解决方案:
一种是对分配内存空间的动作进行同步处理——实际上虚拟机采用CAS配上失败重试的方式保证更新操作的原子性;
另一种是把内存分配按照线程划分在不同的空间之中进行,即每个线程在Java堆中预先分配一小块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer,TLAB)。
内存分配完成之后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头)。
接下来虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息。
以上步骤完成了虚拟机视角上的对象创建,Java程序视角上来看,还需要执行init方法。
对象的内存布局
在HotSpot虚拟机中,对象在内存中存储的布局可以分为3块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。
对象头
HotSpot虚拟机对象头包括两部分信息,第一部分用于存储对象自身的运行时数据如哈希码(HashCode)、GC分代年龄、锁状态标识、线程持有的锁、偏向线程ID、偏向时间戳等,这部分数据的长度在32位和64位的虚拟机中分别为32bit和64bit,官方称它为“Mark Word”。
对象头的另一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
实例数据
实例数据部分是对象真正存储的有效信息,也是在程序代码中所定义的各种类型的字段内容。无论是从父类继承下来的,还是在子类中定义的,都需要记录下来。
对齐填充部分并不是必然存在的,也没有特别含义,仅仅起着占位符的作用。
对象的访问定位
目前主流的访问对象方式有使用句柄和直接指针两种。
- 如果使用句柄的话,那么Java堆中将会划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息,如下图所示:
- 如果使用直接指针访问,那么Java堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,而reference中存储的直接就是对象地址,如下图所示:
使用句柄访问的最大好处就是reference中存储的是稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据指针,而reference本身不需要修改。
使用直接指针访问的最大好处就是速度更快。
以上内容参考自《深入理解Java虚拟机——JVM高级特性与最佳实践(第2版)》第二章,第八章第二节。