Spring雜談 | 從橋接方法到JVM方法呼叫

程式設計師DMZ發表於2020-06-09

前言

之所以寫這麼一篇文章是因為在Spring中,經常會出現下面這種程式碼

// 判斷是否是橋接方法,如果是的話就返回這個方法
BridgeMethodResolver.findBridgedMethod(specificMethod);

這些程式碼對我之前也造成了不小疑惑,在徹底弄懂後通過本文分享出來,也能減少大家在閱讀程式碼過程中的障礙!

橋接方法

什麼時候會出現橋接方法?

第一種情況:方法重寫的時候子父類方法返回值不一致導致

public class Parent {
    public Number get(Number number){
        System.out.println("parent's method invoke");
        return 1;
    }
}

public class Son extends Parent {
    // 這裡對父類的方法進行了重寫,但是返回值型別跟父類中不一樣,父類中的返回值型別為Number,子類中的返回值型別為Integer,Integer是Number的子類
    @Override
    public Integer get(Number number) {
        System.out.println("son's method invoke");
        return 2;
    }
}

public class PMain {
    public static void main(String[] args) {
        Son son = new Son();
        Method[] declaredMethods = son.getClass().getDeclaredMethods();
        for (int i = 0; i < declaredMethods.length; i++) {
            Method declaredMethod = declaredMethods[i];
            String methodName = declaredMethod.getName();
            Class<?> returnType = declaredMethod.getReturnType();
            Class<?> declaringClass = declaredMethod.getDeclaringClass();
            boolean bridge = declaredMethod.isBridge();
            System.out.print("第" + (i+1) + "個方法名稱:" + methodName + ",方法返回值型別:" + returnType + "  ");
            System.out.print(bridge ? " 是橋接方法" : " 不是橋接方法");
            System.out.println("  這個方法是在"+declaringClass.getSimpleName()+"上申明的");
        }
    }
}

// 程式列印如下:
第1個方法名稱:get,方法返回值型別:class java.lang.Integer   不是橋接方法  這個方法是在Son上申明的
第2個方法名稱:get,方法返回值型別:class java.lang.Number   是橋接方法  這個方法是在Son上申明的

可以看到在上面的例子中Son類中就出現了橋接方法

看到上面的程式碼的執行結果,大家肯定會有這麼兩個疑問

  1. 為什麼再Son中會有兩個get方法?明明實際申明的只有一個啊
  2. 為什麼其中一個方法還是橋接方法呢?這個橋接到底橋接的是什麼?
  3. 它的返回值為什麼跟父類中被複寫的引數型別一樣,也是Number型別?

有這些疑問沒關係,我們帶著疑問往下看。

如果你認真看了上面的程式碼,你應該就會知道上面例子的特殊之處在於:

子類對父類的方法進行了重寫,並且子類方法中的返回值型別跟父類方法的返回值型別不一樣!!!!

那麼到底是不是這個原因導致的呢?我們不妨將上面例子中Son類的程式碼更改如下:

public class Son extends Parent {
//    @Override
//    public Integer get(Number number) {
//        System.out.println("son's method invoke");
//        return 2;
//    }

    @Override
    public Number get(Number number) {
        System.out.println("son's method invoke");
        return 2;
    }
}
// 執行結果
第1個方法名稱:get,方法返回值型別:class java.lang.Number   不是橋接方法  這個方法是在Son上申明的

再次執行程式碼,會發現,橋接方法不見了,也只能看到一個方法。

那麼到現在我們就基本能確定了是因為重寫的時候子父類方法返回值不一致導致出現了橋接方法。

第二種情況:子類重寫了父類中帶有泛型的方法

參考連結:https://docs.oracle.com/javase/tutorial/java/generics/bridgeMethods.html#bridgeMethods

public class Node<T> {

    public T data;

    public Node(T data) { this.data = data; }

    public void setData(T data) {
        System.out.println("Node.setData");
        this.data = data;
    }
}

public class MyNode extends Node<Integer> {

    public MyNode(Integer data) { super(data); }

    @Override
    public void setData(Integer data) {
        System.out.println("MyNode.setData");
        super.setData(data);
    }
}

public class Main {
    public static void main(String[] args) {
        MyNode mn = new MyNode(5);
        Method[] declaredMethods = mn.getClass().getDeclaredMethods();
        for (int i = 0; i < declaredMethods.length; i++) {
            Method declaredMethod = declaredMethods[i];
            String methodName = declaredMethod.getName();
            Class<?>[] parameterTypes = declaredMethod.getParameterTypes();
            Class<?> declaringClass = declaredMethod.getDeclaringClass();
            boolean bridge = declaredMethod.isBridge();
            System.out.print("第" + (i + 1) + "個方法名稱:" + methodName + ",引數型別:" + Arrays.toString(parameterTypes) + "  ");
            System.out.print(bridge ? " 是橋接方法" : " 不是橋接方法");
            System.out.println("  這個方法是在" + declaringClass.getSimpleName() + "上申明的");
        }
    }
}

// 執行結果:
第1個方法名稱:setData,引數型別:[class java.lang.Integer]   不是橋接方法  這個方法是在MyNode上申明的
第2個方法名稱:setData,引數型別:[class java.lang.Object]   是橋接方法  這個方法是在MyNode上申明的

看完上面的程式碼可能你的問題又來了

  1. 為什麼再MyNode中會有兩個setData方法?明明實際申明的只有一個啊
  2. 為什麼其中一個方法還是橋接方法呢?這個橋接到底橋接的是什麼?
  3. 它的引數型別為什麼跟父類中被複寫的方法的引數型別一樣,也是Integer型別?

這些問題基本跟第一種情況的問題一樣,所以不要急,我們還是往下看

上面例子的特殊之處在於,子類重寫父類中帶有泛型引數的方法。實際上子類重寫父類帶有泛型返回值的方法也會出現上面這種情況,比如,我們將程式碼改成這樣

public class Node<T> {

    public T data;

    public Node(T data) {
        this.data = data;
    }

    public void setData(T data) {
        System.out.println("Node.setData");
        this.data = data;
    }
	// 新增一個getData方法,返回值為泛型T
    public T getData() {
        System.out.println("Node.getData");
        return this.data;
    }
}

public class MyNode extends Node<Integer> {

    public MyNode(Integer data) { super(data); }

    @Override
    public void setData(Integer data) {
        System.out.println("MyNode.setData");
        super.setData(data);
    }
	
    // 子類對新增的那個方法進行復寫
    @Override
    public Integer getData() {
        System.out.println("MyNode.getData");
        return super.getData();
    }
}
// 程式執行結果
第1個方法名稱:setData,引數型別:[class java.lang.Object]   是橋接方法  這個方法是在MyNode上申明的
第2個方法名稱:setData,引數型別:[class java.lang.Integer]   不是橋接方法  這個方法是在MyNode上申明的
第3個方法名稱:getData,引數型別:[]   是橋接方法  這個方法是在MyNode上申明的
第4個方法名稱:getData,引數型別:[]   不是橋接方法  這個方法是在MyNode上申明的

可以發現,又出現了一個橋接方法。

為什麼需要橋接方法?

接下來回牽涉到一些JVM的知識,希望大家能耐心看完哦。

我一直認為最好的學習方式是帶著問題去學習,但是在這個過程中你可能又會碰到新的問題,那麼怎麼辦呢?

堅持,就是最好的辦法,再難的事情不過也就是打怪升級!

在上面我們探究什麼時候會出現橋接方法時,應該能感覺到,橋接方法的出現都是要滿足下面兩個條件才會出現

  1. 子類重寫了父類的方法
  2. 子類中進行重寫的方法跟父類不一致(引數不一致或者返回值不一致)

當滿足了上面兩個條件時,編譯器會自動為我生成橋接方法,因為編譯的後檔案是交由JVM執行的,生成的這個橋接方法肯定就是為了JVM進行方法呼叫時服務的,我們不妨大膽猜測,在這種情況下,是因為JVM在進行方法呼叫時,沒有辦法滿足我們的執行時多型,所以生成了橋接方法。要弄清楚這個問題,我們還是要從JVM的方法呼叫說起。

JVM是怎麼呼叫方法的?

我們應該知道,JVM要執行一個方法時必定需要先找到那個方法,對計算機而言,就是要定位到方法所在的記憶體地址。那麼JVM是如何定位到方法所在記憶體呢?我們知道JVM所執行的是class檔案,我們的.java檔案會經過編譯生成class檔案後才能被JVM執行。如圖所示:

未命名檔案

因為目前我們關注的是方法的呼叫,所以對class檔案的具體結構我們就不做過多分析了,我們主要就看看常量池方法表

常量池

常量池中主要儲存下面三類資訊

  • 類和介面的全限定名
  • 欄位的名稱和描述符
  • 方法的名稱和描述符

方法表

  • 方法標誌,比如public,native,abstract,以及本文所探討的橋接(bridge)
  • 方法名稱索引,因為具體的方法名稱儲存在常量池中,所以這裡儲存的是對常量池的索引
  • 描述符索引,即返回值+引數
  • 屬性表集合,方法具體的執行程式碼便儲存在這裡

對於常量池跟方法表我們不做過多介紹,這兩個隨便一個拿出來都能寫一篇文章,對於閱讀本文而言,你只需要知道它們儲存了上面的這些資訊即可。如果大家感興趣的話,推薦閱讀周志明老師的《深入理解Java虛擬機器》

位元組碼分析

接下來我們就通過一段位元組碼的分析來看看JVM到底是如何呼叫方法的,這裡就以我們前文中第一個例子中的程式碼來進行分析。java程式碼如下:

public class Parent {
	public Number get(Number number){
		return 1;
	}
}

public class Son extends Parent {
	// 重寫了父類的方法,返回值型別只要是Number類的子類即可
	@Override
	public Integer get(Number number)  {

		return 2;
	}
}

/**
 * @author 程式設計師DMZ
 * @Date Create in 21:03 2020/6/7
 * @Blog https://daimingzhi.blog.csdn.net/
 */
public class LoadMain {
	public static void main(String[] args) {
		Parent person = new Son();
		person.get(1);
	}
}

對編譯好的class檔案執行javap -v -c 指令,得到如下位元組碼

Classfile /E:/spring-framework/spring-dmz/out/production/classes/com/dmz/spring/java/LoadMain.class
  Last modified 2020-6-7; size 673 bytes
  MD5 checksum 4b8832849fb5f63e472324be91603b1b
  Compiled from "LoadMain.java"
public class com.dmz.spring.java.LoadMain
  minor version: 0
  major version: 52
  flags: ACC_PUBLIC, ACC_SUPER
// 常量池
Constant pool:
   #1 = Methodref          #7.#23         // java/lang/Object."<init>":()V
   #2 = Class              #24            // com/dmz/spring/java/Son
   #3 = Methodref          #2.#23         // com/dmz/spring/java/Son."<init>":()V
   #4 = Methodref          #25.#26        // java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
   #5 = Methodref          #27.#28        // com/dmz/spring/java/Parent.get:(Ljava/lang/Number;)Ljava/lang/Number;
   #6 = Class              #29            // com/dmz/spring/java/LoadMain
   #7 = Class              #30            // java/lang/Object
   #8 = Utf8               <init>
   #9 = Utf8               ()V
  #10 = Utf8               Code
  #11 = Utf8               LineNumberTable
  #12 = Utf8               LocalVariableTable
  #13 = Utf8               this
  #14 = Utf8               Lcom/dmz/spring/java/LoadMain;
  #15 = Utf8               main
  #16 = Utf8               ([Ljava/lang/String;)V
  #17 = Utf8               args
  #18 = Utf8               [Ljava/lang/String;
  #19 = Utf8               person
  #20 = Utf8               Lcom/dmz/spring/java/Parent;
  #21 = Utf8               SourceFile
  #22 = Utf8               LoadMain.java
  #23 = NameAndType        #8:#9          // "<init>":()V
  #24 = Utf8               com/dmz/spring/java/Son
  #25 = Class              #31            // java/lang/Integer
  #26 = NameAndType        #32:#33        // valueOf:(I)Ljava/lang/Integer;
  #27 = Class              #34            // com/dmz/spring/java/Parent
  #28 = NameAndType        #35:#36        // get:(Ljava/lang/Number;)Ljava/lang/Number;
  #29 = Utf8               com/dmz/spring/java/LoadMain
  #30 = Utf8               java/lang/Object
  #31 = Utf8               java/lang/Integer
  #32 = Utf8               valueOf
  #33 = Utf8               (I)Ljava/lang/Integer;
  #34 = Utf8               com/dmz/spring/java/Parent
  #35 = Utf8               get
  #36 = Utf8               (Ljava/lang/Number;)Ljava/lang/Number;
{
  public com.dmz.spring.java.LoadMain();
    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 8: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       5     0  this   Lcom/dmz/spring/java/LoadMain;guan

  public static void main(java.lang.String[]);
    // 方法的描述符,括號中的是引數,[Ljava/lang/String代表引數是一個String陣列,V是返回值,代表void
    descriptor: ([Ljava/lang/String;)V
    // 方法的標誌,public,static
    flags: ACC_PUBLIC, ACC_STATIC
    // 方法執行程式碼對應的位元組碼
    Code:
      // 運算元棧深為2,本地變數表中有2兩個元素,引數個數為1
      stack=2, locals=2, args_size=1
         // 前三行指定對應的程式碼就是Parent person = new Son()
         // new指定,建立一個物件,並返回這個物件的引用
         0: new           #2                  // class com/dmz/spring/java/Son
         // dup指令,將new指令返回的引用進行備份,一個賦值給區域性變數表中的值,另外一個用於執行invokespecial指令
         3: dup
         // 進行初始化
         4: invokespecial #3                  // Method com/dmz/spring/java/Son."<init>":()V	  // 將建立出來的物件的引用儲存到區域性變數表中下標為1也就是第二個元素中,第一個元素儲存的是main方法的引數
         7: astore_1
         // 將引用壓入到運算元棧中,此時棧頂儲存的是一個指向son型別物件的引用
         8: aload_1
         // 常數1壓入運算元棧
         9: iconst_1
         // 執行常量池中 #4所對應的方法,也就是java/lang/Integer.valueOf方法
        10: invokestatic   #4                  // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
        // 真正呼叫get方法的指令          
        13: invokevirtual #5                  // Method com/dmz/spring/java/Parent.get:(Ljava/lang/Number;)Ljava/lang/Number;
        // 彈出運算元棧頂的值
        16: pop
        17: return
        // 程式碼行數跟指令的對應關係,比如在我的idea中,第10行程式碼對應的就是Parent person = new Son()
      LineNumberTable:
        line 10: 0
        line 11: 8
        line 12: 17
        // 區域性變數表中的值
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      18     0  args   [Ljava/lang/String;
            8      10     1 person   Lcom/dmz/spring/java/Parent;
}
SourceFile: "LoadMain.java"

接下來,我們使用圖解的方式來對上面的位元組碼做進一步的分析

位元組碼圖解1

位元組碼分析2

位元組碼分析3

接下來就要執行invokevirtual指令,在執行這個指令我們將運算元棧的狀態放大來看看

位元組碼圖解4

棧頂儲存的是1,也就是執行對應方法的引數,棧底儲存的是執行Parent person = new Son()得到的一個引用。

在上面的位元組碼中,我們發現invokevirtual指令後面跟了一個#5,這代表它引用了常量池中的第五號常量,對應的就是這個方法引用:

com/dmz/spring/java/Parent.get:(Ljava/lang/Number;)Ljava/lang/Number;

上面整個表示式代表了方法的簽名,com/dmz/spring/java/Parent代表了方法所在類名,get代表方法名,(Ljava/lang/Number;)代表方法執行引數,Ljava/lang/Number代表方法返回值。

根據運算元棧的資訊以及invokevirtual所引用的方法簽名資訊,我們不難得出這條指令要去執行person 引用所指向的物件中的一個方法名為get方法引數為Number返回值為Number的方法,但是請注意,我們的Son物件中沒有這樣的一個方法,我們在Son中重寫的方法是這樣的

public Integer get(Number number)  {

    return 2;
}

其返回值型別是Integer,可能有的同學會有疑問,Integer不是Number的子類嗎?為什麼不能識別呢?

嗯,我也沒辦法回答這個問題,JVM在對方法覆蓋的定義就是這樣,必須要方法簽名相同

但是Java對於重寫的定義呢?只是要求方法的返回值型別相同就行了,正是因為這二者的差異,導致了編譯器不得不生成一個橋接方法來進行平衡。

那麼到底是不是這樣呢?我們不妨再來看看生成橋接方法的類的位元組碼,也就是Son.class的位元組碼,對應如下(只放關鍵的部分了,實在太佔篇幅了):

 public java.lang.Integer get(java.lang.Number);
    descriptor: (Ljava/lang/Number;)Ljava/lang/Integer;
    flags: ACC_PUBLIC
    Code:
      stack=1, locals=2, args_size=2
         0: iconst_2
         1: invokestatic  #2                  // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
         4: areturn
      LineNumberTable:
        line 13: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       5     0  this   Lcom/dmz/spring/java/Son;
            0       5     1 number   Ljava/lang/Number;

  public java.lang.Number get(java.lang.Number);
    descriptor: (Ljava/lang/Number;)Ljava/lang/Number;
	// 看到這個ACC_BRIDGE的標記了嗎,代表它就是橋接方法
    // ACC_SYNTHETIC,代表是編譯器生成的,編譯器生成的方法不一定是橋接方法,但是橋接方法一定是編譯器生成的
	// ACC_PUBLIC不用說了吧
    flags: ACC_PUBLIC, ACC_BRIDGE, ACC_SYNTHETIC
    Code:
      stack=2, locals=2, args_size=2
         0: aload_0
         1: aload_1
         // 這一步看到了嗎?呼叫了那個被橋接的方法,也就是我們真正定義的重寫的方法
         2: invokevirtual #3                  // Method get:(Ljava/lang/Number;)Ljava/lang/Integer;
         5: areturn
      LineNumberTable:
        line 8: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       6     0  this   Lcom/dmz/spring/java/Son;

總結

到這裡你明白了嗎?橋接方法到底橋接的什麼?其實就是編譯器對JVM到JAVA的一個橋接,編譯器為了滿足JAVA的重寫的語義,生成了一個方法描述符與父類一致的方法,然後又呼叫了真實的我們定義的邏輯。這樣既滿足了JAVA重寫的要求,也符合了JVM的規範。

如果本文對你由幫助的話,記得點個贊吧!也歡迎關注我的公眾號,微信搜尋:程式設計師DMZ,或者掃描下方二維碼,跟著我一起認認真真學Java,踏踏實實做一個coder。

公眾號

我叫DMZ,一個在學習路上匍匐前行的小菜鳥!

相關文章