一、概述
現在的軟件越來越依賴於不同廠商、作者開發的共享組件,組件管理也變得越來越重要。在這方面,一個極其重要的問題是類的不同版本的二進制兼容性,即一個類改變時,新版的類是否可以直接替換原來的類,卻不至於損壞其他由不同廠商/作者開發的依賴於該類的組件?
Java二進制兼容性概念的主要目標是推動Internet上軟件的廣泛重用,同時它還避免了大多數C++環境面臨的基礎類脆弱性問題——例如,在C++中,對域(數據成員或實例變量)的訪問被編譯成相對於對象起始位置的偏移量,在編譯時就確定,如果類加入了新的域並重新編譯,偏移量隨之改變,原先編譯的使用老版本類的代碼就不能正常執行;虛擬方法調用也存在同樣的問題。
C++環境通常采用重新編譯所有引用了被修改類的代碼來解決問題。在Java中,少量開發環境也采用了同樣的策略,但這種策略存在諸多限制。例如,假設有人開發了一個程序P,P引用了一個外部的庫L1,但P的作者沒有L1的源代碼;L1要用到另一個庫L2。現在L2改變了,但L1無法重新編譯,所以P的開發和更改也受到了限制。
為此,Java引入了二進制兼容的概念——如果對L2的更改是二進制兼容的,那麼更改後的L2、原來的L1和現在的P能夠順利連接,不會出現任何錯誤。
首先來看一個簡單的例子。Authorization和Hello類分別來自兩個不同的作者,Authorization提供身份驗證和授權服務,Hello類要調用Authorization類。
package com.author1;
public class Authorization {
public boolean authorized(String userName) {
return true;
}
}
package com.author2;
import com.author1.*;
class Hello {
public static void main(String arg[]) {
Authorization auth = new Authorization();
if(auth.authorized("MyName"))
System.out.println("您已經通過驗證");
else
System.out.println("您未能通過身份驗證");
}
}
現在author1發布了Authorization類的2.0版,Hello類的作者author2希望在不更改原有Hello類的情況下使用新版的Authorization類。2.0版的Authorization要比原來的復雜不少:
package com.author1;
public class Authorization {
public Token authorized(String userName, String pwd) {
return null;
}
private boolean determineAuthorization(String userName, String pwd) {
return true;
}
public boolean authorized(String userName) {
return true;
}
public class Token { }
}
作者author1承諾2.0版的Authorization類與1.0版的類二進制兼容,或者說,2.0版的Authorization類仍舊滿足1.0版的Authorization類與Hello類的約定。顯然,author2編譯Hello類時,無論使用Authorization類的哪一個版本都不會出錯——實際上,如果僅僅是因為Authorization類升級,Hello類根本無需重新編譯,同一個Hello.class可以調用任意一個Authorization.class。
這一特性並非Java獨有。UNIX系統很早就有了共享對象庫(.so文件)的概念,Windows系統也有動態鏈接庫(.dll文件)的概念,只要替換一下文件就可以將一個庫改換為另一個庫。就象Java的二進制兼容特性一樣,名稱的連接是在運行時完成,而不是在代碼的編譯、連接階段完成,而因它也同樣擁有Java二進制兼容性所具有的優點,例如修改代碼時只需重新編譯一個庫,便於對程序的某一部分進行修改。但是,Java的二進制兼容性還有其獨特的優勢:
⑴ Java將二進制兼容性的粒度從整個庫(可能包含數十、數百個類)細化到了單個的類。
⑵ 在C/C++之類的語言中,創建共享庫通常是一種有意識的行為,一個應用軟件一般不會提供很多共享庫,哪些代碼可以共享、哪些代碼不可共享都是預先規劃的結果。但在Java中,二進制兼容變成了一種與生俱來的天然特性。
⑶ 共享對象只針對函數名稱,但Java二進制兼容性考慮到了重載、函數簽名、返回值類型。
⑷ Java提供了更完善的錯誤控制機制,版本不兼容會觸發異常,但可以方便地捕獲和處理。相比之下,在C/C++中,共享庫版本不兼容往往引起嚴重問題。
二、類和對象的兼容性
二進制兼容的概念在某些方面與對象串行化的概念相似,兩者的目標也有一定的重疊。串行化一個Java對象時,類的名稱、域的名稱被寫入到一個二進制輸出流,串行化到磁盤的對象可以用類的不同版本來讀取,前提是該類要求的名稱、域都存在,且類型一致。下表比較了二進制兼容和串行化這兩個概念。
對象串行化 二進制兼容 適用於 對象 類 兼容要求 類,域 類,域,方法 刪除操作導致不兼容 總是 不一定 修改訪問屬性(public,private等)後是否兼容 是 否二進制兼容和串行化都考慮到了類的版本不斷更新的問題,允許為類加入方法和域,而且純粹的加入不會影響程序的語義;類似地,單純的結構修改,例如重新排列域或方法,也不會引起任何問題。
三、延遲綁定
理解二進制兼容的關鍵是要理解延遲綁定(Late Binding)。延遲綁定是指Java直到運行時才檢查類、域、方法的名稱,而不象C/C++的編譯器那樣在編譯期間就清除了類、域、方法的名稱,代之以偏移量數值——這是Java二進制兼容得以發揮作用的關鍵。
由於采用了延遲綁定技術,方法、域、類的名稱直到運行時才解析,意味著只要域、方法等的名稱(以及類型)一樣,類的主體可以任意替換——當然,這是一種簡化的說法,還有其他一些規則制約Java類的二進制兼容性,例如訪問屬性(private、public等)以及是否為abstract(如果一個方法是抽象的,那麼它肯定是不可直接調用的)等,但延遲綁定機制無疑是二進制兼容的核心所在。
只有掌握了二進制兼容的規則,才能在改寫類的時候保證其他類不受到影響。下面再來看一個例子,FrodoMail和SamMail是兩個Email程序:
abstract class Message implements Classifiable { }
class EmailMessage extends Message {
public boolean isJunk() { return false; }
}
interface Classifiable {
boolean isJunk();
}
class FrodoMail {
public static void main(String a[]) {
Classifiable m = new EmailMessage();
System.out.println(m.isJunk());
}
}
class SamMail {
public static void main(String a[]) {
EmailMessage m = new EmailMessage();
System.out.println(m.isJunk());
}
}
如果我們重新實現Message,不再讓它實現Classifiable接口,SamMail仍能正常運行,但FrodoMail會拋出異常:java.lang.IncompatibleClassChangeError at FrodoMail.main。這是因為SamMail不要求EmailMessage是一個Classifiable,但FrodoMail卻要求EmailMessage是一個Classifiable,編譯FrodoMail得到的二進制.class文件引用了Classifiable這個接口名稱。符合Classifiable接口定義的方法仍舊存在,但該類卻根本沒有提到Classifiable這個接口。
四、兼容規則:方法
從二進制兼容的角度來看,一個方法由四部分構成,分別是:方法的名稱,返回值類型,參數,方法是否為static。改變這四個項目中的任意一個,對JVM而言它已經變成了另一個方法。
以“boolean isValid()”方法為例,如果讓isValid接收一個Date參數,變成“boolean isValid(Date when)”,修改後的類不能直接替換原有的類,試圖訪問新類的isValid()方法只能得到類似下面的錯誤信息:java.lang.NoSuchMethodError: Ticket.isValid()Z。JVM用“()Z”這個符號表示方法不接受參數且返回一個boolean。關於這一問題,下文將有更詳細的說明。
JVM利用一種稱為虛擬方法調度(Virtual Method Dispatch)的技術判斷要調用的方法體,它根據被調用方法所在的實際實例來決定要使用的方法體,可以看作一種擴展的延遲綁定策略。
如果該類沒有提供一個名稱、參數、返回值類型完全匹配的方法,它就使用從超類繼承的方法。由於Java的二進制兼容性規則,這種繼承實際上在運行期間確定,而不是在編譯期間確定。假設有下面幾個類:
class Poem {
void perform() {
System.out.println("白日依山盡");
} }
class ShakespearePoem extends Poem {
void perform() {
System.out.println("To be or not to be.");
} }
class Hamlet extends ShakespearePoem { }
那麼,
Poem poem = new Hamlet();
poem.perform();
將輸出“To be or not to be.”。這是因為perform的方法體是運行時才確定的。雖然Hamlet沒有提供perform的方法體,但它從ShakespearePoem繼承了一個。至於為何不用Poem定義的perform方法,那是因為ShakespearePoem定義的perform已經覆蓋了它。我們可以隨時修改Hamlet,卻無需重新編譯ShakespearePoem,如下例所示:
class Hamlet extends ShakespearePoem {
System.out.println("連一支耗子都沒鬧");
}
現在,前面的例子將輸出“連一支耗子都沒鬧”。但是,
Poem poem = new ShakespearePoem();
poem.perform();
這段代碼的輸出結果是“To be or not to be.”如果我們刪除ShakespearePoem的內容,同樣的代碼將輸出“白日依山盡”。
五、兼容規則:域
域和方法不同。刪除了類的一個方法後,它有可能通過繼承獲得一個具有同樣名稱、參數的不同方法,但域不能覆蓋,這使得域在二進制兼容方面的表現也有所不同。
例如,假設有下面三個類:
class Language {
String greeting = "你好";
}
class German extends Language {
String greeting = "Guten tag";
}
class French extends Language {
String greeting = "Bon jour";
}
則“void test1() { System.out.println(new French().greeting); }”的輸出結果是“Bon jour”,但是,“void test2() { System.out.println(((Language) new French()).greeting); }”的輸出結果是“你好”。這是因為,實際訪問的域依賴於實例的類型。在第一個輸出例子中,test1訪問的是一個French對象,所以輸出結果是French的問候語;但在第二個例子中,雖然實際上訪問的是一個French對象,但由於French對象已經被定型成Language對象,所以輸出結果是Language的問候語。
如果把上例的Language改成下面的形式:
class Language { }
再次運行test2(不重新編譯),得到的結果是一個錯誤信息:java.lang.NoSuchFieldError: greeting。如果重新編譯test2,則出現編譯錯誤:cannot resolve symbol,symbol : variable greeting ,location: class Language System.out.println(((Language) new French()).greeting);。test1仍能正常運行,無需重新編譯,因為它不需要Language包含的greeting變量。
六、深入理解延遲綁定
下面幾個類用於確定今天晚餐要喝的酒以及酒的溫度。
class Sommelier {
Wine recommend(String meal) { ... }
}
abstract class Wine {
// 推薦酒的溫度
abstract float temperature();
}
class RedWine extends Wine {
// 紅酒的溫度通常略高於白酒
float temperature() { return 63; }
}
class WhiteWine extends Wine {
float temperature() { return 47; }
}
class Bordeaux extends RedWine {
float temperature() { return 64; }
}
class Riesling extends WhiteWine {
// 繼承WhiteWine類的溫度
}
下面的例子利用上面的類推薦一種酒:
void example1() {
Wine wine = sommelier.recommend("duck");
float temp = wine.temperature();
}
example1的第二個調用中,對於wine對象我們唯一可以肯定的是它是一個Wine,但可以是Bordeaux,也可以是Riesling或其他。另外,我們可以肯定wine對象不可能是Wine類本身的實例,因為Wine類是一個抽象類。編譯源代碼,源代碼中的wine.temperature()調用將變成“invokevirtual Wine/temperature ()F”(class文件實際包含的是該文本表示形式的二進制代碼,這種文本化的指令描述方法稱為Oolong方法),它表示的是一個方法調用——一個普通的(虛擬)方法調用,而不是一個靜態調用。它調用的方法是Wine對象的temperature,右邊的“()F”參數稱為簽名(signature),“()F”這個簽名中的空括號表示方法不需要輸入參數,F表示返回值是一個浮點數。
JVM執行到該語句時,它調用的不一定是Wine定義的temperature方法。實際上,在本例中,JVM不可能調用Wine定義的temperature方法,因為該temperature方法是一個虛擬方法。JVM首先檢查該對象所屬的類,尋找一個符合invokevirtual語句指定的名稱、簽名特征的方法,如果找不到,則檢查該類的超類,然後是超類的超類,直至找到一個合適的方法實現為止。
在本例中,如果實際創建的對象是一個Bordeaux,則JVM調用Bordeaux類定義的temperature()F,該temperature()F方法將返回64。如果對象是一個Riesling,JVM在Riesling類中找不到適當的方法,所以繼續查找WhiteWine類,在WhiteWine類中找到了一個合適的temperature()F方法,該方法的返回值是47。
因此,查找可用方法的過程就是沿著類的繼承樹通過字符串匹配尋找合適方法的過程。了解這一原理有助於理解哪些修改不至於影響二進制兼容性。
首先,重新排列類裡面的方法顯然不會影響到二進制兼容性——這在C++程序中一般是不允許的,因為C++程序利用數值性偏移量而不是名稱來確定要調用的方法。延遲綁定的關鍵優勢正是在此,如果Java也使用方法在類裡面的偏移量來確定要調用的方法,必然極大地限制二進制兼容機制的發揮,即使極小的改動也可能導致大量的代碼需要重新編譯。
● 說明:也許有人會認為C++的處理方式要比Java的快,理由是根據數值性偏移量尋找方法肯定要比字符串匹配快。這種說法有一定道理,但只說明了類剛剛裝入時的情況,此後Java的JIT編譯器處理的也是數值性偏移量,而不再靠字符串匹配的辦法尋找方法,因為類裝入內存之後不可能再改變,所以這時的JIT編譯器根本無須顧慮到二進制兼容問題。因此,至少在方法調用這一點上,Java沒有理由一定比C++慢。
其次,還有很重要的一點是:不僅僅編譯時需要檢查類的繼承關系,而且運行時JVM還要檢查類的繼承關系。
七、重載與覆蓋
通過前面的例子應當掌握的最重要的一點是:方法匹配的依據是方法的名字和簽名的文本描述。下面我們為Sommelier類加入一些有關酒杯的方法:
Glass fetchGlass(Wine wine) { ... }
Glass fetchGlass(RedWine wine) { ... }
Glass fetchGlass(WhiteWine wine) { ... }
再來編譯下面的代碼:
void example2() {
Glass glass;
Wine wine = sommelier.recommend("duck");
if(wine instanceof Bordeaux)
glass = sommelier.fetchGlass((Bordeaux) wine);
else
glass = sommelier.fetchGlass(wine);
}
這裡有兩個fetchGlass調用:第一個調用的參數是一個Bordeaux對象,第二個調用的參數是一個Wine對象。Java編譯器為這兩行代碼生成的指令分別是:
invokevirtual Sommelier/fetchGlass (LRedWine;)LGlass;
invokeVirtual Sommelier/fetchGlass (LWine;)LGlass;
注意這兩者的區別是編譯時確定的,而不是運行時確定的。JVM用“L<類名稱>”這個符號表示一個類(就象前面例子中F的作用一樣),這兩個方法調用的輸入參數是一個Wine或RedWine,返回值是一個Glass。
Sommelier類沒有提供輸入參數是Bordeaux的方法,但有一個方法的輸入參數是RedWine,所以第一個調用的方法簽名就用了輸入參數是RedWine的方法。至於第二個調用,編譯時只知道參數是一個Wine對象,所以編譯後的指令使用了輸入參數是Wine對象的方法。對於第二個調用,即使sommelier推薦的是一個Riesling對象,實際調用的也不會是fetchGlass(whiteWine),而是fetchGlass(wine),原因也一樣,被調用的方法總是一個簽名完全匹配的方法。
在這個例子中,fetchGlass方法的不同定義是重載(Overload)關系,而不是覆蓋(Override)關系,因為這些fetchGlass方法的簽名互不相同。如果一個方法要覆蓋另一個方法,那麼兩者必須有相同的參數和返回值類型。虛擬方法調用是在運行時查找特定的類型,只針對覆蓋的方法(擁有相同的簽名),而不是針對重載的方法(擁有不同的簽名)。重載方法的解析在編譯時完成,覆蓋方法的解析則在運行時進行。
如果刪除fetchGlass(RedWine),不重新編譯,再運行example2,JVM將提示錯誤信息:java.lang.NoSuchMethodError: Sommelier.fetchGlass (LRedWine;)LGlass;。
但是,刪除該方法之後,編譯example2仍舊可以順利通過,不過這時兩個sommelier.fetchGlass調用將生成同樣的invokevirtual指令,即:invokevirtual Sommelier/fetchGlass (LWine;)LGlass;。
如果再次放回fetchGlass(RedWine)方法,除非重新編譯example2,否則fetchGlass(RedWine)不會被調用,JVM將使用fetchGlass(wine)。當傳入的對象是一個Riesling時,由於同樣的原因,它也不會使用fetchGlass(WhiteWine):因為編譯時根本不能確定具體的對象。,所以選用了一個更一般化的方法。
在“invokevirtual Wine/temperature ()F”這個指令中,JVM沒有嚴格堅持使用Wine對象,而是自動尋找實際實現了temperature方法的對象;但在“invokevirtual Sommelier/fetchGlass (LRedWine;)LGlass;”指令中,JVM卻很在乎RedWine。這是為什麼呢?因為第一個指令中,Wine不屬於方法簽名,只是用於調用之前的類型檢查;而在第二個指令中,RedWine屬於方法簽名的一部分,JVM必須根據方法簽名和方法名稱來尋找要調用的方法。
假設我們為Sommelier類加入了一個fetchGlass方法:
class RedWineGlass extends Glass { ... }
RedWineGlass fetchGlass(RedWine wine) { ... }
再來看原來編譯的example2,它用“invokevirtual Sommelier/fetchGlass (LRedWine;)LGlass;”指令調用fetchGlass方法。新加入的方法不會自動起作用,因為RedWineGlass和Glass是兩種不同的類型。但是,如果我們重新編譯example2,調用Bordeaux的例子將變成“invokevirtual Sommelier/fetchGlass (LRedWine;)LRedWineGlass;”。
綜上所述,我們可以總結出如下Java二進制兼容性的重要原則:
⑴ 編譯時,Java編譯器選擇最匹配的方法簽名。
⑵ 運行時,JVM查找精確匹配的方法名稱和簽名。相似的名稱和簽名將被忽略。
⑶ 如果找不到適當的方法,JVM拋出異常,且不裝入指定的類。
⑷ 重載的方法在編譯時處理,覆蓋的方法在運行時處理。