JVM:方法调用之动态类型语言支持。

Java虚拟机的字节码指令集的数量从Sun公司的第一款Java虚拟机问世至JDK 7来临之前的十余年时间里,一致没有发生任何变化。随着JDK 7的发布,字节码指令集终于迎来了第一位新成员——invokedynamic指令。这条新增加的指令是JDK 7实现“动态类型语言”(Dynamically Typed Language)支持而进行的改进之一,也是为JDK 8可以顺理实现Lambda表达式做技术准备。在本文中,我们将详细讲解JDK 7这项新特性出现的前因后果和他的深远意义。

动态类型语言

在介绍Java虚拟机的动态类型语言支持之前,我们要先弄明白动态类型语言是什么?他与Java语言、Java虚拟机有什么关系?了解JDK 1.7提供动态类型语言支持的技术背景,对理解这个语言特性是很有必要的。
什么是动态类型语言?动态类型语言的关键特征是他的类型检查的主体过程是在运行期而不是编译期,满足这个特征的语言有好呢多,常用的包括:APL、Clojure、Erlang、Groovy、JavaScript、Jython、Lisp、Lua、PHP、Prolog、Python、Ruby、Smalltalk和Tcl等。相对的,在编译期就进行类型检查过程的语言(C++和Java等)空i时间哦最常用的静态类型语言。
觉得上面定义过于概念化?那我们不妨通过两个例子以最浅显的方式来说明什么是“在编译器/运行期进行”和什么是“类型检查”。首先看下面这段简单的java代码,他是否能正常编译和运行?

public static void main(String[] args) {
    int[][][] array = new int[1][0][-1];
}

这段代码能够正常编译,但运行得时候会报NegativeArraySizeException异常。在Java虚拟机规范中明确规定了NegativeArraySizeException是一个运行时异常,通俗一点来说,运行时异常就是只要代码不运行到这一行就不会有问题。与运行时异常相对应的是连接时异常,例如很常见的NoClassDefFoundError便属于异常,即使会导致链接时异常的代码放在一条无法执行到的分支路径上,类加载时(Java的连接过程不在编译阶段,而在类加载阶段)也照样会抛出异常。
不过,在C语言中,含义相同的代码会在编译器报错:

int main(void) {
    int i[1][0][-1];    // GCC拒绝编译,报“size of array is negative”
    return 0;
}

由此看来,一门语言的哪一种检查行为要在运行期进行,哪一种检查要在编译器进行并灭有必然的因果逻辑关系,关键是语言规范中人为规定的。再举一个例子来解释“类型检查”,例如下面这一句非常简单的代码:

obj.println("hello world");

虽然每个人都能看懂这行代码要做什么,但对于计算机来说,这一行代码“没头没尾”是无法执行的,他需要一个具体的上下文才有讨论的意义。
现在假设这行代码是在Java语言中,并且变量obj的静态类型为java.io.PrintStream,那变量obj的实际类型就必须是PrintStream的子类(实现了PrintStream接口的类)才是合法的。否则,哪怕obj属于一个确实有用println(String)方法,但与PrintStream接口没有继承关系,代码依然不可能运行——因为类型检查不合法。
但是相同的代码在ECMAScript(JavaScript)中情况则不一样,无论Object具体是何种类型,只要这种类型的定义中确实包含有println(String)方法,那方法调用便可成功。
这种差别产生的原因是Java语言在编译期间已将println(String)方法完整的符号引用(本例中为一个CONSTANT_InterfaceMethodref_info)生成出来,作为方法调用指令的参数存储到CLass文件中,例如下面这段代码:

invokevirtual#4; //Method java/io/PrintStream.println: (Ljava/lang/String; )V

这个符号引用包含了此方法定义在哪个具体类型之中、方法的名字以及参数顺序、参数类型和方法返回值等信息,通过这个符号引用,虚拟机可以翻译出这个方法的直接引用。而在ECMAScript等动态类型语言中,变量obj本身是没有类型的,变量obj的值才具有类型,编译时最多只能确定方法名称、参数、返回值这些信息,而不会去确定方法所在的具体类型(即方法接收者不固定)。“变量无类型而变量值才有类型”这个特点也是动态类型语言的一个重要特征。
了解了动态和静态类型语言的区别后,也许读者的下一个问题就是动态、静态类型语言两者谁更好,或者谁更加先进?这种比较不会有确切答案,因为他们都有自己的优点,选择哪种语言是需要经过权衡的。静态类型语言在编译器确定类型,最显著的好处是编译器可以提供严谨的类型检查,这样与类型相关的问题能在编码的时候就及时发现,利于稳定性及代码达到更大规模。而动态类型语言子啊运行期确定类型,这可以为开发人员提供更大的灵活性,某些在静态类型语言中需要大量“臃肿”代码来实现的功能,由动态类型语言来实现可能会更加清晰和简洁,清晰和简洁通常也意味着开发效率的提升。

JDK 1.7与动态类型

回到本文的主题,来看看Java语言、虚拟机与动态类型语言之间有什么关系。Java虚拟机毫无疑问是Java语言的运行平台,但他的使命并不仅限于此,早在1997年出版的《虚拟机规范》中就规划了这样一个愿景:“在未来,我们会对Java虚拟机进行适当的扩展,以便更好地支持其他语言运行于Java虚拟机之上”。而目前确实已经有许多动态类型语言运行于Java虚拟机之上了,如Clojure、Groovy、Jython和JRuby等,能够在同一个虚拟机上可以达到静态类型语言的严谨性与动态类型语言的灵活性,这是一件很美妙的事情。
但遗憾的是:Java虚拟机层面对动态类型语言的支持一直都有所欠缺,主要表现在方法调用方面:JDK 1.7以前的字节码指令集中,4条方法调用指令(invokevirtual、invokespecial、invokestatic、invokeinterface)的第一个参数都是被调用的方法的符号引用(CONSTANT_Methodref_info或者CONSTANT_InterfaceMethodref_info常量),前面已经提到过,方法的符号引用在编译时产生,而动态类型语言只有在运行期才能确定接收者类型。这样,在Java虚拟机上实现的动态类型语言就不得不使用其他方式(如编译时留个占位符类型,运行时动态生成字节码实现具体类型到占位符类型的适配)来实现,这样势必让动态类型语言实现的复杂度增加,也可能带来额外的性能或者内存开销。尽管可以利用一些办法(如Call Site Caching)让这些开销尽量变小,但这种底层问题终归是应当在虚拟机层面上去解决才最合适,因此在Java虚拟机层面上提供动态类型的直接支持就成为了java平台的发展趋势之一,这就是JDK1.7(JSR)中invokedynamic指令以及java.lang.invoke包出现的技术背景。

java.lang.invoke包

JDK 1.7实现了JSR-292,新加入的java.lang.invoke包就是JSR-292的一个重要组成部分,这个包的主要目的是在之前单纯依靠符号引用来确定调用的目标方法这种方式以外,提供了一种新的动态确定目标方法的机制,称为MethodHandle。这种表达方式也许不太好懂?那不妨把MethodHandle与C/C++中的Function Pointer,或者C#里面的Delegate类比一下。举个例子,如果我们要实现一个带谓词的排序函数,在C/C++中常用的做法是把谓词定义为函数,用函数指针把谓词传递到排序方法,如下:

void sort(int liast[], const int size, int(*compare)(int,int))

但Java语言做不到这一点,即没有办法单独的把一个函数作为参数进行传递。普遍的做法是设计一个带有compare()方法的Comparator接口,以实现了这个接口的对象作为参数,例如Collection.sort()就是这样定义的:

void sort(List list, Comparator c)

不过,在拥有Method Handle之后,Java语言也可以拥有类似于函数指针或者委托的方法别名的工具了。下面代码演示了MethodHandle的基本用途,无论obj是何种类型(临时定义的ClassA抑或是实现PrintStream接口的实现类System.out),都可以正确的调用到println()方法。

import static java.lang.invoke.MethodHandles.lookup;
import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodType;

/**
 * JSR-292 Method Handle基础用法演示
 */
public class MethodHandleTest {
	static class ClassA {
		public void println(String s) {
			System.out.println(s);
		}
	}

	public static void main(String[] args) throws Throwable {
		Object obj = System.currentTimeMillis() % 2 == 0 ? System.out
				: new ClassA();
		// 无论obj 最终是哪个实现类,下面这句都正确调用到println方法
		getPrintlnMH(obj).invokeExact("icyfenix");
	}

	private static MethodHandle getPrintlnMH(Object reveiver) throws Throwable {
		/*
		 * MethodType:代表“方法类型”,包含了方法的返回值(methodType()的第一个参数)和具体参数(methodType()
		 * 第二个及以后参数)
		 */
		MethodType mt = MethodType.methodType(void.class, String.class);
		/*
		 * lookup()方法来自于MethodHandles.lookup,这句的作用是在指定类中查找符合给定的方法名称、方法类型,
		 * 并且符合调用权限的方法句柄
		 */
		/*
		 * 因为这里调用的是一个虚方法,按照Java语言的规则,方法第一个参数是隐式的,代表该方法的接收者,也即是this指向的对象,
		 * 这个参数以前是放在参数列表中进行传递的,而现在提供了bindTo()方法来完成这件事情
		 */
		return lookup().findVirtual(reveiver.getClass(), "println", mt).bindTo(
				reveiver);
	}
}

实际上,方法getPrintlnMH()中模拟了invokevirtual指令的执行过程,只不过他的分派逻辑并非固化在Class文件的字节码上,而是通过一个具体方法来实现。而这个方法本身的返回值(MethodHandle对象),可以视为对最终调用方法的一个“引用”。以此为基础,有了MethodHandle就可以写出类似于下面这样的函数声明:

void sort(List list, MethodHandle compare)

从上面的例子可以看出,使用Method并没有什么困难,不过看完它的用法之后,读者大概就会产生疑问,相同的事情,用反射不是早就可以实现了吗?
确实,仅站在Java语言的角度来看,MethodHandle的使用方法和效果与Reflection有众多相似之处,不过,他们还是有以下这些区别:
从本质上讲,Reflection和MethodHandle机制都是在模拟方法调用,但Reflection是在模拟Java代码层次的方法调用,而MethodHandle是在模拟字节码层次的方法调用。在MethodHandles.lookup中的3个方法——findStatic()、findVirtual()、findSpecial()正是为了对应于invokestatic、invokevirtual&invokeinterface和invokespecial这几条字节码指令的执行权限校验行为,而这些底层细节在使用Reflection API时是不需要关心的。
Reflection中的java.lang.reflect.Method对象远比MethodHandle机制中的java.lang.invoke.MethodHandle对象所包含的信息多。前者是方法在Java一端的全面映像,包含了方法的签名、描述府以及方法属性表中各种属性的java端表示方式,还包含执行权限等的运行期信息。而后者仅仅包含与执行该方法相关的信息。用通俗的话来讲,Reflection是重量级,而MethodHandle是轻量级。
由于MethodHandle是对字节码的方法指令调用的模拟,所以理论上虚拟机在这方面做的各种优化(如方法内联),在MethodHandle上也应当可以采用类似思路去支持。而通过反射去调用方法则不行。
MethodHandle与Reflection除了上面列举的区别外,最关键的一点还在于去掉前面讨论施加的前提“仅站在Java语言的角度来看”:Reflection API的设计目标是只为java语言服务的,而MethodHandle则设计成可服务于所有Java虚拟机之上的语言,其中也包括Java语言。

invokedynamic指令

本文一开始就提到了JDK 1.7为了更好的支持动态类型语言,引入了第5条方法调用的字节码指令invokedynamic,之后一直没有再提到过他,甚至把上面的代码中使用MethodHandle的示例代码反编译后也不会看见invokedynamic的身影,他的应用之处在哪里呢?
在某种程度上,invokedynamic指令与MethodHandle机制的作用是一样的,都是为了解决原有4条“invoke*”指令方法分派规则固化在虚拟机之中的问题,把如何查找目标方法的决定权从虚拟机转嫁到具体用户代码之中,让用户(包含其他语言的设计者)有更高的的自由度。而且,他们两者的思路也是可类比的,可以把他们想象成为了达到同一个目的,一个采用上层Java代码和API来实现,另一个用字节码和Class中其他属性、常量来完成。因此,如果理解了前面的MethodHandle例子,那么理解invokedynamic指令也并不困难。
每一次含有invokedynamic指令的位置都称作“动态调用点”(Dynamic Call Site),这条指令的第一个参数不再是代表方法符号引用的CONSTANT_Methodref_info常量,而是变为JDK 1.7新加入的CONSTANT_InvokeDynamic_info常量,从这个新常量中可以得到3项信息:引导方法(Bootstrap Method,此方法存放在新增的BootstrapMethods属性中)、方法类型(MethodType)和名称。引导方法是固定的参数,并且返回值是java.lang.invoke.CallSite对象,这个代表真正要执行的目标方法调用。根据CONSTANT_InvokeDynamic_info常量中提供的信息,虚拟机可以找到并且执行引导方法,从而获得一个CallSite对象,最终调用要执行的目标方法。我们还是举一个实际的例子来解释这个过程,如下所示。

import static java.lang.invoke.MethodHandles.lookup;

import java.lang.invoke.CallSite;
import java.lang.invoke.ConstantCallSite;
import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodHandles;
import java.lang.invoke.MethodType;

public class InvokeDynamicTest {
	public static void main(String[] args) throws Throwable {
		INDY_BootstrapMethod().invokeExact("icyfenix");
	}

	public static void testMethod(String s) {
		System.out.println("hello String:" + s);
	}

	public static CallSite BootstrapMethod(MethodHandles.Lookup lookup,
			String name, MethodType mt) throws Throwable {
		return new ConstantCallSite(lookup.findStatic(InvokeDynamicTest.class,
				name, mt));
	}

	private static MethodType MT_BootstrapMethod() {
		return MethodType
				.fromMethodDescriptorString(
						"(Ljava/lang/invoke/MethodHandles $Lookup ; Ljava/lang/String ; Ljava/lang/invoke/MethodType; )Ljava/lang/invoke/CallSite;",
						null);
	}

	private static MethodHandle MH_BootstrapMethod() throws Throwable {
		return lookup().findStatic(InvokeDynamicTest.class, "BootstrapMethod",
				MT_BootstrapMethod());
	}

	private static MethodHandle INDY_BootstrapMethod() throws Throwable {
		CallSite cs = (CallSite) MH_BootstrapMethod().invokeWithArguments(
				lookup(),
				"testMethod",
				MethodType.fromMethodDescriptorString("(Ljava/lang/String; )V",
						null));
		return cs.dynamicInvoker();
	}
}

这段代码与前面MethodHandleTest的作用基本上是一样的,虽然没有加以注释,但是阅读起来应当不困难。由于invokedynamic指令所面向的使用者并非Java语言,而是其他Java虚拟机之上的动态语言,因此仅依靠java语言的编译器Javac没办法生成带有invokedynamic指令的字节码,所以要使用java语言来演示invokedynamic指令只能用一些变通的办法。John Rose(Da Vinci Machine Project的Leader)编写了一个把程序的字节码转换为使用invokedynamic的简单工具INDY来完成这件事情,我们要使用这个工具来产生最终要的字节码,因此这个示例代码中的方法名称不能随意改动,更不能把几个方法合并到一起写,因为他们是要被INDY工具读取的。
把上面代码编译、再使用INDY转换后重新生成的字节码如下所示(结果使用javap输出,因版面原因,精简了许多无关的内容)。

从main()方法的字节码可见,原本的方法调用指令已经替换为invokedynamic,他的参数为第123项常量(第二个值为0的参数在HotSpot中用不到,与invokeinterface指令那个值为0的而参数一样都是占位的)。

2:invokedynamic#123 , 0//InvokeDynamic#0 : testMethod : (Ljava/lang/String; )V

从常量池中可见,第123项常量显示“#123=InvokeDynamic#0:#121”说明他是一项CONSTANT_InvokeDynamic_info类型常量,常量值中前面的“#0”代表引导方法取BootstrapMethods属性表的第0项(javap没有列出属性表的具体内容,不过示例中仅有一个引导方法,即BootstrapMethod()),而后面的“#121”代表引用第121项类型为CONSTANT_NameAndType_info的常量,从这个常量中可以获取方法名称和描述符,即后面输出的“testMethod:(Ljava/lang/String;) V”。
再看一下BootstrapMethod(),这个方法Java源码中没有,是INDY产生的,但是他的字节码很容易读懂,所有逻辑就是调用MethodHandles $Lookup的findStatic()方法,产生testMethod()方法的MethodHandle,然后用他创建一个COnstantCallSite对象。最后,这个对象返回给invokedynamic指令实现对testMethod()方法的调用,invokedynamic指令的调用过程到此就宣告完成了。

掌控方法分派规则

invokedynamic指令与前面4条“invoke*”指令的最大差别就是他的分派逻辑不是由虚拟机决定的,而是由程序员决定的。在介绍Java虚拟机动态语言支持的最后一个小结中,这里通过一个简单例子(如下所示),帮助读者理解程序员在可以掌控方法分派规则之后,能做什么以前无法做到的事情。

class GrandFather {
	void think() {
		System.out.println("i am grandfather");
	}
}
class Father extends GrandFather {
	void think() {
		System.out.println("i am father");
	}
}
class Son extends Father {
	void thinking() {
		// 请读者在这里填入适当的代码(不能修改其他地方的代码)
		// 实现调用祖父类的thinking()方法,打印“i am grandfather”
	}
}

在Java程序中,可以通过“super”关键字很方便的调用到父类中方法,但如果要访问祖类的方法呢?
在JDK 1.7之前,使用纯粹的Java语言很难处理这个问题(直接生成字节码就很缉拿但,如使用ASM等字节码工具),原因是在Son类的thinking()方法中无法获取一个实际类型是GrandFather的对象引用,而invokevirtual指令的分派逻辑就是按照方法接收者的实际类型进行分派,这个逻辑是固化在虚拟机中的,程序员无法改变。在JDk 1.7中,可以使用下面代码的程序来解决这个问题。

import static java.lang.invoke.MethodHandles.lookup;

import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodType;

public class Test {
	class GrandFather {
		void think() {
			System.out.println("i am grandfather");
		}
	}

	class Father extends GrandFather {
		void think() {
			System.out.println("i am father");
		}
	}

	class Son extends Father {
		void thinking() {
			try {
				MethodType mt =MethodType.methodType(void.class);
				MethodHandle mh = lookup().findSpecial(GrandFather.class, "thinking", mt, getClass());
				mh.invoke(this);
			} catch (Throwable e) {
			}
		}
	}
	public static void main(String[] args) {
		(new Test().new Son()).thinking();
	}
}

运行结构:

i am grandfather

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值