Java虚拟机字节码执行引擎

本博文对应《深入理解java虚拟机》第八章

概述

java虚拟机通常会有解释器(解释执行)和即时编译器(编译执行)两种选择,而且大部分的虚拟机都是包含这两种引擎的。

栈帧结构

java虚拟机以方法作为最基本的执行单元,而之前也提到过,一个栈帧对应了一个方法。

栈帧存储了方法的局部变量表、操作数栈、动态链接和方法的返回地址等信息。一个方法从开始执行到执行结束,对应的是在虚拟机栈里面从入栈到出栈的全过程。

在编译的时候,栈帧需要多大的局部变量表,多深的操作数栈其实已经知道了,并且写到了方法表的Code属性里。也就是说栈帧需要多少内存,完全是在编译的时候就已经决定好了。

局部变量表

局部变量表用来存放方法的参数(别忘了隐形参数this)+内部定义的局部变量。

虚拟机使用“槽”—slot的概念来存放这些数据,除了long和double,都可以用32位放下,就称这32位为一个槽。如果对于long和double的读取,虚拟机不允许用任何方式单独访问其中的一个槽,必须两个一起。

槽是可以复用的,当一个槽里面的数据的作用域已经超出了当前的代码,那这个槽显然就可以被用来存放新的数据了。下面用一个例子来说明这一举措,这一例子我个人觉得非常有用。

1
2
3
4
5
6
7
public static void main(String[] args) {
// 分配64M空间
{
byte[] placeHolder = new byte[64 * 1024 * 1024];
}
System.gc();
}

带上vm参数:-verbose:gc可以看到垃圾回收的详情,如下图所示:

image-20200614000741877

然后是稍微修改一点点的代码,改成这样子:

1
2
3
4
5
6
7
8
public static void main(String[] args) {
// 分配64M空间
{
byte[] placeHolder = new byte[64 * 1024 * 1024];
placeHolder = null;
}
System.gc();
}

然后瞬间就回收了很多:

image-20200614001548956

这个解释起来也很简单,第一段代码中,虽然离开了placeHolder的作用区域,但是并没有新的槽被使用,而局部变量表作为GC Roots中的一员,显然是不会被回收的。然而当对其赋值null的时候,相当于读写了局部变量表,这就导致了变量槽的变更,所以就能够回收掉这64M的内存。

当然平时的时候没有必要这么做,因为你随便另外一个变量赋值也能达到相同的效果,而且还有编译器优化等。

操作数栈

程序翻译而成的指令,要进行相应的操作,用的就是操作数栈。以加法为例,iadd指令会取出栈顶的两个元素,然后对它们进行求和之后再放回栈顶。

两个方法,从本质上讲是属于两个栈帧的,而每个栈帧都会有一个操作数栈,理论上这两个操作数栈应该毫无关系,但是实际中却会让一个栈帧的操作数栈和另外一个栈帧的局部变量表进行重合,这样做的目的是为了能够方便的共享数据。

动态连接

我们需要知道当前栈帧对应的是哪个方法,于是在每个栈帧中就会有一个指向运行时常量池的引用,当我们把这个引用(符号引用)转化为真实地址的时候,就叫动态连接。

返回地址

方法要么正常结束返回,要么遇到了异常且没有在方法中得到处理,这种方式退出的话是没有返回值的。

如果方法是正常退出,我们只需要去找到调用当前的方法的PC计数器,PC计数器的值就是返回地址。当方法是异常退出的时候,那就需要通过异常处理器来确定了。

方法调用

这里的方法调用,不是要讲进入到方法内部执行方法内部的代码,而是确定要调用哪个方法。

之前讲到过,class文件里面存储的是符号引用,所以还需要到运行时常量池里面找到对应的内存地址(即直接引用)。

在java中,有静态方法和私有方法这两种方法,它们的特点是,不可以通过继承等方法弄出一个新的版本来,所以它们适合在类加载的时候就进行解析(之前就分析过了)

同时java也支持五种方法调用的指令,它们分别是:

  • invokestatic:调用静态方法
  • invokespecial:调用构造方法、私有方法和父类中的方法
  • invokevirtual:调用虚方法
  • invokeinterface:调用接口方法,在运行的时候才确定是哪个实现该接口的对象
  • invokedynamic:在运行时动态解析出所引用的方法,然后再执行

静态方法、私有方法、构造方法和父类方法这四种,可以在解析的时候就能确定,再加上一个final修饰的方法,可以在类加载的时候就直接把符号引用变成直接引用。

分派

静态分派

分派揭示了多态特征的基本体现,深入了解它,就可以知道“重载”和“重写”的最基本原理。

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

static class Man extends Human {
}

static class Woman extends Human {
}

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

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

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

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

这段代码会输出两个hello guy。也就是说虽然方法进行了重载,但是这俩都选择了第一个方法。

上面的Human我们称之为变量的静态类型(Static Type),而Man则成为叫实际类型(Actual Type)或者叫运行时类型(Runtime Type)。显然在编译器就可以知道静态类型,而实际类型则需要到运行期才可以知晓。

重载的时候,确定使用哪个方法,完全取决于传入参数的数量和数据类型。上面的代码中,静态类型都是Human,实际类型一个是Man一个是Woman,但是重载是通过参数的静态变量类型来作为判决依据的,所以就选择了Human。真想使用的话,得使用强制类型转化:sd.sayHello((Man)man);才可以。

动态分派

静态分派影响了重载,而动态分派则影响了重写。还是一样先来个例子:

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
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指令,该指令大致分成几个步骤:

  1. 找到栈顶元素(man)所指向对象的实际类型(Man),记做C
  2. 看看C能否找到描述符和简单名称都相符的方法,并进行权限验证,找到就返回直接引用。
  3. 否则就按照继承的关系从下往上找
  4. 找到最顶层都没找到的话,就返回异常。

上面也说了,是invokevirtual这个指令才这么做的,也就是说只会对方法有效,而字段无效;所以当父类的字段子类也有的时候,这两个字段内存中都会存在,但是子类的字段会盖过父类的。

单分派与多分派

静态分派和动态分派解决了重载和重写的问题。下面是一段示例代码:

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
public class Dispatch {

static class QQ {

}

static class _360 {

}

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

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


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

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

public static void main(String[] args) {
Father father = new Father();
Son son = new Son();
father.hardChoice(new _360());
son.hardChoice(new QQ());
}

}

很简单,相信任何人都知道该程序会输出什么。我们按照编译过程+执行过程的流程来走一遍。

首先编译器发现了father调用了hardChoice,那么根据father的静态类型是Father,所以定位到了Father里面,看到hardChoice发生了重载,那么也就是有两条invokevirtual指令,分别是参数为360的和参数是QQ的,于是就得到了确认。也就是说,静态分派根据方法名字+方法参数才确定了使用哪一个重载方法,所以说静态分派是多分派类型。

到了运行期,虚拟机已经明确知道需要执行哪个类的哪个方法了,唯一影响的,就只有该方法的接受者的实际类型是什么,显然这里father的实际类型就是Father,所以我们说动态分派是单分派类型。

总结下来就是,java是一种静态多分派、动态单分派的语言。

动态语言支持

java虚拟机的字节码指令从第一台虚拟机到今天,都只增加过一条指令invokedynamic,所以我们需要围绕它来了解一下java对动态语言的支持。

java.lang.invoke包

Python里面有一个很不错的“特性”,可以把函数赋值给一个参数,然后代入到方法中,这样函数本身就被“传”了进去,最典型的应用就是排序算法了。

但是java不可以,它不能把函数当成一个参数给传入进去,java中实际的做法是让一个类继承Comparator接口,并且新建出对象,传入对象来解决。

为了解决这个短板,引入了这个包,它提供了一种新的动态确定目标方法的机制,称为“方法句柄”Method Handle。当然如果只是相同的效果,那么其实反射早就可以完成了,但是反射没有它来的底层。

invokedynamic指令

该指令的第一个参数是一个invokedynamic_info常量,该常量有三项信息,分别是:引导方法,方法类型和名称。引导方法固定是返回java.lang.invoke.CallSite对象,该对象就是最终需要执行的目标方法。

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

这部分我个人觉得偏向于编译原理,所以可能比较难懂。

javac编译器完成了词法分析、语法分析、构造抽象语法树,并且遍历语法树生成线性字节码指令流。而解释器则在虚拟机内部,所以java的编译可以说是半独立实现。

javac编译器最后得到的.class文件,即字节码指令流,是一种基于栈的指令集架构,即依赖于操作数栈来完成指令的工作。而我们日常使用的PC则使用的是基于寄存器的指令集。

举一个1+1的例子来更直观的区分这两种指令集的区别,首先是基于栈的:

1
2
3
4
iconst_1   // 把1压入栈中
iconst_1 // 把1压入栈中
iadd // 弹出2个数字,相加,放回栈顶
istore_0 // 放回到局部变量表的第0个slot中

基于寄存器的

1
2
mov eax,1
add eax,1

java的这套基于栈的指令集,考虑的是可移植性的问题,当然性能肯定没有寄存器的那么优秀。而且我们也可以发现,所需要的指令数会比仅仅使用寄存器的多不少。更加要命的是,栈是在内存中的,而内存的速度肯定是远远比不上寄存器的。

这里提一句android的Dalvik虚拟机就是基于寄存器的,尽可能把虚拟机的寄存器映射到物理机的寄存器中去获得更好的性能。