执行引擎是Java 虚拟机核心的组成部分之一。“虚拟机” 是一个相对于 “物理机” 的概念,这两种机器都有代码执行能力,其区别是物理机的执行引擎是直接建立在处理器,缓存,指令集和操作系统层面上的,而虚拟机的执行引擎则是由软件自行实现的,因此可以不受物理条件制约地定制指令集与执行引擎的结构体系,能够执行那些不被硬件直接支持的指令集格式。
在《Java 虚拟机规范》中制定了Java 虚拟机字节码执行引擎的概念模型,这个概念模型成为各大发行商的Java 虚拟机执行引擎的统一外观。在不同的虚拟机实现中,执行引擎在执行字节码的时候,通常会有解释执行(解释器执行)和编译执行(即时编译器)两种选择,也可能两者兼备,还可能同时包含几个不同级别的即时编译器一起工作的执行引擎。但从外观上来看,所有的Java 虚拟机的执行引擎输入、输出都是一致的:输入的是字节码二进制流,处理过程是字节码解析执行的等效过程,输出的是执行结果。
开始之前我们先来看一份Java 代码,我们通过这样一份代码来捋一捋Java 虚拟机栈宏观的工作流程。
public class ClassMemoryModelDemo {
public static int SI = 10;
public static final int SFI = 20;
public int add_return_method(int a,int b){
int c = a + b;
int d = 5;
int e = 6;
int f = d + e;
return c + f;
}
public void joinReturnMethod(){
SI = SI+SFI;
}
public static void main(String[] args) {
ClassMemoryModelDemo classMemoryModelDemo = new ClassMemoryModelDemo();
classMemoryModelDemo.add_return_method(1,2);
classMemoryModelDemo.joinReturnMethod();
}
}
下面是一份 javap -verbose ClassMemoryModelDemo.class 文件信息
C:\applications\javatools\workspace\work\JVM-Module\target\classes\com\waf>javap -verbose ClassMemoryModelDemo.class
Classfile /C:/applications/javatools/workspace/work/JVM-Module/target/classes/com/waf/ClassMemoryModelDemo.class
Last modified 2021-4-3; size 867 bytes
MD5 checksum ae5870fb92adf1dff777827a3af0c95c
Compiled from "ClassMemoryModelDemo.java"
public class com.waf.ClassMemoryModelDemo
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #7.#34 // java/lang/Object."<init>":()V
#2 = Fieldref #3.#35 // com/waf/ClassMemoryModelDemo.SI:I
#3 = Class #36 // com/waf/ClassMemoryModelDemo
#4 = Methodref #3.#34 // com/waf/ClassMemoryModelDemo."<init>":()V
#5 = Methodref #3.#37 // com/waf/ClassMemoryModelDemo.add_return_method:()I
#6 = Methodref #3.#38 // com/waf/ClassMemoryModelDemo.joinReturnMethod:()V
#7 = Class #39 // java/lang/Object
#8 = Utf8 SI
#9 = Utf8 I
#10 = Utf8 SFI
#11 = Utf8 ConstantValue
#12 = Integer 20
#13 = Utf8 <init>
#14 = Utf8 ()V
#15 = Utf8 Code
#16 = Utf8 LineNumberTable
#17 = Utf8 LocalVariableTable
#18 = Utf8 this
#19 = Utf8 Lcom/waf/ClassMemoryModelDemo;
#20 = Utf8 add_return_method
#21 = Utf8 ()I
#22 = Utf8 a
#23 = Utf8 b
#24 = Utf8 c
#25 = Utf8 joinReturnMethod
#26 = Utf8 main
#27 = Utf8 ([Ljava/lang/String;)V
#28 = Utf8 args
#29 = Utf8 [Ljava/lang/String;
#30 = Utf8 classMemoryModelDemo
#31 = Utf8 <clinit>
#32 = Utf8 SourceFile
#33 = Utf8 ClassMemoryModelDemo.java
#34 = NameAndType #13:#14 // "<init>":()V
#35 = NameAndType #8:#9 // SI:I
#36 = Utf8 com/waf/ClassMemoryModelDemo
#37 = NameAndType #20:#21 // add_return_method:()I
#38 = NameAndType #25:#14 // joinReturnMethod:()V
#39 = Utf8 java/lang/Object
{
public static int SI;
descriptor: I
flags: ACC_PUBLIC, ACC_STATIC
public static final int SFI;
descriptor: I
flags: ACC_PUBLIC, ACC_STATIC, ACC_FINAL
ConstantValue: int 20
public com.waf.ClassMemoryModelDemo();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 3: 0
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this Lcom/waf/ClassMemoryModelDemo;
public int add_return_method();
descriptor: ()I
flags: ACC_PUBLIC
Code:
stack=2, locals=4, args_size=1
0: iconst_5
1: istore_1
2: bipush 6
4: istore_2
5: iload_1
6: iload_2
7: iadd
8: istore_3
9: iload_3
10: ireturn
LineNumberTable:
line 10: 0
line 11: 2
line 12: 5
line 13: 9
LocalVariableTable:
Start Length Slot Name Signature
0 11 0 this Lcom/waf/ClassMemoryModelDemo;
2 9 1 a I
5 6 2 b I
9 2 3 c I
public void joinReturnMethod();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=2, locals=1, args_size=1
0: getstatic #2 // Field SI:I
3: bipush 20
5: iadd
6: putstatic #2 // Field SI:I
9: return
LineNumberTable:
line 17: 0
line 18: 9
LocalVariableTable:
Start Length Slot Name Signature
0 10 0 this Lcom/waf/ClassMemoryModelDemo;
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=2, args_size=1
0: new #3 // class com/waf/ClassMemoryModelDemo
3: dup
4: invokespecial #4 // Method "<init>":()V
7: astore_1
8: aload_1
9: invokevirtual #5 // Method add_return_method:()I
12: pop
13: aload_1
14: invokevirtual #6 // Method joinReturnMethod:()V
17: return
LineNumberTable:
line 21: 0
line 22: 8
line 23: 13
line 24: 17
LocalVariableTable:
Start Length Slot Name Signature
0 18 0 args [Ljava/lang/String;
8 10 1 classMemoryModelDemo Lcom/waf/ClassMemoryModelDemo;
static {};
descriptor: ()V
flags: ACC_STATIC
Code:
stack=1, locals=0, args_size=0
0: bipush 10
2: putstatic #2 // Field SI:I
5: return
LineNumberTable:
line 5: 0
}
SourceFile: "ClassMemoryModelDemo.java"
我们先抛开魔数来解析一下这个javap -verbose 文件 注意,这个文件是和这个ClassMemoryModelDemo.class 的二进制字节码文件 是一一对应的,可以理解为这个文件的格式是专门给执行引擎使用的。
先看第一部分
public class com.waf.ClassMemoryModelDemo
minor version: 0 --小版本
major version: 52 --JDK版本号(魔数字节10进制为52,JDK1.8)
flags: ACC_PUBLIC, ACC_SUPER
Constant pool: --常量池(运行时常量池和静态常量池存放在方法区,字符串常量池存放在堆里面)
#1 = Methodref #7.#34 // java/lang/Object."<init>":()V --父类的<init>构造方法,()V没有返回值 #7.#37 为符号引用(间接引用),运行期间会变成直接引用就是指向内存地址
#2 = Fieldref #3.#35 // com/waf/ClassMemoryModelDemo.SI:I -- 静态属性 SI, I类型为int 类型
#3 = Class #36 // com/waf/ClassMemoryModelDemo --本类
#4 = Methodref #3.#34 // com/waf/ClassMemoryModelDemo."<init>":()V--本类的构造方法 返回类型 void
#5 = Methodref #3.#37 // com/waf/ClassMemoryModelDemo.add_return_method:()I -- add_return_method方法名 ()I 返回值为int 类型
#6 = Methodref #3.#38 // com/waf/ClassMemoryModelDemo.joinReturnMethod:()V -- joinReturnMethod方法名 返回类型 void
#7 = Class #39 // java/lang/Object
#8 = Utf8 SI -- 静态属性 SI(准备阶段赋值默认0,初始化后再次赋值)
#9 = Utf8 I -- int 类型
#10 = Utf8 SFI -- 静态常量属性 SFI
#11 = Utf8 ConstantValue -- 静态常量属性值存放的位置
#12 = Integer 20 --静态常量值(编译期间赋值)
再看属性
{
public static int SI;
descriptor: I
flags: ACC_PUBLIC, ACC_STATIC
一目了然
public static int SI = 10;
public static final int SFI;
descriptor: I
flags: ACC_PUBLIC, ACC_STATIC, ACC_FINAL
ConstantValue: int 20
public static final int SFI = 20;
还有方法,我们先看默认的构造方法
public com.waf.ClassMemoryModelDemo();
descriptor: ()V --void 无返回值
flags: ACC_PUBLIC public
Code:()
stack=1, locals=1, args_size=1
0: aload_0 -- 0 程序计数器指针 aload_0 字节码指令
1: invokespecial #1 invokespecial 根据编译时类型来调用实例方法 #1符号引用 // Method java/lang/Object."<init>":()V
4: return -- 返回地址
LineNumberTable:
line 3: 0
LocalVariableTable: --局部变量表
Start Length Slot Name Signature
0 5 0 this Lcom/waf/ClassMemoryModelDemo;
通过这段代码我们可以知道:执行这个方法时,在运行时数据区中的虚拟机栈内存里面开辟一块内存空间.
这个空间叫栈帧,而栈帧的结构有局部变量表,操作数栈,动态连接,返回地址
操作数栈 将 args_size=1 的变量压入栈帧中,再将这个变量存放到局部变量表中,这个变量哪里来的呢,而我们默认的构造方法又是无参的。
再看局部变量表中 有个 this 。那这个this又是哪里来的呢,什么时候给this赋值的呢。
我们根据操作数栈的那句话中可以发现 局部变量表中有一个this,那这个this肯定是隐式传进去的,因为局部变量表中确实只有这一个this。Java 虚拟机在为这个类开辟空间的时候就已经将this 和这块空间进行了引用。
动态连接 就是将间接引用变为直接引用,直接引用就是指向了内存地址(堆)
返回地址 就是执行引擎修改程序计数器指针,修改到程序具体返回到的位置的指针
解释了第一个方法后,我们直接来看看add_return_method方法运行时栈内存的变化。
joinReturnMethod方法运行时栈内存的变化。
更正上图 setup4 指令10:return 从当前方法返回void
最后main方法
概念取自 周志明 深入理解Java 虚拟机 第三版
运行时栈帧结构
Java 虚拟机以方法作为最基本的执行单元,栈帧则是用于支持虚拟机进行方法调用和方法执行背后的数据结构,它也是虚拟机运行时数据区中的虚拟机栈的栈元素。栈帧存储了方法的局部变量表,操作数栈,动态连接和方法返回地址等信息。每一个方法从调用开始至执行结束的过程,都对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程。
每一个栈帧都包括了局部变量表,操作数栈,动态连接,方法返回地址和一些额外的附加信息。在编译Java 程序源码的时候,栈帧中需要多大的局部变量表,需要多深的操作数栈就已经被分析计算出来,并且写入到方法表的 code 属性中(javap -verbose 文件中的 code 区)。一个栈帧需要分配多少内存,并不会收到程序运行期变量数据的影响,而仅仅取决于程序源码和具体的虚拟机实现的栈内存布局形式。
一个线程中的方法调用链可能会很长,以Java 程序的角度来看,同一时刻,同一条线程里面,在调用堆栈的所有方法都同时处于执行状态。而对于执行引擎来讲,在活动线程中,只有位于栈顶的方法才是在运行的,只有位于栈顶的栈帧才是生效的,其被称为当前栈帧,与这个栈帧所关联的方法被称为当前方法。执行引擎所运行的所有字节码指令都只针对当前栈帧进行操作。
局部变量表
局部变量表(Local Variables Table)是一组变量值的存储空间,用于存放方法参数和方法内部定义的局部变量。在Java 文件被编译成class文件是,就在方法的code属性的max_locals 数据项中确定了该方法所需分配的局部变量表的最大容量。
局部变量表的容量以变量槽为最小单位,在《Java 虚拟机规范》中并没有明确指出一个变量槽应占用的内存空间大小,只是很有导向性地说明每个变量槽都应该能存放一个boolean,char,byte,int ,short,float,reference 或 returnAddress类型的数据,这8种数据类型,都可以使用32bit 或更小的物理内存来存储,但这种描述与明确指出 “每个变量槽应占32位长度的内存空间” 是有本质差别的,它允许变量槽的长度可以随着处理器、操作系统或虚拟机实现的不同而发生变化,保证了即使在64位虚拟机中使用了64位的物理内存空间去实现一个变量槽,虚拟机仍要使用对齐和补白的手段让变量槽在外观上看起来和32位虚拟机中的一致。
既然前面提到了Java 虚拟机的数据类型,在此堆它们再简单的介绍一下。一个变量槽可以存放一个32位以内的数据类型,Java 中占用不超过32位存储空间的数据类型有 boolean,char,byte,int ,short,float,reference 或 returnAddress8种类型。前面6种不需要多加解释,我们可以按照Java 语言中对应数据类型的概念去理解他们,而第七种reference 类型表示对一个对象实例的引用,《Java 虚拟机规范》既没有说明它的长度,也没有明确指出这种引用应有怎样的结构。但是一般来说,虚拟机实现至少都应当通过这个引用做到两件事情,一是从根据引用直接或间接地查找到对象在Java 堆中的数据存放的起始地址或索引,二是根据引用直接或间接地查找到对象所属数据类型在方法区中的存储的类型信息,否则将无法实现《Java 虚拟机规范》中定义的语法约定。第八种returnAdress 类型目前已经很少见了,它是为字节码指令jsr、jsr_w和ret服务的,指向了一条字节码指令的地址,某些很古老的Java 虚拟机曾经使用这几条指令来实现异常处理时的跳转,但现在也已经全部改为采用异常表来代替了。
对于64位的数据类型,Java 虚拟机会以高位对其的方式为其分配两个连续的变量槽空间。Java 语言中明确的64位的数据类型只有double 和 long 两种。这里把double和long数据类型分割储存的做法与“long 和 double 的非原子协定” 中允许把一次long和double 数据类型读写分割为两次32位读写的做法有些类似。不过局部变量表是建立在线程堆栈中的,属于线程私有的数据,无论读写两个连续的变量槽是否为原子操作,都不会引起数据竞争和线程安全问题。
Java 虚拟机通过索引定位的方式使用局部变量表,索引值的范围是从0开始至局部变量表最大的变量槽数量。如果访问的是32位数据类型的变量,索引N就代表了使用第N个变量槽,如果访问的是64位数据类型的变量,则说明会同时使用第N和N+1两个变量槽。对于两个相邻的共同存放一个64位数据的两个变量槽,虚拟机不允许采用任何方式单独访问其中的某一个,《Java 虚拟机规范》中明确要求了如果遇到进行这种操作的字节码序列,虚拟机就应该在类加载的校验阶段中抛出异常。
当一个方法被调用时,Java 虚拟机会使用局部变量表来完成参数值到参数变量列表的传递过程,即实参到形参的传递。如果执行的是实例方法(没有被static修饰的方法),那局部变量表中的第0位索引的变量槽默认是用于传递方法所属对象实例的引用,在方法中可以通过关键字 “this” 来访问到这个隐含的参数。其余参数则按照参数表顺序排列,占用从1开始的局部变量槽,参数表分配完毕后,再根据方法体内部定义的变量顺序和作用域分配其余的变量槽。
为了尽可能节省栈帧耗用的内存空间,局部变量表中的变量槽是可以重用的,方法体中定义的变量,其作用域并不一定会覆盖整个方法体,如果当前字节码程序计数器的值已经超出了某个变量的作用域,那这个变量对应的变量槽就可以交给其他变量来重用。不过,这样的设计除了节省栈帧空间以外,还会伴随有少量额外的副作用,例如在某些情况下变量槽复用会直接影响到系统的垃圾收集行为。
操作数栈
操作数栈(Operand Stack)也常被称为操作栈,它是一个后入先出(Last In First Out,LIFO)栈。同局部变量表一样,操作数栈的最大深度也在编译的时候被写入到了code属性的max_stacks数据项之中。操作数栈的每一个元素都可以是包括long和double在内的任意Java 数据类型。32位数据类型所占的栈容量为1,64位数据类型所占的栈容量为2。Javac编译器的数据流分析工作保证了在方法执行的任何时候,操作数栈的深度都不会超过在max_stacks数据项中设定的最大值。
当一个方法刚刚开始的执行的时候,这个方法的操作数栈是空的,在方法的执行过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是出栈和入栈操作。譬如在做算术运算的时候是通过将运算涉及的操作数栈压入栈顶后调用运算指令来进行,又譬如在调用其他方法的时候是通过操作数栈来进行方法参数的传递。举个例子,例如整数加法的字节码指令iadd,这条指令在运行的时候要求操作数栈中最接近栈顶的两个元素已经存入了两个int型的数值,当执行这个指令时,会把这两个int值出栈并相加,然后将相加的结果重新入栈。
操作数栈中元素的数据类型必须与字节码指令的序列严格匹配,在编译程序代码的时候,编译器必须要严格保证这一点,在类校验阶段的数据流分析中还要再次验证这一点。再以上面的iadd指令为例,这个指令只能用于整形数的加法,它在执行时,最接近栈顶的两个元素的数据类型必须为int型,不能出现一个long和一个float使用iadd命令相加的情况。
另外在概念模型中,两个不同栈帧作为不同方法的虚拟机栈的元素,是完全相互独立的。但是在大多虚拟机的实现里都会进行一些优化处理,令两个栈帧出现一部分重叠。让下面栈帧的部分操作数栈与上面栈帧的部分局部变量表重叠在一起,这样做不仅节约了一些空间,更重要的是在进行方法调用时就可以直接共用一部分数据,无须进行额外的参数复制传递了。
动态连接
每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接。通过上述的内容我们知道class文件的常量池中存有大量的符号引用(#1,#2,#3),字节码中的方法调用指令就以常量池里指向方法的符号引用作为参数。这些符号引用一部分会在类加载阶段或者第一次使用的时候就被转化为直接引用,这种转化被称为静态解析。另外一部分将在每一次运行期间都转化为直接引用,这部分就称为动态连接。
方法返回地址
当一个方法开始执行后,只有两种方式退出这个方法。第一种方式是执行引擎遇到任意一个方法返回的字节码指令,这个时候可能会有返回值传递给上层的方法调用者(调用当前方法的方法称为调用者或者主调方法),方法是否有返回值以及返回值的类型将根据遇到何种方法返回指令来决定,这种推出方法的方式称为 正常调用完成。
另外一种退出方式是在方法执行的过程中遇到了异常,并且这个异常没有在方法体内得到妥善处理。无论是Java 虚拟机内部产生的异常,还是代码中使用 athrow 字节码指令产生的异常,只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,这种退出方法的方式称为 异常调用完成。一个方法使用异常完成出口的方式退出,是不会给它的上层调用者提供任何返回值的。
无论采用何种退出方式,在方法退出之后,都必须返回到最初方法被调用时的位置,程序才能继续执行,方法返回时可能需要在栈帧中保存一些信息,用来帮助恢复它的上层主调方法的执行状态。一般来说,方法正常退出时,主调方法的程序计数器的值就可以作为返回地址,栈帧中很可能会保存这个计数器值。而方法异常退出时,返回地址是要通过异常处理器表来确定的,栈帧中就一般不会保存这部分信息。
方法退出的过程实际上等同于把当前栈帧出栈,因此退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值(如果有)压入调用者栈帧的操作数栈中,调整程序计数器的值以指向方法调用指令后面的一条指令等。
字符串常量池
String.intern();