從字節碼層面理解泛型

命令行

//編譯成 class 文件
javac Test.java 

//反匯編 class 文件
javap -V Test.class

Android Studio 編譯的 class
文件位于 build/intermediates/clases/debug/包名

IDEA 插件

  • jclasslib Bytecode viewer

  • ASM Bytecode Viewer

這兩款插件都可以在 Android Studio Plugins 里直接下載安裝

字節碼的組成

方法調用在JVM中轉換成的是字節碼執行,字節碼指令執行的數據結構就是棧幀。

棧幀的數據結構主要分為四個部分:局部變量表、操作數棧、動態鏈接以及方法返回地址(包括正常調用和異常調用的完成結果)。

局部變量表(local variables)

當方法被調用時,參數會傳遞到從0開始的連續的局部變量表的索引位置上。local variables的最大長度是在編譯期間決定的。一個局部變量表的占用了32位的存儲空間(一個存儲單位稱之為slot,槽),所以可以存儲一個boolean、byte、char、short、float、int、refrence和returnAdress數據,long和double需要2個連續的局部變量表來保存,通過較小位置的索引來獲取。如果被調用的是實例方法,那么第0個位置存儲“this”關鍵字代表當前實例對象的引用。

操作數棧

操作數棧同局部變量表一樣,也是編譯期間就能決定了其存儲空間(最大的單位長度)。

操作數棧是在JVM字節碼執行一些指令(第二部分會介紹一些指令集)時創建的,主要是把局部變量表中的變量壓入操作數棧,在操作數棧中進行字節碼指令的操作,再將變量出操作數棧,結果入操作數棧。

動態鏈接

每個棧幀指向運行時常量池中該棧幀所屬的方法的引用,也就是字節碼的發放調用的引用。動態鏈接就是將符號引用所表示的方法,轉換成方法的直接引用。加載階段或第一次使用時轉化為直接引用的(將變量的訪問轉化為訪問這些變量的存儲結構所在的運行時內存位置)就叫做靜態解析。JVM的動態鏈接還支持運行期轉化為直接引用。也可以叫做Late Binding,晚期綁定。

方法返回地址

方法正常退出會把返回值壓入調用者的棧幀的操作數棧,PC計數器的值就會調整到方法調用指令后面的一條指令。這樣使得當前的棧幀能夠和調用者連接起來,并且讓調用者的棧幀的操作數棧繼續往下執行。

方法的異常調用完成,主要是JVM拋出的異常,如果異常沒有被捕獲住,或者遇到athrow字節碼指令顯示拋出,那么就沒有返回值給調用者。

Java 代碼:

public class MyTest {
    private int myNum = 20;

    public void func() {
        myNum = 50;
    }
}

編譯后的 class 源文件:

cafe babe 0000 0033 0015 0a00 0400 1109
0003 0012 0700 1307 0014 0100 056d 794e
756d 0100 0149 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 1a4c 636f
6d2f 7961 7a68 6964 6576 2f64 656d 6f2f
4d79 5465 7374 3b01 0004 6675 6e63 0100
0a53 6f75 7263 6546 696c 6501 000b 4d79
5465 7374 2e6a 6176 610c 0007 0008 0c00
0500 0601 0018 636f 6d2f 7961 7a68 6964
6576 2f64 656d 6f2f 4d79 5465 7374 0100
106a 6176 612f 6c61 6e67 2f4f 626a 6563
7400 2100 0300 0400 0000 0100 0200 0500
0600 0000 0200 0100 0700 0800 0100 0900
0000 3900 0200 0100 0000 0b2a b700 012a
1014 b500 02b1 0000 0002 000a 0000 000a
0002 0000 0007 0004 0008 000b 0000 000c
0001 0000 000b 000c 000d 0000 0001 000e
0008 0001 0009 0000 0035 0002 0001 0000
0007 2a10 32b5 0002 b100 0000 0200 0a00
0000 0a00 0200 0000 0b00 0600 0c00 0b00
0000 0c00 0100 0000 0700 0c00 0d00 0000
0100 0f00 0000 0200 10

javap 反匯編后的代碼:

Classfile /Users/zengyazhi/Documents/zyzdev/AndroidDemo/app/build/intermediates/classes/debug/com/yazhidev/demo/MyTest.class
  Last modified 2018-12-28; size 393 bytes
  MD5 checksum 2872209fbe3efb46c70b23bf85be75fd
  Compiled from "MyTest.java"
public class com.yazhidev.demo.MyTest
  minor version: 0
  major version: 51
  flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
   #1 = Methodref          #4.#17         // java/lang/Object."<init>":()V
   #2 = Fieldref           #3.#18         // com/yazhidev/demo/MyTest.myNum:I
   #3 = Class              #19            // com/yazhidev/demo/MyTest
   #4 = Class              #20            // java/lang/Object
   #5 = Utf8               myNum
   #6 = Utf8               I
   #7 = Utf8               <init>
   #8 = Utf8               ()V
   #9 = Utf8               Code
  #10 = Utf8               LineNumberTable
  #11 = Utf8               LocalVariableTable
  #12 = Utf8               this
  #13 = Utf8               Lcom/yazhidev/demo/MyTest;
  #14 = Utf8               func
  #15 = Utf8               SourceFile
  #16 = Utf8               MyTest.java
  #17 = NameAndType        #7:#8          // "<init>":()V
  #18 = NameAndType        #5:#6          // myNum:I
  #19 = Utf8               com/yazhidev/demo/MyTest
  #20 = Utf8               java/lang/Object
{
  public com.yazhidev.demo.MyTest();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: aload_0
         5: bipush        20
         7: putfield      #2                  // Field myNum:I
        10: return
      LineNumberTable:
        line 7: 0
        line 8: 4
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      11     0  this   Lcom/yazhidev/demo/MyTest;

  public void func();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=1, args_size=1
         0: aload_0
         1: bipush        50
         3: putfield      #2                  // Field myNum:I
         6: return
      LineNumberTable:
        line 11: 0
        line 12: 6
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       7     0  this   Lcom/yazhidev/demo/MyTest;
}
SourceFile: "MyTest.java"

操作碼

opcode(指令) = 操作碼 + 操作數

例如 bipush 10 這是一條指令,是由操作碼 bipush 后跟一個操作數 10 組成,該指令的作用是將整型數 10 壓到操作數棧中。

  • aload_0(指令碼:0x2a)

    從局部變量數組中加載一個對象引用到操作數棧的棧頂,最后的數字對應的是局部變量數組中的位置,只能是0,1,2,3。(第一個局部變量是this引用)

  • invokespecial(0xb7)

    只能調用三類方法:<init>方法;私有方法;super.method()。因為這三類方法的調用對象在編譯時就可以確定

  • invokevirtual(0xb6)

    是一種動態分派的調用指令

  • bipush(0x10)

    用來把一個字節作為整型壓到操作數棧中

  • putfield(0xb5)

    后面跟一個操作數(該操作數引用的是運行時常量池里的一個字段,在這里這個字段是 myNum),將棧頂的值賦給這個。賦給這個字段的值,以及包含這個字段的對象引用,在執行這條指令的時候,都會從操作數棧頂上 pop 出來

  • ldc(0x12)

    常量池中的常量值入棧

  • CHECKCAST(0xc0)

    類型強轉

部分字節碼指令集可見:

《Java二進制指令代碼解析》

《JVM 虛擬機字節碼指令表》

解析

回到上面 MyTest 的構造函數里:

0: aload_0
1: invokespecial #1                  // Method java/lang/Object."<init>":()V
4: aload_0
5: bipush        20
7: putfield      #2                  // Field myNum:I
10: return

ASM Bytecode viewer 顯示的字節碼為:

// class version 51.0 (51)
// access flags 0x21
public class com/yazhidev/demo/MyTest {

  // compiled from: MyTest.java

  // access flags 0x2
  private I myNum

  // access flags 0x1
  public <init>()V
   L0
    LINENUMBER 7 L0
    ALOAD 0
    INVOKESPECIAL java/lang/Object.<init> ()V
   L1
    LINENUMBER 8 L1
    ALOAD 0
    BIPUSH 20
    PUTFIELD com/yazhidev/demo/MyTest.myNum : I
    RETURN
   L2
    LOCALVARIABLE this Lcom/yazhidev/demo/MyTest; L0 L2 0
    MAXSTACK = 2
    MAXLOCALS = 1

  // access flags 0x1
  public func()V
   L0
    LINENUMBER 11 L0
    ALOAD 0
    BIPUSH 50
    PUTFIELD com/yazhidev/demo/MyTest.myNum : I
   L1
    LINENUMBER 12 L1
    RETURN
   L2
    LOCALVARIABLE this Lcom/yazhidev/demo/MyTest; L0 L2 0
    MAXSTACK = 2
    MAXLOCALS = 1
}

從字節碼看泛型

Java 的泛型是完全在編譯器中實現的,由編譯器執行類型檢查和類型推斷,然后生成普通的非泛型的字節碼,虛擬機完全不感知泛型的存在。編譯器使用泛型類型信息保證類型安全,然后在生成字節碼之前將其清除。

Java 代碼:

public class Generic<T> {

    private T data;

    public T get() {
        return data;
    }

    public void set(T data) {
        this.data = data;
    }
}

從生成的字節碼中可以看到,泛型 T 已經被擦除了:

private Ljava/lang/Object; data

public getData()Ljava/lang/Object;

public setData(Ljava/lang/Object;)V

類型擦除與多態沖突的問題

子類 B,指定了泛型類型:

public class B extends Generic<Number> {

    private Number n;

    public Number get() {
        return n;
    }

    public void set(Number n) {
        this.n = n;
    }
}

子類 C,未指定泛型類型:

public class C extends Generic {

    private Number n;

    public Number get() {
        return n;
    }

    public void set(Number n) {
        this.n = n;
    }
}

我們在寫 B 類時,指定了泛型類型為 Number,對于 B 類的方法 get()Numberset(Number),我們的本意應該是對父類的 get()Tset(T) 方法進行重寫。但上面我們知道了,父類的 get()Tset(T) 在字節碼中實際上是 get()Objectset(Object),與類 B 的方法 set(Number) 方法參數不一樣,理論上應該算重載而不是重寫。為了解決這一沖突,JVM 采用了一種特殊的方法:橋接。

我們先看 B 類的字節碼:

public get()Ljava/lang/Number;
public set(Ljava/lang/Number;)V

// access flags 0x1041
public synthetic bridge set(Ljava/lang/Object;)V
    L0
    LINENUMBER 7 L0
    ALOAD 0
    ALOAD 1
    CHECKCAST java/lang/Number
    INVOKEVIRTUAL com/yazhidev/demo/B.set (Ljava/lang/Number;)V
    RETURN
    L1
    LOCALVARIABLE this Lcom/yazhidev/demo/B; L0 L1 0
    MAXSTACK = 2
    MAXLOCALS = 2

// access flags 0x1041
public synthetic bridge get()Ljava/lang/Object;
    L0
    LINENUMBER 7 L0
    ALOAD 0
    INVOKEVIRTUAL com/yazhidev/demo/B.get ()Ljava/lang/Number;
    ARETURN
    L1
    LOCALVARIABLE this Lcom/yazhidev/demo/B; L0 L1 0
    MAXSTACK = 1
    MAXLOCALS = 1

可以發現編譯器自動生成了 set(Object)get()Object 兩個橋接方法來重寫父類方法,同時這兩個橋接方法實際上調用了對應的 set(Number) 方法和 get()Number 方法。虛擬機通過使用橋接方法,來解決了類型擦除和多態的沖突。對于開發者來說,對于指定了泛型類型為 Number 的 B 類來說,其 set(Number) 方法就是對父類方法 set(T) 的重寫,同理 get()Number 也是對父類方法 get()T 的重寫。

但 C 類則有些不同,C 類未指定泛型類型,所以父類中的方法為 get()Objectset(Object),C 類中的 set(Number) 與父類 set(Object) 方法參數不同,理所當然是重載,我們都知道,只有返回值不同不滿足重載條件,所以對 C 類的 get()Number 方法來說,應該算是對父類方法 get()T 的重寫。

我們來看 C 類的字節碼:

public get()Ljava/lang/String;
public set(Ljava/lang/String;)V

// access flags 0x1041
public synthetic bridge get()Ljava/lang/Object;
    L0
    LINENUMBER 7 L0
    ALOAD 0
    INVOKEVIRTUAL com/yazhidev/demo/C.get ()Ljava/lang/String;
    ARETURN
    L1
    LOCALVARIABLE this Lcom/yazhidev/demo/C; L0 L1 0
    MAXSTACK = 1
    MAXLOCALS = 1

可以發現編譯期自動生成了 get()Object 橋接方法來重寫父類方法。但我們發現字節碼里卻同時存在了兩個只有返回值類型不同的同名方法,這是為什么呢?

這里就需要提到方法特征簽名,只有特征簽名不同的方法才可以共存。

  • Java 層方法簽名 = 方法名 + 參數類型 + 參數順序

    所以在 Java 語言里,重載一個方法需要兩個同名方法的參數類型不同,或者參數順序不同,只有返回值類型不同是無法通過編譯的。

  • JVM 層方法簽名 = 方法名 + 參數類型 + 參數順序 + 返回值類型 + 可能拋出的異常

    所以在 class 文件里,是可以存在兩個只有返回值類型不同的同名方法。也就是上面的 get()Objectget()Number

參考

《描述符與特征簽名的區別》

?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容