0%

jvm09_虚拟机字节码执行引擎

概述

执行引擎是Java虚拟机核心的组成部分之一。

“虚拟机”是一个相对于“物理机”的概念,这两种机器都有代码执行能力,其区别是物理机的执行引擎是直接建立在处理器、缓存、指令集和操作系统层面上的,而虚拟机的执行引擎则是由软件自行实现的,因此可以不受物理条件制约地定制指令集与执行引擎的结构体系,能够执行那些不被硬件直接支持的指令集格式。

在《Java虚拟机规范》中制定了Java虚拟机字节码执行引擎的概念模型,这个概念模型成为各大发 行商的Java虚拟机执行引擎的统一外观(Facade)。

运行时帧结构

Java虚拟机以方法作为最基本的执行单元,“栈帧”(Stack Frame)则是用于支持虚拟机进行方法调用和方法执行背后的数据结构,它也是虚拟机运行时数据区中的虚拟机栈(Virtual Machine Stack)的栈元素。

栈帧存储了方法的局部变量表、操作数栈、动态连接和方法返回地址等信息。

一个栈帧需要分配多少内存,并不会受到程序运行期变量数据的影响,而仅仅取决于程序源码和具体的虚拟机实现的栈内存布局形式。

对于执行引擎来讲,在活动线程中,只有位于栈顶的方法才是在运行的,只有位于栈顶的栈帧才是生效的,其被称为“当前栈帧”(Current Stack Frame),与这个栈帧所关联的方法被称为“当前方法”(Current Method)。执行引擎所运行的所有字节码指令都只 针对当前栈帧进行操作,在概念模型上,典型的栈帧结构如图所示。

局部变量表

局部变量表(Local Variables Table)是一组变量值的存储空间,用于存放方法参数和方法内部定义的局部变量。

在Java程序被编译为Class文件时,就在方法的Code属性的max_locals数据项中确定了该方法所需分配的局部变量表的最大容量。

局部变量表的容量以变量槽(Variable Slot)为最小单位。

《Java虚拟机规范》中并没有明确指出一个变量槽应占用的内存空间大小,只是很有导向性地说到每个变量槽都应该能存放一个boolean、 byte、char、short、int、float、reference或returnAddress类型的数据。

这种描述与明确指出“每个变量槽应占用32位长度的内存空间”是有本质差别的,它允许变量槽的长度可以随着处理器、操作系统或虚拟机实现的不同而发生变化,保证了即使在64位虚拟机中使用了64位的物理内存空间去实现一个变量槽,虚拟机仍要使用对齐和补白的手段让变量槽在外观上看起来与32位虚拟机中的一致。

reference类型表示对一个对象实例的引用,《Java虚拟机规范》既没有说明它的长度,也没有明确指出这种引用应有怎样的结构。但是一般来说,虚拟机实现至少都应当能通过这个引用做到两件事情:

  • 一是从根据引用直接或间接地查找到对象在Java堆中的数据存放的起始地址或索引
  • 二是根据引用直接或间接地查找到对象所属数据类型在方法区中的存储的类型信息

为了尽可能节省栈帧耗用的内存空间,局部变量表中的变量槽是可以重用的,方法体中定义的变量,其作用域并不一定会覆盖整个方法体,如果当前字节码PC计数器的值已经超出了某个变量的作用域,那这个变量对应的变量槽就可以交给其他变量来重用。

局部变量表对垃圾回收的影响

1
2
3
4
5
6
7
public static void main(String[] args){  
{
byte[] placeholder = new byte[64 * 1024 * 1024];

}
System.gc();
}

加入了花括号之后,placeholder的作用域被限制在花括号以内,从代码逻辑上讲,在执行System.gc()的时候,placeholder已经不可能再被访问了,但执行这段程序,会发现运行结果,还是有64MB的内存没有被回收掉。

我们先对这段代码进行第二次修改,在调用System.gc()之前加入一行“int a=0;”

1
2
3
4
5
6
7
public static void main(String[] args) {  
{
byte[] placeholder = new byte[64 * 1024 * 1024];
}
int a = 0;
System.gc();
}

这个修改看起来很莫名其妙,但运行一下程序,却发现这次内存真的被正确回收了。

placeholder能否被回收的根本原因就是:局部变量表中的变量槽是否还存有关于placeholder数组对象的引用

第一次修改中,代码虽然已经离开了placeholder的作用域,但在此之后,再没有发生过任何对局部变量表的读写操作,placeholder原本所占用的变量槽还没有被其他变量所复用,所以作为GCRoots一部分的局部变量表仍然保持着对它的关联。这种关联没有被及时打断,绝大部分情况下影响都很轻微。

但如果遇到一个方法,其后面的代码有一些耗时很长的操作,而前面又定义了占用了大量内存但实际上已经不会再使用的变量,手动将其设置为null值(用来代替那句inta=0,把变量对应的局部变量槽清空)便不见得是一个绝对无意义的操作,这种操作可以作为一种在极特殊情形(对象占用内存大、此方法的栈帧长时间不能被回收、方法调用次数达不到即时编译器的编译条件)下的“奇技”来使用。

Java语言的一本非常著名的书籍《Practical Java》中将把“不使用的对象应手动赋值为null”作为一条推荐的编码规则,但是并没有解释具体原因,很长时间里都有读者对这条规则感到疑惑。

ps 为什么很多过时的书籍推荐,把不用的变量设置为null来释放内存的原因。

不应当对赋null值操作有什么特别的依赖,更没有必要把它当作一个普遍的编码规则来推广。

  1. 从编码角度讲,以恰当的变量作用域来控制变量回收时间才是最优雅的解决方法,示例代码那样的场景除了做实验外几乎毫无用处。
  2. 从执行角度来讲,使用赋null操作来优化内存回收是建立在对字节码执行引擎概念模型的理解之上的,虚拟机使用解释器执行时,通常与概念模型还会比较接近,但经过即时编译器施加了各种编译优化措施以后,两者的差异就会非常大,只保证程序执行的结果与概念一致。在实际情况中,即时编译才是虚拟机执行代码的主要方式,赋null值的操作在经过即时编译优化后几乎是一定会被当作无效操作消除掉的,这时候将变量设置为null就是毫无意义的行为。

如果一个局部变量定义了但没有赋初始值,那它是完全不能使用的。

所以不要认为Java中任何情况下都存在诸如整型变量默认为0、布尔型变量默认为false等这样的默认值规则。

操作数栈

操作数栈(Operand Stack)也常被称为操作栈,它是一个后入先出(Last In First Out,LIFO)栈。

同局部变量表一样,操作数栈的最大深度也在编译的时候被写入到Code属性的max_stacks数据项之中。

操作数栈的每一个元素都可以是包括long和double在内的任意Java数据类型。32位数据类型所占的栈容量为1,64位数据类型所占的栈容量为2。

Java虚拟机的解释执行引擎被称为“基于栈的执行引擎”,里面的“栈”就是操作数栈。

动态连接

每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接(Dynamic Linking)。

Class文件的常量池中存有大量的符号引用,字节码中的方法调用指令就以常量池里指向方法的符号引用作为参数。这些符号引用一部分会在类加载阶段或者第一次使用的时候就被转化为直接引用,这种转化被称为静态解析

另外一部分将在每一次运行期间都转化为直接引用,这部分就称为动态连接

方法返回地址

在方法退出之后,必须返回到最初方法被调用时的位置,程序才能继续执行,方法返回时可能需要在栈帧中保存一些信息,用来帮助恢复它的上层主调方法的执行状态

一般来说,方法正常退出时,主调方法的PC计数器的值就可以作为返回地址,栈帧中很可能会保存这个计数器值。

而方法异常退出时,返回地址是要通过异常处理器表来确定的,栈帧中就一般不会保存这部分信息。

附加信息

《Java虚拟机规范》允许虚拟机实现增加一些规范里没有描述的信息到栈帧之中,例如与调试、性能收集相关的信息,这部分信息完全取决于具体的虚拟机实现,这里不再详述。

在讨论概念时,一般会把动态连接、方法返回地址与其他附加信息全部归为一类,称为栈帧信息。

方法调用

Class文件的编译过程中不包含传统程序语言编译的连接步骤,一切方法调用在Class文件里面存储的都只是符号引用,而不是方法在实际运行时内存布局中的入口地址(也就是之前说的直接引用)。

这个特性给Java带来了更强大的动态扩展能力,但也使得Java方法调用过程变得相对复杂,某些调用需要在类加载期间,甚至到运行期间才能确定目标方法的直接引用。

根据方法调用在什么时候确定的,可以将Java方法调用大致分为两大类:

  1. 解析 (在类加载的解析阶段,能将符号引用转化为直接引用的方法调用)
  2. 分派 (运行时,才能确定的方法调用)

解析

调用目标在程序代码写好、编译器进行编译那一刻就已经确定下来。这类方法的调用被称为解析(Resolution)。

所有方法调用的目标方法在Class文件里面都是一个常量池中的符号引用,在类加载的解析阶段,会将其中的一部分符号引用转化为直接引用,这种解析能够成立的前提是:方法在程序真正运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期是不可改变的。

Java语言里符合这个条件的方法共有静态方法、私有方法、实例构造器、父类方法,被final修饰的方法,共五种。

这5种方法调用会在类加载的时候就可以把符号引用解析为该方法的直接引用。这些方法统称为 “非虚方法”(Non-Virtual Method),与之相反,其他方法就被称为 “虚方法”(Virtual Method)

分派(重载和重写在虚拟机中的实现)

分派(Dispatch)调用则要复杂许多,它可能是静态的也可能是动态的,按照分派依据的宗量数可分为单分派和多分派

这两类分派方式两两组合就构成了4种分派组合情况

  • 静态单分派、
  • 静态多分派、
  • 动态单分派、
  • 动态多分派

静态分派

所有依赖静态类型来决定方法执行版本的分派动作,都称为静态分派。

静态分派的最典型应用表现就是方法重载。

静态分派发生在编译阶段,因此确定静态分派的动作实际上不是由虚拟机来执行的,这点也是为何一些资料选择把它归入“解析”而不是“分派”的原因。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
package geek.synctest;  

public class StaticDispatch {

static abstract class Human {
}

static class Man extends Human {
}

static class Woman extends Human {
}

public void sayHello(Human guy) {
System.out.println("hello,guy!");
}

public void sayHello(Man guy) {
System.out.println("hello,gentleman!");
}

public void sayHello(Woman guy) {
System.out.println("hello,lady!");
}

public static void main(String[] args) {
Human man = new Man();
Human woman = new Woman();
StaticDispatch sr = new StaticDispatch();
sr.sayHello(man);
sr.sayHello(woman);

System.out.println("==============");
sr.sayHello((Man) man);
sr.sayHello((Woman) woman);
}
}

1
2
3
4
5
hello,guy!
hello,guy!
==============
hello,gentleman!
hello,lady!

我们把上面代码中的“Human”称为变量的“静态类型”(Static Type),后面的“Man”则被称为变量的“实际类型”(Actual Type)。

静态类型和实际类型在程序中都可能会发生变化,区别是静态类型的变化仅仅在使用时发生,变量本身的静态类型不会被改变,并且最终的静态类型是在编译期可知的;而实际类型变化的结果在运行期才可确定,编译器在编译程序的时候并不知道一个对象的实际类型是什么。

1
2
3
4
5
6
7
8
9
public static void main(String[] args) {  
StaticDispatch sr = new StaticDispatch();
// 实际类型变化
Human human = (new Random()).nextBoolean() ? new Man() : new Woman();

// 静态类型变化
sr.sayHello((Man) human) ;
sr.sayHello((Woman) human);
}

对象human的实际类型是可变的,编译期间它完全是个“薛定谔的人”,到底是Man还是Woman,必须等到程序运行到这行的时候才能确定。而human的静态类型是Human,也可以在使用时(如sayHello()方法中的强制转型)临时改变这个类型,但这个改变仍是在编译期是可知的,两次sayHello()方法的调用,在编译期完全可以明确转型的是Man还是Woman。

main()里面的两次sayHello()方法调用,在方法接收者已经确定是对象“sr”的前提下,使用哪个重载版本,就完全取决于传入参数的数量和数据类型。代码中故意定义了两个静态类型相同,而实际类型不同的变量,但编译器在重载时是通过参数的静态类型而不是实际类型作为判定依据的。

由于静态类型在编译期可知,所以在编译阶段,Javac编译器就根据参数的静态类型决定了会使用哪个重载版本,因此选择了sayHello(Human)作为调用目标。

动态分派

了解了静态分派,我们接下来看一下Java语言里动态分派的实现过程,它与Java语言多态性的另外一个重要体现——重写(Override) 有着很密切的关联。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public class DynamicDispatch {  

static abstract class Human {
protected abstract void sayHello();
}

static class Man extends Human {
@Override
protected void sayHello() {
System.out.println("man say hello");

}
}

static class Woman extends Human {
@Override
protected void sayHello() {
System.out.println("woman say hello");

}
}

public static void main(String[] args) {
Human man = new Man();
Human woman = new Woman();
man.sayHello();
woman.sayHello();
man = new Woman();
man.sayHello();
}
}

invokevirtual指令执行的第一步就是在运行期确定接收者的实际类型,所以两次调用中的invokevirtual指令并不是把常量池中方法的符号引用解析到直接引用上就结束了,还会根据方法接收者的实际类型来选择方法版本,这个过程就是Java语言中方法重写的本质

我们把这种在运行期根据实际类型确定方法执行版本的分派过程称为动态分派。

字段永远不参与多态,哪个类的方法访问某个名字的字段时,该名字指的就是这个类能看到的那个字段。

当子类声明了与父类同名的字段时,虽然在子类的内存中两个字段都会存在,但是子类的字段会遮蔽父类的同名字段。

单分派与多分派

方法的接收者与方法的参数统称为方法的宗量,这个定义最早应该来源于著名的《Java与模式》一书。根据分派基于多少种宗量,可以将分派划分为单分派和多分派两种。

单分派是根据一个宗量对目标方法进行选择,多分派则是根据多于一个宗量对目标方法进行选择。

Java语言是一门静态多分派、动态单分派的语言。

基于栈的字节码解释执行引擎

基于栈的指令集与基于寄存器的指令集

分别使用这两种指令集去计算“1+1”的结果,基于栈的指令集会是这样子的:

1
2
3
4
iconst_1 
iconst_1
iadd
istore_0

两条iconst_1指令连续把两个常量1压入栈后,iadd指令把栈顶的两个值出栈、相加,然后把结果 放回栈顶,最后istore_0把栈顶的值放到局部变量表的第0个变量槽中。

如果用基于寄存器的指令集,那程序可能会是这个样子:

1
2
mov eax, 1 
add eax, 1

mov指令把EAX寄存器的值设为1,然后add指令再把这个值加1,结果就保存在EAX寄存器里面。 这种二地址指令是x86指令集中的主流,每个指令都包含两个单独的输入参数,依赖于寄存器来访问和存储数据。

基于栈的指令集主要优点是可移植,因为寄存器由硬件直接提供,程序直接依赖这些硬件寄存器则不可避免地要受到硬件的约束。

栈架构指令集的主要缺点是理论上执行速度相对来说会稍慢一些,所有主流物理机的指令集都是寄存器架构也从侧面印证了这点。

不过这里的执行速度是要局限在解释执行的状态下,如果经过即时编译器输出成物理机上的汇编指令流,那就与虚拟机采用哪种指令集架构没有什么关系了。