当前位置: 首页 > 图灵资讯 > 技术篇> 字节码原理浅析 —— 基于栈的执行引擎

字节码原理浅析 —— 基于栈的执行引擎

来源:图灵教育
时间:2023-06-26 15:39:58

字节码正在运行 JVM 为了理解字节码,需要正确的 JVM 了解操作原理。本文将以栈帧为切入点,了解字节码 JVM 执行的细节。

虚拟机

实现虚拟机有两种常见的方法:Stack based 的和 Register based。比如基于 Stack 有Hotspot的虚拟机 JVM、.net CLR,这种基于 Stack 实现虚拟机是一种广泛的实现方法。而基于 Register 的虚拟机有 Lua 语言虚拟机 LuaVM 和 Google Android虚拟机开发 DalvikVM。

两者有什么区别?举一个计算两数相加的例子:c = a + b 基于 HotSpot JVM 源代码和字节码如下

void源码 bar(int a, int b) {    int c =  a + b;}对应字节码0: iload_1 // 将 a 压入操作数栈1: iload_2 // 将 b 压入操作数栈2: iadd    // 栈顶两个值出栈,加起来,然后把结果放回屋顶33: istore_3 // 将栈顶值存储在局部变量表中 3 个 slot 中

基于寄存器 LuaVM 的 lua 使用源代码和字节码如下,查看字节码luac -l -l -v -s test.lua 命令

local源码 function my_add(a, b) return a + b;end对应字节码1 [3] ADD       2 0 1

基于寄存器 add 指令直接将寄存器 R0 和 R1 结果保存在寄存器中 R2 中。

基于栈和基于寄存器的过程对比如下:

字节码原理浅析 —— 基于栈的执行引擎_字节码

基于栈和寄存器的指令集各有优缺点。基于栈的指令集移植性更好,代码更紧凑,编译器更容易实现。但是,完成相同功能所需的指令数量一般比寄存器架构多,需要频繁进出栈,栈架构指令集的执行速度会相对较慢。

为了了解字节码的细节,我们需要详细了解字节码的执行过程。众所周知,Hotspot JVM 它是一个基于栈的虚拟机,每个线程都有一个存储的虚拟机栈「栈帧」。每次调用方法都伴随着栈帧的创建和销毁。

栈帧

栈帧(Stack Frame)数据结构用于支持虚拟机的方法调用和方法执行 栈帧随着方法的调用而创建,并随着方法的结束而销毁。栈帧的存储空间分布在 Java 在虚拟机栈中,每个栈帧都有自己的局部变量表(Local Variables)、操作数栈(Operand Stack) 和 常量池在指向运行时的引用

字节码原理浅析 —— 基于栈的执行引擎_字节码_02

局部变量表

每个栈框都包含一组称为局部变量表的组(Local Variables)在编译过程中确定了局部变量表的大小。Java 当一种方法被调用时,虚拟机使用局部变量表来完成参数传输。 0 连续局部变量列表位置的开始。当调用实例方法(非静态方法)时,第一 0 本实例调用对象引用局部变量(也就是我们所说的) this )

字节码原理浅析 —— 基于栈的执行引擎_java_03

操作数栈

每个栈帧都包含一个后进先出,叫做操作数栈(LIFO)在编译过程中也确定了栈和栈的大小。Java 虚拟机提供的一些字节码指令用于将常量或变量从局部变量表或对象实例的字段复制到操作数字堆栈,一些指令用于从操作数字堆栈中取出数据、操作数据,并将操作结果重新进入堆栈。在调用方法时,操作数栈也用于准备调用方法的参数和接收方法返回的结果。

比如 iadd 将两个指令用于指令 int 类型的数值加起来,要求在执行之前,有两个操作数栈已经放入了前面的其他指令 int 型数值,在 iadd 执行指令时,有两个 int 值从操作数栈中出栈,相加求和,然后将求和结果重新进入栈中。

比如 1 + 2 该指令的执行过程如下

字节码原理浅析 —— 基于栈的执行引擎_字节码_04

整个 JVM 指令执行的过程是局部变量表与操作数栈之间的连续性 load、store 的过程

字节码原理浅析 —— 基于栈的执行引擎_字节码_05

让我们再来看一个稍微复杂一点的例子

public class ScoreCalculator {    public void record(double score) {    }    public double getAverage() {        return 0;    }}public static void main(String[] args) {    ScoreCalculator calculator = new ScoreCalculator();    int score1 = 1;    int score2 = 2;    calculator.record(score1);    calculator.record(score2);    double avg = calculator.getAverage();}

javap 检查字节码输出如下

public static void main(java.lang.String[]);descriptor: ([Ljava/lang/String;)Vflags: ACC_PUBLIC, ACC_STATICCode:  stack=3, locals=6, args_size=1     0: new           #2                  // class ScoreCalculator     3: dup     4: invokespecial #3                  // Method ScoreCalculator."<init>":()V     7: astore_1          8: iconst_1     9: istore_2         10: iconst_2    11: istore_3        12: aload_1    13: iload_2    14: i2d    15: invokevirtual #4                  // Method ScoreCalculator.record:(D)V        18: aload_1    19: iload_3    20: i2d    21: invokevirtual #4                  // Method ScoreCalculator.record:(D)V        24: aload_1    25: invokevirtual #5                  // Method ScoreCalculator.getAverage:()D    28: dstore        4        30: return

  • 0 ~ 7:新建了一个 ScoreCalculator 对象,使用 astore_1 存储在局部变量中 calculator 中:astore_1 意思是将栈顶的值存储在局部变量表下,并标记为 1 为什么这里有一个位置? dup,我们以后再谈
  • 8 ~ 11:iconst_1 和 iconst_2 用来将整数 1 和 2 加载到栈顶,istore_2 和 istore_3 将栈顶的元素存储在局部变量表中 2 和 3 的位置上
  • 12 ~ 15:可以看到 store 指令将移除栈顶元素,因此下次我们需要使用这些局部变量时,需要使用它们 load 命令重新加载到栈顶。例如,我们必须执行calculator。.record(score1)对应的字节码如下

12: aload_113: iload_214: i2d15: invokevirtual #4 // Method ScoreCalculator.record:(D)V

可以看到 aload_1 从局部变量表开始 1 的位置加载 calculator 对象,iload_2 从 局部变量表中 2 在i2的位置加载一个整形值d 该指令用于将整形值转换为整形值 double 并将新值重新放入栈中。到目前为止,所有参数都准备好了,可以使用 invokevirtual 调用了执行方法

  • 24 ~ 28:这也是一种普通的调用方法。过程仍然是第一个 aload_1 加载 calculator 对象,invokevirtual 调用 getAverage 方法,并将 将栈顶元素存储在局部变量表下,标记为 4 需要注意的是,位置上有一点需要注意 javap locals=6输出,但我们目前看到的局部变量只有args、calculator、score1、score2、avg这 5 为什么这里等于一个? 6 呢?这是因为 avg 为 double 型变量需要两个槽位(slot) 整个过程的局部变量表如下图所示

字节码原理浅析 —— 基于栈的执行引擎_java_06

事实上,局部变量表可以通过 javap 用 -l 参数直接输出,但我们使用它 javap -v -p -l MyLocalVariableTest 局部变量表相关信息未输出。这是因为默认情况下,局部变量表属于调试级信息,javac 编译时没有编译到字节码,我们可以添加 javac -g 所有调试信息同时生成字节码,如下所示

javac -g  MyLocalVariableTest.java javap  -v -p -l   MyLocalVariableTestLocalVariableTable:Start  Length  Slot  Name   Signature    0      31     0  args   [Ljava/lang/String;    8      23     1 calculator   LScoreCalculator;   10      21     2 score1   I   12      19     3 score2   I   30       1     4   avg   D

从二进制看 class 文件和字节码

public class Get {    String name;    public String getName() {        return name;    }}javap 查看字节码如下:public java.lang.String getName();descriptor: ()Ljava/lang/String;flags: ACC_PUBLICCode:  stack=1, locals=1, args_size=1     0: aload_0     1: getfield      #2 / Field name:Ljava/lang/String;     4: areturn

字节码原理浅析 —— 基于栈的执行引擎_java_07

直接从二进制来看这个 class 文件 xxd Get.class

字节码原理浅析 —— 基于栈的执行引擎_java_08

我们可以手动使用 16 编辑器修改这些字节码文件只是容易出错,所以有一些字节码操作工具,最著名的是 ASM 和 Javassist。当我们以后谈到软件反向po解时,我们将介绍直接修改字节码并通过 ASM 动态修改字节码这两种方法

小结

让我们回顾一下本文的要点:

  • 一是基于栈和寄存器指令集的优缺点;
  • 第二,解释 JVM 栈帧的构成(局部变量表、操作数栈、指向运行时常量池的引用),顺便解释一下 javap -l 参数及其在局部变量表中的应用;
  • 第三,从类文件二进制的角度看字节码的实现,并引出 ASM 字节码重写技术。

转载: https://mp.weixin.qq.com/s/toz7t8tm1ZLZets3KnNeRw