前言
之所以寫這麼一篇文章是因爲在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類中就出現了橋接方法。
看到上面的代碼的執行結果,大家肯定會有這麼兩個疑問
- 爲什麼再Son中會有兩個get方法?明明實際申明的只有一個啊
- 爲什麼其中一個方法還是橋接方法呢?這個橋接到底橋接的是什麼?
- 它的返回值爲什麼跟父類中被複寫的參數類型一樣,也是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上申明的
看完上面的代碼可能你的問題又來了
- 爲什麼再MyNode中會有兩個setData方法?明明實際申明的只有一個啊
- 爲什麼其中一個方法還是橋接方法呢?這個橋接到底橋接的是什麼?
- 它的參數類型爲什麼跟父類中被複寫的方法的參數類型一樣,也是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
的知識,希望大家能耐心看完哦。我一直認爲最好的學習方式是帶着問題去學習,但是在這個過程中你可能又會碰到新的問題,那麼怎麼辦呢?
堅持,就是最好的辦法,再難的事情不過也就是打怪升級!
在上面我們探究什麼時候會出現橋接方法時,應該能感覺到,橋接方法的出現都是要滿足下面兩個條件纔會出現
- 子類重寫了父類的方法
- 子類中進行重寫的方法跟父類不一致(參數不一致或者返回值不一致)
當滿足了上面兩個條件時,編譯器會自動爲我生成橋接方法,因爲編譯的後文件是交由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"
接下來,我們使用圖解的方式來對上面的字節碼做進一步的分析
接下來就要執行invokevirtual
指令,在執行這個指令我們將操作數棧的狀態放大來看看
棧頂保存的是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,一個在學習路上匍匐前行的小菜鳥!