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

本文最后更新于:1 年前

虚拟机字节码执行引擎:

  • 虚拟机的执行引擎可以不受物理条件制约因为是由软件自行实现的地定制指令集与执行引擎的结构体系,能够执行那些不被硬件直接支持的指令集格式。物理机的执行引擎是直接建立在处理器、缓存、指令集和操作系统层面上的。
  • 概念模型在《Java 虚拟机规范》中制定:输入的是字节码二进制流,处理过程是字节码解析执行的等效过程,输出的是执行结果。

运行时栈帧结构:

  • Java 虚拟机以方法作为最基本的执行单元,栈帧存储了局部变量表、操作数栈、动态连接、方法返回地址和一些额外的附加信息是用于支持虚拟机进行方法调用方法执行背后的数据结构每一个方法从调用开始至执行结束的过程,都对应着一个栈帧在虚拟机里面从入栈到出栈的过程
  • 一个栈帧需要分配多少内存,并不会受到程序运行期变量数据的影响在编译 Java 程序源码的时候,栈帧中需要多大的局部变量表,需要多深的操作数栈就已经被分析计算出来了,并且写入到方法表的 Code 属性之中
  • 执行引擎所运行的所有字节码指令都只针对当前栈帧位于栈顶的栈帧进行操作。

局部变量表:

  • 局部变量表是一组变量值的存储空间,用于存放方法参数和方法内部定义的局部变量。
  • Java 虚拟机通过索引定位的方式使用局部变量表,索引值的范围是从 0 开始至局部变量表最大的变量槽局部变量表的容量的最小单位数量。
    • 如果访问的是 32 位数据类型的变量,索引 N 就代表了使用第 N 个变量槽。
    • 如果访问的是 64 位数据类型的变量,会同时使用第 N 和第 N+1 两个变量槽。
  • Java 虚拟机使用局部变量表完成参数值参数变量列表的传递实参到形参的传递
    • 如果执行的是实例方法,那局部变量表中第 0 位索引的变量槽默认是用于传递方法所属的对象实例的引用在方法中可以通过关键字 this 来访问到这个隐含的参数
  • 变量槽是可以重用的所以方法体中定义的变量,其作用域并不一定会覆盖整个方法体
    • 优:节省栈帧耗用的内存空间。
    • 【引入问题影响不大】在某些情况后面的代码有一些耗时很长的操作,而前面又定义了占用了大量内存但实际上已经不会再使用的变量下会影响到系统的垃圾收集行为见后面例子
      【原因】变量槽在被重用前,局部变量表作为 GC Roots 一部分保持着对它的关联。
      【解决问题除了做实验外几乎毫无用处,在经过即时编译优化后几乎一定会被当作无效操作消除掉】打断关联关系比如重用变量槽,或把不使用的对象手动赋值 null
  • 局部变量没有赋初始值时不能使用没有类变量的在准备阶段被赋予系统初始值的过程,编译器能在编译期间就检查到并提示。

例子:变量槽可被重用的设计,影响了垃圾收集行为。

添加运行参数:

应该能 gc 但是实际没有 gc:

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

修改之后能 gc:

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


操作数栈:

  • Java 虚拟机的解释执行引擎被称为:基于操作数栈的执行引擎。
  • 当一个方法刚刚开始执行的时候,这个方法的操作数栈是空的,在方法的执行过程中,会有各种字节码指令往操作数栈中写入和提取内容也就是出栈和入栈的操作
  • 在概念模型中,两个不同栈帧作为不同方法的虚拟机栈的元素是完全相互独立的。但是在大多数虚拟机的实现里都会进行一些优化处理令两个栈帧出现一部分重叠:一块内存同时作为一个栈帧的部分局部变量表和下一个栈帧的部分操作数栈
    • 优:节约一些空间;无须进行额外的参数复制传递。

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

方法返回地址:

  • 当一个方法开始执行后,只有两种方式退出这个方法:
    • 正常调用完成:遇到任意一个方法返回的字节码指令。
      主调方法的 PC 计数器的值就可以作为返回地址栈帧中很可能会保存这个计数器值
    • 异常调用完成:遇到了异常并且这个异常没有在方法体内得到妥善处理
      返回地址要通过异常处理器表来确定栈帧中一般不会保存这部分信息
  • 方法退出等同于把当前栈帧出栈时可能执行的操作:
    1. 恢复上层方法的局部变量表和操作数栈。
    2. 把返回值压入调用者栈帧的操作数栈。
    3. 调整 PC 计数器的值以指向方法调用指令后面的一条指令

栈帧信息:
包括动态连接、方法返回地址、附加信息《Java 虚拟机规范》允许虚拟机实现增加一些规范里没有描述的信息到栈帧之中

方法调用:

  • 唯一的任务就是确定被调用方法的版本未涉及方法内部的具体运行过程
  • Class 文件的常量池中存有大量的符号引用方法调用的字节码指令就以常量池中指向方法的符号所有方法调用的目标方法在 Class 文件里面都是一个常量池中的符号引用作为参数。
    符号引用转化为直接引用有两种情况:
    • 【静态】解析:在类加载解析阶段或者第一次使用时转化。
      • 【在类加载解析阶段转化】
        • 前提:编译期可知方法在程序真正运行之前就有一个可确定的调用版本,运行期不可变方法的调用版本在运行期是不可改变的
        • 符合前提要求的方法:主要有静态方法与类型直接关联私有方法在外部不可被访问,各自的特点决定了它们都不可能通过继承或别的方式重写出其他版本。
    • 【动态】连接:在每一次运行期间都转化。
  • 对应不同类型的方法调用的字节码指令:
    • invokestatic:调用静态方法。
    • invokespecial:调用实例构造器 <init>() 方法、私有方法和父类中的方法。
    • invokevirtual:调用所有的虚方法不能在类加载时把符号引用解析为直接引用的方法
      • 【普通方法】解析过程:
        1. 找到操作数栈顶的第一个元素所指向的对象方法接收者的实际类型记作 C方法重写的本质:在运行期确定方法接收者的实际类型。
        2. 如果在 C 中找到与常量中的描述符和简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找过程结束。
        3. 否则,按照继承关系从下往上依次对 C 的各个父类进行第二步的搜索和验证过程。
        4. 如果始终没有找到合适的方法,则抛出异常。
    • invokeinterface:调用接口方法会在运行时再确定一个实现该接口的对象
    • invokedynamic:先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法。

例子:方法重载。

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
package io.weichao;

public class Test {
static abstract class Human {
}

static class Man extends Human {
}

static class Woman extends Human {
}

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

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

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

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

Test test = new Test();
test.sayHello(man); // hello, Human!
test.sayHello(woman); // hello, Human!
}
}

上面的是静态类型,下面的是实际类型

1
2
3
4
5
6
7
8
IntStream.range(0, 10).forEach(i -> {
Human human = (new Random()).nextBoolean() ? new Man() : new Woman();
if (human instanceof Man) {
test.sayHello((Man) human);
} else {
test.sayHello((Woman) human);
}
});

可能的输出:

  • Human 是变量的静态类型在编译期可知,也叫外观类型,Man 是变量的实际类型在运行期才可确定,也叫运行时类型
  • 编译器在重载时是将参数的静态类型作为判定依据。

分派调用:

  1. 静态分派:在编译期编译器根据静态类型确定方法执行版本的分派过程最典型应用表现就是方法重载
    • 字面量天生具有模糊性没有显式的静态类型,静态类型只能通过语言、语法的规则去理解和推断,会导致重载版本并不是唯一的,只能确定一个相对更合适的版本见后面例子
  2. 动态分派:在运行期虚拟机根据实际类型确定方法执行版本的分派过程最典型应用表现就是方法重写
    • 【存在的问题】频繁搜索类型元数据导致执行性能低。
      【解决问题】
      • 为类型在方法区中建立一个虚方法表存放着各个方法的实际入口地址。与此对应的,在 invokeinterface 执行时也会用到接口方法表,使用虚方法表索引为了程序实现方便,具有相同签名的方法,在父类、子类的虚方法表中都应该具有一样的索引序号来代替元数据查找。
      • 其他优化技术:类型继承关系分析、守护内联、内联缓存等。

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
31
32
33
34
35
36
37
package io.weichao;

import java.io.Serializable;

public class Overload {
public static void sayHello(char... arg) {
System.out.println("char...");
}

public static void sayHello(Object arg) {
System.out.println("Object");
}

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

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

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

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

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

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

依次注释掉重载的方法,会依次打印 char、int、long、Character、Serializable、Object、char...变长参数的重载优先级是最低的。


例子: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
31
32
33
34
35
36
package io.weichao;

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()); // father choose 360
son.hardChoice(new QQ()); // son choose qq
}
}

确定方法调用的版本:

  1. 静态多分派:
    • 方法接收者的静态类型Father 还是 Son
    • 方法参数的静态类型QQ 还是 360
  2. 动态单分派:
    • 方法接收者的实际类型Father 还是 Son
    • 方法参数的实际类型不重要,会自动确定一个相对更合适的版本。

运行时异常:只要代码不执行到这一行就不会出现问题。
连接时异常:即使导致连接时异常的代码被放在一条根本无法被执行到的路径分支上,类加载连接过程在类加载阶段也会抛出异常。

语言类型:

  • 静态类型语言:能够在编译期确定变量类型所以编译器可以提供全面严谨的类型检查
    • Java 语言在编译期就已经将方法完整的符号引用生成出来,并作为方法调用指令的参数存储到 Class 文件中。
  • 动态类型语言:在运行期才确定变量类型所以代码会清晰简洁
    • 特征:
      • 类型检查的主体过程是在运行期而不是编译期进行的。
      • 变量无类型而变量值才有类型。

【存在的问题】Java 虚拟机层面对动态类型语言的支持一直都还有所欠缺,主要表现在方法调用方面:
JDK 7 以前的字节码指令集,4 条方法调用指令的第一个参数都是被调用的方法的符号引用在编译时产生,而动态类型语言只有在运行期才能确定方法的接收者
【解决问题】编译时留个占位符类型,运行时动态生成字节码实现具体类型到占位符类型的适配。

  • 【引入问题】
    • 增加了内存开销。
    • 动态类型方法调用时,方法内联无法有效进行因为无法确定调用对象的静态类型
      【解决问题】在 Java 虚拟机层面上提供动态类型的直接支持:JDK 7 加入 invokedynamic 指令和 java.lang.invoke 包。
      • java.lang.invoke 包
        • 方法句柄 MethodHandle:一种动态确定目标方法的机制,可以单独把一个函数作为参数进行传递。
      • invokedynamic 指令:用字节码和 Class 中其他属性、常量实现的 MethodHandle 机制
        • 根据第一个参数 CONSTANT_InvokeDynamic_info 提供的信息,可以找到并且执行引导方法,最终调用到目标方法。

例子:访问祖类的方法。

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
package io.weichao;

import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodHandles;
import java.lang.invoke.MethodType;
import java.lang.reflect.Field;

public class GrandFatherTestCase_2 {
class GrandFather {
void thinking() {
System.out.println("i am grandfather");
}
}

class Father extends GrandFatherTestCase_2.GrandFather {
void thinking() {
System.out.println("i am father");
}
}

class Son extends GrandFatherTestCase_2.Father {
void thinking() {
try {
MethodType mt = MethodType.methodType(void.class);
// 添加 VM arguments:--add-opens java.base/java.lang.invoke=ALL-UNNAMED
// 否则报错:java.lang.reflect.InaccessibleObjectException: Unable to make field static final java.lang.invoke.MethodHandles$Lookup java.lang.invoke.MethodHandles$Lookup.IMPL_LOOKUP accessible: module java.base does not "opens java.lang.invoke" to unnamed module @372f7a8d
Field lookupImpl = MethodHandles.Lookup.class.getDeclaredField("IMPL_LOOKUP");
lookupImpl.setAccessible(true);
MethodHandle mh = ((MethodHandles.Lookup) lookupImpl.get(null)).findSpecial(GrandFather.class, "thinking", mt, GrandFather.class);
mh.invoke(this);
} catch (Throwable e) {
e.printStackTrace();
}
}
}

public static void main(String[] args) {
new GrandFatherTestCase_2().new Son().thinking(); // i am grandfather
}
}

从源码到解释执行:

  • 程序源码转换成物理机的目标代码或虚拟机能执行的指令集的过程见首图
    • 完整的编译器比如 C/C++ 语言的:包括词法分析、语法分析、优化器、目标代码生成器。
    • 半独立的编译器比如 Java 语言的:包括词法分析、语法分析。
      • Javac 编译器完成了程序源码经过词法分析语法分析抽象语法树,再遍历语法树到生成线性的字节码指令流基本上是基于栈的指令集架构的过程。
    • 黑匣子比如大多数的 JavaScript 执行引擎
  • 指令集架构:
    • 基于寄存器的指令集架构:指令依赖寄存器进行工作每个指令都包含两个独立的输入参数,依赖于寄存器来访问和存储数据
      • 程序直接依赖硬件寄存器则不可避免地要受到硬件的约束。
    • 基于栈的指令集架构:零地址指令,依赖操作数栈进行工作使用操作数栈中的数据作为指令的运算输入,指令的运算结果也存储在操作数栈之中
      • 可移植:不直接依赖硬件寄存器可以由虚拟机实现来自行决定把一些访问最频繁的数据——如程序计数器、栈顶缓存——放到寄存器,以获取尽量好的性能
      • 代码相对紧凑字节码中每个字节就对应一条指令,而多地址指令集中还需要存放参数
      • 编译器实现更加简单不需要考虑空间分配的问题,所需空间都在栈上操作
      • 【存在的问题】执行速度局限在解释执行状态下,经过即时编译器会输出成物理机上的汇编指令流;稍慢。
        在解释执行时,出栈、入栈操作会产生大量的指令,且栈实现在内存中相对于处理器,内存始终是执行速度的瓶颈
        【优化措施】栈顶缓存:把最常用的操作映射到寄存器中,以避免直接内存访问。
  • Java 虚拟机解释执行字节码指令流的概念模型