方法调用
Java 的方法的执行分为两个部分:
- 方法调用:确定被调用的方法是哪一个;
- 基于栈的解释执行:真正的执行方法的字节码。
在本节中我们将对方法调用进行详细的讲解,我们知道,一切方法的调用在 Class 文件中存储的都是常量池中的符号引用,而不是方法实际运行时的入口地址(直接引用),直到类加载的时候,甚至是实际运行的时候才回去会去确定要被运行的方法的直接引用,而确定要被运行的方法的直接引用的过程就叫做方法调用。
方法调用字节码指令
Java 虚拟机提供了 5 个职责不同的方法调用字节码指令:
invokestatic
:调用静态方法;invokespecial
:调用构造器方法、私有方法、父类方法;invokevirtual
:调用所有虚方法,除了静态方法、构造器方法、私有方法、父类方法、final 方法的其他方法叫虚方法;invokeinterface
:调用接口方法,会在运行时确定一个该接口的实现对象;invokedynamic
:在运行时动态解析出调用点限定符引用的方法,再执行该方法。
除了 invokedynamic
,其他 4 种方法的第一个参数都是被调用的方法的符号引用,是在编译时确定的,所以它们缺乏动态类型语言支持,因为动态类型语言只有在运行期才能确定接收者的类型,即变量的类型检查的主体过程在运行期,而非编译期。
final 方法虽然是通过
invokevirtual
调用的,但是其无法被覆盖,没有其他版本,无需对接收者进行多态选择,或者说多态选择的结果是唯一的,所以属于非虚方法。
解析调用
解析调用,正如其名,就是 在类加载的解析阶段,就确定了方法的调用版本 。我们知道类加载的解析阶段会将一部分符号引用转化为直接引用,这一过程就叫做解析调用。因为是在程序真正运行前就确定了要调用哪一个方法,所以 解析调用能成立的前提就是:方法在程序真正运行前就有一个明确的调用版本了,并且这个调用版本不会在运行期发生改变。
符合这两个要求的只有以下两类方法:
- 通过
invokestatic
调用的方法:静态方法; - 通过
invokespecial
调用的方法:私有方法、构造器方法、父类方法;
这两类方法根本不可能通过继承或者别的方式重写出来其他版本,也就是说,在运行前就可以确定调用版本了,十分适合在类加载阶段就解析好。它们会在类加载的解析阶被解析为直接引用,即确定调用版本。
分派调用
在介绍分派调用前,我们先来介绍一下 Java 所具备的面向对象的 3 个基本特征:封装,继承,多态。
其中多态最基本的体现就是重载和重写了,重载和重写的一个重要特征就是方法名相同,其他各种不同:
- 重载:发生在同一个类中,入参必须不同,返回类型、访问修饰符、抛出的异常都可以不同;
- 重写:发生在子父类中,入参和返回类型必须相同,访问修饰符大于等于被重写的方法,不能抛出新的异常。
相同的方法名实际上给虚拟机的调用带来了困惑,因为虚拟机需要判断,它到底应该调用哪个方法,而这个过程会在分派调用中体现出来。其中:
- 方法重载 —— 静态分派
- 方法重写 —— 动态分派
静态分派(方法重载)
在介绍静态分派前,我们先来介绍一下什么是变量的静态类型和实际类型。
变量的静态类型和实际类型
1 | public class StaticDispatch { |
其中 Human
称为变量的静态类型,Man
称为变量的实际类型。
在重载时,编译器是通过方法参数的静态类型,而不是实际类型,来判断应该调用哪个方法的。
通俗的讲,静态分派就是通过方法的参数(类型 & 个数 & 顺序)这种静态的东西来判断到底调用哪个方法的过程。
重载方法匹配优先级,例如一个字符 ‘a’ 作为入参
- 基本类型
- char
- int
- long
- float
- double
- Character
- Serializable(Character 实现的接口)
- 同时出现两个优先级相同的接口,如 Serializable 和 Comparable,会提示类型模糊,拒绝编译。
- Object
- char…(变长参数优先级最低)
动态分派(方法重写)
动态分派就是在运行时,根据实际类型确定方法执行版本的分派过程。
动态分派的过程
我们先来看一个例子:
1 | public class DynamicDispatch { |
字节码分析:
1 | public static void main(java.lang.String[]); |
通过字节码分析可以看出,invokevirtual
指令的运行过程大致为:
- 去操作数栈顶取出将要执行的方法的所有者,记作 C;
- 查找此方法:
- 在 C 中查找此方法;
- 在 C 的各个父类中查找;
- 查找过程:
- 查找与常量的描述符和简单名称都相同的方法;
- 进行访问权限验证,不通过抛出:IllegalAccessError 异常;
- 通过访问权限验证则返回直接引用;
- 没找到则抛出:AbstractMethodError 异常,即该方法没被实现。
动态分派的实现
动态分派在虚拟机种执行的非常频繁,而且方法查找的过程要在类的方法元数据中搜索合适的目标,从性能上考虑,不太可能进行如此频繁的搜索,需要进行性能上的优化。
常用优化手段: 在类的方法区中建立一个虚方法表。
- 虚方法表中存放着各个方法的实际入口地址,如果某个方法没有被子类方法重写,那子类方法表中该方法的入口地址 = 父类方法表中该方法的入口地址;
- 使用这个方法表索引代替在元数据中查找;
- 该方法表会在类加载的连接阶段初始化好。
通俗的讲,动态分派就是通过方法的接收者这种动态的东西来判断到底调用哪个方法的过程。
总结一下:静态分派看左面,动态分派看右面。
单分派与多分派
除了静态分派和动态分派这种分派分类方式,还有一种根据宗量分类的方式,可以将方法分派分为单分派和多分派。
宗量:方法的接收者 & 方法的参数。
Java 语言的静态分派属于多分派,根据 方法接收者的静态类型 和 方法参数类型 两个宗量进行选择。
Java 语言的动态分派属于单分派,只根据 方法接收者的实际类型 一个宗量进行选择。
动态类型语言支持
什么是动态类型语言?
就是类型检查的主体过程在运行期,而非编译期的编程语言。
动/静态类型语言各自的优点?
- 动态类型语言:灵活性高,开发效率高。
- 静态类型语言:编译器提供了严谨的类型检查,类型相关的问题能在编码的时候就发现。
Java虚拟机层面提供的动态类型支持:
invokedynamic
指令- java.lang.invoke 包
java.lang.invoke 包
目的: 在之前的依靠符号引用确定调用的目标方法的方式之外,提供了 MethodHandle 这种动态确定目标方法的调用机制。
MethodHandle 的使用
获得方法的参数描述,第一个参数是方法返回值的类型,之后的参数是方法的入参:
1
MethodType mt = MethodType.methodType(void.class, String.class);
获取一个普通方法的调用:
1
2
3
4
5
6
7
8/**
* 需要的参数:
* 1. 被调用方法所属类的类对象
* 2. 方法名
* 3. MethodType 对象 mt
* 4. 调用该方法的对象
*/
MethodHandle.lookup().findVirtual(receiver.getClass(), "方法名", mt).bindTo(receiver);获取一个父类方法的调用:
1
2
3
4
5
6
7
8/**
* 需要的参数:
* 1. 被调用方法所属类的类对象
* 2. 方法名
* 3. MethodType 对象 mt
* 4. 调用这个方法的类的类对象
*/
MethodHandle.lookup().findSpecial(GrandFather.class, "方法名", mt, getClass());通过
MethodHandle mh
执行方法:1
2
3
4
5
6
7/*
invoke() 和 invokeExact() 的区别:
- invokeExact() 要求更严格,要求严格的类型匹配,方法的返回值类型也在考虑范围之内
- invoke() 允许更加松散的调用方式
*/
mh.invoke("Hello world");
mh.invokeExact("Hello world");
使用示例:
1 | public class MethodHandleTest { |
MethodHandles.lookup 中 3 个方法对应的字节码指令:
findStatic()
:对应 invokestaticfindVirtual()
:对应 invokevirtual & invokeinterfacefindSpecial()
:对应 invokespecial
MethodHandle 和 Reflection 的区别
- 本质区别: 它们都在模拟方法调用,但是
- Reflection 模拟的是 Java 代码层次的调用;
- MethodHandle 模拟的是字节码层次的调用。
- 包含信息的区别:
- Reflection 的 Method 对象包含的信息多,包括:方法签名、方法描述符、方法的各种属性的Java端表达方式、方法执行权限等;
- MethodHandle 对象包含的信息比较少,既包含与执行该方法相关的信息。
invokedynamic
指令
Lambda 表达式就是通过 invokedynamic
指令实现的。