Gradle 插件 + ASM 實戰 - JVM 虛擬機加載 Class 原理

開篇就提到效能優化涉及的範圍會很廣,考慮後面需要經常用到 asm 字節碼插樁,我們首先從 《Gradle 插件 + ASM 實戰》開始講,但又希望大家能知其然也知其所以然,因此我們首先得講下 JVM 虛擬機加載 Class 字節碼的原理。這往往也是我面試新同學必問的一個內容,因爲如果對這個不瞭解的話,像插件化與熱修復、性能優化、覆蓋率統計等等很多功能都是不好實現的。小公司很少有人用,這也是實話,至於大家要不要學,這就看個人情況了,其實也不是用不用得上的問題,就看大家願不願意做一個喫螃蟹的人。我們主要從以下三個方面來說:

1. class 文件字節碼結構

1.1 class 字節碼示例

我們先來看一個非常簡單的 HelloWorld.java

public class HelloWorld {
    public HelloWorld() {
    }

    public static void main(String[] args) {
        System.out.println("Hello World!");
    }
}

用文本編輯器打開生成的 HelloWorld.class 文件,是這樣的:

cafe babe 0000 0033 0022 0a00 0600 1409
0015 0016 0800 170a 0018 0019 0700 1a07
001b 0100 063c 696e 6974 3e01 0003 2829
5601 0004 436f 6465 0100 0f4c 696e 654e
756d 6265 7254 6162 6c65 0100 124c 6f63
616c 5661 7269 6162 6c65 5461 626c 6501
0004 7468 6973 0100 264c 636f 6d2f 6578
616d 706c 652f 6d79 6170 706c 6963 6174
696f 6e2f 4865 6c6c 6f57 6f72 6c64 3b01
0004 6d61 696e 0100 1628 5b4c 6a61 7661
2f6c 616e 672f 5374 7269 6e67 3b29 5601
0004 6172 6773 0100 135b 4c6a 6176 612f
6c61 6e67 2f53 7472 696e 673b 0100 0a53
6f75 7263 6546 696c 6501 000f 4865 6c6c
6f57 6f72 6c64 2e6a 6176 610c 0007 0008
0700 1c0c 001d 001e 0100 0c48 656c 6c6f
2057 6f72 6c64 2107 001f 0c00 2000 2101
0024 636f 6d2f 6578 616d 706c 652f 6d79
6170 706c 6963 6174 696f 6e2f 4865 6c6c
6f57 6f72 6c64 0100 106a 6176 612f 6c61
6e67 2f4f 626a 6563 7401 0010 6a61 7661
2f6c 616e 672f 5379 7374 656d 0100 036f
7574 0100 154c 6a61 7661 2f69 6f2f 5072
696e 7453 7472 6561 6d3b 0100 136a 6176
612f 696f 2f50 7269 6e74 5374 7265 616d
0100 0770 7269 6e74 6c6e 0100 1528 4c6a
6176 612f 6c61 6e67 2f53 7472 696e 673b
2956 0021 0005 0006 0000 0000 0002 0001
0007 0008 0001 0009 0000 002f 0001 0001
0000 0005 2ab7 0001 b100 0000 0200 0a00
0000 0600 0100 0000 0a00 0b00 0000 0c00
0100 0000 0500 0c00 0d00 0000 0900 0e00
0f00 0100 0900 0000 3700 0200 0100 0000
09b2 0002 1203 b600 04b1 0000 0002 000a
0000 000a 0002 0000 000c 0008 000d 000b
0000 000c 0001 0000 0009 0010 0011 0000
0001 0012 0000 0002 0013 

好傢伙,這怎麼能夠看得懂?但是既然 java 虛擬機能夠看懂,我們也可以想辦法看懂,用 javap -verbose HelloWorld.class 看起來就稍微簡單一點:

Last modified 2021-1-7; size 586 bytes
  MD5 checksum bf91e508b76a0dc7d4c0250b0e55f75b
  Compiled from "HelloWorld.java"
public class com.example.myapplication.HelloWorld
  minor version: 0
  major version: 51
  flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
   #1 = Methodref          #6.#20         // java/lang/Object."<init>":()V
   #2 = Fieldref           #21.#22        // java/lang/System.out:Ljava/io/PrintStream;
   #3 = String             #23            // Hello World!
   #4 = Methodref          #24.#25        // java/io/PrintStream.println:(Ljava/lang/String;)V
   #5 = Class              #26            // com/example/myapplication/HelloWorld
   #6 = Class              #27            // java/lang/Object
   #7 = Utf8               <init>
   #8 = Utf8               ()V
   #9 = Utf8               Code
  #10 = Utf8               LineNumberTable
  #11 = Utf8               LocalVariableTable
  #12 = Utf8               this
  #13 = Utf8               Lcom/example/myapplication/HelloWorld;
  #14 = Utf8               main
  #15 = Utf8               ([Ljava/lang/String;)V
  #16 = Utf8               args
  #17 = Utf8               [Ljava/lang/String;
  #18 = Utf8               SourceFile
  #19 = Utf8               HelloWorld.java
  #20 = NameAndType        #7:#8          // "<init>":()V
  #21 = Class              #28            // java/lang/System
  #22 = NameAndType        #29:#30        // out:Ljava/io/PrintStream;
  #23 = Utf8               Hello World!
  #24 = Class              #31            // java/io/PrintStream
  #25 = NameAndType        #32:#33        // println:(Ljava/lang/String;)V
  #26 = Utf8               com/example/myapplication/HelloWorld
  #27 = Utf8               java/lang/Object
  #28 = Utf8               java/lang/System
  #29 = Utf8               out
  #30 = Utf8               Ljava/io/PrintStream;
  #31 = Utf8               java/io/PrintStream
  #32 = Utf8               println
  #33 = Utf8               (Ljava/lang/String;)V
{
  public com.example.myapplication.HelloWorld();
    descriptor: ()V
    flags: 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 10: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       5     0  this   Lcom/example/myapplication/HelloWorld;

  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=1, args_size=1
         0: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
         3: ldc           #3                  // String Hello World!
         5: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
         8: return
      LineNumberTable:
        line 12: 0
        line 13: 8
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       9     0  args   [Ljava/lang/String;
}

1.2 類文件結構

.class 文件是一組以 8 位字節爲基礎單位的二進制流,各數據項目嚴格按照順序緊湊地排列在 .class 文件中,中間沒有添加任何分隔符,這使得整個 .class 文件中存儲的內容幾乎全都是程序需要的數據,沒有空隙存在。至於具體有哪些內容,這裏有一張表大家可以參考。

虛擬機加載 .class 文件,就是按照上面這樣的規則去解析,最終解析的結果大致就是 javap -verbose 命令所生成的那樣,如果大家只是閱讀文章的話,建議大家自己要一點一點去嘗試解析下,當然直播上我會帶大家一起來看。

2. jvm 類的加載機制

2.1 類的加載時機

在 JVM 虛擬機規範中並沒有規定加載的時機,但是卻規定了初始化的時機,有以下五種情況需要必須立即對類進行初始化:

  • 遇到 new、getstatic、putstatic 或 invokestatic 這 4 條字節碼指令時,如果類沒有進行過初始化,則需要先觸發其初始化。生成這 4 條指令最常見的 Java 代碼場景是:使用 new 關鍵字實例化對象、讀取或設置一個類的靜態字段(被 final 修飾、已在編譯期把結果放入到常量池的靜態字段除外)以及調用一個類的靜態方法的時候
  • 使用 java.lang.reflect 包的方法對類進行反射調用的時候
  • 當初始化一個類的時候,如果發現其父類還沒有被初始化過,則需要先觸發其父類的初始化
  • 當虛擬機啓動時,用戶需要指定一個要執行的主類(包含 main() 方法的類),虛擬機會先初始化這個主類
  • 當使用 JDK 1.7 的動態語言支持時,如果一個 java.lang.invoke.MethodHandle 實例最後的解析結果 REF_getStatic、REF_putStatic、REF_invodeStatic 的方法句柄,並且這個方法句柄所對應的類沒有進行過初始化,則需要先觸發其初始化。

2.2 類的加載流程

類的加載過程大致分爲 5 個步驟:加載、驗證、準備、解析和初始化,作爲過來人早期我犯過很嚴重的錯誤,那就是爲了面試習慣背,這樣過段時間發現很容易忘記,而且開發中遇到類似的問題往往不知所措,因此希望大家能好好的理解理解,這樣才能做到一勞永逸:

2.2.1 加載
  • 通過一個類的全限定名獲取定義此類的二進制字節流
  • 將二進制字節流所代表的靜態存儲結構轉換爲方法區中的運行時數據結構
  • 在內存中生成一個代表此類的 java.lang.Class 的對象,作爲方法區中這個類的訪問入口
  • jvm 虛擬機並沒有規定從哪裏獲取二進制字節流。我們可以從 .class 靜態存儲文件中獲取,也可以從 apk、zip、jar 等包中讀取,可以從數據庫中讀取,也可以從網絡中獲取,甚至我們自己可以在運行時自動生成。
  • 在內存中實例化一個代表此類的 java.lang.Class 對象之後,並沒有規定此 Class 對象是方法 Java 堆中的,有些虛擬機就會將 Class 對象放到方法區中,比如 HotSpot,一個 ClassLoader 只會實例化一個 Class 對象。
2.2.2 驗證
  • 文件格式驗證:主要驗證二進制字節流數據是否符合 .class 文件的規範,並且該 .class 文件是否在本虛擬機的處理範圍之內(版本號驗證)。只有通過了文件格式的驗證之後,二進制的字節流纔會進入到內存中的方法區進行存儲。而且只有通過了文件格式驗證之後,纔會進行後面三個驗證,後面三個驗證都是基於方法區中的存儲結構進行的
  • 元數據驗證:主要是對類的元數據信息進行語義檢查,保證不存在不符合 Java 語義規範的元數據信息
  • 字節碼驗證:字節碼驗證是整個驗證中最複雜的一個過程,在元數據驗證中,驗證了元數據信息中的數據類型做完校驗後,字節碼驗證主要對類的方法體進行校驗分析,保證被校驗的類的方法不會做出危害虛擬機的行爲
  • 符號引用驗證:符號引用驗證發生在連接的第三個階段解析階段中,主要是保證解析過程可以正確地執行。符號引用驗證是類本身引用的其他類的驗證,包括:通過一個類的全限定名是否可以找到對應的類,訪問的其他類中的字段和方法是否存在,並且訪問性是否合適等
2.2.3 準備
  • 在方法區中分配內存的只有類變量(被 static 修飾的變量),而不包括實例變量,實例變量將會跟隨着對象在 Java 堆中爲其分配內存
  • 初始化類變量的時候,是將類變量初始化爲其類型對應的 0 值,比如有如下類變量,在準備階段完成之後,val 的值是 0 而不是設置,爲 val 複製爲具體值,是在初始化階段
  • 對於常量,其對應的值會在編譯階段就存儲在字段表的 ConstantValue 屬性當中,所以在準備階段結束之後,常量的值就是 ConstantValue 所指定的值了。
2.2.4 解析
  • 虛擬機規範中並未規定解析階段發生的具體時間,只規定了在執行newarray、new、putfidle、putstatic、getfield、getstatic 等 16 個指令之前,對它們所使用的符號引用進行解析。所以虛擬機可以在類被加載器加載之後就進行解析,也可以在執行這幾個指令之前才進行解析
  • 對同一個符號引用進行多次解析是很常見的事,除 invokedynamic 指令以外,虛擬機實現可以對第一次解析的結果進行緩存,以後解析相同的符號引用時,只要取緩存的結果就可以了
  • 解析動作主要對類或接口、字段、類方法、接口方法、方法類型、方法句柄和調用點限定符 7 類符號引用進行解析
2.2.5 初始化
  • 類構造器 <clinit>() 是由編譯器自動收集類中出現的類變量、靜態代碼塊中的語句合併產生的,收集的順序是在源文件中出現的順序決定的,靜態代碼塊可以訪問出現在靜態代碼塊之前的類變量,出現的靜態代碼塊之後的類變量,只可以賦值,但是不能訪問。
  • <clinit>() 類構造器和<init>()實例構造器不同,類構造器不需要顯示的父類的類構造,在子類的類構造器調用之前,會自動的調用父類的類構造器。因此虛擬機中第一個被調用的 <clinit>() 方法是 java.lang.Object 的類構造器
  • 由於父類的類構造器優先於子類的類構造器執行,所以父類中的 static{} 代碼塊也優先於子類的 static{} 執行
  • 類構造器<clinit>() 對於類來說並不是必需的,如果一個類中沒有類變量,也沒有 static{},那這個類不會有類構造器 <clinit>()
  • 接口中不能有 static{},但是接口中也可以有類變量,所以接口中也可以有類構造器 <clinit>{},但是接口的類構造器和類的類構造器有所不同,接口在調用類構造器的時候,如果不需要,不用調用父接口的類構造器,除非用到了父接口中的類變量,接口的實現類在初始化的時候也不會調用接口的類構造器
  • 虛擬機會保證一個類的 <clinit>() 方法在多線程環境中被正確地加鎖、同步,如果多個線程同時去初始化一個類,那麼只有一個線程去執行這個類的類構造器 <clinit>(),其他線程會被阻塞,直到活動線程執行完類構造器 <clinit>() 方法

2.3 雙親委派模型

雙親委派模型,我們看一下 ClassLoader 的源碼就能明白了,我們公司的 Shadow 就是利用這個點來做插件類加載的,來公司後我自主學習看的第一個源碼就是 Shadow ,順便打個廣告 Shadow 是一個騰訊自主研發的 Android 插件框架,經過線上億級用戶量檢驗。 Shadow 不僅開源分享了插件技術的關鍵代碼,還完整的分享了上線部署所需要的所有設計。與市面上其他插件框架相比,Shadow 主要具有以下特點:

  • 複用獨立安裝App的源碼:插件App的源碼原本就是可以正常安裝運行的。
  • 零反射無 Hack 實現插件技術:從理論上就已經確定無需對任何系統做兼容開發,更無任何隱藏 API 調用,和 Google 限制非公開 SDK 接口訪問的策略完全不衝突。
  • 全動態插件框架:一次性實現完美的插件框架很難,但 Shadow 將這些實現全部動態化起來,使插件框架的代碼成爲了插件的一部分。插件的迭代不再受宿主打包了舊版本插件框架所限制。
  • 宿主增量極小:得益於全動態實現,真正合入宿主程序的代碼量極小(15KB,160方法數左右)。
    Kotlin 實現:core.loader,core.transform 核心代碼完全用 Kotlin 實現,代碼簡潔易維護。
    protected Class<?> loadClass(String className, boolean resolve) throws ClassNotFoundException {
        // 是否已經被加載了
        Class<?> clazz = findLoadedClass(className);

        if (clazz == null) {
            ClassNotFoundException suppressed = null;
            try {
                // 先從 parent 中加載
                clazz = parent.loadClass(className, false);
            } catch (ClassNotFoundException e) {
                suppressed = e;
            }

            if (clazz == null) {
                try {
                    // 最後再從 this 加載
                    clazz = findClass(className);
                } catch (ClassNotFoundException e) {
                    e.addSuppressed(suppressed);
                    throw e;
                }
            }
        }

        return clazz;
    }

3. jvm 虛擬機執行引擎

瞭解了 .class 裏面有啥,瞭解了 .class 怎麼被解析加載,最後自然得了解下字節碼命令是怎麼執行的。在這之前我們先得了解兩個概念,什麼是棧幀?什麼是分派?

3.1 棧幀

棧幀(Stack Frame)是用於支持虛擬機進行方法調用和方法執行的數據結構,它是虛擬機運行時數據區中的虛擬機棧(Virtual Machine Stack)的棧元素。棧幀存儲了方法的局部變量表、操作數棧、動態連接和方法返回地址等信息。每一個方法從調用開始至執行完成的過程,都對應着一個棧幀在虛擬機棧裏面從入棧到出棧的過程。每一個棧幀都包括了局部變量表、操作數棧、動態連接、方法返回地址和一些額外的附加信息。在編譯程序代碼的時候,棧幀中需要多大的局部變量表,多深的操作數棧都已經完全確定了,並且寫入到方法表的 Code 屬性之中,因此一個棧幀需要分配多少內存,不會受到程序運行期變量數據的影響,而僅僅取決於具體的虛擬機實現。一個線程中的方法調用鏈可能會很長,很多方法都同時處於執行狀態。對於執行引擎來說,在活動線程中,只有位於棧頂的棧幀纔是有效的,稱爲當前棧幀(Current Stack Frame),與這個棧幀相關聯的方法稱爲當前方法(Current Method),執行引擎運行的所有字節碼指令都只針對當前棧幀進行操作。

3.2 分派

分派調用有可能是靜態的,也有可能是動態的,我們如果理解了這個,就會知道 Java 中的多態性是怎麼實現的,像“重載”和“重寫”等。Java 虛擬機識別方法的關鍵在於類名、方法名以及方法描述符。前面兩個就不做過多的解釋了,至於方法描述符,它是由方法的參數類型以及返回類型所構成。在同一個類中,如果同時出現多個名字相同且描述符也相同的方法,那麼 Java 虛擬機會在類的驗證階段報錯。

可以看到,Java 虛擬機與 Java 語言不同,它並不限制名字與參數類型相同,但返回類型不同的方法出現在同一個類中,對於調用這些方法的字節碼來說,由於字節碼所附帶的方法描述符包含了返回類型,因此 Java 虛擬機能夠準確地識別目標方法。

靜態分派指的是在解析時便能夠直接識別目標方法的情況,而動態分派則指的是需要在運行過程中根據調用者的動態類型來識別目標方法的情況。Java 虛擬機中其實是不存在重載概念的,因爲在編譯期間我們就能確定需要執行那個方法,如果非得區分那就是:重載被稱爲靜態綁定或者編譯時多態;而重寫則被稱爲動態綁定。確切地說,Java 虛擬機中的靜態分派指的是在解析時便能夠直接識別目標方法的情況,而動態分派則指的是需要在運行過程中根據調用者的動態類型來識別目標方法的情況。Java 虛擬機執行方法一般有五種指令:

  • invokestatic:用於調用靜態方法。
  • invokespecial:用於調用私有實例方法、構造器,以及使用 super 關鍵字調用父類的實例方法或構造器,和所實現接口的默認方法。
  • invokevirtual:用於調用非私有實例方法。
  • invokeinterface:用於調用接口方法。
  • invokedynamic:用於調用動態方法。

3.3 實例

有了這兩個概念後,我們就需要來看一個具體的實例了:

public class HelloWorld {
    public static void main(String[] args){
        int num1 = 100;
        int num2 = 200;
        int sum = sum(num1, num2);
        System.out.println("sum = "+sum);
    }

    private static final int sum(int num1, int num2){
        return num1 + num2;
    }
}

javap -verbose HelloWorld.class:

public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=3, locals=4, args_size=1
         0: bipush        100
         2: istore_1
         3: sipush        200
         6: istore_2
         7: iload_1
         8: iload_2
         9: invokestatic  #2                  // Method sum:(II)I
        12: istore_3
        13: getstatic     #3                  // Field java/lang/System.out:Ljava/io/PrintStream;
        16: new           #4                  // class java/lang/StringBuilder
        19: dup
        20: invokespecial #5                  // Method java/lang/StringBuilder."<init>":()V
        23: ldc           #6                  // String sum =
        25: invokevirtual #7                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
        28: iload_3
        29: invokevirtual #8                  // Method java/lang/StringBuilder.append:(I)Ljava/lang/StringBuilder;
        32: invokevirtual #9                  // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
        35: invokevirtual #10                 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
        38: return
      LineNumberTable:
        line 12: 0
        line 13: 3
        line 14: 7
        line 15: 13
        line 16: 38
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      39     0  args   [Ljava/lang/String;
            3      36     1  num1   I
            7      32     2  num2   I
           13      26     3   sum   I

這個理解是比較重要的,雖然我們在後面講 asm 的時候會有傻瓜式操作,但是能不能理解怎麼寫爲什麼要那麼寫,就靠我們對着每一條指令集的理解了。我們需要知道每個指令代表的是什麼意思,比如 bipush 100 代表把數字 100 壓入棧中,istore_1 代表把剛壓入棧的 100 放到局部變量表中。我們需要清楚的知道每運行一個指令,當前棧和局部變量表中的數據是怎樣變化的。

本文基本都是文字原理,大家要有耐心,如果能夠理解其實是非常簡單的東西。這本身是三四次課的內容,我把其壓縮到了一兩次課來講。考慮到大家的水平不一,很多同學可能會感覺沒有講到位,因此大家可以去找些額外文章用來輔助理解,但是大的方向肯定是這個方向。

視頻地址:明晚八點

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章