前不久在工作中,遇到了几次编译class引起的NoSuchMethodError,经过分析与测试验证,也算是搞清楚了中间的来龙去脉,现在把一些结论性的东西(附带一些过程性的分析)分享出来。
在使用javac -source 1.6 -target 1.6来编译低版本的(这里为1.6)class时,记得要使用-bootclasspath参数来指定1.6版本的类库(一般是rt.jar),不指定的话,会产生一个警告:
警告: [options] 未与 -source 1.6 一起设置引导类路径
或者英文版的
warning: [options] bootstrap class path not set in conjunction with -source 1.6
如果忽视这个警告(当时我在网上搜索上述中文警告时,没有任何资料说需要引起注意,以及该如何解决),编译出来的class可能无法在低版本的jre中运行,假如源码中调用了一些特殊方法,则会在执行时抛出NoSuchMethodError。比如ConcurrentHashMap的keySet方法,在jdk1.6中,该方法返回的是Set,在jdk1.8中,该方法返回的是KeySetView,它是jdk1.8中新增的一个类,为Set的一个实现。当把这样编译出来的class放到jre1.6中去运行时,会因为找不到返回类型为KeySetView的keySet方法而抛出NoSuchMethodError,虽然编译后的class的版本是1.6。
基于上面的认知,来讨论一下如下场景
现在有apiA_1.0.jar与apiB_1.0.jar,apiB_1.0.jar依赖apiA_1.0.jar,前者是基于后者编译的,也就是这两个版本之间不存在兼容问题。
然后假如apiA进行了修改,升级为apiA_1.1.jar,其中某个类的某个方法的返回值由Object改为了String(从源码上来讲,这样改是兼容的,因为String是一个Object,这应该就是里氏替换吧),此时apiB_1.0.jar就不兼容apiA_1.1.jar了,如果单方面把apiA升级到1.1,apiB在调用apiA中的那个返回值为Object的方法时,会因为找不到方法而抛出NoSuchMethodError(如果对此有异议,请看后文),因为现在在apiA中,只有那个返回值为String的方法了,并且,你也不可能保留返回值为Object的那个方法,它们是互相冲突的。
当然,此时也可以重新发布一个apiB_1.1.jar,基于apiA_1.1.jar编译出来的版本。但这样,也就意味着,apiB依赖了apiA特定的版本,这样非常不利于依赖维护,使用过程中很容易出问题,而且这种问题只有在运行时,调用了有问题的方法时才会发现,应用程序的编译过程中是不会报错的(apiA和apiB是已经编译的jar了)。
也许此时你已经注意到了,难道jdk也不向前兼容了?为什么我用jdk1.6编译出来的程序能在jre1.8中正常的调用ConcurrentHashMap.keySet?它不是也存在上面所说的问题吗?它为什么不会因为找不到返回值为Set的keySet方法而抛异常?
这里就需要介绍一下class中的桥接方法(bridge method)了,它不报错,是因为1.8中确实也存在一个返回值为Set的keySet方法,只不过不是存在于源文件中,而是存在于class文件中,通过javap -v java.util.concurrent.ConcurrentHashMap反编译1.8的ConcurrentHashMap,可以看到一个返回值为java.util.Set的keySet方法:
public java.util.Set keySet(); descriptor: ()Ljava/util/Set; flags: ACC_PUBLIC, ACC_BRIDGE, ACC_SYNTHETIC Code: stack=1, locals=1, args_size=1 0: aload_0 1: invokevirtual #838 // Method keySet:()Ljava/util/concurrent/ConcurrentHashMap$KeySetView; 4: areturn LineNumberTable: line 267: 0
ps:flag参数中的ACC_BRIDGE表明了这是一个桥接方法
虽然java语法层面不允许存在仅返回值不同的两个方法,但在class文件中,并没有此限制,在此桥接方法中,调用了返回值为KeySetView的keySet方法。另外java.lang.reflect.Method.isBridge()就是指的这个。
那为什么ConcurrentHashMap.keySet会有桥接方法呢?其实也不是jdk给自己搞的特殊化,是因为keySet是一个重写方法(接口方法也有此效果),重写了父类AbstractMap的public Set<K> keySet()方法,这个大致可以理解为,父类或接口已经对外宣称了该方法(也就是返回Set),那如果子类或实现者自己返回了其它子类型,那么编译器就得来做这个兼容性工作,即创建桥接方法。如果直接改了顶层方法,编译器自然不可能去做这个事情,它怎么知道要跟谁兼容?同理,静态方法也会有问题。
最后总结一下:
- 如果你要保持跟以前的版本兼容,除了接口方法或重写父类方法,其它时候就不要改变返回值类型,否则就不兼容了。(我认为在参与开源项目时尤其需要注意这一点)
- 使用高版本javac配置source、target参数来编译低版本class或打jar包时,必需用bootclasspath指定对应低版本的类库,否则也可能产生不兼容。这也意味着:不要仅仅装一个jdk8就期待编译出一定能在jre1.6上正常运行的程序,你还需要一个1.6版本的java类库来完成编译。
- 如果有替换个别class文件来打补丁的习惯,那么也需要特别小心兼容问题,原理是一样的。
上面所说的不兼容问题,会延后到真正调用问题方法时候才会暴露,所以值得加以重视。
相关链接:
javac官方文档:http://docs.oracle.com/javase/8/docs/technotes/tools/windows/javac.html