《深入理解Java虚拟机》学习笔记之字节码执行引擎

时间:2023-03-08 16:23:23

Java虚拟机的执行引擎不管是解释执行还是编译执行,根据概念模型都具有统一的外观:输入的是字节码文件,处理过程是字节码解析的等效过程,输出的是执行结果。

运行时栈帧结构

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

一个线程中的方法调用链可能会很长,很多方法都同时处于执行状态。对于执行引擎来讲,活动线程中,只有栈顶的栈帧是有效的,称为当前栈帧(Curren Stack Frame),这个栈帧所关联的方法称为当前方法(Current Method)。执行引擎所运行的所有字节码指令都只针对当前栈帧进行操作,栈帧的概念结构如下:

《深入理解Java虚拟机》学习笔记之字节码执行引擎

  1. 局部不变量表
    局部变量表的容量是以变量槽(Variable Slot)为最小单位,而虚拟机规范并为明确规定一个Slot所占用的内存空间大小,但可以存放一个32位以内的数据类型,如Java的boolean、byte、char、short、int、float、refernce和returnAddress,而对于long和double类型虚拟机以高位在前的方式为其分配两个连续的Slot空间。
    局部变量表中的Slot是可重用的,因为方法体中定义的变量,其作用域并不一定覆盖整个方法体:如
    package net.oseye;
    
    public class App {
    public static void main(String[] args) {
    byte[] temp = new byte[64 * 1024 * 1024];
    }
    }

    通过javap可以看到Code属性

     Code:
    stack=1, locals=2, args_size=1
    0: ldc #16 // int 67108864
    2: newarray byte
    4: astore_1
    5: return
    LineNumberTable:
    line 5: 0
    line 6: 5
    LocalVariableTable:
    Start Length Slot Name Signature
    0 6 0 args [Ljava/lang/String;
    5 1 1 temp [B

    局部变量temp的变量偏移量是5,作用域范围长度是1,仅此当前字节码PC计数器的值已经超出了某个变量的作用域6(5+1,即方法结束的“}”),那么这个变量对应的Slot就可以交给其他变量使用。这样的设计不仅仅是为了节省栈空间,在某些情况下Slot的复用会直接影响到系统的垃圾回收行为。如

    package net.oseye;
    
    public class App {
    public static void main(String[] args) {
    byte[] temp = new byte[64 * 1024 * 1024];
    System.gc();
    }
    }

    向内存填充了64MB的数据,然后通知虚拟机进行垃圾收集,给虚拟机运行参数中加上“-verbose:gc”,运行后发现在System.gc()运行后并没有回收掉这64MB的内存:

    [GC 514K->362K(15872K), 0.0014079 secs]
    [Full GC 362K->362K(15872K), 0.0050833 secs]
    [Full GC 66077K->65898K(81476K), 0.0047269 secs]

    没有回收掉可以理解,因为temp还处于作用域中,那么修改下代码(mark)

    package net.oseye;
    
    public class App {
    public static void main(String[] args) {
    {
    byte[] temp = new byte[64 * 1024 * 1024];
    }
    System.gc();
    }
    }

    加了花括号,temp的作用被限制在花括号这内(奇怪的是我通过javap在本地变量中是找不到了temp了,为何?),那么执行System.gc()后这64MB应该被回收了吧,不然

    [GC 514K->362K(15872K), 0.0014608 secs]
    [Full GC 362K->362K(15872K), 0.0050291 secs]
    [Full GC 66077K->65898K(81476K), 0.0046965 secs]

    依旧没被回收,这是为什么呢?
    在解释为什么之前,我们先对这段代码进行第二次修改

    package net.oseye;
    
    public class App {
    public static void main(String[] args) {
    {
    byte[] temp = new byte[64 * 1024 * 1024];
    }
    int i=0;
    System.gc();
    }
    }

    执行后

    [GC 514K->362K(15872K), 0.0014145 secs]
    [Full GC 362K->362K(15872K), 0.0047972 secs]
    [Full GC 66077K->362K(81476K), 0.0044866 secs]

    终于被回收了,莫名其妙吧!之前不能被回收要是因为虽然temp已经离开了作用域,但没有任何对局部变量表的操作,所以temp所占的Slot没有被复用;而增加了int i=0则对temp所占的slot进行了复用。从这里来看其实有时把temp设置为null(操作局部变量表,复用并清空Slot)以便垃圾回收并不是没有任何用处的

    package net.oseye;
    
    public class App {
    public static void main(String[] args) {
    {
    byte[] temp = new byte[64 * 1024 * 1024];
    temp=null;
    }
    System.gc();
    }
    }

    而把变量设置为null的做法并不值得推广,而是以恰当的变量作用域来控制变量回收时间才是最优雅的解决方法。而且把变量设置为null经常被JIT编译器优化:消除掉。而字节码被编译为本地代码后,对GC Roots的枚举也与解释执行时期有所差别,上面做了mark标记的代码经过JIT编译后,System.gc()执行后内存是可以正确被回收掉的,而无需设置成null或int i=0.
    对局部变量表还有一点需要强调的是它不像类变量那样在准备阶段就被自动初始化为系统零值,它没有被初始化是不能被使用的,好在这点在编译时编译器会提醒我们。

  2. 操作数栈
    同局部变量表一样,操作数栈的最大深度也在编译的时候被写入到Code属性数据项中了,操作数栈的每一个元素可以是任意的Java数据类型,包括long和double,32位内的数据类型所占的栈容量是1,64位数据类型的栈容量为2。
    当一个方法刚刚开始执行的时候,这个方法的操作数栈是空的,在方法的执行过程中会有各种字节码指令像操作数栈写入和提取内容,也就是入栈和出栈操作。如整数加法的字节码指令iadd在运行的时候要求操作数栈最接近栈顶的两个元素已经存入了两个int型的数值,当执行iadd时会将这两个int值出栈并相加,再把结果入栈。操作数栈中元素的数据类型必须严格与字节码指令匹配,这点由编译器来保证。
    在概念模型中,两个栈帧作为虚拟机栈的元素,相互之间是完全独立的,但大多数虚拟机的实现都做了一些优化处理:令两个栈帧出现一部分重叠,这样再进行方法调用时就可以共用一部分数据而无须进行额外的参数复制传递了。重叠的过程如下:
    《深入理解Java虚拟机》学习笔记之字节码执行引擎
    Java虚拟机的解释执行引擎称为“基于栈的执行引擎”,其中所指的“栈”就是操作数栈。
  3. 动态连接
    每个栈帧都包含一个执行运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接。我们知道Class文件的常量池中存有大量的符号引用,这些符号引用一部分会在类加载阶段或第一次使用的时候转化为直接引用,这称为静态解析;另一部分将在每一次的运行期间转化为直接引用,这称为动态连接。
  4. 方法返回地址
    当一个方法被执行后有两种方式退出:正常完成和异常完成。无论是那种方式退出,在退出后都需要返回到方法被调用的位置,程序才能继续执行,这就需要在栈帧中保存一些信息。正常
    正常退出时,调用者的PC计数器的值就可以作为返回地址存储在栈帧中,而异常退出时返回地址是要通过异常处理器表来确定的,而栈帧一般不会保存这部分信息。
    方法退出的过程实际上等同于把当前栈帧出栈,因此退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,并把返回值(如果有的话)压入调用者栈帧的操作数栈中,调整PC计数器的值以指向方法调用指令后面的一条指令等。
  5. 附加信息
    虚拟机规范允许具体的虚拟机实现增加一些规范里没有描述的信息到栈帧中,例如与调试相关的信息,这部分取决于具体的虚拟机。

方法调用

方法调用并不等于方法执行,方法调用阶段的唯一任务就是确定被调用方法的版本(即调用哪一个方法),暂时还不设计方法内部的具体运行过程。而Class文件的编译过程并不包括传统的连接步骤,因此一切方法的调用在Class文件里面存储的都只是符号引用,而不是方法在实际运行时内存布局的入口地址,需要在类加载期间甚至到运行期间才能确定目标方法的直接引用,这虽然给Java的调用过程带来了复杂,但这个特性也给Java带来了更强大的动态扩展能力。

而方法的调用有一类是静态的过程,这类方法在程序真正运行之前就有一个可确定的调用版本,并且这个方法的调用版本再运行期是不可变的,换句话说,调用目标在程序代码写好、编译器进行编译时就必须确定下来,在类加载的解析阶段会将这些方法的涉及到的符号引用转化为直接引用,这称为解析调用;而另外还有一种叫分派(Dispatch)调用,可以静态也可以是动态,而根据宗量数又可以分为单分派和多分派,与解析调用两两组合又可以构成静态单分派、静态多分派、动态单分派和动态多分派四种。

在Java虚拟机里面提供了四条方法调用字节指令:

  • invokestatic:调用静态方法;
  • invokespecial:调用实例构造器<init>方法、私有方法和父类方法;
  • invokevirtual:调用所有虚方法;
  • invokeinterface:调用接口方法,会在运行时再确定一个实现此接口的对象;
  1. 解析
    只要能被invokestatic和invokespecial指令调用的方法都可以在解析阶段确定唯一的调用版本,在解析阶段会把符号引用解析为改方法的直接引用,这些方法称为非虚方法,与之相反,其他方法就称为虚方法(被final修饰的方法除外)。
    虽然final方法是使用invokevirtual指令来调用的,但由于它无法被覆盖,没有其他版本,所以也无须对方法接收者进行多台选择,又或者说多台选择的结果肯定是唯一的。在Java语言规范中明确说明了final方法是一种非虚方法。
    package net.oseye;
    
    public class App {
    public static void main(String[] args) {
    sayHello("oseye");
    } public static void sayHello(String userNmae){
    System.out.println("Hello,"+userNmae+".");
    }
    }

    通过javap

    ....
    
    Constant pool:
    #1 = Class #2 // net/oseye/App
    ....
    #18 = Methodref #1.#19 // net/oseye/App.sayHello:(Ljava/lang/String;)V
    #19 = NameAndType #20:#21 // sayHello:(Ljava/lang/String;)V
    #20 = Utf8 sayHello
    #21 = Utf8 (Ljava/lang/String;)V
    ....
    public net.oseye.App();
    flags: ACC_PUBLIC Code:
    stack=1, locals=1, args_size=1
    0: aload_0
    1: invokespecial #8 // Method java/lang/Object."<init>":()V
    4: return
    LineNumberTable:
    line 3: 0
    LocalVariableTable:
    Start Length Slot Name Signature
    0 5 0 this Lnet/oseye/App; public static void main(java.lang.String[]);
    flags: ACC_PUBLIC, ACC_STATIC Code:
    stack=1, locals=1, args_size=1
    0: ldc #16 // String oseye
    2: invokestatic #18 // Method sayHello:(Ljava/lang/String;)V
    5: return
    LineNumberTable:
    line 5: 0
    line 6: 5
    LocalVariableTable:
    Start Length Slot Name Signature
    0 6 0 args [Ljava/lang/String; public static void sayHello(java.lang.String);
    .....
  2. 分派
    • 静态分派
      我们先来了解两个概念,先看代码:
      Human man=new Man();

      其中“Human”称为变量的静态类型或外观类型,其实称为编译类型更合适,“man”就是有这个声明类型所决定的,编译时已经决定了“man”就是"Human"类型;而后面的“Man”则称为变量实际类型,其实称为运行类型更好理解,而编译类型想转化为运行类型可以使用强制转换。如

      package net.oseye;
      
      public class StaticDispatch {
      public static void main(String[] args) {
      Human man=new Man();
      Human woman=new Wowen(); StaticDispatch sd=new StaticDispatch();
      //编译类型
      sd.sayHello(man);
      sd.sayHello(woman); //运行时类型
      sd.sayHello((Man)man);
      sd.sayHello((Wowen)woman);
      } public void sayHello(Human guy){
      System.out.println("Hello,guy.");
      }
      public void sayHello(Man guy){
      System.out.println("Hello,gentleman.");
      }
      public void sayHello(Wowen guy){
      System.out.println("Hello,lady.");
      }
      } class Human{}
      class Man extends Human{}
      class Wowen extends Human{}

      输出

      Hello,guy.
      Hello,guy.
      Hello,gentleman.
      Hello,lady.

      通过javap可以明确看出

      ........
      25: aload_1
      26: invokevirtual #23 // Method sayHello:(Lnet/oseye/Human;)V
      29: aload_3
      30: aload_2
      31: invokevirtual #23 // Method sayHello:(Lnet/oseye/Human;)V
      34: aload_3
      35: aload_1
      36: checkcast #16 // class net/oseye/Man
      39: invokevirtual #27 // Method sayHello:(Lnet/oseye/Man;)V
      42: aload_3
      43: aload_2
      44: checkcast #19 // class net/oseye/Wowen
      47: invokevirtual #30 // Method sayHello:(Lnet/oseye/Wowen;)V
      ........

      静态分派的典型应用就是方法重载,但这种重载的版本并不是一直都是完全匹配的,往往只能匹配一个“更加合适的”版本,

      package net.oseye;
      
      public class StaticDispatch {
      public static void main(String[] args) {
      StaticDispatch sd=new StaticDispatch();
      Human woman=new Wowen();
      sd.sayHello((Wowen)woman);
      } public void sayHello(Human guy){
      System.out.println("Hello,guy.");
      }
      public void sayHello(Man guy){
      System.out.println("Hello,gentleman.");
      }
      } class Human{}
      class Man extends Human{}
      class Wowen extends Human{}

      输出

      Hello,guy.

      这时woman会转型为父类,如果有多个父类那么将在继承关系中从下向上开始搜索,使用最近的父类。

    • 动态分派
      动态分派主要是用于重写(Override),如
      package net.oseye;
      
      public class StaticDispatch {
      public static void main(String[] args) {
      Human man=new Man();
      man.sayHello();
      man=new Wowen();
      man.sayHello();
      }
      } class Human {
      protected void sayHello() {
      System.out.println("Human say hello.");
      }
      } class Man extends Human {
      public static int abc=100;
      @Override
      protected void sayHello() {
      System.out.println("Man say hello.");
      }
      } class Wowen extends Human { @Override
      protected void sayHello() {
      System.out.println("Wowen say hello.");
      }
      }

      输出

      Man say hello.
      Wowen say hello.

      导致上面原因很明显是因为变量的运行时类型不同,根据运行来决定执行。通过javap

      ........
      public static void main(java.lang.String[]);
      flags: ACC_PUBLIC, ACC_STATIC
      Code:
      stack=2, locals=2, args_size=1
      0: new #16 // class net/oseye/Man
      3: dup
      4: invokespecial #18 // Method net/oseye/Man."<init>":()V
      7: astore_1
      8: aload_1
      9: invokevirtual #19 // Method net/oseye/Human.sayHello:()V
      12: new #24 // class net/oseye/Wowen
      15: dup
      16: invokespecial #26 // Method net/oseye/Wowen."<init>":()V
      19: astore_1
      20: aload_1
      21: invokevirtual #19 // Method net/oseye/Human.sayHello:()V
      24: return
      LineNumberTable:
      line 5: 0
      line 6: 8
      line 7: 12
      line 8: 20
      line 9: 24
      LocalVariableTable:
      ........

      可以看到第8和20行分别把刚刚创建的两个对象(Man和Wowen)压到栈顶,这个两个对象是将要执行sayHello()方法的所有者,称为接收者(Receiver)。
      invokevirtual指令的运行时解析过程:
          1).找到操作数栈顶的第一个元素所指向的对象的实际类型,记作C;
          2).如果在类型C中找到与常量中的描述符和简单名称都相符的方法,则进行访问权限校验,通过则返回该方法直接引用,不通过抛出java.lang.IllegalAccessError;
          3).否则,按照继承关系从下往上一次对C的各个父类进行第二步的搜索和验证;
          4).没找到合适方法,抛出java.lang.AbstractMethodError异常;
      由于第一步是解析成对象的实际类型,因此两次调用的结果不一样,这也是重写的本质,我们把这种在运行时期根据实际类型确定方法执行版本的分派过程称为动态分派。

  3. 单分派与多分派
    方法的接受者与方法的参数统称为方法的宗量。根据分派基于多少种宗量,可以将分派划分为单分派和多分派。
    package net.oseye;
    
    public class StaticDispatch {
    
        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 choose 360
    son choose qq

    我们分别从编译阶段和运行阶段分别分析这个分派的过程。在编译阶段,jvm在选择哪个hardChoice方法的时候有两点依据:一是静态类型是Fatcher还是Son.二是方法参数的QQ还是360。根据这两点,在静态编译的时候,这两行代码会被翻译成 Father.hardChoice(360)和 Father.hardChoice(QQ).到这里,我们就可以知道,Java是静态多分派的语言.
    在运行阶段,执行 son.hardChoice(new QQ()); 的时候,由于编译器已经在编译阶段决定目标方法的签名必须是 “hardChoice(QQ)”,jvm此时不会关心传递过来的QQ参数到底是 “腾讯QQ”还是“奇瑞QQ”,因为这个时候参数的静态类型,实际类型都不会对方法的分派构成任何影响,唯一可以影响jvm进行方法分派的只有该方法的接受者,也就是son。这个时候,其实就是一个宗量作为分派的选择,也就是Java是动态单分派的语言.

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

栈和寄存器指令集

由于目前的主流虚拟机都支持即时编译器,所以Java已经不再是纯“解释执行”的语言,而且Java编译器输出地指令集是基于栈的。

基于栈的指令集的指令大不部分都是零地址指令,他们依赖操作数栈进行工作,与之相对应的另外一套常用指令集架构是基于寄存器的指令集。

基于栈的指令集相对于基于寄存器指令集,它的优点是可移植,而寄存器是由硬件直接提供,所以对硬件依赖很强;者也就显示出了基于栈的指令集比基于寄存器的指令集的执行速度稍慢一下。