深入理解 JVM 之——字节码指令与执行引擎

2023-09-07 10:29:01 浏览数 (1)

  • 硬件依赖性:基于寄存器的指令集直接依赖硬件寄存器,因此在不同的硬件平台上可能存在差异,可移植性较差。
  • 编译器复杂性:基于寄存器的指令集需要考虑寄存器分配等复杂问题,编译器的实现相对较复杂。

基于栈的解释器执行过程


接下看我们具体看一个实际的代码示例:

代码语言:javascript复制
public class Test {
    public static void main(String[] args) {
        int a = 114;
        int b = 514;
        int c = a   b;
    }
}

将上述代码保存为 Test.java 然后对其进行编译和反编译:

代码语言:javascript复制
javac Test.java
javap -v Test.class

可以看到输出了如下内容:

代码语言:javascript复制
Classfile /L:/JAVA/BasicSyntax/Learn_JVM/code/Test.class
  Last modified 2023年9月6日; size 276 bytes
  SHA-256 checksum 4064a19d96fe4d72c9d780ef819e1e937b120c31b37482e0b74c70e37c2a5601
  Compiled from "Test.java"
public class Test
  minor version: 0
  major version: 61
  flags: (0x0021) ACC_PUBLIC, ACC_SUPER
  this_class: #7                          // Test
  super_class: #2                         // java/lang/Object
  interfaces: 0, fields: 0, methods: 2, attributes: 1
Constant pool:
   #1 = Methodref          #2.#3          // java/lang/Object."<init>":()V
   #2 = Class              #4             // java/lang/Object
   #3 = NameAndType        #5:#6          // "<init>":()V
   #4 = Utf8               java/lang/Object
   #5 = Utf8               <init>
   #6 = Utf8               ()V
   #7 = Class              #8             // Test
   #8 = Utf8               Test
   #9 = Utf8               Code
  #10 = Utf8               LineNumberTable
  #11 = Utf8               main
  #12 = Utf8               ([Ljava/lang/String;)V
  #13 = Utf8               SourceFile
  #14 = Utf8               Test.java
{
  public Test();
    descriptor: ()V
    flags: (0x0001) ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: return
      LineNumberTable:
        line 1: 0

  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: (0x0009) ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=4, args_size=1
         0: bipush        114
         2: istore_1
         3: sipush        514
         6: istore_2
         7: iload_1
         8: iload_2
         9: iadd
        10: istore_3
        11: return
      LineNumberTable:
        line 3: 0
        line 4: 3
        line 5: 7
        line 6: 11
}
SourceFile: "Test.java"

我们专注于下列信息:

代码语言:javascript复制
public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: (0x0009) ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=4, args_size=1
         0: bipush        114
         2: istore_1
         3: sipush        514
         6: istore_2
         7: iload_1
         8: iload_2
         9: iadd
        10: istore_3
        11: return

其中:

  • public static void main(java.lang.String[]):表示这是一个公共的静态方法,方法名为 main,它接受一个 java.lang.String 类型的数组作为参数。
  • descriptor: ([Ljava/lang/String;)V:说明了方法的描述符,其中 ([Ljava/lang/String;) 表示参数类型为 java.lang.String 类型的数组,V 表示方法的返回类型为 void
  • flags: (0x0009) ACC_PUBLIC, ACC_STATIC:这是方法的标志,其中 ACC_PUBLIC 表示该方法是公共的,ACC_STATIC 表示该方法是静态的。

我们针对其中的 main 入口代码 Code 展示解释器的执行过程,其中:

代码语言:javascript复制
stack=2, locals=4, args_size=1

提示我们这段代码需要深度为 2 的操作数栈、 4 个变量槽的局部变量空间和 1 个方法参数。

根据给定的字节码指令,我们可以模拟执行程序并跟踪操作数栈、局部变量表和程序计数器的动态变化过程。

首先,我们创建一个操作数栈(operand stack)和一个局部变量表(local variable table),并初始化程序计数器(program counter)为0。

代码语言:javascript复制
执行:0: bipush        114
操作数栈状态:[114(栈顶), null]
局部变量表状态:[this(索引起始), null, null, null]
程序计数器状态:0
代码语言:javascript复制
执行:2: istore_1
操作数栈状态:[null(栈顶), null]
局部变量表状态:[this, 114, null, null]
程序计数器状态:2
代码语言:javascript复制
执行:3: sipush        514
操作数栈状态:[514(栈顶), null]
局部变量表状态:[this, 114, null, null]
程序计数器状态:3
代码语言:javascript复制
执行:6: istore_2
操作数栈状态:[nul(栈顶), null]
局部变量表状态:[this, 114, 514, null]
程序计数器状态:6
代码语言:javascript复制
执行:7: iload_1
操作数栈状态:[114(栈顶), null]
局部变量表状态:[this, 114, 514, null]
程序计数器状态:7
代码语言:javascript复制
执行:8: iload_2
操作数栈状态:[114(栈顶), 514]
局部变量表状态:[this, 114, 514, null]
程序计数器状态:8
代码语言:javascript复制
执行:9: iadd
操作数栈状态:[628(栈顶), null]
局部变量表状态:[this, 114, 514, null]
程序计数器状态:9
代码语言:javascript复制
执行:10: istore_3
操作数栈状态:[null(栈顶), null]
局部变量表状态:[this, 114, 514, 628]
程序计数器状态:10
代码语言:javascript复制
执行:11: return
操作数栈状态:[null(栈顶), null]
局部变量表状态:[this, 114, 514, 628]
程序计数器状态:11

上面的执行过程仅仅是一种概念模型,虚拟机最终会对执行过程做出一系列优化来提高性能,实际的运作过程并不会完全符合概念模型的描述。

更确切地说,实际情况会和上面描述的概念模型差距非常大,差距产生的根本原因是虚拟机中解析器和即时编译器都会对输入的字节码进行优化,即使解释器中也不是按照字节码指令去逐条执行的。

关于编译器优化的细节,我们会在以后的系列文章中提到。


方法调用指令


指令概述


针对调用不同类型的方法,字节码指令集里设计了不同的指令:

  1. invokestatic:用于调用静态方法。可以在类加载时将符号引用解析为直接引用。
  2. invokespecial:用于调用实例构造器 <init>() 方法、私有方法和父类中的方法。也可以在类加载时将符号引用解析为直接引用。
  3. invokevirtual:用于调用所有的虚方法。根据对象的实际类型进行分派(虚方法分派)。
  4. invokeinterface:用于调用接口方法,会在运行时确定实现该接口的对象,并选择适合的方法进行调用。
  5. invokedynamic:先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法。分派逻辑由用户设定的引导方法决定。

这些调用指令可以根据对象的类型和方法的特性进行不同的分派和调用。

invokedynamic 指令是在 JDK 7时加入到字节码中的,当时确实只为了做动态语言(如 JRuby、Scala)支持,Java 语言本身并不会用到它。而到了JDK 8 时代,Java 有了 Lambda 表达式和接口的默认方法,它们在底层调用时就会用到 invokedynamic 指令。

其中,invokestaticinvokespecial 指令可以调用非虚方法,包括静态方法、私有方法、实例构造器和父类方法。而 invokevirtualinvokeinterface 指令用于调用虚方法,根据对象的实际类型进行分派。

也许这些指令看起来简单但很难理解,这是因为我们在上文多次提到过“方法调用”、“解析”、“分派”这些东西,别急,如果想要真正弄清楚这些指令,我们需要一步步来(

方法调用概述

方法调用并不等同于方法中的代码被执行,方法调用阶段唯一的任务就是确定被调用方法的版本(即调用哪一个方法),暂时还未涉及方法内部的具体运行过程。

一切方法调用在 Class 文件里面存储的都只是符号引用,而不是方法在实际运行时内存布局中的入口地址(直接引用)。

这个特性给 Java 带来了更强大的动态扩展能力,但也使得 Java 方法调用过程变得相对复杂,某些调用需要在类加载期间,甚至到运行期间才能确定目标方法的直接引用。

Javav 中,方法调用过程中同时存在解析(Resolution)和分派(Dispatch)两个过程,方法调用过程中首先进行解析,将符号引用转化为直接引用,然后根据实际对象的类型进行分派,确定方法的实际执行版本。

解析

解析:

  • 在类加载的解析阶段将方法调用的符号引用转化为直接引用的过程。

解析的前提:

  • 方法在程序编写、编译阶段就有一个可确定的调用版本,并且这个版本在运行期是不可改变的。
  • 必须是静态方法、私有方法和被 final 修饰的实例方法,因为它们都不可能通过继承或其他方式重写出其他版本。

解析调用过程:

  • 解析调用是静态的过程,在编译期间就完全确定,不延迟到运行期再完成。
  • 在类加载的解析阶段,涉及的符号引用会被转变为明确的直接引用,存储在常量池中。

这种转化使得方法调用在运行时可以更高效地执行,无需再进行符号解析,直接使用已经解析的直接引用。

分派(重点)

Java 作为一门面向对象的编程语言,具备继承、封装和多态这三个基本特征。

而分派调用过程在 Java 虚拟机中揭示了多态性的体现,特别是在方法的重载和重写方面:

  1. 重载(Overloading):重载是指在同一个类中定义多个方法,它们具有相同的名称但参数列表不同。在虚拟机中实现重载时,会根据方法调用的静态类型(声明类型)选择合适的方法版本。这属于静态分派,根据参数的静态类型来确定方法的版本。
  2. 重写(Overriding):重写是指子类重新定义父类中已有的方法,具有相同的名称和参数列表。在虚拟机中实现重写时,会根据方法调用的实际类型(运行时类型)选择合适的方法版本。这属于动态分派,根据实际对象的类型来确定方法的版本。

下面我们就来揭示 JVM 实现重载和重写的底层原理,这也是我们真正的重点部分。

静态分派

“分派”(Dispatch)这个词本身就具有动态性,一般不应用在静态语境之中,这部分原本在英文原版的《Java虚拟机规范》和《Java语言规范》里的说法都是“Method Overload Resolution”,即应该归入上节的“解析”里去讲解,但部分其他外文资料和国内翻译的许多中文资料都将这种行为称为“静态分派”。

为了解释静态分派和重载,我们看如下示例代码:

代码语言:javascript复制
public class StaticDispatch {
    static abstract class Human {
    }

    static class Man extends Human {
    }

    static class Woman extends Human {
    }

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

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

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

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

上面的代码中定义了一个 StaticDispatch 类,包含了一个抽象类 Human 和两个继承自 Human 的子类 ManWoman。类中定义了三个重载的 sayHello 方法,分别接受 HumanManWoman 类型的参数,并输出相应的问候语。

理论上我们重载了 sayHello() 方法,运行结果应该是:

代码语言:javascript复制
hello,gentleman!
hello,lady!

但实际上控制台哼哼哼啊啊啊地输出了:

代码语言:javascript复制
hello, guy!
hello, guy!

你先别急,让我先急

0 人点赞