二進制兼容原理 - 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.http://en.wikipedia.org/wiki/Binary_code_compatibility

2.http://techbase.kde.org/Policies/Binary_Compatibility_Issues_With_C%2B%2B

3.http://www.javaworld.com/community/node/2915

4.http://www.javapractices.com/topic/TopicAction.do?Id=45

5.http://docs.oracle.com/javase/6/docs/platform/serialization/spec/version.html

6.http://java.sun.com/developer/technicalArticles/Programming/serialization/

7.https://blogs.oracle.com/darcy/entry/kinds_of_compatibility

發佈了69 篇原創文章 · 獲贊 127 · 訪問量 13萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章