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

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

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 命令所生成的那樣,若是你們只是閱讀文章的話,建議你們本身要一點一點去嘗試解析下,固然直播上我會帶你們一塊兒來看。markdown

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 初始化
  • 類構造器 () 是由編譯器自動收集類中出現的類變量、靜態代碼塊中的語句合併產生的,收集的順序是在源文件中出現的順序決定的,靜態代碼塊能夠訪問出如今靜態代碼塊以前的類變量,出現的靜態代碼塊以後的類變量,只能夠賦值,可是不能訪問。
  • () 類構造器和()實例構造器不一樣,類構造器不須要顯示的父類的類構造,在子類的類構造器調用以前,會自動的調用父類的類構造器。所以虛擬機中第一個被調用的 () 方法是 java.lang.Object 的類構造器
  • 因爲父類的類構造器優先於子類的類構造器執行,因此父類中的 static{} 代碼塊也優先於子類的 static{} 執行
  • 類構造器() 對於類來講並非必需的,若是一個類中沒有類變量,也沒有 static{},那這個類不會有類構造器 ()
  • 接口中不能有 static{},可是接口中也能夠有類變量,因此接口中也能夠有類構造器 {},可是接口的類構造器和類的類構造器有所不一樣,接口在調用類構造器的時候,若是不須要,不用調用父接口的類構造器,除非用到了父接口中的類變量,接口的實現類在初始化的時候也不會調用接口的類構造器
  • 虛擬機會保證一個類的 () 方法在多線程環境中被正確地加鎖、同步,若是多個線程同時去初始化一個類,那麼只有一個線程去執行這個類的類構造器 (),其餘線程會被阻塞,直到活動線程執行完類構造器 () 方法

2.3 雙親委派模型

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

  • 複用獨立安裝App的源碼:插件App的源碼本來就是能夠正常安裝運行的。
  • 零反射無 Hack 實現插件技術:從理論上就已經肯定無需對任何系統作兼容開發,更無任何隱藏 API 調用,和 Google 限制非公開 SDK 接口訪問的策略徹底不衝突。
  • 全動態插件框架:一次性實現完美的插件框架很難,但 Shadow 將這些實現所有動態化起來,使插件框架的代碼成爲了插件的一部分。插件的迭代再也不受宿主打包了舊版本插件框架所限制。
  • 宿主增量極小:得益於全動態實現,真正合入宿主程序的代碼量極小(15KB,160方法數左右)。

Kotlin 實現:core.loader,core.transform 核心代碼徹底用 Kotlin 實現,代碼簡潔易維護。app

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 放到局部變量表中。咱們須要清楚的知道每運行一個指令,當前棧和局部變量表中的數據是怎樣變化的。

本文基本都是文字原理,你們要有耐心,若是可以理解實際上是很是簡單的東西。這自己是三四次課的內容,我把其壓縮到了一兩次課來說。考慮到你們的水平不一,不少同窗可能會感受沒有講到位,所以你們能夠去找些額外文章用來輔助理解,可是大的方向確定是這個方向。

視頻地址:pan.baidu.com/s/1ozvNawIJ…

視頻密碼:q9kj 

相關文章
相關標籤/搜索