0%

第8章: 虚拟机字节码执行引擎

概述

在Java虚拟机规范中制定了虚拟机字节码执行引擎的概念模型,所有的虚拟机执行引擎都是一致的:输入的是字节码文件,处理过程是字节码解析的等效过程,输出的是执行结果。

运行时栈帧结构

栈帧(Stack Frame)是用于支持虚拟机进行方法调用和方法执行的数据结构,它是虚拟机运行时数据区中的虚拟机栈(Virtual Machine Stack)的栈元素。栈帧存储了方法的局部变量表、操作数栈、方法返回地址等信息。每一个方法从调用开始到执行完成的过程,对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程。

在编译代码的时候,栈帧中需要多大的局部变量表,多深的操作数栈都已经完全确定了,因此一个栈帧需要分配多少内存,不会受到程序运行期变量数据的影响。典型的栈帧结构如下图:

类的生命周期

一个线程中的方法调用链可能会很长,在活动线程中,只有位于栈顶的栈帧才是有效的。

局部变量表

局部变量表示一组变量值存储空间,用于存放 方法参数 和 方法内部蒂尼的局部变量。在方法执行时,虚拟机是使用局部变量表完成参数值到参数变量列表的传递过程的,如果执行的是实例方法(非static方法),那局部变量表中第0位索引默认用于传递方法所属实例的引用,在方法中可以通过”this”关键字来访问这个隐含参数。剩下的参数则按照参数表顺序排列,参数表分配完毕后,再分配方法体内部定义的变量。

为了节省栈帧内存,局部变量表的空间是可以重用的,方法体中定义的变量,其作用域不一定会覆盖整个方法体,当超出作用范围时,它的空间就可能交给其他变量使用,不过这样的设计出了节省栈帧空间外,还会伴随额外的副作用,比如导致垃圾不能及时回收,以下举例说明():

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

代码中向内存中填充了64MB数据,然后通知虚拟机进行垃圾收集,但是我们可以发现结果并没有回收。不过,这里没有回收placeholder所占的内存还说得过去,因为在执行gc时,placeholder还处于作用域之呢,虚拟机自然不会回收,下面把代码改下(代码8-2):

1
2
3
4
5
6
7
8
public static void main(String args){

{
byte[] placeholder = new byte[64 * 1024 * 1024];
}

System.gc();
}

加入花括号之后,placeholder的作用域被限制在花括号之内,从代码逻辑上讲,gc的时候,placeholder就已经不可能再被访问了,但是执行以下,发现还是没有被回收。在解释之前,再次修改下代码试试(代码8-3):

1
2
3
4
5
6
7
8
9
public static void main(String args){

{
byte[] placeholder = new byte[64 * 1024 * 1024];
}

int a = 0;//添加这句
System.gc();
}

再次运行,发现内存被正确地回收了,看起来很莫名其妙。placeholder 能否被回收的根本原因是:局部变量表中是否还存有关于placeholder数组的引用。第一次修改中,代码虽然已经离开了placeholder的作用域,但在此之后,没有任何对局部变量表的读写操作,placeholder原本所占的空间还没有被其他变量复用,所以作为GC Roots一部分的局部变量表仍然保持着对它的引用。这种关联没有被及时打断,在绝大部分情况下影响都很轻微。

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

上述例子说明了在某些情况下赋null值操作确实是有用的,但是不应对这种操作有过多依赖,更没必要当做普遍的编码规则来推广,原因有两点:一是从编码角度讲,以恰当的变量作用域来控制变量的回收时间才是最优雅的解决方案。二是从执行角度讲,使用赋null值操作来优化内存回收是建立在对字节码执行引擎概念模型的理解之上的,赋null值的方式在经过JIT编译优化之后就会被消除掉,这时候将变量设置为null是没有意义的。以前面的例子来说,代码8-2 的形式经过JIT编译后,System.gc() 执行时,就可以正确地回收内存了,无需再写成代码 8-3 的样子。

还有一点需要注意,类变量有两次赋初始值的过程,一次在准备阶段,赋予系统初始值;一次在初始化阶段,赋予程序员定义的初始值,因此,即使在初始化阶段没有为类变量赋值也没有关系,类变量仍然具有一个确定的初始值。但局部变量不一样,它并不存在“准备阶段”,因此如果定义了局部变量但是没有赋初始值是不能使用的。

这段做的笔记有点多,这是因为个人以前在一些书籍上看到有观点说,推荐及时将不使用的对象手动置为null,但是解释语焉不详,在这里从虚拟机角度看到了解释,故详细记下来

操作数栈

操作数栈(operand stack)是一个后入先出的栈,痛局部变量表一样,操作数栈的最大深度也在编译时写入到Code属性中了。当一个方法刚刚开始执行的时候,这个方法的操作数栈是空的,在方法执行的过程中,会有各种字节码指令往操作数栈中写入和提取内容。

举个例子,整数加法的字节码指令iadd在运行的时候操作数栈中最接近栈顶的两个元素已经存入了两个int型的数值,当执行这个指令是,会将这两个int值出栈并相加,然后将相加的结果入栈。

方法调用

方法调用并不等同于方法执行,方法调用唯一的任务就是确定被调用方法的版本(即调用哪一个方法),还不设计方法内部的具体运行。前面已经讲过,Class文件的编译过程不包含传统编译中的连接步骤,一切方法调用在Class文件里面都只是符号引用,而不是方法在时机运行时内存布局中的入口地址(相当于之前说的直接引用)。

解析

前面提到,所有方法调用中的目标方法在Class文件中都是一个常量池中的符号引用,在类加载的解析阶段,会将其中一部分符号引用转化为直接引用。这种解析能成立的前提是:“编译期可知,运行期不可变”,这类方法的调用就称为“解析(Resolution)”。符合这种特性的方法,主要包括静态方法和私有方法两大类,前者与类型直接关联,后者在外部不可被访问,这两种方法各自的特点决定了它们都不可能通过继承或别的方式重写其他版本,因此它们都适合在类加载阶段进行解析。

只能被invokestatic 和 invokespecial 指令调用的方法都可以在解析阶段中确定唯一的调用版本,符合这个条件的有静态方法、私有方法、实力构造器、父类方法4类,它们在类加载的时候就会把符号引用解析为该方法的直接引用。这些方法称为非虚方法,与之对应的称为虚方法(final方法除外)。

被final 修饰的方法虽然是通过invokevertual 指令调用的,但是由于它无法被覆盖,没有其他版本,所以Java语言规范中明确说明final是一种非虚方法。

分派

Java具备面向对象的3个基本特征:继承、封装以及多态。分派调用过程将会揭示多态性特征的一些基本体现,如“重载”和“重写”在Java虚拟机中是如何实现的。

1、 静态分派

在讲解静态分派之前,看一段经常出现在面试题中的代码,方法静态分派代码如下面代码8-6所示:

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 StaticDispatch{
static abstract class Human{
}

static class Man extends Human{
}

static class Woman extends Human{
}

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

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

public void sysHello(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);
}
}

打印结果:

hello ,guy!
hello ,guy!

这段代码实际上是考验读者对重载的理解程度,但这里为什么会选择执行参数类型为 Human 的重载呢?解决问题前,先按如下代码定义两个重要概念:

Human man = new Man();

上面代码中的”Human”称为变量的静态类型(Static Type)或叫做外观类型(Apparent Type),后面的 “Main” 则称为变量的实际类型(Actual Type)。静态类型和实际类型在程序中都可以发生一些变化,**区别是,静态类型的变化仅仅在使用时发生,变量本身的静态类型不会被改变,并且最终的静态类型是在编译期可知的;而实际类型变化的结果在运行期才确定,例如下面代码:

1
2
3
4
5
6
7
//实际类型变化
Human man = new Man();
man = new Woman();

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

回到上面代码 8-6 中,由于虚拟机(准确地说是编译器)在重载时是通过参数的静态类型而不是实际类型作为判定依据的,并且静态类型是编译期可知的,因此在编译阶段,根据静态类型选择了 sayHello(Human) 作为调用目标,并把这个方法的符号引用写到main方法里的两条invokevirtual指令参数中。静态分派的典型应用是方法重载,另外,编译器虽然能确定出方法的重载版本,但很多情况下这个重载版本并不是”唯一“的,往往只能确定一个”更加合适的“版本,以下代码 8-7演示了何为”更加合适“的版本:

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
public class Overload{
public static void sysHello(Object arg){
System.out.println("hello Object");
}

public static void sysHello(int arg){
System.out.println("hello int");
}

public static void sysHello(long arg){
System.out.println("hello long");
}

public static void sysHello(Character arg){
System.out.println("hello Character");
}

public static void sysHello(char arg){
System.out.println("hello char");
}

public static void sysHello(char... arg){
System.out.println("hello char...");
}

public static void sysHello(Serializable arg){
System.out.println("hello Serializable");
}

public static void main(String[] args){
sayHello('a')
}
}

打印结果:

hello char

如果注释掉 sysHello(char arg) 方法,则会打印 “hello int”;再注释掉 sysHello(int arg) 方法,则会打印 “hello long”;再注释掉 sysHello(long arg) 方法,则会打印 “hello Character”;如此下去,输出的结果会不断变化。这其实也还好理解: ‘a’首先是个char,自然首先输出 hello char;如果没有该方法,则自动类型转换为int,如果再没有此方法,则会进一步转换为long类型(按照 char -> int -> long -> float -> double的顺序进行匹配,但不会匹配到byte和short类型,因为转型到这两种是不安全的)。所以上述代码在注释掉 sysHello(long arg) 后,输出变为 “hello Character” ,此时发生了自动装箱。如此注释下去,”hello char…” 将会是最后一个打印的,可见变长参数的重载优先级是最低的,甚至比Object还低。值得注意的是,有一些在单个参数中成立的自动转型,如char转型为 int,在变长参数中是不成立的。

2、 动态分派

动态分派和多态性的另一个重要体现——重写(Override)有很密切的关联,结合前面Man和Woman一起sayHello的例子来看如下代码:

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();
}
}

打印结果:

man say hello
woman say hello
woman say hello

结果不出人意料,但是虚拟机是如何知道要调用哪个方法?这里显然不能再根据静态类型来决定,因为静态类型都是Human的两个变量 man 和woman 在调用 sayHello 方法时执行了不同的行为,并且man在两次调用中执行了不同的方法。因此可以看出,这只是因为变量的实际类型不同。因为invokevirtual指令的运行时解析过程大致如下:

第一步:找到操作数栈顶的第一个元素所指向的对象的实际类型,记做C
第二步:如果在C中找到与常量中的描述符合简单名称都相符的方法,则进行访问权限校验,如果通过,则返回方法的直接引用;否则,返回 java.lang.IllegalAccessError异常。
第三步:否则,按照继承关系从下往上一次对C的各个父类进行第二步的搜索和验证。
第四步: 如果始终没有找到合适的方法,则抛 java.lang.AbstractMethodError异常。

3、 单分派与多分派

方法的接收者与方法的参数统称方法的宗量。根据分派基于多少种宗量,可以将分派划分为但分派和多分派,单分派是根据一个宗量对目标方法进行选择;多分派就是根据多个宗量对目标方法进行选择。定义比较拗口,对照下面这个例子,分析 Father 和 Son 做“艰难决定”之后,就不难理解了:

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
//单分派、多分派的例子
public class Dispatch{
static class QQ {}

static class _360 {}

public static class Father{
public void hardChoice(QQ arg){
System.out.println("father choose qq");
}

public void hardChoice(_360 arg){
System.out.println("father choose 360");
}
}

public static class Son extends Father{
public void hardChoice(QQ arg){
System.out.println("son choose qq");
}

public void hardChoice(_360 arg){
System.out.println("son choose 360");
}

}

public static void main(String[] args){
Father father = new Father();
Father son = new Son();

father.hardChoice(new _360());
son.hardChoice(new QQ());
}
}

运行结果:

father choose 360
son choose qq

根据以上代码,我们看看编译阶段编译器的选择过程,也即静态分配过程。这时候选择目标方法的依据有两点:静态类型以及参数,最终产物是产生了两条invokevirtual 指令,两条指令的参数分别为常量池中指向 Father.hardChoice(_360) 以及 Father.hardChoice(QQ) 方法的符号引用,因为是根据两个宗量选择,所以Java语言的静态分派属于多分派类型。

再看看运行阶段虚拟机的选择,也即动态分派的过程。在执行 “son.hardChoice(new QQ())” 时,由于编译期已经决定目标方法的签名必须为 hardChoice(QQ) ,此时,参数的静态类型和参数的实际类型都对方法的选择不会构成任何影响(只要是QQ类型,管你是“腾讯QQ”还是“奇瑞QQ”),唯一可以影响虚拟机选择的因素只有方法接受者的实际类型是 Father 还是 Son ,因为只有一个宗量作为选择依据,所以Java语言的动态分派属于单分派类型。

因此,到目前为止(Java 1.8),我们可以说Java语言是一门静态多分派、动态单分派的语言。

4、虚拟机动态分派的实现

上述的分派结局虚拟机在分派过程中“会做什么”,具体如何做到的,不同虚拟机之间会有差异。由于动态分派是非常频繁的动作,而且动态分派的方法版本选择过程需要运行时在类的方法元数据中搜寻合适的目标方法。但是在实际实现中并不会进行如此频繁的搜索,面对这种情况,最常用的手段就是为类在方法区中建立一个**虚方法表(Vitual Method Table,即vtab),使用虚方法表索引来替代元数据查找以提高性能.基于上方的代码,做如下虚方法表示意:

虚方法表

虚方法表中存放着各个方法的实际入口地址,如果方法在子类中没有被重写,那子类的虚方法表里面的地址入口和父类方法相同,否则,替换为子类具体实现版本的地址入口。因此图中Son的hardChoice方法并没有和父类指向同一处。

5、静态变量、方法的继承、重写、重载(非书本内容,自己测试过后添加)

首先看一段代码:

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
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
public class StaticClassFarther {
public static int a = 3;

public static void tt(){
System.out.println("father 的 tt 方法");
}
}

public class StaticClassSon extends StaticClassFarther {
public static int a = 4;

//注释1
/*public void tt(int b){
System.out.println("son 的 非静态 tt 重载方法");
}*/

public static void tt(int b){
System.out.println("son 的 静态 tt 重载方法");
}

//注释2
/*public void tt(){
System.out.println("son 的非静态 tt 方法");
}*/

public static void tt(){
System.out.println("son 的 tt 方法");
}
}

public class StaticMainClass {
public static void main(String[] args) {
StaticClassFarther farther = new StaticClassFarther();
StaticClassFarther mix = new StaticClassSon();
StaticClassSon son = new StaticClassSon();

farther.tt();
System.out.println("farther 中a = " + farther.a + "\n");


mix.tt();
System.out.println("mix 中a = " + mix.a + "\n");

son.tt();
son.tt(4);
System.out.println("son 中a = " + son.a + "\n");

System.out.println("StaticClassFarther.a = " + StaticClassFarther.a);
System.out.println("开始调用:StaticClassFarther.tt ");
StaticClassFarther.tt();

}
}

其中注释1 是非静态重载,是可以编译通过的,但是注释2非静态重写是不行的,一定要静态重写。再看看输出结果:

father 的 tt 方法
farther 中a = 3

father 的 tt 方法
mix 中a = 3

son 的 tt 方法
son 的 静态 tt 重载方法
son 中a = 4

StaticClassFarther.a = 3
开始调用:StaticClassFarther.tt
father 的 tt 方法

从结果可以看出,子类重写并没有改变父类的值,通过 StaticClassFarther.tt 调用结果还是没变化。并且,具体实现已经是子类的情况下:StaticClassFarther mix = new StaticClassSon();
输出的结果还是父类的(即输出以下内容): father 的 tt 方法 和 mix 中a = 3

动态语言支持

略,后续看到这里补上

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

Java 语言经常被人们定位为“解释执行”的语言,在Java 1.0 时代,这定义还算准确,但当主流的虚拟机中都包含了即时编译器后,Class 文件中的代码到底会被解释执行还是编译执行,就成了只有虚拟机才知道的事情。再后来,Java也发展处了可以直接生成本地代码的编译,而C/C++也出现了通过解释器执行的版本,这时候再笼统地说“解释执行”,对于整个Java语言来说几乎没有意义。

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

Java 编译器输出的指令,基本上是一种基于栈的指令集架构,它们依赖操作数栈进行工作;与之相对的另一套常用的指令集架构是基于寄存器的指令集。那么二者有何不同呢?举个简单的例子,分别使用两种指令集计算“1+1”,基于栈的指令集会是这个样子:

iconst_1
iconst_1
iadd
istore 0

两条icons_1指令连续把两个常量1压入栈后,iadd指令把栈顶的两个值出栈、相加,然后把结果放回栈顶,最后 istore_0把栈顶的值放到局部变量表的第0个Slot中。而如果基于寄存器,那程序可能就会是这样:

mov eax, 1
add eax, 1

mov 指令把EAX寄存器的值设为 1,然后add指令再把这个值加1,结果就保存在 EAX寄存器里面。了解了区别之后,那么这两套指令集哪一种更好?其实是各有所长,基于栈的指令集的主要优点是可移植,而基于寄存器的话,程序要直接依赖于这些硬件寄存器而不可便面地受到硬件的约束。栈架构指令集的主要缺点是执行速度相对来说会稍慢一些,完成相同功能所需要的指令一般会比寄存器的要多,因为出栈和入栈操作本身就产生了相当多的指令数量,更重要的是,栈实现在内存之中,频繁的栈访问也意味着频繁的内存访问,对处理器来说,内存始终是执行速度的瓶颈。

基于栈的解释器执行过程

以示例讲述解释器执行过程, 略

本章小结

6、7、8章,我们分析了 Java程序是如何存储的、如何载入(创建)的以及如何执行的问题,第9章将一起看看这些理论知识在具体开发中的经典应用。

谢谢你的鼓励