05-字节码执行引擎

nobility 发布于 2021-07-14 618 次阅读


字节码执行引擎

字节码执行引擎功能就是输入字节码文件,然后对字节码文件进行解析和处理,最后输出执行结果,实现方式有如下两种,都是基于栈的(两种共存):

  • 通过解释器直接解释执行字节码
  • 通过即时编译器产生本地代码,即编译执行

在Java中,程序的执行可以说就是方法的执行,方法的执行就会产生栈帧

  • 栈帧是用于JVM进行方法调用和方法执行的数据结构,是线程私有的,也就是说栈帧会锁着方法的调用而创建,随着方法结束而销毁
  • 主要存储了方法的局部变量、操作数栈、动态连接、方法返回地址等信息

局部变量表

最消耗内存的就是变量,栈帧中使用局部变量表存储局部变量(引用类型值存放了地址),局部变量表就是用来存放方法参数和方法内部定义的局部变量存储空间

  • 局部变量内存大小以变量槽slot为单位,目前一个slot存放32位以内的数据类型,对于64位数据就占2个slot
  • 对实例方法来说,第0位slot存放的就是this,后面依次是方法的参数,再之后依次是方法体内部的局部变量;对于静态方法来说就将第0位slot去除了
  • 不同局部变量作用域中位置相同的局部变量,slot是复用的,以节省栈帧的空间,这种设计可能会影响系统的垃圾回收行为

为了方便查看程序的效果,使用-XX:+UseConcMarkSweepGC -Xms5m -Xmx5m -Xlog:gc+heap=debug参数运行程序

public class Main {
  public static void main(String[] args) {
    byte[] bytes = new byte[2 * 1024 * 1024];   //分配2MB空间
    System.gc();  //建议GC

    System.out.println("总内存(totalMemory):" + Runtime.getRuntime().totalMemory() / 1024.0 / 1024.0 + "MB");
    System.out.println("剩余内存(freeMemory):" + Runtime.getRuntime().freeMemory() / 1024.0 / 1024.0 + "MB");
    System.out.println("最大内存(maxMemory):" + Runtime.getRuntime().maxMemory() / 1024.0 / 1024.0 + "MB");
  }
}

发现上述程序是否使用System.gc();建议GC并没有起到作用,原因是GC语句与该分配内存空间同属于一个作用域,由于该方法的局部变量表还对其有引用,所以无法回收;将程序该为下面就可回收,原因就是slot是复用的,当作用域结束时,下面局部变量会覆盖到上面局部变量的slot,该方法的局部变量表不在引用上面分配内存空间,所以就可以回收

public class Main {
  public static void main(String[] args) {
    {
      byte[] bytes1 = new byte[1024 * 1024];   //分配1MB空间
      byte[] bytes2 = new byte[1024 * 1024];   //分配1MB空间
    }
    int a = 0;  //覆盖bytes1
//    int b = 0;  //覆盖bytes2
    System.gc();  //建议GC

    System.out.println("总内存(totalMemory):" + Runtime.getRuntime().totalMemory() / 1024.0 / 1024.0 + "MB");
    System.out.println("剩余内存(freeMemory):" + Runtime.getRuntime().freeMemory() / 1024.0 / 1024.0 + "MB");
    System.out.println("最大内存(maxMemory):" + Runtime.getRuntime().maxMemory() / 1024.0 / 1024.0 + "MB");
  }
}

同样的将不使用的局部变量置为空,也有相同的效果

public class Main {
  public static void main(String[] args) {

    byte[] bytes = new byte[2 * 1024 * 1024];   //分配2MB空间
    bytes = null;  //置为null

    System.gc();  //建议GC

    System.out.println("总内存(totalMemory):" + Runtime.getRuntime().totalMemory() / 1024.0 / 1024.0 + "MB");
    System.out.println("剩余内存(freeMemory):" + Runtime.getRuntime().freeMemory() / 1024.0 / 1024.0 + "MB");
    System.out.println("最大内存(maxMemory):" + Runtime.getRuntime().maxMemory() / 1024.0 / 1024.0 + "MB");
  }
}

操作数栈

操作数栈就是用来存放方法运行期间,各个指令操作的数据

  • 操作数栈中的数据类型必须和字节码指令顺序严格匹配,也就是说iload_1这条指令,slot位置1的数据必须是int
  • 从概念模型上说,栈帧作为虚拟机栈的元素,应该是相互独立的,但是虚拟机在实现栈帧时可能会做一些优化,让两个栈帧出现部分重叠,用于存放公用数据

使用下面程序进行演示

public class Main {
  public int add(int a, int b) {
    int c = a + b;
    return a + b + c;
  }

  public static void main(String[] args) {
    int result = new Main().add(1, 2);
    System.out.println(result);
  }
}

使用javap工具进行反编译该类的Class文件,下面是反编译后的部分内容,基本执行过程如下中文注释

{
  public int add(int, int);
    //  ...
    Code:
      //  ...                                // 将this和参数a和b依次添加到局部变量表中
         0: iload_1                          // 将slot位置1的数入栈
         1: iload_2                          // 将slot位置2的数入栈
         2: iadd                             // 将操作数栈中栈顶和次栈顶数相加后入栈
         3: istore_3                         // 将栈顶的数添加到局部变量表位置3
         4: iload_1                          // 将slot位置1的数入栈
         5: iload_2                          // 将slot位置2的数入栈
         6: iadd                             // 将操作数栈中栈顶和次栈顶数相加后入栈
         7: iload_3                          // 将slot位置3的数入栈
         8: iadd                             // 将操作数栈中栈顶和次栈顶数相加后入栈
         9: ireturn                          // 返回栈顶内容
      //  ...
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      10     0  this   LMain;
            0      10     1     a   I
            0      10     2     b   I
            4       6     3     c   I

  public static void main(java.lang.String[]);
    //  ...
    Code:
      //  ...
         0: new           #2                  // class Main
         3: dup
         4: invokespecial #3                  // Method "<init>":()V
         7: iconst_1                          // 常量1,就是向方法中传递的字面量
         8: iconst_2                          // 常量2,就是向方法中传递的字面量
         9: invokevirtual #4                  // Method add:(II)I  调用add()方法
        12: istore_1                          // 将栈顶的数添加到局部变量表位置1 
        13: getstatic     #5                  // Field java/lang/System.out:Ljava/io/PrintStream;
        16: iload_1                           // 将slot位置1的数入栈
        17: invokevirtual #6                  // Method java/io/PrintStream.println:(I)V
        20: return
      //  ...
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      21     0  args   [Ljava/lang/String;
           13       8     1 result   I
}

动态连接

动态连接就是每个栈帧持有一个指向运行时常量池中该栈帧所属方法的引用,以支持方法调用过程的动态连接,简单的说栈帧中会持有该方法的引用,动态连接解析方式分为下面两种方式

  • 静态解析:类加载时,符号引用就转化成直接引用
  • 动态连接:运行期转化为直接引用

方法返回地址

方法执行无论是出现异常,还是正常结束,都需要返回到方法被调用的那个位置,程序才能正常执行,该位置就是方法的返回地址

方法分派和调用

方法调用就是确定具体调用哪一个方法,并不涉及方法内部的执行过程

  • 部分方法直接在类加载解析阶段就确定了直接引用关系,比如:静态方法,私有方法,构造方法以及父类方法
  • 但对于实例方法,也称为虚方法,因为重载和多态,所以需要运行期动态委派

由于继承和多态的特性,方法分派是用来决定方法执行的版本,又分为静态分派和动态分派

  • 静态分派:所有依赖静态类型来定位方法执行版本的分派方式,比如:方法重载
  • 动态分派:根据运行期的实际类型来居然顶定位方法执行版本的分派方式,比如:方法重写

在静态分派和动态分派基础之上,又引申出了单分派和多分派:按照分派思考的维度划分,多余一个就算多分派,只有一个就算单分派,比如只有方法重载或方法重新就是单分派,两者都有就是多分派

此作者没有提供个人介绍
最后更新于 2021-07-14