9.2.4 Backport工具:Java的时光机器

9.2.4 Backport工具:Java的时光机器

一般来说,以“做项目”为主的软件公司比较容易更新技术,在下一个项目中换一个技术框架、升级到最时髦的JDK版本,甚至把Java换成C#、Golang来开发都是有可能的。但是当公司发展壮大,技术有所积累,逐渐成为以“做产品”为主的软件公司后,自主选择技术的权利就会逐渐丧失,因为之前积累的代码和技术都是用真金白银砸出来的,一个稳健的团队也不会随意地改变底层的技术。然而在飞速发展的程序设计领域,新技术总是日新月异层出不穷,偏偏这些新技术又如鲜花之于蜜蜂一样, 对程序员们散发着天然的吸引力。

在Java世界里,每一次JDK大版本的发布,都会伴随着规模不等或大或小的技术革新,而对Java程序编写习惯改变最大的,肯定是那些对Java语法做出重大改变的版本,譬如JDK 5时加入的自动装箱、 泛型、动态注解、枚举、变长参数、遍历循环(foreach循环);譬如JDK 8时加入的Lambda表达式、 Stream API、接口默认方法等。事实上在没有这些语法特性的年代,Java程序也照样能写,但是现在回头看来,上述每一种语法的改进几乎都是“必不可少”的,如同用惯了32寸液晶、4K分辨率显示器的程序员,就很难再在19寸显示器、1080P分辨率的显示器上编写代码了。但假如公司“不幸”因为要保护现有投资、维持程序结构稳定等,必须使用JDK 5或者JDK 8以前的版本呢?幸好,我们没有办法把19寸显示器变成32寸的,但却可以跨越JDK版本之间的沟壑,把高版本JDK中编写的代码放到低版本JDK 环境中去部署使用。为了解决这个问题,一种名为“Java逆向移植”的工具(Java Backporting Tools)应运而生,Retrotranslator^1和Retrolambda是这类工具中的杰出代表。

Retrotranslator的作用是将JDK 5编译出来的Class文件转变为可以在JDK 1.4或1.3上部署的版本, 它能很好地支持自动装箱、泛型、动态注解、枚举、变长参数、遍历循环、静态导入这些语法特性, 甚至还可以支持JDK 5中新增的集合改进、并发包及对泛型、注解等的反射操作。Retrolambda^2的作用与Retrotranslator是类似的,目标是将JDK 8的Lambda表达式和try-resources语法转变为可以在JDK 5、JDK 6、JDK 7中使用的形式,同时也对接口默认方法提供了有限度的支持。

了解了Retrotranslator和Retrolambda这种逆向移植工具的作用以后,相信读者更关心的是它是怎样做到的?要想知道Backporting工具如何在旧版本JDK中模拟新版本JDK的功能,首先要搞清楚JDK升级中会提供哪些新的功能。JDK的每次升级新增的功能大致可以分为以下五类:

  • 1)对Java类库API的代码增强。譬如JDK 1.2时代引入的java.util.Collections等一系列集合类,在 JDK 5时代引入的java.util.concurrent并发包、在JDK 7时引入的java.lang.invoke包,等等。
  • 2)在前端编译器层面做的改进。这种改进被称作语法糖,如自动装箱拆箱,实际上就是Javac编 译器在程序中使用到包装对象的地方自动插入了很多Integer.valueOf()、Float.valueOf()之类的代码;变 长参数在编译之后就被自动转化成了一个数组来完成参数传递;泛型的信息则在编译阶段就已经被擦 除掉了(但是在元数据中还保留着),相应的地方被编译器自动插入了类型转换代码^3
  • 3)需要在字节码中进行支持的改动。如JDK 7里面新加入的语法特性——动态语言支持,就需要 在虚拟机中新增一条invokedynamic字节码指令来实现相关的调用功能。不过字节码指令集一直处于相 对稳定的状态,这种要在字节码层面直接进行的改动是比较少见的。
  • 4)需要在JDK整体结构层面进行支持的改进,典型的如JDK 9时引入的Java模块化系统,它就涉 及了JDK结构、Java语法、类加载和连接过程、Java虚拟机等多个层面。
  • 5)集中在虚拟机内部的改进。如JDK 5中实现的JSR-133[^4]规范重新定义的Java内存模型(Java Memory Model,JMM),以及在JDK 7、JDK 11、JDK 12中新增的G1、ZGC和Shenandoah收集器之 类的改动,这种改动对于程序员编写代码基本是透明的,只会在程序运行时产生影响。

上述的5类新功能中,逆向移植工具能比较完美地模拟了前两类,从第3类开始就逐步深入地涉及了直接在虚拟机内部实现的改进了,这些功能一般要么是逆向移植工具完全无能为力,要么是不能完整地或者在比较良好的运行效率上完成全部模拟。想想这也挺合理的,如果在语法糖和类库层面可以完美解决的问题,Java虚拟机设计团队也没有必要舍近求远地改动处于JDK底层的虚拟机嘛。

在能够较好模拟的前两类功能中,第一类模拟相对更容易实现一些,如JDK 5引入的java.util.concurrent包,实际是由多线程编程的大师Doug Lea开发的一套并发包,在JDK 5出现之前就已经存在(那时候名字叫作dl.util.concurrent,引入JDK时由作者和JDK开发团队共同进行了一些改进),所以要在旧的JDK中支持这部分功能,以独立类库的方式便可实现。Retrotranslator中就附带了一个名叫“backport-util-concurrent.jar”的类库(由另一个名为“Backport to JSR 166”的项目所提供)来代替JDK 5的并发包。

至于第二类JDK在编译阶段进行处理的那些改进,Retrotranslator则是使用ASM框架直接对字节码进行处理。由于组成Class文件的字节码指令数量并没有改变,所以无论是JDK 1.3、JDK 1.4还是JDK 5,能用字节码表达的语义范围应该是一致的。当然,肯定不会是简单地把Class的文件版本号从49.0改回48.0就能解决问题了,虽然字节码指令的数量没有变化,但是元数据信息和一些语法支持的内容还是要做相应的修改。

以枚举为例,尽管在JDK 5中增加了enum关键字,但是Class文件常量池的CONSTANT_Class_info 类型常量并没有发生任何语义变化,仍然是代表一个类或接口的符号引用,没有加入枚举,也没有增加过“CONSTANT_Enum_info”之类的“枚举符号引用”常量。所以使用enum关键字定义常量,尽管从Java语法上看起来与使用class关键字定义类、使用interface关键字定义接口是同一层次的,但实际上这是由Javac编译器做出来的假象,从字节码的角度来看,枚举仅仅是一个继承于java.lang.Enum、自动生成了values()和valueOf()方法的普通Java类而已。

Retrotranslator对枚举所做的主要处理就是把枚举类的父类从“java.lang.Enum”替换为它运行时类库中包含的“net.sf.retrotranslator.runtime.java.lang.Enum_”,然后再在类和字段的访问标志中抹去ACC_ENUM标志位。当然,这只是处理的总体思路,具体的实现要比上面说的复杂得多。可以想象既然两个父类实现都不一样,values()和valueOf()的方法自然需要重写,常量池需要引入大量新的来自父类的符号引用,这些都是实现细节。图9-3是一个使用JDK 5编译的枚举类与被Retrotranslator转换处理后的字节码的对比图。

image-20211125161344990

image-20211125161520599

图9-3 Retrotranslator处理前后的枚举类字节码对比

用Retrolambda模拟JDK 8的Lambda表达式属于涉及字节码改动的第三类情况,Java为支持Lambda 会用到新的invokedynamic字节码指令,但幸好这并不是必须的,只是基于效率的考量。在JDK 8之前,Lambda表达式就已经被其他运行在Java虚拟机的编程语言(如Scala)广泛使用了,那时候是怎么生成字节码的现在照着做就是,不使用invokedynamic,除了牺牲一点效率外,可行性方面并没有太大的障碍。

Retrolambda的Backport过程实质上就是生成一组匿名内部类来代替Lambda,里面会做一些优化措施,譬如采用单例来保证无状态的Lambda表达式不会重复创建匿名类的对象。有一些Java IDE工具, 如IntelliJ IDEA和Eclipse里会包含将此过程反过来使用的功能特性,在低版本Java里把匿名内部类显示成Lambda语法的样子,实际存在磁盘上的源码还是匿名内部类形式的,只是在IDE里可以把它显示为Lambda表达式的语法,让人阅读起来比较简洁而已。

[^4]: JSR-133:Java Memory Model and Thread Specification Revision(Java内存模型和线程规范修订)。