二進製兼容原理 - C/C++ &Java
從某種意義上來講,現代軟件已經不是數據結構與算法的簡單聚合,更多的是構件開發以及基於體係結構的構件組裝.而這些構件,通常都是由不同廠商、作者開發的共享組件,所以組件管理變得越來越重要。在這方麵,一個極其重要的問題是類的不同版本的二進製兼容性,即一個類改變時,新版的類是否可以直接替換原來的類,卻不至於損壞其他由不同廠商/作者開發的依賴於該類的組件?
在C++中,對域(類變量或實例變量)的訪問被編譯成相對於對象起始位置的偏移量,在編譯時就確定,如果類加入了新的域並重新編譯,偏移量隨之改變,原先編譯的使用老版本類的代碼就不能正常執行( 也許有人會認為這是C++要比Java的快的一個原因,根據數值性偏移量尋找方法肯定要比字符串匹配快。這種說法有一定道理,但隻說明了類剛剛裝入時的情況,此後Java的JIT編譯器處理的也是數值性偏移量,而不再靠字符串匹配的辦法尋找方法,因為類裝入內存之後不可能再改變,所以這時的JIT編譯器根本無須顧慮到二進製兼容問題。因此,至少在方法調用這一點上,Java沒有理由一定比C++慢),不僅如此,虛函數的調用也存在同樣的問題。這些我們都稱之為二進製不兼容,與之對應的是源碼不兼容,如修改成員變量名字等.
C++環境通常采用重新編譯所有引用了被修改類的代碼來解決問題。在Java中,少量開發環境也采用了同樣的策略,但這種策略存在諸多限製。例如,假設有人開發了一個程序P,P引用了一個外部的庫L1,但P的作者沒有L1的源代碼;L1要用到另一個庫L2。現在L2改變了,但L1無法重新編譯,所以P的開發和更改也受到了限製。為此,Java引入了二進製兼容的概念—如果對L2的更改是二進製兼容的,那麼更改後的L2、原來的L1和現在的P能夠順利連接,不會出現任何錯誤。
首先來看一個簡單的例子。Authorization和Customer類分別來自兩個不同的作者,Authorization提供身份驗證和授權服務,Customer類要調用Authorization類。
package com.author1; public class Authorization { public boolean authorized(String userName) { return true; } } package com.author2; import com.author1.*; class Customer{ public static void main(String arg[]) { Authorization auth = new Authorization(); if(auth.authorized("messi")) System.out.println("pass"); else System.out.println("go away"); } }現在author1發布了Authorization類的2.0版,Customer類的作者author2希望在不更改原有Customer類的情況下使用新版的Authorization類。2.0版的Authorization要比原來的複雜不少:
package com.author1; public class Authorization { public Token authorized(String userName, String pwd) { return null; } public boolean authorized(String userName) { return true; } public class Token { } }作者author1承諾2.0版的Authorization類與1.0版的類二進製兼容,或者說,2.0版的Authorization類仍舊滿足1.0版的Authorization類與Customer類的約定。顯然,author2編譯Customer類時,無論使用Authorization類的哪一個版本都不會出錯—實際上,如果僅僅是因為Authorization類升級,Customer類根本無需重新編譯,同一個Customer.class可以調用任意一個Authorization.class。
這一特性並非Java獨有。UNIX係統很早就有了共享對象庫(.so文件)的概念,Windows係統也有動態鏈接庫(.dll文件)的概念,隻要替換一下文件就可以將一個庫改換為另一個庫。就象Java的二進製兼容特性一樣,名稱的鏈接是在運行時完成,而不是在代碼的編譯、鏈接階段完成。但是,Java的二進製兼容性還有其獨特的優勢:
⑴ Java將二進製兼容性的粒度從整個庫(可能包含數十、數百個類)細化到了單個的類。
⑵ 在C/C++之類的語言中,創建共享庫通常是一種有意識的行為,一個應用軟件一般不會提供很多共享庫,哪些代碼可以共享、哪些代碼不可共享都是預先規劃的結果。但在Java中,二進製兼容變成了一種與生俱來的天然特性。
⑶ 共享對象隻針對函數名稱,但Java二進製兼容性考慮到了重載、函數簽名、返回值類型。
⑷ Java提供了更完善的錯誤控製機製,版本不兼容會觸發異常,但可以方便地捕獲和處理。相比之下,在C/C++中,共享庫版本不兼容往往引起嚴重問題。
二進製兼容的概念在某些方麵與對象串行化的概念相似,兩者的目標也有一定的重疊。串行化一個Java對象時,類的名稱、域的名稱被寫入到一個二進製輸出流,串行化到磁盤的對象可以用類的不同版本來讀取,前提是該類要求的名稱、域都存在,且類型一致。二進製兼容和串行化都考慮到了類的版本不斷更新的問題,允許為類加入方法和域,而且純粹的加入不會影響程序的語義;類似地,單純的結構修改,例如重新排列域或方法,也不會引起任何問題。
理解二進製兼容的關鍵是要理解延遲綁定(Late Binding)。在Java語言裏,延遲綁定是指直到運行時才檢查類、域、方法的名稱,而不象C/C++的編譯器那樣在編譯期間就清除了類、域、方法的名稱,代之以偏移量數值—這是Java二進製兼容得以發揮作用的關鍵。由於采用了延遲綁定技術,方法、域、類的名稱直到運行時才解析,意味著隻要域、方法等的名稱(以及類型)一樣,類的主體可以任意替換—當然,這是一種簡化的說法,還有其他一些規則製約Java類的二進製兼容性,例如訪問屬性(private、public等)以及是否為abstract(如果一個方法是抽象的,那麼它肯定是不可直接調用的)等,但延遲綁定機製無疑是二進製兼容的核心所在。
隻有掌握了二進製兼容的規則,才能在改寫類的時候保證其他類不受到影響。下麵再來看一個例子,KakaMail和MessiMail是兩個Email程序:
abstract class Message implements Classifiable {} class EmailMessage extends Message { public boolean isJunk() { return false; } } interface Classifiable { boolean isJunk(); } class KakaMail { public static void main(String a[]) { Classifiable m = new EmailMessage(); System.out.println(m.isJunk()); } } class MessiMail { public static void main(String a[]) { EmailMessage m = new EmailMessage(); System.out.println(m.isJunk()); } }如果我們重新實現Message,不再讓它實現Classifiable接口,MessiMail仍能正常運行,但KakaMail會拋出異常"java.lang.IncompatibleClassChangeError"。這是因為MessiMail不要求EmailMessage是一個Classifiable,但KakaMail卻要求EmailMessage是一個Classifiable,編譯KakaMail得到的二進製.class文件引用了Classifiable這個接口名稱。
從二進製兼容的角度來看,一個方法由四部分構成,分別是:方法的名稱,返回值類型,參數,方法是否為static。改變其中任何一個,對JVM而言,它已經變成了另一個方法。如果該類沒有提供一個名稱、參數、返回值類型完全匹配的方法,它就使用從超類繼承的方法。由於Java的二進製兼容性規則,這種繼承實際上在運行期間確定,而不是在編譯期間確定。也正是因為繼承,在代碼重構過程中,會招致各種錯誤.比反說刪除父類的某個在子類覆蓋的域,然後調用了強製類型轉換後的子類同名字段,往往會出現"java.lang.NoSuchFieldError".
最新的jls7一文中,有一章節是專門介紹Java語言的二進製兼容性原理的,感興趣的同學可以下載翻閱,以便加深理解~
ps: 案例拾遺
運行期異常: Exception in thread "main" java.lang.AbstractMethodError: org.apache.batik.dom.GenericElement.setTextContent(Ljava/lang/String;)V
Why?AbstractMethodError這個錯誤挺經典的,一般發生在compile time,那出現在運行期,就可能意味著發生了不兼容類更改,為什麼這麼說,我們看一個例子,直接上代碼:
public class Node { public void setTextContent(String text) { System.out.println("setting " + text); } } public class SVGNode extends Node { public static void main(String args[]) { Node node = new Node(); node.setTextContent("messi"); } }這麼寫當然沒有任何問題了~好,那Node類出於升級等目的,改為抽象類,setTextContent改為抽象方法,使用Java 命令行方式執行Java SVGNode,隨你怎麼編譯新版Node,javac也行,後麵就昭然若揭了~
總結一下: 該問題在引用外部包的時候常有發生,尤其當類的繼承層次比較複雜時,一般不容肉眼識別,但萬變不離其宗~其根本原因可能是父類出現了不兼容修改~另外,要確保編譯器和JVM類加載路徑完全一致,爭取在編譯期就發現問題~
參考文獻:
1.https://en.wikipedia.org/wiki/Binary_code_compatibility
2.https://techbase.kde.org/Policies/Binary_Compatibility_Issues_With_C%2B%2B
3.https://www.javaworld.com/community/node/2915
4.https://www.javapractices.com/topic/TopicAction.do?Id=45
5.https://docs.oracle.com/javase/6/docs/platform/serialization/spec/version.html
6.https://java.sun.com/developer/technicalArticles/Programming/serialization/
7.https://blogs.oracle.com/darcy/entry/kinds_of_compatibility
最後更新:2017-04-02 16:47:59