从某种意义上来讲,现代软件已经不是数据结构与算法的简单聚合,更多的是构件开发以及基于体系结构的构件组装.而这些构件,通常都是由不同厂商、作者开发的共享组件,所以组件管理变得越来越重要。在这方面,一个极其重要的问题是类的不同版本的二进制兼容性,即一个类改变时,新版的类是否可以直接替换原来的类,却不至于损坏其他由不同厂商/作者开发的依赖于该类的组件?
在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