虚拟机字节码执行引擎

2,382 阅读14分钟

所谓的「虚拟机字节码执行引擎」其实就是 JVM 根据 Class 文件中给出的字节码指令,基于栈解释器的一种执行机制。通俗点来说,也就是 JVM 解析字节码指令,输出运行结果的一个过程。接下来我们详细看看这部分内容。

方法调用的本质

在描述「字节码执行引擎」之前,我们先从汇编层面看看基于栈帧的方法调用是怎样的。(以 IA32 型 CPU 指令集为例)

IA32 的程序中使用栈帧数据结构来支持过程调用(Java 语言中称作方法),每个过程对应一个栈帧,过程的调用对应与栈帧的入栈和出栈。某个时刻,只有位于栈顶的栈帧可用,它代表了某个方法正在执行中的各种状态。最顶端的栈帧用两个指针界定,栈指针,帧指针。他们对应于栈中的地址分别存储在寄存器 %ebp%esp 中。栈中的大致结构如下:

image

栈指针始终指向栈顶元素,控制着栈中元素的出入栈,帧指针指向的是当前栈帧的底部,注意是当前栈帧,不是整个栈的底部。

下面我们看看一段 C 代码:

#include<stdio.h>
void sayHello(int age)
{
    int x = 32;
    int y = 2323;
    age = x + y;
}

void main()
{
    int age = 22;
    sayHello(age);
}

很简单的一段代码,我们汇编生成相应的汇编代码,省略了部分链接代码,留下的是核心的部分:

main:
	pushl	%ebp
	movl	%esp, %ebp
	subl	$20, %esp
	movl	$22, -4(%ebp)
	movl	-4(%ebp), %eax
	movl	%eax, (%esp)
	call	sayHello
	leave
	ret
	
sayHello:
	pushl	%ebp
	movl	%esp, %ebp
	subl	$16, %esp
	movl	$32, -4(%ebp)
	movl	$2323, -8(%ebp)
	movl	-8(%ebp), %eax
	movl	-4(%ebp), %edx
	addl	%edx, %eax
	movl	%eax, -12(%ebp)
	leave
	ret

先看 main 函数的汇编代码,main 函数里的前两个汇编指令和 sayHello 中的前两条指令是一样的,我们在留到后者里介绍。

subl 指令将寄存器 %esp 中的地址减去 20,即栈指针向上扩展了 20 个字节(栈是倒着生长的),也就是为当前栈帧分配了 20 个字节大小。接着,movl 将值 20 写入地址 -4(%ebp),这个地址其实就是相对寄存器 %ebp 帧指针位置之上的四个字节处。假如 %ebp 的值为:0x14,那么 20 就被存储到地址 0x10 的栈地址中。

接着一条 movl 指令将参数 age 的值取出来存入寄存器 %eax 中。

这时就到了核心的 call 方法了,计算机中有程序计数器(PC)来指向下一条指令的位置,而常常我们的程序会调用到其他方法里,那么调用结束后又该如何恢复调用前的状态并继续执行呢?

这里的解决办法是,call 指令的第一步就是将返回地址压栈,然后跳向 sayHell 方法中执行,这里我们看不到它压栈的过程,被集成为一条指令了。

然后跳向了 sayHello 方法的第一条指令开始执行,pushl 将寄存器 %ebp 中的地址压栈,这时候的 %ebp 是上一个栈帧的帧指针地址,这个操作其实是一个保存的动作。然后,movl 指令将帧指针指向栈指针的位置,也就是栈顶位置,继而将栈指针向上扩展 16 个字节。

接着,将数值 32 和 2323 分别写入不同的栈地址中,这个地址相对于帧指针的地址,是可以计算出来的。

后面的操作是将 x 和 y 分别写入寄存器 %eax 和 %edx,然后 add 指令做加法运算并存入寄存器 %eax 中。接着将结果压栈。

leave 指令等效于以下两条指令之和:

movl %ebp %esp
popl %ebp

什么意思呢?

把栈指针退回到帧指针的位置,也就是当前栈帧的底部,接着弹栈,这样的话整个 sayHello 所占用的栈帧就已经无法引用了,相当于释放了当前栈帧。

ret 指令用于恢复调用前的状态,继续执行 main 方法。

整个 IA32 的方法调用基本如上,对于 64 位的 x86-64 来说,增加了 16 个寄存器,优先使用寄存器进行参数的计算与传递,效率提高了。但是与这个基于栈的存储方式来说,劣势之处在于「可移植性差」,不同的机器的寄存器使用肯定是有所差别的。所以我们的 Java 毋庸置疑使用的是栈。

运行时栈帧结构

在 Java 中,一个栈帧对应一个方法调用,方法中需涉及到的局部变量、操作数,返回地址等都存放在栈帧中的。每个方法对应的栈帧大小在编译后基本已经确定了,方法中需要多大的局部变量表,多深的操作数栈等信息早以被写入方法的 Code 属性中了。所以运行期,方法的栈帧大小早已固定,直接计算并分配内存即可。

局部变量表

局部变量表用来存放方法运行时用到的各种变量,以及方法参数。虚拟机规范中指明,局部变量表的容量用变量槽(slot)为最小单位,却没有指明一个 slot 的实际空间大小,只是说,每个 slot 应当能够存放任意一个 boolean,byte,char,short,int,float,reference 等。

按照我的理解,一个 slot 相当于一个黑盒子,具体占几个字节适情况而定,但是这个黑盒子明确可以保存一个任意类型的变量。

局部变量表不同于操作数栈,它采用索引机制访问元素,而不同于操作数栈的出入栈方式。例如:

public void sayHello(String name){
        int x = 23;
        int y = 43;
        x++;
        x = y - 2;
        long z = 234;
        x = (int)z;
        String str = new String("hello wrold ");
    }

我们反编译看看它的局部变量表:

image

可以看到,局部变量表第一项是名为 this 的一个类引用,它指向堆中当前对象的引用。接着就是我们的方法参数,局部变量 x,y,z 和 str。

这其实也间接说明了,我们的每个实例方法都默认传入了一个参数 this,指向当前类的实例引用。

操作数栈

操作数栈也称作操作栈,它不像局部变量表采用的索引机制访问其中元素,而是标准的栈操作,入栈出栈,先入后出。操作数栈在方法执行之初为空,随着方法的一步一步运行,操作数栈中将不停的发生入栈出栈操作,直至方法执行结束。

操作数栈是方法执行过程中很重要的一个部分,方法执行过程中各个中间结果都需要借助操作数栈进行存储。

返回地址

一个方法在调用另一个方法结束之后,需要返回调用处继续执行后续的方法体。那么调用其他方法的位置点就叫做「返回地址」,我们需要通过一定的手段保证,CPU 执行其他方法之后还能返回原来调用处,进而继续调用者的方法体。

正如我们一开始介绍的汇编代码一样,这个返回地址往往会被提前压入调用者的栈帧中,当方法调用结束时,取出栈顶元素即可得到后续方法体执行入口。

方法调用

方法调用算是本篇的一个核心内容了,它解决了虚拟机对目标调用方法的确定问题,因为往往一条虚拟机指令要求调用某个方法,但是该方法可能会有重载,重写等问题,那么虚拟机又该如何确定调用哪个方法呢?这就是本阶段要处理的唯一任务。

首先我们要谈谈这个解析过程,从上篇文章中可以知道,当一个类初次加载的时候,会在解析阶段完成常量池中符号引用到直接引用的替换。这其中就包括方法的符号引用翻译到直接引用的过程,但这只针对部分方法,有些方法只有在运行时才能确定的,就不会被解析。我们称在类加载阶段的解析过程为「静态解析」。

那么哪些方法是被静态解析了,哪些方法需要动态解析呢?

比如下面这段代码:

Object obj = new String("hello");
obj.equals("world");

Object 类中有一个 equals 方法,String 类中也有一个 equals 方法,上述程序显然调用的是 String 的 equals 方法。那么如果我们加载 Object 类的时候将 equals 符号引用直接指向了本身的 equals 方法的直接引用,那么上述的 obj 永远调用的都是 Object 的 equals 方法。那我们的多态就永远实现不了。

只有那些,「编译期可知,运行时不变」的方法才可以在类加载的时候将其进行静态解析,这些方法主要有:private 修饰的私有方法,类静态方法,类实例构造器,父类方法

其余的所有方法统称为「虚方法」,类加载的解析阶段不会被解析。这些方法的调用不存在问题,虚拟机直接根据直接引用即可找到方法的入口,但是「非虚方法」就不同了,虚拟机需要用一定的策略才能定位到实际的方法,下面我们一起来看看。

静态分派

首先我们看一段代码:

public class Father {
}
public class Son extends Father {
}
public class Daughter extends Father {
}
public class Hello {
    public void sayHello(Father father){
        System.out.println("hello , i am the father");
    }
    public void sayHello(Daughter daughter){
        System.out.println("hello i am the daughter");
    }
    public void sayHello(Son son){
        System.out.println("hello i am the son");
    }
}
public static void main(String[] args){
    Father son = new Son();
    Father daughter = new Daughter();
    Hello hello = new Hello();
    hello.sayHello(son);
    hello.sayHello(daughter);
}

输出结果如下:

hello , i am the father

hello , i am the father

不知道你答对了没有?这是一道很常见的面试题,考的就是你对方法重载的理解以及方法分派逻辑懂不懂。下面我们来分析一下:

首先需要介绍两个概念,「静态类型」和「实际类型」。静态类型指的是包装在一个变量最外层的类型,例如上述 Father 就是所谓的静态类型,而 Son 或是 Daughter 则是实际类型。

我们的编译器在生成字节码指令的时候会根据变量的静态类型选择调用合适的方法。就我们上述的例子而言:

image

这两个方法就是我们 main 函数中调用的两次 sayHello 方法,但是你会发现传入的参数类型是相同的,Father,也就是调用的方法是相同的,都是这个方法:

(LStaticDispathch/Father;)V

也就是

public void sayHello(Father father){}

所有依赖静态类型来定位方法执行版本的分派动作称作「静态分派」,而方法重载是静态分派的一个典型体现。但需要注意的是,静态分派不管你实际类型是什么,它只根据你的静态类型进行方法调用。

动态分派

public class Father {
    public void sayHello(){
        System.out.println("hello world ---- father");
    }
}
public class Son extends Father {
    @Override
    public void sayHello(){
        System.out.println("hello world ---- son");
    }
}
public static void main(String[] args){
    Father son = new Son();
    son.sayHello();
}

输出结果:

hello world ---- son

显然,最终调用了子类的 sayHello 方法,我们看生成的字节码指令调用情况:

image

image

看到没?编译器为我们生成的方法调用指令,选择调用的是静态类型的对应方法,但是为什么最终的结果却调用了是实际类型的对应方法呢?

当我们将要调用某个类型实例的具体方法时,会首先将当前实例压入操作数栈,然后我们的 invokevirtual 指令需要完成以下几个步骤才能实现对一个方法的调用:

  • 弹出操作数栈顶部元素,判断其实际类型,记做 C
  • 在类型 C 中查找需要调用方法的简单名称和描述符相同的方法,如果有则返回该方法的直接引用
  • 否则,向 C 的父类再做搜索,有即返回方法的直接引用
  • 否则,抛出异常 java.lang.AbstractMethodError 异常

所以,我们此处的示例调用的是子类 Son 的 sayHello 方法就不言而喻了。

至于虚拟机为什么能这么准确高效的搜索某个类中的指定方法,各个虚拟机的实现各有不同,但最常见的是使用「虚方法表」,这个概念也比较简单,就是为每个类型都维护一张方法表,该表中记录了当前类型的所有方法的描述信息。于是虚拟机检索方法的时候,只需要从方法表中进行搜索即可,当前类型的方法表中没有就去父类的方法表中进行搜索。

动态类型特性的支持

动态类型语言的一个关键特征就是,类型检查发生在运行时。也就是说,编译期间编译器是不会管你这个变量是什么类型,调用的方法是否存在的。例如:

Object obj = new String("hello-world");
obj.split("-");

Java 中,两行代码是不能通过编译器的,原因就是,编译器检查变量 obj 的静态类型是 Object,而 Object 类中并没有 subString 这个方法,故而报错。

而如果是动态类型语言的话,这段代码就是没问题的。

静态语言会在编译期检查变量类型,并提供严格的检查,而动态语言在运行期检查变量实际类型,给了程序更大的灵活性。各有优劣,静态语言的优势在于安全,缺点在于缺乏灵活性,动态语言则是相反的。

JDK1.7 提供了两种方式来支持 Java 的动态特性,invokedynamic 指令和 java.lang.invoke 包。这两者的实现方式是类似的,我们只介绍后者的基本内容。

//该方法是我自定义的,并非 invoke 包中的
public static MethodHandle getSubStringMethod(Object obj) throws NoSuchMethodException, IllegalAccessException {
    //定义了一个方法模板,规定了待搜索的方法的返回值和参数类型
    MethodType methodType = MethodType.methodType(String[].class,String.class);
    //查找符合指定方法简单名称和模板信息的方法
    return lookup().findVirtual(obj.getClass(),"split",methodType).bindTo(obj);
}
public static void main(String[] args){
    Object obj = new String("hello-world");
    //定位方法,并传入参数执行方法
    String[] strs = (String[]) getSubStringMethod(obj).invokeExact("-");
    System.out.println(strs[0]);
}

输出结果:

hello

你看,虽然我们 obj 的静态类型是 Object,但是通过这种方式,我就是能够越过编译器的类型检查,直接在运行期执行我指定的方法。

具体如何实现的我就不带大家看了,比较复杂,以后有机会单独写一篇文章学习一下。反正通过这种方式,我们可以不用管一个变量的静态类型是什么,只要它有我想要调的方法,我们就可以在运行期直接调用。

总结一下,HotSpot 虚拟机基于操作数栈进行方法的解释执行,所有运算的中间结果以及方法参数等等,基本都伴随着出入栈的操作取出或存储。这种机制最大的优势在于,可移植性强。不同于基于寄存器的方法执行机制,对底层硬件依赖过度,无法很轻易的跨平台,但是劣势也很明显,就是同样的操作需要相对更多的指令才能完成。


文章中的所有代码、图片、文件都云存储在我的 GitHub 上:

(https://github.com/SingleYam/overview_java)

欢迎关注微信公众号:扑在代码上的高尔基,所有文章都将同步在公众号上。

image