提高Java程序动态性的一个新途径
2015-10-21严忠林
严忠林
摘 要: 为支持Groovy、JRuby等新的动态类型语言,JDK1.7在Java虚拟机上特意引入了新的动态调用指令。文章提出将其应用于Java程序,在生成的Java类文件中用它替换某些成员访问指令,由此可以突破Java原本固有的运行模式,引入满足应用需要的新运行机制,使程序更简单、灵活,提高开发效率。同时分析了原有成员访问指令的局限,讨论了新的动态调用指令的优势,给出了指令替换的实现方法。
关键词: 动态调用指令; 成员访问; Java虚拟机; Java类文件
中图分类号:TP311 文献标志码:A 文章编号:1006-8228(2015)09-01-03
New approach to improve dynamic of Java programs
Yan Zhonglin
(College of information, mechanical and electrical engineering, Shanghai Normal University, Shanghai 200234, China)
Abstract: Since JDK1.7, the invokedynamic instruction has been introduced to JVM to support dynamically typed languages such as Groovy, JRuby. This paper proposes replacing some appropriate member access instructions with it in a Java class file, which may break Java inherent operation mode, introduce a new mechanism that meets the application needs, make the program more simple and flexible, and improve development efficiency. At the same time, the limitations of the original member access instruction and the advantages of the invokedynamic instruction is analyzed, and the method to realize the instruction replacement is given.
Key words: invokedynamic instruction; member access; JVM; Java class file
0 引言
自JDK1.7起,为了方便在JVM上实现像JRuby、Jython、Groovy、Clojure那样的动态类型语言,在虚拟机层面引入了动态类型语言支持(JSR-292)[1]。与Java这样的静态类型语言不同,动态类型语言的变量、方法不需要事先声明,直到运行时,才根据当时数据的实际类型决定所要进行的操作。例如对表达式a+b,要一直到运行至此处时,才根据a、b当时的数值,决定是做整数加、浮点数加,还是字符串加。因此对于这类语言,计算机所执行的代码不是在编译时,而要到运行时才确定。
所谓动态性是指这种能根据运行时状态,自主决定实际操作的能力。Java本是静态类型语言,大多数操作都不能在运行时改变,与此相适应的JVM指令也因此很难满足动态类型语言的要求。为改变这种状况,JSR-292引入了新的动态调用指令invokedynamic,它执行的操作取决于内含的方法句柄(MethodHandle)。MethodHandle是新添加的java.lang.invoke包中的类,是对类内各种成员的引用[2]。使用它,动态类型语言就能按照自己的规则,根据运行时参数和其他状态,实现希望的操作。
新指令原本是为动态语言而设计的,但我们尝试将它用于Java程序,替换某些成员访问指令。这样可以改变Java一些固有的行为模式,引入希望的运行机制,给软件开发带来便利。这种替换不要求改变运行时方法栈中的参数和返回值,能保持Java原有的语法习惯和可理解性。
1 成员访问指令的特性
JVM原有的数据访问指令有getfield/putfield(获得/修改实例变量),getstatic/putstatic(获得/修改类变量)。方法调用有invokestatic(调用类方法),invokespecial(调用可在编译时绑定的实例方法,如构造、private、super方法等),invokevirtual(调用动态绑定的实例方法),invokeinterface(调用接口定义的方法)。Java程序中所有成员访问都是通过这8条指令实现的[3]。它们是专为Java设计的,有固定的处理流程,有良好的执行效率,多数情况下能满足要求。但由于缺乏灵活性,在某些场合,也会出现不便使用,导致程序复杂化的情形。
这些指令除最后两条外,所做的操作在运行前就已确定,不能在运行时动态改变。以图1的处理点(Point)、线(Line)的代码为例,Line对象用k、b记录直线的斜率和y轴截距,但对垂直于x轴的直线,这俩数据都无意义,还需构建子类VLine,添加新的b字段,记录x轴的截距(此处仅为说明问题,并非倡導此种设计。继承关系也极简单,实际上只有在相当规模和复杂度的系统中,此处讨论的缺乏动态性带来的不便才会突显出来)。语句①本来是想获得数组中各直线的有意义的截距进行处理,但实际上却只能获得y轴的截距。传统上解决此类问题的途径是在相关类中编写大量get/set方法,这将使程序臃肿庞大,把一个简单操作复杂化了。
方法调用也有类似问题,用super可调用父类方法,但和this不同,它是静态的。图1中的语句②会根据this对象的不同执行不同的f0,但语法上极其类似的语句③永远只会执行GElem类中的f0,如果设计者的真实想法是调用每个对象自己的父类中的方法,恐怕又得添加很多辅助代码[4]。
即使是采用动态绑定的最后两条指令(invokevirtual和invokeinterface),其动态性也是有限的。它只能根据方法的第一个参数(即this引用)的不同作出选择。当处理方式不取决于单个对象,而必须考虑所有参与处理的对象时,就会帶来困难。例如,为求两直线交点,在Line和VLine中分别重载实现了针对不同类型直线的crossPoint方法。main()中语句④想求出数组内任意两条直线间的交点,但它是错误的。要完成此任务,必须先用if语句作类型判断,再选择合适的方法体。这显然增加了程序复杂性,更重要的是这种“硬编码”会破坏系统的可扩展性,如果再要添加一个子类,将不得不修改所有相关的if语句,对于大系统,这绝不是一件轻松的工作。
2 动态调用指令的优势
上述问题都是由于对应的成员访问指令只能按照固定模式处理,缺乏动态性造成的。如使用新的invokedynamic指令替代,就能引入新的执行模式,让其在运行时根据实际参数动态选择合适的数据、方法,这些问题就可在bytecode层面解决了,上层的Java源代码则不必做任何改变。由于新指令中的方法句柄是自定义的,因此可根据需要实现多种特定机制,运行时不但可做类型判断,还可进行各种添加、变换。比如变量赋值前进行合法性校验,调用关键操作时作日志记录,将对某个方法的调用转为对其他方法的调用等等,面向方面编程(AOP)所需的在方法调用前后“编织”的横切操作,都可以在这儿实现。
这种通过使用invokedynamic指令引入新机制、获得动态性的方法,实际上把系统实现分成了上、下两层,上层是和具体事务相关的业务逻辑,下层是根据参数和其他状态进行选择、变换、处理的机制实现。只要设计合理,两者可以清晰划分。动态机制在上层看来,是自动实现的,可直接使用,编程时只需专注于业务处理。而下层实现,比如根据参数类型选择重载方法等,也和上层绝少关联。这对软件的开发、维护无疑都是极其有利的。
在过去,要在程序中实现各种动态机制,大多要用到“反射”。它有一套特殊的API,通常难以将处理过程隐藏于无形,会增加程序的复杂性。更重要的是它还会影响程序的执行效率,众所周知,现代Java程序的高效运行是通过JIT、Hotspot等技术将bytecode转为本地码,并采用大量积极的优化措施取得的。而“反射”机制不在bytecode层面实现,无法使用这些手段,因而其运行是低效的。
与“反射”相比,invokedynamic指令更有优势。动态实现都隐藏于最基本的访问指令中,上层代码就是普通的Java程序,直接表达事务的处理过程,清晰自然。它还便于系统的修改、切换,例如在试验阶段,底层可添加检查、校验等功能,到成品阶段能方便地撤除。在A环境下的一些操作,到B环境下可用另一些方法替换。这些修改只发生在下层,上层源代码不需改变。
从运行效率上来看,它比“反射”也更有利。JSR-292的设计目标就是要让动态类型语言能在JVM上高速运行,所有MethodHandle都直接在虚拟机层面执行。为保证高效率,甚至于在其他访问指令中每次都要执行的权限检查操作,也被挪到该对象初始构造时进行,执行时没有性能消耗。JIT、Hotspot等技术带来的丰富的优化措施,也可充分利用。所以使用它可以获得很高的执行效率。
3 指令替换的实现
JSR-292是为新型语言而设计,没有打算用于Java。所以通过Java编译器不可能生成invokedynamic指令,我们只能在编译获得的类文件中自行完成需要的替换。好在Java程序各个类分开存储,有定义明确的格式和语义(图2),较易于理解和处理。
类文件的常量池含有代码中使用的所有常量和标识符,以及各种类型表达。类本身、各数据、方法的细节信息都通过对常量池的索引获得描述。一个类和其他类的关联、对其他类的访问也基于常量池中的符号引用进行。bytecode代码出现于对应方法的Code属性中,在我们关注的成员访问指令中有编译时确定的常量池索引,指出了它所访问的数据或方法。
要进行替换,首先要找到这些指令,为此定义了标注DynReplace(图3)。其中Instruction是成员访问指令的枚举,用于说明查找指令的种类,refClass、refName、refType是对被访问成员所在类、名字和类型的描述,构成对它的符号引用。bsmClass、bsmMethod指出替换后新指令所需代码所在的类和方法名。这是一个可重复标注,可同时说明需要替换的多条指令。它们用于启动类前,处理程序将从启动类开始,遍历相关类文件,进行搜寻处理。
[public @interface DynReplace { Instruction instr();
String refClass(); String refName(); String refType();
String bsmClass(); String bsmMethod();
}]
图3 DynReplace标注
每条invokedynamic指令都对应一个CallSite对象,它含有指令执行所需的方法句柄。但指令首次执行时,该对象不存在,需执行一个特殊的自举方法(Bootstrap Method),生成此对象。JSR-292为此在类文件中引入新结构,在类属性池中加入了BootstrapMethods属性,这是一个自举方法句柄数组,通过常量池索引指出各条invokedynamic指令的自举方法。常量池中也引入三种新类型常量,MethodHandle_info(句柄的类型和对应方法的符号引用),MethodType_info(句柄的参数、返回值描述),InvokeDynamic_info(被invokedynamic指令直接使用,指出它的自举方法句柄索引和调用的方法名及类型参数)。
每条替换用的invokedynamic指令都需要以下代码,这些代码应出现在上述标注的bsmClass字段指定的类中。作为举例,图4是对图1中语句④的调用指令进行替换,使它能正确执行所需提供的类代码。
[public class Handle { static HashMap
new HashMap
private static void putMHs() {
Class<?> c0= Point.class, c1= Line.class, c2= VLine.class, ... ...
map.put(methodType(c1, c2), //将各方法句柄按对应参数放入map中
lookup().findVirtual(c1, "crossPoint", methodType(c0, c2)));
public static Point crossPoint(Line la, Line lb) {
MethodHandle mh=map.get(methodType(la.getClass(),lb.getClass()));
return (Point) mh.invoke(la,lb);
public static CallSite bsm(Lookup caller, String name, MethodType type) {
putMHs();
MethodHandle mh=caller.findStatic(Handle.class, name, type);
return new ConstantCallSite(mh);
图4 示例代码2
⑴ 代替原来的成员访问而实现希望的动态机制的方法。它可以参照参数和其他状态选择执行代码,也可插入“横切”操作。它为static方法,参数应与原指令在方法栈中的参数相同。图4中的crossPoint()即为此方法,它用两个参数的实际类型构造MethodType对象,以此为key在散列表map中获取方法句柄加以执行。使用散列表的原因是为了避免在参数个数较多,继承关系复杂的情况下做过多的if判断,以保证执行效率。
⑵ 可选的初始化方法。如果动态处理方法需要初始化操作,应提供该方法,它将在invokedynamic指令首次执行时,在其自举方法中被调用。图4中的putMHs()即为该方法,它生成需要的map对象,为crossPoint()调用时参数的各种可能组合准备好正确的方法句柄。这里以MethodType为key,可防止生成过多的对象。MethodType是为描述方法句柄返回值和参数引入的不可变对象,它的methodType方法对相同的类型只生成一个对象,以后可一直复用。这样就避免了每次调用crossPoint()时产生新对象,保证了运行效率。
⑶ invokedynamic指令首次执行需要的自举方法,该方法有固定参数,仅执行一次。它应该用上述的动态处理方法句柄构造CallSite对象并返回,如有初始化方法也应先执行之。图4中的bsm即是该方法。其方法名应出现在标注的bsmMethod字段,这样执行指令替换的代码能将其置入新指令中。
执行指令替换的代码要对类文件做下列操作。实现这些操作需要熟悉Java类文件的结构。笔者在学习过程中制作了一个解析和处理程序,但读者不必一切从头开始,完全可以使用已有的ASM[5]等开源工具来完成。
⑷ 生成正确的invokedynamic指令。该指令内有指向常量池中InvokeDynamic_info的索引,通过它可在BootstrapMethods属性中获得对应的自举方法句柄,该句柄也在常量池中用MethodHandle_info描述。因此在常量池和类属性池中需要正确添加这些元素。
⑸ 进行指令替换。在方法代码中将原成员访问指令替换为对应的invokedynamic指令。由于该指令长度是5byte,而旧指令长度有3和5两种,故替换时,可能要将后续代码下移。这又会引起代码长度、转移偏移量以及诸如局部变量、StackMap等声明的有效范围的改变,替换时对这些元素也要同时修正。
这五个步骤只有前两步骤是应该公开的,其他都是与处理业务无关的执行指令替换的细节,完全可以封装起来,一般程序员不必了解,不会影响他们的开发工作。
4 结束语
用invokedynamic指令替代JVM中原来的各成员访问指令,其内部的处理规则就不再像旧指令那样固定不变,可灵活制定。实现的操作能在运行时由计算机根据实际情况自行选择、变换,更具动态性。能高效地实现原来采用“反射”才能完成的功能。虽然它的实现需要对Java虚拟机有较深的理解,但这仅是对个别核心开发人员的要求,一般程序员只会感觉编程更方便。和原来的成员访问指令相比,在性能上它也可能有一点损失,但在软件规模及复杂性日渐增长的今天,提高开发人员的工作效率无疑是最重要的,这里介绍的做法正好符合这种要求。
参考文献:
[1] John Rose. JSR-292 Supporting Dynamically Typed Languages
on the Java Platform (FinalRelease)[EB/OL].https://jcp.org/aboutJava/communityprocess/final/jsr292/
[2] Oracle co. Java Platform, Standard Edition 8 API Specification
[EB/OL].http:// docs.oracle.com/javase/8/docs/api/
[3] Tim Lindholm, Frank Yellin. The Java Virtual Machine Specifica-
tion Java SE 8 Edition[M] New Jersey Addison-Wesley Professional,2015.2.
[4] 周志明.深入理解Java虛拟机:JVM高级特性与最佳实践(第2版)[M].
机械工业出版社,2013.
[5] Eric Bruneton. ASM 4.0 A Java bytecode engineering library [EB/OL].
http://download.forge.objectweb.org/asm/asm4-guide.pdf