JVM 与执行引擎

栈帧结构

栈帧主要包括了局部变量表、操作数栈、动态连接、方法返回地址等信息,在内存结构章节中我们已经探讨过栈结构,但是还未从实现层面来讨论过。

  1. 局部变量表
    用于存放方法参数和方法内部的局部变量。局部变量表的大小在方法的 Code 属性中就已经定义好了,为max_locals的值,局部变量表的单位为slot,32位以内的类型只占用一个slot(包括 returnAddress 类型),64 位的类型占用两个 slot。
    • 对于实例方法而言,索引为 0 的 slot 存放的是 this 引用,之后再依次存放方法参数、局部变量;
    • slot 可以被重用,当局部变量已经超出了作用域时,在作用域外再定义局部变量时,可以重用之前的 slot 空间。
    • 同时,局部变量没有赋值是不能够使用的——会产生编译错误,这和类变量和实例变量是有不同的,如下面代码:
      1
      2
      3
      4
      public void test() {
      int i;
      System.out.println(i);
      }
  2. 操作数栈
    执行方法时,存放操作数的栈,栈的深度在方法的 Code 属性中已经定义好了,为max_stack的值,32 位以内的类型占用一个栈单位,64 位的类型占用两个栈单位。操作数栈可以与其他栈的局部变量表共享区域,这样可以共用一部分数据。
  3. 动态连接
    动态连接是为了支持在运行期间将符号引用转化为直接引用的操作。我们知道,每一个方法对应一个栈帧,而每一个栈帧,都包含指向对应方法的引用,这个引用就是为了支持动态连接,如 invokedynamic 指令。动态连接与静态解析对应,静态解析是在类加载(解析阶段)或者第一次使用时将符号引用转化为直接引用,动态连接则是每一次运行的时候都需要进行转化(invokedynamic 指令)。
  4. 方法返回地址
    正常方法返回,返回地址为到调用该方法的指令的下一条指令的地址;异常返回,返回地址由异常表确定。方法返回时,需要恢复上层方法的局部变量表、操作数栈、将返回值压入调用者栈帧的操作数栈、设置 PC 值。

方法的调用和执行

方法调用决定了调用哪个方法,并创建对应的栈帧,接下来会开始方法的执行

解析

在程序执行前就已经确定了方法调用的版本,即编译期就确定了调用方法版本,这个版本在运行时是不可变的。

  • 静态方法私有方法final方法在编译时就可以确定具体的调用版本,静态方法直接与类型相关、私有方法在外部不可访问、final 不可被继承,也可唯一确定,这些方法称为非虚方法,翻译成字节码是 invokestatic(调用静态方法)、invokespecial(调用实例构造器方法、私有方法、父类方法),在类加载的解析阶段就可以进行解析,如下方法调用在编译期就可以确定方法调用的版本。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    class Father {
    public static void print(String str) {
    System.out.println("father " + str);
    }
    private void show(String str) {
    System.out.println("father " + str);
    }
    }
    class Son extends Father {
    }
    public class Test {
    public static void main(String[] args) {
    Son.print("coder"); // 调用的是Father的print()方法
    //Father fa = new Father();
    //fa.show("cooooder"); // 私有方法无法调用
    }
    }
  • 其他方法称为虚方法

分派

分派调用与多态密切相关,分为静态分派动态分派单分派多分派

静态分派

与静态分派相关的就是方法的重载,重载时根据参数的静态类型引用类型而非实际类型决定调用哪个版本。
选取的过程共分为三个阶段:

  1. 在不考虑对基本类型自动装拆箱(auto-boxing,auto-unboxing),以及可变长参数的情况下选取重载方法;
  2. 如果在第 1 个阶段中没有找到适配的方法,那么在允许自动装拆箱,但不允许可变长参数的情况下选取重载方法;
  3. 如果在第 2 个阶段中没有找到适配的方法,那么在允许自动装拆箱以及可变长参数的情况下选取重载方法。

如果 Java 编译器在同一个阶段中找到了多个适配的方法,那么它会在其中选择一个最为贴切的,而决定贴切程度的一个关键就是形式参数类型的继承关系。

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 class Human {
}
static class Women extends Human {
}
static class Men extends Human {
}

public void sayHello(Human human) {
System.out.println("say human");
}
public void sayHello(Women women) {
System.out.println("say women");
}
public void sayHello(Men men) {
System.out.println("say men");
}

public static void main(String[] args) {
StaticDispatch ds = new StaticDispatch();
Human women = new Women();
Human men = new Men();
// 编译时确定方法的调用版本是以Human作为参数的方法
ds.sayHello(women);
ds.sayHello(men);
}
}

动态分派

与动态分派相关的就是方法的重写,在子类中我们会重写父类的方法,而在调用的时候根据实际类型来选择适合的调用版本。

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
public class DynamicDispatch {
abstract static class Human {
abstract public void sayHello();
}

static class Women extends Human {
@Override
public void sayHello() {
System.out.println("say women");
}
}

static class Men extends Human {
@Override
public void sayHello() {
System.out.println("say men");
}
}

public static void main(String[] args) {
Human women = new Women();
Human men = new Men();
women.sayHello(); // 实际类型是Women
men.sayHello(); // 实际类型是Men
}
}

单分派与多分派

方法的接收者(方法的所有者)与方法的参数统称为方法的宗量,根据分派基于多少种宗量,可以将分派划分为单分派多分派
单分派根据一个宗量确定调用方法的版本;多分派根据多个宗量确定调用方法的版本。

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 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.hardChoice(QQ)和 Father.hardChoice(_360)的指令,可以知道,在编译期,是由多个宗量确定调用版本,是静态多分派。
动态分派过程如下,在运行期,在执行 hardChoice(QQ)或者 hardChoice(_360)时,已经确定了参数必须为 QQ、_360,方法签名确定,静态类型和实际类型此时都不会对方法本身产生任何影响,而虚拟机会根据实际类型来确定调用版本,只根据一个宗量进行确定,因此,在运行时,是动态单分派。
在面向对象编程中我们会很频繁地使用到动态分配,虚拟机采用在类的方法区建立一个虚方法表(非虚方法不会出现在表中)来实现。
动态分派的实现

  • 只有虚方法才会出现在虚方法表中,也就是说静态方法私有方法final 方法是不会出现在这张表中的。
  • 从 Object 类继承的方法都会指向 Object 类型数据中各方法的实际入口地址。
  • 类自身的方法会指向类的数据类型中方法的实际入口地址。
  • 父类的没有被重写的方法在虚方法表中的索引与子类方法表中的索引相同,这样,当类型变化时,只需要改变方法表就行,索引还是相同。
  • 方法表一般在类加载的连接阶段进行初始化,准备了类变量的初始值后,方法表也初始化完毕。

方法退出

当一个方法开始执行后,只有两种方式可以退出:

  1. 第一种方式是执行引擎遇到任意一个方法返回的字节码指令,这种方式称为正常完成出口
  2. 另外一种退出方式是,在方法执行过程中遇到异常,且该异常没有被被捕获,称为异常完成出口

无论是哪种退出方式,在方法退出后,都需要返回到该方法被调用的位置(地址),让程序继续执行。一般来说,方法执行前,会保存调用者当前的 PC 计数器中的值,当方法正常退出时,将该 PC 计数器的值会作为返回地址,返回给调用者。在方法异常退出时,返回地址是通过异常处理器表来确定的。

方法退出的过程实际上就等于把当前栈帧出栈,一般过程为:

  1. 恢复上层方法的局部变量表和操作数栈
  2. 把返回值压入调用者栈帧的操作数栈中
  3. 调整 PC 计数器的值,以指向方法调用指令后面的一条指令

动态类型语言支持

Java 是一种静态类型语言,它与 Python、JavaScript 等动态类型语言的主要区别是:

  • 静态类型语言的类型检查主要过程是在编译期进行而不是运行期。

静态类型语言与动态类型语言的比较如下:

  • 静态类型语言在编译期确定类型,最显著的好处是编译器可以提供严谨的类型检查,这样与类型相关的问题能在编码的时候就及时发现,利于稳定性及代码达到更大规模。
  • 动态类型语言在运行期确定类型,这可以为开发人员提供更大的灵活性,某些在静态类型语言中需用大量“臃肿”代码来实现的功能,由动态类型语言来实现可能会更加清晰和简洁,从而提升开发效率。

在 JDK1.7 以前的字节码指令集中,4 条方法调用指令(invokevirtual、invokespecial、invokestatic、invokeinterface)的第一个参数都是被调用的方法的符号引用(CONSTANT_Methodref_info 或者 CONSTANT_InterfaceMethodref_info 常量),前面已经提到过,方法的符号引用在编译时产生,而动态类型语言只有在运行期才能确定接收者类型。
Java 不像 C/C++那样有 Function Pointer 或者 C#里的 Delegate。在 Java 中要实现类似的功能可以有以下两种方式:

  1. 实现一个函数接口,比如 Comparator
  2. MethodHandle,它的实现原理是第 5 条方法调用的字节码指令 invokedynamic,与其他 invoke*指令的最大差别是它的分派逻辑不是由虚拟机决定的,而是由程序员决定的。

MethodHandle 的例子:

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
import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodType;

import static java.lang.invoke.MethodHandles.lookup;

/**
* @author hgc
* @date 2/16/20
*/
public class MethodHandleTest {

static class ClassA {
public void println(String s) {
System.out.println(s);
}
}

private static MethodHandle getPrintlnMH(Object reveiver) throws NoSuchMethodException, IllegalAccessException {
// MethodType代表方法类型,包含了方法的返回值(methodType()的第一个参数)和具体参数(methodType()第二个及之后的参数)
MethodType mt = MethodType.methodType(void.class, String.class);
// lookup()方法来自于MethodHandles.lookup,这句的作用是在指定类中查找符合给定方法名称、方法类型,并且符合调用权限的方法句柄
// 因为这里调用的是一个虚方法,按照Java语言的规则,方法第一个参数是隐式的,代表该方法的接收者,也即是this指向的对象,这个参数以前是放在参数列表中进行传递的,而现在提供了bindTo()方法来完成这件事
return lookup().findVirtual(reveiver.getClass(), "println", mt).bindTo(reveiver);
}

public static void main(String[] args) throws Throwable {
Object obj = System.currentTimeMillis() % 2 == 0 ? System.out : new ClassA();
getPrintlnMH(obj).invokeExact("icyfenix");
}
}

MethodHandle 与反射(Reflection)的区别是:

  • Reflection API 的设计初衷是只为 Java 服务,而 MethodHandle 则设计为可服务于所有 Java 虚拟机之上的语言,当然也包括 Java 语言。
  • MethodHandle 与 Reflection 机制都是在模拟方法调用,但 Reflection 是在模拟代码层次的方法调用,而 MethodHandle 则是在模拟字节码层次的方法调用。
    MethodHandles.lookup 中的 3 个方法——findStatic()、findVirtual()、findSpecial()对应了 invokestatic、invokevirtual + invokeinterface 和 invokespecial 这几条字节码指令的执行权限校验行为,而这些底层细节在使用 Reflection API 时无需考虑。
  • Reflection 中的 Method 比 MethodHandle 对象包含的信息多,Reflection 是重量级的,MethodHandle 是轻量级的。
  • MethodHandle 模拟了字节码的方法指令调用,所以理论上虚拟机在这方面做的各种优化(如方法内联)在 MethodHandle 上也可以采用类似思路来支持,而通过反射去调用方法则不行。

至于 MethodHandle 是如何实现的,可以参考《深入理解 Java 虚拟机》,大致上就是运行期去常量表里根据用户指定的参数找方法。

###基于栈的字节码解释执行引擎
JVM 的指令都是基于栈的,比如iadd表示弹出栈顶的两个元素,然后求出二者的和后重新压入栈中。
基于栈的指令集与基于寄存器的指令集各有优势:

  • 基于栈的指令集的主要优点就是可移植。
    寄存器是由硬件直接提供的,基于寄存器的指令集由于直接依赖这些硬件寄存器则不可避免地要受到硬件的约束。
    如果使用栈架构的指令集,用户程序不会直接使用这些寄存器,就可以由虚拟机实现来自行决定把一些访问最频繁的数据(程序计数器、栈顶缓存等)放到寄存器中以获取尽量好的性能,这样实现起来也更加简单一些。
  • 栈架构的指令集还有一些其他优点,比如代码相对来说更加紧凑(字节码中每个字节就对应一条指令,而多地址指令集中还需要存放参数)、编译器实现更加简单(不需要考虑空间分配的问题,所需空间都在栈上操作)等。
  • 栈架构指令集的主要缺点是执行速度相对来说会稍慢一些。所有主流物理机的指令集都是寄存器架构也从侧面印证了这一点。
    一方面,虽然栈架构指令集的代码非常紧凑,但是完成相同功能所需的指令数量一般会比寄存器架构多。比如出栈入栈操作本身就产生了相当多的指令数量。
    另一方面,栈实现在内存之中,频繁的栈访问也就意味着频繁的内存访问,相对于处理器来说,内存始终是执行速度的瓶颈。尽管虚拟机可以采取栈顶缓存的手段,把最常用的操作映射到寄存器中避免直接内存访问,但这也只能是优化措施而不是解决本质问题的方法。

QA

  1. 哪个方法会被调用?
    重载会触发静态分派,会根据传参的静态类型来决定调用哪个方法,因此会调用 print(Father),但输出时调用了 Child 类的 toString 方法,因为方法被重写了,会触发方法的动态分派,根据传参的实际类型来决定调用哪个方法。
    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
    public class DynamicDispatchTest {

    public void print(Father father) {
    System.out.println(father);
    }

    public void print(Child child) {
    System.out.println(child);
    }

    public static class Father {

    @Override
    public String toString() {
    return "Father";
    }
    }

    public static class Child extends Father {

    @Override
    public String toString() {
    return "Child";
    }
    }

    public static void main(String[] args) {
    Father father = new Child();
    DynamicDispatchTest dynamicDispatchTest = new DynamicDispatchTest();
    dynamicDispatchTest.print(father);
    }
    }