基於棧的執行引擎

字節碼是運行在 JVM 上的,爲了能弄懂字節碼,需要對 JVM 的運行原理有所瞭解。這篇文章將以棧幀爲切入點理解字節碼在 JVM 上執行的細節。

虛擬機

兩者有什麼不同呢?舉一個計算兩數相加的例子: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    // 將棧頂兩個值出棧,相加,然後將結果放回棧頂
3: 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) 和 指向運行時常量池的引用

局部變量表

每個棧幀內部都包含一組稱爲局部變量表(Local Variables)的變量列表,局部變量表的大小在編譯期間就已經確定。Java 虛擬機使用局部變量表來完成方法調用時的參數傳遞,當一個方法被調用時,它的參數會被傳遞到從 0 開始的連續局部變量列表位置上。當一個實例方法(非靜態方法)被調用時,第 0 個局部變量是調用這個實例方法的對象的引用(也就是我們所說的 this )

操作數棧

每個棧幀內部都包含了一個稱爲操作數棧的後進先出(LIFO)棧,棧的大小同樣也是在編譯期間確定。Java 虛擬機提供的一些字節碼指令用來從局部變量表或者對象實例的字段中複製常量或者變量到操作數棧,也有一些指令用於從操作數棧取走數據、操作數據和把操作結果重新入棧。在方法調用時,操作數棧也用來準備調用方法的參數和接收方法返回的結果。

比如 iadd 指令用來將兩個 int 類型的數值相加,它要求執行之前操作數棧已經存在兩個由前面其它指令放入的 int 型數值,在 iadd 指令執行時,兩個 int 值從操作數棧中出棧,相加求和,然後將求和的結果重新入棧。

比如 1 + 2 這樣的指令執行過程如下

整個 JVM 指令執行的過程就是局部變量表與操作數棧之間不斷 load、store 的過程

我們再來看一個稍微複雜一點的例子

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;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
  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
12: aload_1
13: iload_2
14: i2d
15: invokevirtual #4 // Method ScoreCalculator.record:(D)V

可以看到 aload_1 先從局部變量表中 1 的位置加載 calculator 對象,iload_2 從 局部變量表中 2 的位置加載一個整型值,i2d 這個指令用來將整型值轉爲 double 並將新的值重新入棧,到目前爲止參數全部就緒,可以用 invokevirtual 執行方法調用了

其實局部變量表可以通過 javap 用 -l 參數直接輸出,但是我們用 javap -v -p -l MyLocalVariableTest 並沒有輸出任何局部變量表相關的信息。這是因爲默認情況下局部變量表屬於調試級別的信息,javac 編譯的時候並沒有編譯進字節碼,我們可以加上 javac -g 生成字節碼的時候同時生成所有的調試信息,如下所示

javac -g  MyLocalVariableTest.java 
javap  -v -p -l   MyLocalVariableTest
LocalVariableTable:
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_PUBLIC
Code:
  stack=1, locals=1, args_size=1
     0: aload_0
     1: getfield      #2 / Field name:Ljava/lang/String;
     4: areturn

直接從二進制來看下這個 class 文件 xxd Get.class

我們可以手動用 16 進制編輯器去修改這些字節碼文件,只是比較容易出錯,所以產生了一些字節碼操作的工具,最出名的莫過於 ASM 和 Javassist。我們後面講到軟件破解的時候,會介紹直接修改字節碼和通過 ASM 動態修改字節碼這兩種方式

小結

一起來回顧一下這篇文章的要點:

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/tOZ7t8Tm1ZLZets3KnNeRw