方法调用并不等同于方法中的代码被执行, 方法调用阶段唯一的任务就是确定被调用方法的版本 (即调用哪一个方法), 暂时还未涉及方法内部的具体运行过程。
Class 文件的编译过程中不包含传统程序语言编译的连接步骤, 一切方法调用在 Class 文件里面存储的都只是符号引用, 而不是方法在实际运行时内存布局中
的入口地址 (也就是之前说的直接引用), 这个特性给 Java 带来了更强大的动态扩展能力, 但也使得 Java 方法调用过程变得相对复杂, 某些调用需要在类加
载期间, 甚至到运行期间才能确定目标方法的直接引用。
1 解析
所有方法调用的目标方法在 Class 文件里面都是一个常量池中的符号引用, 在类加载的解析阶段, 会将其中的一部分符号引用转化为直接引用, 这种解析能够
成立的前提是: 方法在程序真正运行之前就有一个可确定的调用版本, 并且这个方法的调用版本在运行期是不可改变的。调用目标在程序代码写好, 编译器进行编译那一刻就已经确定下来。
这类方法的调用被称为解析 (Resolution)。
在 Java 语言中符合 “编译期可知, 运行期不可变” 这个要求的方法, 主要有静态方法和私有方法两大类。前者与类型直接关联, 后者在外部不可被访问, 这
两种方法各自的特点决定了它们都不可能通过继承或别的方式重写出其他版本, 因此它们都适合在类加载阶段进行解析。
调用不同类型的方法, 字节码指令集里设计了不同的指令。在 Java 虚拟机支持以下 5 条方法调用字节码指令, 分别是
- invokestatic: 用于调用静态方法
- invokespecial: 用于调用实例构造器
() 方法, 私有方法和父类中的方法 - invokevirtual: 用于调用所有的虚方法
- invokeinterface: 用于调用接口方法, 会在运行时在确定一个实现该接口的对象
- invokedynamic: 先在运行时动态解析出调用点限定符所引用的方法, 然后再执行该方法
前面 4 条调用指令, 分派逻辑都固化在 Java 虚拟机内部, 而 invokedynamic 指令的分派逻辑是由用户设定的引导方法来决定的。
只要能被 invokestatic 和 invokespecial 指令调用的方法, 都可以在解析阶段中确定唯一的调用版本, Java 语言里符合这个条件的方法共有静态方法,
私有方法, 实例构造器, 父类方法 4 种, 再加上被 final 修饰的方法 (final 方法是通过 invokevirtual 指令调用的)。
这 5 种方法调用会在类加载的时候就可以把符号引用解析为该方法的直接引用。这些方法统称为 “非虚方法” (No-Virtual Method), 与之相反, 其他方法
就被称为 “虚方法” (Virtual Method)。
解析调用一定是个静态的过程, 在编译期间就完全确定, 在类加载的解析阶段就会把涉及的符号引用全部转变为明确的直接引用, 不必延迟到运行期再去完成。
而另一种主要的方法调用形式: 分派 (Dispatch) 调用则要复杂许多, 它可能是静态的也可能是动态的。按照分派依据的宗量数可分为单分派和多分派。
这两类分派方式两两组合就构成了静态单分派, 静态多分派, 动态单分派, 动态多分派 4 种分派组合情况。
2 分派
2.1 静态分派
先看一下一段代码
public class StaticDispatch {
static abstract class Human {
}
static class Man extends Human {
}
static class Woman extends Human {
}
public void sayHello(Human guy) {
System.out.println("hello,guy!");
}
public void sayHello(Man guy) {
System.out.println("hello,gentleman!");
}
public void sayHello(Woman guy) {
System.out.println("hello,lady!");
}
public static void main(String[] args) {
Human man = new Man();
Human woman = new Woman();
StaticDispatch sr = new StaticDispatch();
// 此处输出结果: hello,guy!
sr.sayHello(man);
// 此处输出结果: hello,guy!
sr.sayHello(woman);
}
}
再确定为什么输出结果是这样之前, 先理解一点概念性的东西。
Human man = new Man();
在上面的代码中,
“Human” 称为变量的 “静态类型 (Static Type)” 或者 “外观类型 (Apparent Type)”,
“Man” 称为变量的 “实际类型 (Actual Type)” 或者 “运行时类型 (Runtime Type)”。
静态类型和实际类型在程序中都可能发生变化, 区别是静态类型的编号仅仅在使用时发生, 变量本身的静态类型不会被改变, 并且最终的静态类型咋编译期就可知。
实际类型变化的结果在运行期才可确定, 编译器在编译期并不知道一个对象的实际类型是什么。
例子:
// 实际类型变化
Human human = (new Random()).nextBoolean() ? new Man() : new Woman();
// 静态类型变化
sr.sayHello((Man) human);
sr.sayHello((Woman) human)
对象 human 的实际类型是可变的, 必须等到程序运行到这行的时候才能确定。
而 human 的静态类型是 Human, 使用时临时改变这个类型 (例如: 通过强转), 但这个改变在编译期是可知的。
理解了这 2 个概念后, 分析一下上面的代码了。
在方法接收者已经确定是对象 “sr” 的前提下, 使用哪个重载版本, 就完全取决于传入参数的数量和数据类型。
虚拟机 (或者准确地说是编译器) 在重载时是通过参数的静态类型而不是实际类型作为判定依据的。由于静态类型在编译期可知, 所以在编译阶段, Javac 编
译器就根据参数的静态类型决定了会使用哪个重载版本, 因此选择了 sayHello(Human) 作为调用目标, 并把这个方法的符号引用写到 main() 方法里的两条
invokevirtual 指令的参数中。
所有依赖静态类型来决定方法执行版本的分派动作, 都称为静态分派。
静态分派的最典型应用表现就是方法重载。静态分派发生在编译阶段, 因此确定静态分派的动作实际上不是由虚拟机来执行的。
Javac 编译器虽然能确定出方法的重载版本, 但在很多情况下这个重载版本并不是 “唯一” 的, 往往只能确定一个 “相对更合适的” 版本。
产生这种模糊结论的主要原因是字面量天生的模糊性, 它不需要定义, 所以字面量就没有显式的静态类型, 它的静态类型只能通过语言, 语法的规则去理解和推断。
public class Overload {
public static void sayHello(Object arg) {
System.out.println("hello Object");
}
public static void sayHello(int arg) {
System.out.println("hello int");
}
public static void sayHello(long arg) {
System.out.println("hello long");
}
public static void sayHello(Character arg) {
System.out.println("hello Character");
}
public static void sayHello(char arg) {
System.out.println("hello char");
}
public static void sayHello(char... arg) {
System.out.println("hello char ...");
}
public static void sayHello(Serializable arg) {
System.out.println("hello Serializable");
}
public static void main(String[] args) {
sayHello('a');
}
}
上面的代码输出的结果为: “hello char”。
因为 ‘a’ 是一个 char 类型的数据。
但是注释掉 sayHello(char arg) 方法后, 会输出 “hello, int”。
这时候发生了一次自动类型转换, ‘a’ 可以代表一个字符, 还可以代表数字 97 (字符 ‘a’ 的 Unicode 数值为十进制数字 97)。
再一次注释掉 sayHello(int arg), 输出结果为 “hello long”, int 可以转换为 long。
一般 char 依次可以转换为 char > int > long > float > double 的顺序进行转换。
注释 sayHello(long arg) 输出: “hello Character”。
注释 sayHello(Character arg) 输出 “hello Serializable” (Character 实现了 Serializable)。
这时候如果代码里面还有一个 Comparable
2 个重载方法的优先级是一样的。
注释 sayHello(Serializable arg) 输出: “hello Object”。
注释 sayHello(Object arg) 输出: “hello char …”。
可见变长参数的重载优先级是最低的。
2.2 动态分派
动态分派主要与 Java 语言多态性的重写有关。
public class DynamicDispatch {
static abstract class Human {
protected abstract void sayHello();
}
static class Man extends Human {
@Override
protected void sayHello() {
System.out.println("man say hello");
}
}
static class Woman extends Human {
@Override
protected void sayHello() {
System.out.println("woman say hello");
}
}
public static void main(String[] args) {
Human man = new Man();
Human woman = new Woman();
// man say hello
man.sayHello();
// woman say hello
woman.sayHello();
man = new Woman();
// woman say hello
man.sayHello();
}
}
选择调用的方法版本是不可能再根据静态类型来决定的, 因为静态类型同样都是 Human 的两个变量 man 和 woman 在调用 sayHello() 方法时产生了不同
的行为, 甚至变量 man 在两次调用中还执行了两个不同的方法。
通过 javac 和 javap 获取到字节码文件
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=3, args_size=1
0: new #2 // class DynamicDispatch$Man
3: dup
4: invokespecial #3 // Method DynamicDispatch$Man."<init>":()V
7: astore_1
8: new #4 // class DynamicDispatch$Woman
11: dup
12: invokespecial #5 // Method DynamicDispatch$Woman."<init>":()V
15: astore_2
16: aload_1
17: invokevirtual #6 // Method DynamicDispatch$Human.sayHello:()V
20: aload_2
21: invokevirtual #6 // Method DynamicDispatch$Human.sayHello:()V
24: new #4 // class DynamicDispatch$Woman
27: dup
28: invokespecial #5 // Method DynamicDispatch$Woman."<init>":()V
31: astore_1
32: aload_1
33: invokevirtual #6 // Method DynamicDispatch$Human.sayHello:()V
36: return
0 ~ 15 行的字节码是准备动作, 作用是建立 man 和 woman 的内存空间, 调用 Man 和 Woman 类型的实例构造器, 将这两个实例的引用存放在第 1, 2 个
局部变量表的变量槽中, 对应的代码就是
Human man = new Man();
Human woman = new Woman();
16 ~ 21 行的字节码我们很容易对应的就是代码的
man.sayHello();
woman.sayHello();
但是在字节码的 17 和 21 行都是 “invokevirtual #6 // Method DynamicDispatch$Human.sayHello:()V”, 无论是指令, 还是指令的参数都是一样的,
但是他们最终执行的目标方法并不相同。问题主要是 “invokevirtual” 指令。
根据《Java虚拟机规范》, invokevirtual 指令的运行时解析过程大致分为以下几步
- 找到操作数栈顶的第一个元素所指向的对象的实际类型, 记作 C。
- 如果在类型 C 中找到与常量中的描述符和简单名称都相符的方法, 则进行访问权限校验, 如果通过则返回这个方法的直接引用, 查找过程结束; 不通过则
返回 java.lang.IllegalAccessError 异常- 否则, 按照继承关系从下往上依次对 C 的各个父类进行第二步的搜索和验证过程
- 如果始终没有找到合适的方法, 则抛出 java.lang.AbstractMethodError 异常
正是因为 invokevirtual 指令执行的第一步就是在运行期确定接收者的实际类型, 所以两次调用中的 invokevirtual 指令并不是把常量池中方法的符号引
用解析到直接引用上就结束了, 还会根据方法接收者的实际类型来选择方法版本, 这个过程就是 Java 语言中方法重写的本质。我们把这种在运行期根据实际类
型确定方法执行版本的分派过程称为动态分派。
注: Java 中字段是不参与多态的, 调用某个类的某个字段, 这个字段需要在这个类中能看到, 当子类和父类存在同名字段, 子类的内存中 2 个字段都存在,
但是子类的字段会遮蔽父类的同名字段, 既子类的字段优先。
2.3 单分派和多分派
方法的接收者 (可以理解为具体的类) 与方法的参数统称为方法的宗量。
根据分派基于多少种宗量, 可以将分派划分为单分派和多分派两种。
单分派是根据一个宗量对目标方法进行选择, 多分派则是根据多于一个宗量对目标方法进行选择。
public class Dispatch {
static class QQ{
}
static class WeChat{
}
public static class Father {
public void hardChoice(QQ arg) {
System.out.println("Father choose QQ");
}
public void hardChoice(WeChat arg) {
System.out.println("Father choose WeChat");
}
}
public static class Son extends Father {
@Override
public void hardChoice(QQ arg) {
System.out.println("Son choose QQ");
}
@Override
public void hardChoice(WeChat arg) {
System.out.println("Son choose WeChat");
}
}
public static void main(String[] args) {
Father father = new Father();
Father son = new Son();
// Father choose WeChat
father.hardChoice(new WeChat());
// Son choose QQ
son.hardChoice(new QQ());
}
}
对应的字节码
0: new #2 // class Dispatch$Father
3: dup
4: invokespecial #3 // Method Dispatch$Father."<init>":()V
7: astore_1
8: new #4 // class Dispatch$Son
11: dup
12: invokespecial #5 // Method Dispatch$Son."<init>":()V
15: astore_2
16: aload_1
17: new #6 // class Dispatch$WeChat
20: dup
21: invokespecial #7 // Method Dispatch$WeChat."<init>":()V
24: invokevirtual #8 // Method Dispatch$Father.hardChoice:(LDispatch$WeChat;)V
27: aload_2
28: new #9 // class Dispatch$QQ
31: dup
32: invokespecial #10 // Method Dispatch$QQ."<init>":()V
35: invokevirtual #11 // Method Dispatch$Father.hardChoice:(LDispatch$QQ;)V
38: return
首先分析编译阶段, 也就是静态分派阶段:
静态分派阶段 2 个 hardChoice() 方法的版本, 选择依据有: 一是静态类型是 Father, 还是 Son, 二就是方法参数是 QQ, 还是 WeChat。这一次选择最终产生
了两条 invokevirtual 指令, 两条指令分别是上面的 21 和 35, 也就是 Father::hardChoice(WeChat) 和 Father::hardChoice(QQ)。
静态分派根据 2 个总量进行选择, 所以静态分派是属于多分派类型
再分析运行阶段, 也就是动态分派阶段
在执行 2 个 invokevirtual 指令时, 由于编译期已经决定了目标方法的参数是 QQ, 还是 WeChat, 虚拟机可以不关心入参, 此时影响虚拟机选择的因素只有该
方法的接收者的实际类型是 Father, 还是 Son。因为只有一个宗量作为选择依据, 所以动态分派属于单分派类型。
所以, 如今 Java 语言是一门静态多分派, 动态单分派语言。
2.4 动态分派的实现
动态分派是执行非常频繁的动作, 而且动态分派的方法版本选择过程需要运行时在接收者类型的方法元数据中搜索合适的目标方法, 因此, Java 虚拟机实现基
于执行性能的考虑, 真正运行时一般不会如此频繁地去反复搜索类型元数据。
面对这种情况, 一种基础而且常见的优化手段是为类型在方法区中建立一个虚方法表 (Virtual Method Table, 也成为 vtable。
与此对应的, 在 invokeinterface 执行时也会用到接口方法表 – Interface Method Table, 简称 itable)。
虚方法表中存放着各个方法的实际入口地址。如果某个方法在子类中没有被重写, 那子类的虚方法表中的地址入口和父类相同方法的地址入口是一致的, 都指向父
类的实现入口, 如果子类中重写了这个方法, 子类虚方法表中的地址也会被替换为指向子类实现版本的入口地址。
虚方法表一般在类加载的连接阶段进行初始化, 准备了类的变量初始值后, 虚拟机会把该类的虚方法也一同初始化。
虚拟机除了使用虚方法表之外, 为了进一步提高性能, 还使用类型继承关系分析 (Class Hierarchy Analysis, CHA), 守护内联 (Guarded Inlining),
内联缓存 (Inline Cache) 等多种非稳定的激进优化来争取更多的性能空间。
3 Java 与动态类型
动态类型语言与动态语言, 弱类型语言并不是一个概念, 动态类型语言的关键特征是它的类型检查的主体过程是在运行期而不是编译期进行的, 所以 Java 本身
是一门静态类型语言。
3.1 java.lang.invoke 包
JDK7 时新加入的 java.lang.invoke 包, 用于在单纯依靠符号引用来确定调用目标方法之外, 提供一种新的动态确定目标方法的方式, 称为 “方法句柄 (Method Handler)”。
举个例子, 在 Java 中要给一个集合排序的话, 需要怎么做的? 普遍的做法是设计一个带有 compare() 方法的 Comparator 接口, 以实现这个接口的对象作为参数。
void sort(List list, Comparator c);
但是在 C/C++ 中常用做法是, 实现一个带谓词 (也就是动词, 一个排序时比较大小的动作) 的排序函数, 然后把函数指针传递到排序方法, 像这样
void sort(int list[], int size, int (*compare) (int, int))
句柄的作用就是在 Java 语言上实现类似于函数指针或者委托的方法别名这样的工具。
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("string");
}
private static MethodHandle getPrintlnMH(Object receiver) throws Throwable {
// MethodType: 代表 "方法类型", 包含了方法的返回值 (methodType() 的第一个参数) 和具体参数 (methodType() 第二个及以后的参数)
MethodType mt = MethodType.methodType(void.class, String.class);
// 这句的作用是在指定类中查找符合给定的方法 名称、方法类型, 并且符合调用权限的方法句柄
// 因为这里调用的是一个虚方法, 按照 Java 语言的规则, 方法第一个参数是隐式的, 代表该方法的接收者, 也即 this 指向的对象, 这个参
// 数以前是放在参数列表中进行传递, 现在提供了 bindTo() 方法来完成这件事情
return MethodHandles.lookup().findVirtual(receiver.getClass(), "println", mt).bindTo(receiver);
}
}
方法 getPrintlnMH() 中实际上是模拟了 invokevirtual 指令的执行过程, 只不过它的分派逻辑并非固化在 Class 文件的字节码上, 而是通过一个由用
户设计的 Java 方法来实现。而这个方法本身的返回值 (MethodHandler 对象), 可以视为对最终调用方法的一个 “引用”。
有了 MethodHandle 就可以写出类似于 C/C++ 那样的函数声明了
void sort(List list, MethodHandle compare)
- MethodHandler 和 Reflection 的 区别
Reflection 和 MethodHandle 机制本质上都是在模拟方法调用, 但是 Reflection 是在模拟 Java 代码层次的方法调用, 而 MethodHandle 是在模拟
字节码层次的方法调用。 在 MethodHandles.Lookup 上的 3 个方法 findStatic(), findVirtual(), findSpecial() 对应 invokestatic, invokevirtual
(以及 invokeinterface) 和 invokespecial 这几条字节码指令的执行权限校验行为。Reflection 中的 java.lang.reflect.Method 对象远比 MethodHandle机 制中的 java.lang.invoke.MethodHandle 对象所包含的信息来得多。
前者是方法在 Java 端的全面映像, 包含了方法的签名, 描述符以及方法属性表中各种属性的 Java 端表示方式, 还包含执行权限等的运行期信息。而后者
仅包含执行该方法的相关信息。用开发人员通俗的话来讲, Reflection 是重量级, 而 MethodHandle 是轻量级由于 MethodHandle 是对字节码的方法指令调用的模拟, 那理论上虚拟机在这方面做的各种优化 (如方法内联), 在 MethodHandle 上也应当可以采用类
似思路去支持 (但目前实现还在继续完善中), 而通过反射去调用方法则几乎不可能直接去实施各类调用点优化措施Reflection API 的设计目标是只为 Java 语言服务的, 而 MethodHandle 则设计为可服务于所有 Java 虚拟机之上的语言
3.2 invokedynamic 指令
invokedynamic 指令与 MethodHandle 机制的作用是一样的, 都是为了解决原有 4 条 “invoke*” 指令方法分派规则完全固化在虚拟机之中的问题, 把如
何查找目标方法的决定权从虚拟机转嫁到具体用户代码之中, 让用户 (广义的用户, 包含其他程序语言的设计者) 有更高的自由度。而且, 它们两者的思路也是
可类比的, 都是为了达成同一个目的, 只是一个用上层代码和 API 来实现, 另一个用字节码和 Class 中其他属性, 常量来完成。
每一处含有 invokedynamic 指令的位置都被称作 “动态调用点 (Dynamically-Computed Call Site)”, 这条指令的第一个参数不再是代表方法符号引用
的 CONSTANT_Methodref_info 常量, 而是变为 JDK7 时新加入的 CONSTANT_InvokeDynamic_info 常量。
从这个新常量中可以得到 3 项信息: 引导方法 (Bootstrap Method, 该方法存放在新增的 BootstrapMethods 属性中), 方法类型 (MethodType)
和名称。引导方法是有固定的参数, 并且返回值规定是 java.lang.invoke.CallSite 对象, 这个对象代表了真正要执行的目标方法调用。 根据 CONSTANT_InvokeDynamic_info
常量中提供的信息, 虚拟机可以找到并且执行引导方法, 从而获得一个 CallSite 对象, 最终调用到要执行的目标方法上。
由于 invokedynamic 指令面向的主要服务对象并非 Java 语言, 而是其他 Java 虚拟机之上的其他动态类型语言, 因此, 光靠 Java 语言的编译器 Javac
的话, 在 JDK7 时甚至还完全没有办法生成带有 invokedynamic 指令的字节码。
一般情况的字节码是这样的
Constant pool:
#121 = NameAndType #33:#30 // testMethod:(Ljava/lang/String;)V
#123 = InvokeDynamic #0:#121 // #0:testMethod:(Ljava/lang/String;)V
public static void main(java.lang.String[]) throws java.lang.Throwable;
Code:
stack=2, locals=1, args_size=1
0: ldc #23 // String abc
2: invokedynamic #123, 0 // InvokeDynamic #0:testMethod: (Ljava/lang/String;)V
7: nop
8: return
在 main 方法中, invokedynamic 它的参数为第 123 项常量池 (第二个值为 0, 虚拟机中不会直接使用, 只是其占位作用, 是为了给常量池缓存留出足够
的空间)
从常量池中可见, 第 123 项常量显示 “#123=InvokeDynamic#0:#121” 说明它是一项 CONSTANT_InvokeDynamic_info 类型常量, 常量值中前面 “#0”
代表引导方法取 Bootstrap Methods 属性表的第 0 项。而后面的 “#121”, 代表引用第 121 项类型为 CONSTANT_NameAndType_info 的常量, 从这个
常量中可以获取到方法名称和描述符, 即后面输出的 “testMethod: (Ljava/lang/String;)V”
4 参考
《深入理解Java虚拟机》- 周志明