背景
有Java基礎(chǔ)的同學(xué)都知道Java中有Primitive Type(原始類型),比如int、short。作為面向?qū)ο蟮恼Z言求晶,Java同時提供了每個原始類型的包裝類型(本質(zhì)是引用類型Reference Type),比如Integer衷笋、Long芳杏、Boolean.
為了方便大家寫代碼,JDK 5以后引入了自動拆裝箱的機制. 比如對于函數(shù):
add(Integer a)
我們在調(diào)用的時候右莱,傳一個Integer對象并不是必須的蚜锨,有時直接傳一個原始類型即可:
//int型變量直接傳
int i = 1;
add(i);
//或者數(shù)字字面量,本身也是int型
add(5);
Java會自動將int裝換成Integer慢蜓,這個過程稱為裝箱,由于是Java自動做的郭膛,所以叫自動裝箱(autoboxing)晨抡,反之如果是將Integer自動裝換成int,則稱為自動拆箱(autounboxing)则剃。有了自動拆裝箱耘柱,平時在寫代碼的時候很Happy,瞬間覺得世界真美好~
意外
然而棍现,事情并不總是很順利调煎,比如我們有時會遇到這種場景(演示實例來自同事琛總):
//A.java有類A,A調(diào)了B的方法add(int i)己肮,這時傳的是個原始類型士袄, 完美匹配
class A {
public static void main(String[] args) {
B.add(1);
}
}
//B.java
class B {
public static void add(int i) {
System.out.println(i);
}
}
執(zhí)行命令
javac A.java //這時會同時生成A.class和B.class
java A //運行成功
然后我們做一件事,把B稍作修改谎僻,讓B的add方法接受Integer包裝類型
//B.java
class B {
public static void add(Integer i) { // 這里把int i 改成 Integer i后重新編譯
System.out.println(i);
}
}
接著重新編譯B.java文件娄柳,注意:只重新編譯B.java,相當于B類做了升級艘绍,而調(diào)用方A并不做任何改變赤拒,A.class也不重新生成。然后,我們執(zhí)行java A
命令運行挎挖,結(jié)果卻并沒有像我們想象中的那樣这敬,而是報了如下錯誤
Exception in thread "main" java.lang.NoSuchMethodError: B.add(I)V
at A.main(A.java:4)
說好的自動拆裝箱呢
NoSuchMethodError的錯誤報出來的時候,一臉的黑人問號:不是有個add(Integer i)方法嗎蕉朵?怎么會說找不到方法鹅颊?說好的自動拆裝箱呢?
肯定是哪里出了問題墓造!帶著問題搜到了知乎R大的一個關(guān)于Java自動拆裝箱的回答:
根據(jù)R大的解釋堪伍,Java的自動拆裝箱發(fā)生在編譯期,即javac編譯的那一刻觅闽,而不是在運行期帝雇!筆者的潛意識里認為,自動拆裝箱會發(fā)生在運行期蛉拙,所以會覺得NoSuchMethodError的錯誤簡直不可思議尸闸。
如果編譯器發(fā)現(xiàn)需要自動拆裝箱,會用語法糖的方法自動給你加上Integer.valueOf()孕锄,即將A類里面的1變成Integer.valueOf(1)吮廉,然后生成在A.class文件里。但是我們編譯A文件的時候畸肆,B的add方法接受的是int型宦芦,所以A.class文件里并沒有Integer.valueOf(1)這一步,A.class文件里要調(diào)用的還是add(int i)轴脐。
后來调卑,我們把B文件的add(int i)方法變成了add(Integer i), 本質(zhì)上相當于刪除了一個舊方法,添加了一個全新的方法大咱,這個時候A.class還是老的樣子恬涧,一旦運行java A
,java虛擬機就去找B中的add(int i)方法碴巾,然而它已經(jīng)找不到這個方法了溯捆,因為已經(jīng)被刪除了,只留下了add(Integer i)方法厦瓢,所以會報NoSuchMethodError.
繼續(xù)扒開自動拆裝箱的底褲
我們執(zhí)行以下命令來查看A.class的具體信息
javap -verbose A.class
當B類的方法為add(int i)時提揍,A.class的信息如下:
class A
minor version: 0
major version: 52
flags: ACC_SUPER
Constant pool:
#1 = Methodref #4.#13 // java/lang/Object."<init>":()V
#2 = Methodref #14.#15 // B.add:(I)V
#3 = Class #16 // A
#4 = Class #17 // java/lang/Object
#5 = Utf8 <init>
#6 = Utf8 ()V
#7 = Utf8 Code
#8 = Utf8 LineNumberTable
#9 = Utf8 main
#10 = Utf8 ([Ljava/lang/String;)V
#11 = Utf8 SourceFile
#12 = Utf8 A.java
#13 = NameAndType #5:#6 // "<init>":()V
#14 = Class #18 // B
#15 = NameAndType #19:#20 // add:(I)V
#16 = Utf8 A
#17 = Utf8 java/lang/Object
#18 = Utf8 B
#19 = Utf8 add
#20 = Utf8 (I)V
{
A();
descriptor: ()V
flags:
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 1: 0
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=1, locals=1, args_size=1
0: iconst_1
1: invokestatic #2 // Method B.add:(I)V
4: return
LineNumberTable:
line 4: 0
line 5: 4
}
SourceFile: "A.java"
重點關(guān)注下1: invokestatic #2
這段,即對應(yīng)A.java中的add(1)要調(diào)用add方法的邏輯旷痕,#2指向常量池#2 = Methodref #14.#15
, 然后把 #14.#15繼續(xù)展開碳锈,即 #18.#19:#20 , 最后展開的樣子其實就是注釋的樣子 B.add:(I)V,這說明到了匯編這一層欺抗,運行期找的就是add(int i)方法售碳。
然后,如果我們把B類的方法改為add(Integer i)時,重新編譯后的A.class的信息如下:
class A
minor version: 0
major version: 52
flags: ACC_SUPER
Constant pool:
#1 = Methodref #5.#14 // java/lang/Object."<init>":()V
#2 = Methodref #15.#16 // java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
#3 = Methodref #17.#18 // B.add:(Ljava/lang/Integer;)V
#4 = Class #19 // A
#5 = Class #20 // java/lang/Object
#6 = Utf8 <init>
#7 = Utf8 ()V
#8 = Utf8 Code
#9 = Utf8 LineNumberTable
#10 = Utf8 main
#11 = Utf8 ([Ljava/lang/String;)V
#12 = Utf8 SourceFile
#13 = Utf8 A.java
#14 = NameAndType #6:#7 // "<init>":()V
#15 = Class #21 // java/lang/Integer
#16 = NameAndType #22:#23 // valueOf:(I)Ljava/lang/Integer;
#17 = Class #24 // B
#18 = NameAndType #25:#26 // add:(Ljava/lang/Integer;)V
#19 = Utf8 A
#20 = Utf8 java/lang/Object
#21 = Utf8 java/lang/Integer
#22 = Utf8 valueOf
#23 = Utf8 (I)Ljava/lang/Integer;
#24 = Utf8 B
#25 = Utf8 add
#26 = Utf8 (Ljava/lang/Integer;)V
{
A();
descriptor: ()V
flags:
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 1: 0
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=1, locals=1, args_size=1
0: iconst_1
1: invokestatic #2 // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
4: invokestatic #3 // Method B.add:(Ljava/lang/Integer;)V
7: return
LineNumberTable:
line 4: 0
line 5: 7
}
SourceFile: "A.java"
重點在這里
0: iconst_1
1: invokestatic #2 // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
4: invokestatic #3 // Method B.add:(Ljava/lang/Integer;)V
7: return
明顯多了一行invokestatic #2
, 而這一行顯然就是Integer.valueOf(1)的過程贸人,即自動裝箱的過程间景,也就是編譯器自動幫我們加上的一段代碼,之后4: invokestatic #3
才去調(diào)用B類的add(Integer i) 方法艺智。
所以倘要,我們潛意識里以為會在運行期執(zhí)行的拆裝箱的過程,其實在編譯期就做好了十拣;在運行期JVM會嚴格按照class文件中的執(zhí)行過程來尋找相應(yīng)的匹配方法封拧,而add(int i)和add(Integer i)方法顯然不是同一個方法,當然會報NoSuchMethodError.
聊聊代碼兼容問題
以上案例是我們刻意設(shè)計出來的夭问,其實在真實的場景中是會碰到這種問題的泽西。比如我們在自己的項目中引用了兩個不同項目jar1 和jar2,而jar1同時引用了jar2的方法缰趋,如果jar2把方法add(int i)改為add(Integer i)捧杉,而我們引用的jar1還是老的,這個時候我們項目再去調(diào)jar1里的方法秘血,jar1再調(diào)新jar2的方法add(int)味抖,就會報NoSuchMethodError.
其實這類問題背后反映的是代碼兼容性的問題,比如:
//B類version1
class B {
public static void add(int i) {
System.out.println(i);
}
}
//B類version2
class B {
public static void add(Integer i) {
System.out.println(i);
}
}
B類從version1 到 version2的升級灰粮,并不是一個兼容性的升級仔涩, add(int i)方法和 add(Integer i)不是同一個方法,version2的版本相當于刪除了原來的方法谋竖,新加了一個方法红柱,如果有歷史jar包還在調(diào)用老的方法而且沒有重新編譯,而且JVM中加載的又是version2的B類蓖乘,那么最終的結(jié)果一定是報錯。
兼容性的升級是重載這個方法:
//B類version3
class B {
public static void add(int i) {
System.out.println(i);
}
public static void add(Integer i) {
System.out.println(i);
}
}
類似的代碼兼容性問題還有很多韧骗,比如我們給別人提供的RPC方法中嘉抒,顯然是不能隨便刪除字段的,這個很容易理解袍暴,刪除字段后些侍,別人在線上跑的應(yīng)用用的還是舊的API,他們獲取不到想要的字段肯定是會出問題的政模。
而添加字段就是一個對兼容友好的升級行為岗宣,我們添加的字段,使用舊API的消費方雖然看不到新字段的存在淋样,但是老字段依然還是可用的耗式。比如服務(wù)端給客戶端提供的JSON API,客戶端只關(guān)心自己需要的字段,服務(wù)端添加字段上線刊咳,并不會影響老版本的客戶端的使用彪见,因為老版本客戶端在做JSON反序列化的時候只根據(jù)字段名反序列化。
而服務(wù)端通信協(xié)議Thrift的反序列化和JSON又不一樣娱挨,Thrift反序列化過程并不以是字段名為參考余指,而是和順序強相關(guān),比如對于Thrfit 的 struct類型:
//Person version1
struct Person{
1:i32 id
2:string name
}
//Person version2
struct Person{
1:i32 id
2:i32 age
3:string name
}
//Person version3
struct Person{
1:i32 id
2:string name
3:i32 age
}
如果服務(wù)提供方將Person升級到version2跷坝,那么對于還在使用version1 的消費者來說酵镜,Person實例請求回來要反序列化的時候,會把第二個age反序列化成name柴钻,顯然這不是我們想要的結(jié)果淮韭,而對兼容友好的升級應(yīng)該是version3那種,不影響前面字段的排序顿颅,在后面添加字段缸濒,這樣就不會對老版本的API造成反序列的錯亂。
軟件行業(yè)兼容性的典型案例就是微軟家的Windows操作系統(tǒng)粱腻,有人嘗試過過把Windows 3.1的很多程序放到Windows XP上去安裝使用庇配,竟然發(fā)現(xiàn)還能正常運行,甚至很流暢绍些,真是驚嘆Windows對兼容性的執(zhí)著捞慌!有人說,Windows幾乎是業(yè)界兼容性做的最好的OS柬批,而這一點也許是Windows在桌面市場領(lǐng)域能獨占鰲頭的重要原因之一啸澡。