一、概述
现在的软件越来越依赖于不同厂商、作者开发的共享组件,组件管理也变得越来越重要。在这方面,一个极其重要的问题是类的不同版本的二进制兼容性,即一个类改变时,新版的类是否可以直接替换原来的类,却不至于损坏其他由不同厂商、作者开发的依赖于该类的组件?
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程序:
interface Classifiable { boolean isJunk(); } abstract class Message implements Classifiable { } class EmailMessage extends Message { public boolean isJunk() { return false; } } 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变量。
六、深入理解延迟绑定
下面几个类用于确定今天晚餐要喝的酒以及酒的温度。
abstract class Wine { // 推荐酒的温度 abstract float temperature(); } class RedWine_Wine extends Wine { // 红酒的温度通常略高于白酒 float temperature() { return 63; } } class WhiteWine_Wine extends Wine { float temperature() { return 47; } } class Bordeaux_RedWine_Wine extends RedWine_Wine { float temperature() { return 64; } } class Riesling_WhiteWine_Wine extends WhiteWine_Wine { // 继承WhiteWine类的温度 }
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类加入一些有关酒杯的方法:
class Sommelier { Wine recommend(String meal) { return null; } Glass fetchGlass(Wine wine) { return null; } Glass fetchGlass(RedWine_Wine wine) { return null; } Glass fetchGlass(WhiteWine_Wine wine) { return null; } }
再来编译下面的代码:
void example2() { Glass glass; Wine wine = sommelier.recommend("duck"); if(wine instanceof Bordeaux_RedWine_Wine) { // invokevirtual Sommelier/fetchGlass (LRedWine;)LGlass; glass = sommelier.fetchGlass((Bordeaux_RedWine_Wine) wine); }else { // invokeVirtual Sommelier/fetchGlass (LWine;)LGlass; 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 Sommelier { Wine recommend(String meal) { return null; } Glass fetchGlass(Wine wine) { return null; } Glass fetchGlass(RedWine_Wine wine) { return null; } Glass fetchGlass(WhiteWine_Wine wine) { return null; } class RedWineGlass extends Glass { } RedWineGlass fetchGlass(RedWine_Wine wine) { return null; } }
再来看原来编译的example2,它用“invokevirtual Sommelier/fetchGlass (LRedWine;)LGlass;”指令调用fetchGlass方法。新加入的方法不会自动起作用,因为RedWineGlass和Glass是两种不同的类型。但是,如果我们重新编译example2,调用Bordeaux的例子将变成“invokevirtual Sommelier/fetchGlass (LRedWine;)LRedWineGlass;”。
综上所述,我们可以总结出如下Java二进制兼容性的重要原则:
(1)编译时,Java编译器选择最匹配的方法签名。
(2)运行时,JVM查找精确匹配的方法名称和签名。相似的名称和签名将被忽略。
(3)如果找不到适当的方法,JVM抛出异常,且不装入指定的类。
(4)重载的方法在编译时处理,覆盖的方法在运行时处理。
原文地址:https://www.cnblogs.com/extjs4/p/9035449.html