【深入理解 Java 虚拟机笔记】虚拟机字节码执行引擎

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/qq_37138933/article/details/84871080

7.虚拟机字节码执行引擎

执行引擎是 Java 虚拟机最核心的组成部分之一。在 Java 虚拟机规范中制定了虚拟机字节码执行引擎的概念模型,这个概念模型成为各种虚拟机执行引擎的统一外观(Facade)。不同的虚拟机实现,执行引擎可能会有解释执行和编译执行两种,有可能两种兼备。

从外观来说,所有的 JVM 执行引擎都一样:输入的是字节码文件,处理过程是字节码解析的等效过程,输出的是执行结果。

运行时栈帧结构

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

在编译程序代码的时候,栈帧需要多大的局部变量表,多深的操作数栈都已经完全确定了,并且写入方法表的 Code 属性中。

只有栈顶的栈帧才是有效的,称为当前栈帧(Current Stack Frame),与这个栈帧相关联的方法称为当前方法(Current Method)。执行引擎的所有字节码指令都只针对当前栈帧进行操作。

局部变量表

局部变量表(Local Variable Table)是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量。在方法的 Code 属性的 max_locals 数据项中确定了该方法所需要分配的局部变量表的最大容量。

局部变量表的容量以变量槽(Variable Slot,下称 Slot)为最小单位,虚拟机规范中并没有指明一个 Slot 占用的内存空间,但导向性地说道每个 Slot 都应该能存放一个 boolean、byte、char、short、int、float、reference 或 returnAddress 类型的数据。它允许 Slot 的长度随着处理器、操作系统或虚拟机不同而变化。

其中第七种 reference 类型表示对一个对象实例的引用,虚拟机规范没有说明它的长度,但虚拟机实现至少能通过这个引用:

  1. 从此引用中直接或间接地查找到对象在 Java 堆中的数据存放的起始地址索引
  2. 通过该引用直接或间接查找到对象所属数据类型在方法区中存储的类型信息

而第八种 returnAddress 已经很少见了,它是为字节码指令 jsr、jsr_w 和 ret 服务的。

对于 64 位的数据类型,虚拟机使用高位对齐的方式分配两个连续的 Slot 空间。Java 明确的 64 位数据类型只有 long 和 double 两种。

虚拟机通过索引定位的方式使用局部变量表,索引值是从 0 开始到局部变量表最大的 Slot 数量。如果是 32 位数据类型的变量,索引 n 就代表使用第 n 个 slot;而如果是 64 位数据类型,则说明会同时使用 n 和 n+1 两个 Slot,并且不允许单独访问其中某一个 Slot。

在方法执行时,虚拟机是使用局部变量表来完成参数值到参数变量列表的传递过程的,如果执行的是实例方法,第 0 位索引的 Slot 默认为当前实例的引用,通过“this”来访问。

局部变量表的 Slot 可以重用,方法体中定义的常量,作用域并不一定会覆盖整个方法体, 如果当前字节码 PC 计数器的值已经超出了某个变量的作用域,那这个变量对应的 Slot 就可以交给其他变量使用。

可能还会有额外的副作用:

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

placeholder 的作用域被限制在花括号中,执行 System.gc() 后,内存却没有被回收,结果如下:

[GC (System.gc())  68869K->66304K(125952K), 0.0017614 secs]
[Full GC (System.gc())  66304K->66200K(125952K), 0.0071574 secs]

改动一下:

public static void main(String[] args) {
   {
      byte[] placeholder = new byte[64 * 1024 * 1024];
   }
   int a = 0;
   System.gc();
}

此时的运行结果:

[GC (System.gc())  68869K->66320K(125952K), 0.0014657 secs]
[Full GC (System.gc())  66320K->664K(125952K), 0.0071680 secs]

这是因为在未修改的例子中,代码离开了 placeholder 的作用域,但并没有对局部变量表的读写操作,placeholder 原本占用的 Slot 并没有被复用,所以作为 GC Roots 一部分的局部变量表仍然保持着对它的关联。所以手动将其设为 null(用来代替 int a = 0),便不见得是绝对无意义的操作,但作者的观点是并不应当对赋 null 值太多依赖,因为赋 null 值可能会被 JIT 编译优化后被消除掉。

类变量有两次赋值过程,一次是准备阶段,赋予系统初始值;另一次在初始化阶段,赋予程序员设置的初始值(通过执行 <clinit>()方法)。但局部变量不相同,如果不赋初始值,编译器会检测出并提示。

操作数栈

操作数栈(Operator Stack)也称为操作栈,是后入先出(Last In First Out,LIFO)栈。其最大深度在编译的时候写入到 Code 属性的 max_stacks 数据项中。操作数栈的元素可以是任意的 Java 数据类型。32位数据类型占容量为1,64 位占容量 2。在方法执行过程中,会有各种字节码往操作数栈中写入和提取内容,例如,在做算术运算的时候是通过操作数栈来进行的。操作数栈的元素数据类型必须与字节码指令的序列严格匹配,例如,iadd 指令只能用于整数型加法,栈顶两个元素必须为 int 型。

在大多虚拟机实现中,令两个栈帧出现一部分重叠。这样在方法调用时可以共用一部分数据,无需进行额外的参数复制传递。Java 虚拟机的解释执行引擎是“基于栈的执行引擎”,而栈指的是操作数栈

动态连接

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

Class 文件的常量池存有大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用作为参数。符号引用的一部分在类加载阶段或第一次使用就转化为直接引用,称为静态解析。另外一部分在每一次运行期间转化为直接引用,称为动态连接。

方法返回地址

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

  1. 执行引擎遇到任意一个方法返回的字节码指令,可能会有返回值传递给方法调用者,这种退出方法的方式称为正常完成出口(Normal Method Invocation Completion)。
  2. 在方法执行中遇到异常,并且在方法体没有处理,无论是虚拟机内部的异常, 还是代码中使用 athrow 字节码指令产生的异常,只要在本方法的异常表中没有对应的异常处理器,都会导致方法退出,这种退出称为异常完成出口(Abrupt Method Invocation Completion)。并且不会给上层调用者产生返回值。

方法退出的过程实际上就等同于把当前栈帧出栈,所以退出时可能的操作有:恢复上层方法的局部变量表和操作数栈。把返回值(如果有)压入调用者栈帧的操作数栈中,调整 PC 计数器的值以指向方法调用指令后的指令等。

附加信息

虚拟机规范允许虚拟机实现增加一些规范中没有描述的信息到栈帧中。

方法调用

方法调用不等同于方法执行,方法调用阶段的唯一任务是确定被调用方法的版本(调用哪一个方法),暂时不涉及方法内部的具体运行过程。Java 方法调用过程需要在类加载期间甚至到运行期间才能确定方法的直接引用。

解析

所有方法调用中的目标方法在 Class 文件中都是一个常量池中的符号引用。调用目标在程序代码写好、编译器进行编译时就必须确定下来,这种方法的调用称为解析(Resolution)。

符合“编译器可知,运行期不变”这个要求的方法,主要包括静态方法(与类型相关联)和私有方法(在外部不可被访问)两大类,这两种方法的特点决定它们不可能被重写其他版本,所以适合在类加载阶段进行解析。

Java 虚拟机提供了 5 条方法调用字节码:

  1. invokestatic:调用静态方法
  2. invokespcial:调用实例构造器 <init> 方法、私有方法和父类方法
  3. invokevirtual:调用所有虚方法
  4. invokeinterface:调用接口方法,会在运行时确定一个实现此接口的对象
  5. invokedynamic:先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法,前四条指令分派逻辑是固化在 Java 虚拟机内部的,而 invokedynamic 指令是由用户所设定的引导方法决定的

只要能被 invokestaticinvokespcial 指令调用的方法,都可以在解析阶段中确定唯一的调用版本,符合这个条件的有静态方法、私有方法、实例构造器、父类方法 4 类,它们在类加载的时候就会把符号引用解析为该方法的直接引用,这些方法可以称为非虚方法,与之相反,其他方法称为虚方法(除去 final 方法,虽然 final 方法也是通过 invokevirtual 指令调用,但它无法被覆盖,所以它多态选择的结果是唯一的)。

解析调用是静态的过程,编译期间就完全确定。而分派(Dispatch)调用则可能是静态,有可能是动态,根据分派依据的宗量数可分为单分派和多分派,两两组合就构成了静态单分派、静态多分派、动态单分派和动态多分派。

分派

分派调用过程会揭示多态性特征的最基本实现,如“重写”和“重载”。

1.静态分派

例子:

Human man = new Man();

Human 称为变量的静态类型(Static Type),或者叫外观类型(Apparent Type),后面的 Man 称为变量的实际类型(Actual Type),静态类型和实际类型在程序中都可以发生一些变化,静态类型仅在使用时变化,本身的静态类型不会改变,并且最终的静态类型是编译器可知的;而实际类型变化的结果在运行期才可确定,编译器不知道一个对象的实际类型是什么,如:

//实际类型变化
Human man = new Man();
man = new Woman();
//静态类型变化
sr.sayHello((Man)man);
sr.sayHello((Woman)man);

编译器重载时是通过参数的静态类型而不是实际类型来作为判定依据的。静态类型是编译可知的。因此,在编译阶段, Javac 编译器会根据参数的静态类型来决定使用哪个重载版本。

所有依赖静态类型来定位执行版本的分派动作称为静态分派。静态分派典型应用是方法重载。

重载方法匹配优先级:

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

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

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

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

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

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

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

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

代码运行:

Hello char!

但如果注释掉 sayHello(char arg),输出变为:

Hello int!

这次发生了自动类型转换,'a' 还可以表示数字 97(字符 'a' 的 Unicode 数值为十进制数字 97),再注释掉 sayHello(int arg) ,输出变为:

Hello long!

这时发生了两次自动类型转换,'a'转型为整数 97 后,进一步转型为长整数 97L 。自动转型为按照 char - int - long - float - double 的顺序转型进行匹配,但不会匹配到 byte 和 short 类型,因为 char 到 byte 或 short 的转型并不安全,我们继续注释掉 sayHello(long arg),输出为:

Hello Character!

此时发生了自动装箱,'a' 被包装为它的封装类型 java.lang.Character ,所以匹配到了参数类型为 Character 的重载,继续注释 sayHello(Character arg),输出:

Hello Serializable!

出现这个结果是因为 java.lang.Serializablejava.lang.Character 类实现的一个接口,自动装箱之后找不到装箱类,所以找到装箱类实现的接口类型,接着又发生自动转型。char 可以转型为 int,但 Character 不会转型为 Integer,它只能转型为它实现的接口或父类。Character 还实现了另外一个接口 java.lang.Comparable<Character>,如果同时出现两个参数分别是 SerializableComparable<Character> 的重载方法,此时它们优先级相同,所以会提示类型模糊,拒绝编译,程序必须在调用时显式指定静态类型,如 sayHello((Comparable<Character>)'a');,才能编译通过。

继续注释 sayHello(Serializable arg),输出为:

Hello Object!

此时 char 装箱后转型为父类,如果有多个父类,按照继承关系从下往上搜索,越上层,优先级越低。即时方法调用传入的参数值为 null 时,这个规则仍然适用。把 sayHello(Object arg) 注释掉,输出变为:

Hello char...!

变长参数的重载优先级是最低的,此时 'a' 当做一个数组元素,但有些在单个参数能成立的自动转型,如 char 转型为 int,在变长参数中是不成立的。

解析与分派之间的关系并不是二选一的排他关系,它们是在不同层次上去选择、确定目标方法的过程。静态方法会在类加载期就进行解析,但静态方法也可以拥有重载方法,选择重载版本的过程也是通过静态分派完成的。

2.动态分派

动态分派和重写(Override)有很密切的关系。

举个栗子:

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 指令的多态查找过程,其解析过程大致分为:

  1. 找到操作数栈顶的第一个元素所指向的对象的实际类型,记作 C
  2. 如果在类型 C 中找到与常量中描述符和简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找过程结束;否则返回 java.lang.IllegalAccessError 异常。
  3. 否则,按照继承关系从下往上对 C 的各个父类进行第 2 步的搜索和验证过程
  4. 如果还是没找到,则抛出 java.lang.AbstractMethodError 异常

由于 invokevirtual 指令执行的第一步就是在运行期确定接收者的实际类型,所以指令都把常量池中的类方法符号引用解析到了不同的直接引用上,这个过程就是 Java 重写的本质。这种在运行期根据实际类型来确定方法之行版本的分派过程称为动态分派

3.单分派与多分派

方法的接收者与方法的参数称为方法的宗量,根据分派基于多少种宗量,可以把分派分为单分派与多分派。单分派是根据一个宗量来对目标方法进行选择,多分派则是根据多于一个宗量对目标方法进行选择。

一个例子:

public class Dispatch {
   static class A {
   }

   static class B {
   }

   public static class Father {
      public void hardChoice(A args) {
         System.out.println("Father choose A");
      }

      public void hardChoice(B args) {
         System.out.println("Father choose B");
      }
   }

   public static class Son extends Father {
      @Override
      public void hardChoice(A args) {
         System.out.println("Son choose A");
      }

      @Override
      public void hardChoice(B args) {
         System.out.println("Son choose B");
      }
   }

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

运行结果:

Father choose A
Son choose B

先看编译期间编译器的选择过程(静态分派过程),此时选择目标方法的依据:

  1. 静态类型是 Father 还是 Son
  2. 方法参数是 A 还是 B

选择结果的产物是产生了两条 invokevirtual 指令,两条指令的参数分别为常量池中指向 Father.hardChoice(A)Father.hardChoice(B) 的符号引用,因为是根据两个宗量来进行选择,所以 Java 的静态分派是属于多分派类型

再看运行阶段虚拟机的选择(动态分派过程)。在执行 son.hardChoice(new B()) 这句代码所对应的 invokevirtual 指令时,由于编译期已经决定目标方法签名必须是 hardChoice(new B()),所以虚拟机并不会关心传递过来的参数,因为参数的静态类型和实际类型都对方法的选择不会构成影响,唯一可以影响的只有方法的接收者的实际类型。只有一个宗量可以作为选择依据,所以 Java 的动态分派属于单分派类型

所以 Java 是一门静态多分派、动态单分派的语言。

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

由于动态分派是非常频繁的动作,而且动态分派的方法版本选择过程需要运行时在类的方法元数据中搜索,因此在虚拟机实现中基于性能的考虑,最常用的“稳定优化“手段就是为类在方法区中建立一个虚方法表(Virtual Method Table,也称 vtable,与此对应的,在 invokeinterface 执行时也会用到接口方法表 Interface Method Table,简称 itable),使用方法表索引来代替元数据查找以提高性能。

虚方法表中存放着各个方法的实际入口地址。如果方法没有被重写,子类的虚方法表地址入口和父类相同方法的入口一致,而子类重写,地址入口会被替换成子类实现版本的入口地址。

方法表一般在类加载的连接阶段进行初始化,准备了类的变量初始值后,虚拟机会把该类的方法表也初始化完毕。

虚拟机除了使用方法表以外,还会使用内联缓存(Inline Cache)和基于“类型继承关系分析”(Class Hierarchy Analysis,CHA)技术的守护内联(Guarded Inlining)两种非稳定的“激进优化”手段。

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

Java 虚拟机的执行引擎在执行 Java 代码的时候都有解释执行(通过解释器执行)和编译执行(通过即时编译器产生本地代码执行)两种选择。

解释执行

只有确定了谈论对象是某种具体的 Java 实现版本和执行引擎运行模式时,谈解释执行还是编译执行才会比较确切。在 Java 语言中,Javac 编译器完成了程序代码经过词法分析、语法分析到抽象语法树,再遍历语法树生成线性的字节码指令流过程。这一部分的动作是在 Java 虚拟机之外进行的,而解释器在虚拟机内部,所以 Java 程序的编译是半独立的实现。

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

Java 编译器输出的指令流,基本上是一种基于栈的指令集架构(Instruction Set Architecture,ISA),指令流中的指令大部分都是零地址指令,它们依赖操作数栈进行工作。与此相对的是基于寄存器的指令集,最典型的就是 x86 的二进制指令集,也就是我们主流 PC 机直接支持的指令集架构,依赖寄存器进行工作。

举个例子,用两种指令集计算“1+1”的结果,基于栈:

iconst_1
iconst_1
iadd
istore_0

两条 iconst_1 连续把两个常量 1 压入栈后,iadd把栈顶两个值出栈、相加,然后把结果放回栈,最后 istore_0 把栈顶的值放到局部变量表的第 0 个 Slot 中,而基于寄存器:

mov eax,1
add eax,1

mov 指令把 eax 寄存器的值设为 1,然后 add 指令将这个值加 1,结果保存在 eax 寄存器中。

基于栈的指令集主要优点在于可移植,因为寄存器由硬件直接提供,程序直接依赖这些硬件寄存器则不可避免地受到硬件的约束。栈架构的指令集还有代码相对更加紧凑、编译器实现更加简单等优点。

但栈架构指令集的主要缺点是执行速度相对来说会稍慢一点。

基于栈的解释器执行过程

准备一段 Java 代码:

public int calc() {
    int a = 100;
    int b = 200;
    int c = 300;
    return (a + b) * c;
}

javap 命令查看它的字节码指令:

 public int calc();
    descriptor: ()I
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=4, args_size=1
         0: bipush        100
         2: istore_1
         3: sipush        200
         6: istore_2
         7: sipush        300
        10: istore_3
        11: iload_1
        12: iload_2
        13: iadd
        14: iload_3
        15: imul
        16: ireturn
}

本章小结

本章中,分析了虚拟机在执行代码时,如何找到正确的方法,如何执行方法内的字节码,以及执行代码时涉及的内存结构。

猜你喜欢

转载自blog.csdn.net/qq_37138933/article/details/84871080