Java位元組碼,你還可以搲的更深一些!

碼農談IT發表於2023-02-16

Java位元組碼,你還可以搲的更深一些!

原創:小姐姐味道(微信公眾號ID:xjjdog),歡迎分享,非公眾號轉載保留此宣告。

Java真的是長盛不衰,擁有頑強的生命力。其中,位元組碼機制功不可沒。位元組碼,就像是 Linux 的 ELF。有了它,JVM直接搖身一變,變成了類似作業系統的東西。

要學習位元組碼,不能僅僅靠看枯燥的文件。本文會介紹幾個有用的工具,可以非常容易的上手,來實際觀測class檔案這個小魔獸,助你搲的更深一些。

1、位元組碼結構

1.1、基本結構

在開始之前,我們先簡要的介紹一下class檔案的內容。這個結構,可以使用jclasslib工具來檢視。

Java位元組碼,你還可以搲的更深一些!

上圖是class檔案基本內容。這部分內容枯燥乏味,關於它的細節在Java的官方都能非常容易的找到。

如下圖,展示了一個簡單方法的位元組碼描述,我們可以看到真正的執行指令在整個檔案結構中的具體位置。

Java位元組碼,你還可以搲的更深一些!

1.2、實際觀測

為了讓大家避免避免枯燥的二進位制對比分析,直接定位到真正的資料結構,這裡介紹一個小工具,使用這種方式學習位元組碼會節省很多時間。


這個工具就是asmtools,執行下面的命令,將看到類的 JCED 語法結果。

java -jar asmtools-7.0.jar jdec LambdaDemo.class

輸出的結果類似於下面的結構,它與我們上面介紹的位元組碼組成是一一對應的,對照官網或者書籍,學習速度飛快。

class LambdaDemo {
  0xCAFEBABE;
  0; // minor version
  52; // version
  [] { // Constant Pool
    ; // first element is empty
    Method #8 #25; // #1
    InvokeDynamic 0s #30; // #2
    InterfaceMethod #31 #32; // #3
    Field #33 #34; // #4
    String #35; // #5
    Method #36 #37; // #6
    class #38; // #7
    class #39; // #8
    Utf8 "<init>"; // #9
    Utf8 "()V"; // #10
    Utf8 "Code"; // #11

瞭解了類的檔案組織方式,下面我們來看一下,類檔案在載入到記憶體中以後,是一個什麼表現形式。

2、記憶體表示

準備以下程式碼,使用javac -g InvokeDemo.java進行編譯。然後使用java命令執行。程式將阻塞在sleep函式上,我們來看一下它的記憶體分佈。

interface I {
    default void infMethod() { }

    void inf();
}

abstract class Abs {
    abstract void abs();
}

public class InvokeDemo extends Abs implements I {


    static void staticMethod() { }

    private void privateMethod() { }

    public void publicMethod() { }

    @Override
    public void inf() { }

    @Override
    void abs() { }

    public static void main(String[] args) throws Exception{
        InvokeDemo demo = new InvokeDemo();

        InvokeDemo.staticMethod();
        demo.abs();
        ((Abs) demo).abs();
        demo.inf();
        ((I) demo).inf();
        demo.privateMethod();
        demo.publicMethod();
        demo.infMethod();
        ((I) demo).infMethod();


        Thread.sleep(Integer.MAX_VALUE);
    }
}

為了更加明顯的看到這個過程,下面介紹一下 jhsdb 這個工具,這是在 Java9 之後 JDK 先加入的除錯工具,我們可以在命令列使用 jhsdb hsdb 來啟動它。注意,要載入相應的程式時,必須確保是同一個版本的應用程式,否則會產生報錯。

Java位元組碼,你還可以搲的更深一些!

attach啟動後的Java程式後,可以在 Class Browser 選單檢視載入的所有類資訊。我們在搜尋框輸入 InvokeDemo,找到要檢視的類。

Java位元組碼,你還可以搲的更深一些!

@符號後面的,就是具體的記憶體地址,我們可以複製一個,然後在Inspector 檢視檢視具體的屬性。可以大體認為這就是類在方法區的具體儲存。

Java位元組碼,你還可以搲的更深一些!

在Inspector檢視中,我們找到方法相關的屬性 _methods,可惜的是它無法點開,也無法檢視。

Java位元組碼,你還可以搲的更深一些!

接下來可以使用命令列來檢查這個陣列裡面的值。開啟選單中Console,然後輸入examine命令。可以看到這個陣列裡的內容,對應的地址就是Class檢視中的方法地址。

examine 0x000000010e650570/10

Java位元組碼,你還可以搲的更深一些!

我們可以在Inspect檢視看到方法所對應的記憶體資訊,這確實是一個Method方法的表示。

Java位元組碼,你還可以搲的更深一些!

相比較起來,物件就簡單的,它只需要儲存一個到達Class物件的指標即可。我們需要先從物件檢視進入,然後找到它,一步步進入Inspect檢視。

Java位元組碼,你還可以搲的更深一些!

由以上的這些分析,我們可以得出下面這張圖。執行引擎想要執行某個物件的方法,需要先在棧上找到這個物件的引用,然後再透過的物件的指標,找到相應的方法位元組碼。

Java位元組碼,你還可以搲的更深一些!

3、方法呼叫指令

關於方法的呼叫,Java一共提供了5個指令,用來呼叫不同型別的函式。

  1. invokestatic  用來呼叫靜態方法的。
  2. invokevirtual  用於呼叫非私有例項方法,比如public和protected。大多數方法呼叫屬於這一種。
  3. invokeinterface 和上面這條指令類似,不過是作用於介面類。
  4. invokespecial 用於呼叫私有例項方法、構造器,以及super關鍵字等。
  5. invokedynamic 用於呼叫動態方法。

我們依然使用上面的程式碼片段看一下前四個指令的使用場景。程式碼中包含一個介面I,一個抽象類Abs,一個實現和繼承了兩者的類InvokeDemo

參考Java的類載入機制,在class檔案被載入到方法區以後,就完成了從符號引用到具體地址的轉換過程。

我們可以看一下編譯後的main方法位元組碼。尤其需要注意的是對於介面方法的呼叫。使用例項物件直接呼叫,和強制轉化成介面呼叫,所呼叫的位元組碼指令分別是 invokevirtualinvokeinterface,它們是不同的。

public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=2, args_size=1
         0: new           #2                  // class InvokeDemo
         3: dup
         4: invokespecial #3                  // Method "<init>":()V
         7: astore_1
         8: invokestatic  #4                  // Method staticMethod:()V
        11: aload_1
        12: invokevirtual #5                  // Method abs:()V
        15: aload_1
        16: invokevirtual #6                  // Method Abs.abs:()V
        19: aload_1
        20: invokevirtual #7                  // Method inf:()V
        23: aload_1
        24: invokeinterface #8,  1            // InterfaceMethod I.inf:()V
        29: aload_1
        30: invokespecial #9                  // Method privateMethod:()V
        33: aload_1
        34: invokevirtual #10                 // Method publicMethod:()V
        37: aload_1
        38: invokevirtual #11                 // Method infMethod:()V
        41: aload_1
        42: invokeinterface #12,  1           // InterfaceMethod I.infMethod:()V
        47: return

另外還有一點,和我們想象中的不同,大多數普通方法呼叫,使用的是 invokevirtual 指令,它其實是和invokeinterface 一類的,都屬於虛方法呼叫。很多時候,JVM需要根據呼叫者的動態型別,來確定呼叫的目標方法,這就是動態繫結的過程。

invokevirtual指令有多型查詢的機制,該指令的執行時解析過程步驟如下:

  1. 找到運算元棧頂的第一個元素所指向的物件的實際型別,記做c。
  2. 如果在型別c中找到與常量中的描述符和簡單名稱都相符的方法,則進行訪問許可權校驗,如果透過則返回這個方法的直接引用,查詢過程結束,不透過則返回java.lang.IllegalAccessError。
  3. 否則,按照繼承關係從下往上依次對c的各個父類進行第二步的搜尋和驗證過程。
  4. 始終沒找到合適的方法,丟擲java.lang.AbstractMethodError異常。這就是java語言中方法重寫的本質。

相對比,invokestatic指令,加上invokespecial指令,就屬於靜態繫結過程。

所以靜態繫結,指的是能夠直接識別目標方法的情況,而動態繫結指的是需要在執行過程中根據呼叫者的型別來確定目標方法的情況。

可以想象,相對於靜態繫結的方法呼叫來說,動態繫結的呼叫就更加耗時一些。由於方法的呼叫非常的頻繁,JVM對動態呼叫的程式碼進行了比較多的最佳化。比如使用方法表來加快對具體方法的定址,以及使用更快的緩衝區來直接定址( 內聯快取)。

4、invokedynamic

有時候在寫一些python指令碼或者js指令碼的時候,會特別羨慕這些動態語言。如果把查詢目標方法的決定權,從虛擬機器轉嫁給使用者程式碼,我們就會有更高的自由度。

我們單獨把invokedynamic抽離出來介紹,是因為它比較複雜。和反射類似,它用於一些動態的呼叫場景,但它和反射有著本質的不同,效率也比反射要高的多。

這個指令通常在lambda語法中出現,我們來看一下一小段程式碼。

public class LambdaDemo {
    public static void main(String[] args) {
        Runnable r = () -> System.out.println("Hello Lambda");
        r.run();
    }
}

使用javap -p -v 命令可以在main方法中看到invokedynamic指令。

public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=1, locals=2, args_size=1
         0: invokedynamic #2,  0              // InvokeDynamic #0:run:()Ljava/lang/Runnable;
         5: astore_1
         6: aload_1
         7: invokeinterface #3,  1            // InterfaceMethod java/lang/Runnable.run:()V
        12: return

另外,我們在javap的輸出中找到了一些奇怪的東西。

BootstrapMethods:
  0: #27 invokestatic java/lang/invoke/LambdaMetafactory.metafactory:(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodHandle;Ljava/lang/invoke/MethodType;)Ljava/lang/invoke/CallSite;
    Method arguments:
      #28 ()V
      #29 invokestatic LambdaDemo.lambda$main$0:()V
      #28 ()V

BootstrapMethods屬性在Java1.7以後才有,位於類檔案的屬性列表中,這個屬性用於儲存 invokedynamic 指令引用的引導方法限定符。

和上面介紹的四個指令不同,invokedynamic並沒有確切的接收物件,取而代之的,是一個叫做 CallSite 的物件。

static CallSite bootstrap(MethodHandles.Lookup caller, String name, MethodType type);

其實,invokedynamic指令的底層,是使用方法控制程式碼(MethodHandle)來實現的。方法控制程式碼是一個能夠被執行的引用,它可以指向靜態方法和例項方法,以及虛構的get和set方法,從IDE中可以看到這些函式。

Java位元組碼,你還可以搲的更深一些!

控制程式碼型別(MethodType)就是我們對方法的具體描述,配合方法名稱,能夠定位到一類函式。訪問方法控制程式碼和呼叫原來的指令是基本一致的,但它的呼叫異常,包括一些許可權檢查,是在執行時才能被發現的。

lambda語言實際上是透過方法控制程式碼來完成的,在呼叫鏈上自然也多了一些呼叫步驟,那麼在效能上,是否就意味著lambda效能低呢?對於大部分“非捕獲”的lambda表示式來說,JIT編譯器的逃逸分析能夠最佳化這部分差異,效能和傳統方式無異;但對於“捕獲型”的表示式來說,就需要透過方法控制程式碼,不斷的生成介面卡,效能自然就低了很多(不過和便捷性相比,一丁點效能損失是可接受的)。

除了lambda表示式,我們還沒有其他的方式來產生invokedynamic指令。但是我們可以使用一些外部的位元組碼修改工具,比如ASM,來生成一些帶有這個指令的位元組碼,這通常能夠完成一些非常酷的功能,比如完成一門弱型別檢查的JVM-Base語言。

END

本文從Java位元組碼的頂層結構介紹開始,透過一個實際程式碼,瞭解了類載入以後,在JVM記憶體裡的表現形式,並瞭解了jhsdb對Java程式的觀測方式。

我們瞭解到Java7之後的invokedynamic指令,它實際上是透過方法控制程式碼來實現的。和我們關係最大的就是Lambda語法,瞭解了這些原理,可以忽略那些對Lambda效能高低的爭論,要儘量寫一些“非捕獲”的Lambda表示式。

什麼?你問什麼叫非捕獲?那就需要你自己搲了。

7. 有些程式設計師,本質是一群羊!

來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/70024924/viewspace-2935560/,如需轉載,請註明出處,否則將追究法律責任。

相關文章