目录
一 JVM执行方法调用过程
示例:重载可变长参数方法
void invoke(Object obj, Object... args) { ... }
void invoke(String s, Object obj, Object... args) { ... }
invoke(null, 1); // 调用第二个 invoke 方法
invoke(null, 1, 2); // 调用第二个 invoke 方法
invoke(null, new Object[]{1}); // 只有手动绕开可变长参数的语法糖,
// 才能调用第一个 invoke 方法
1.1 重载
在 Java 程序里,如果同一个类中出现多个名字相同,并且参数类型相同
的方法,那么它无法通过编译
。也就是说,在正常情况下,如果我们想要在同一个类中定义名字相同的方法,那么它们的参数类型必须不同。这些方法之间的关系,我们称之为重载
。(重载在java语法中不涉及返回值!)
重载的方法在编译过程中即可完成识别
。具体到每一个方法调用,Java 编译器会根据所传入参数的声明类型
(注意与实际类型区分)来选取重载方法
。选取的过程共分为三个阶段:
- 在不考虑对基本类型自动装拆箱(auto-boxing,auto-unboxing),以及可变长参数的情况下选取重载方法;
- 如果在第 1 个阶段中没有找到适配的方法,那么在允许自动装拆箱,但不允许可变长参数的情况下选取重载方法;
- 如果在第 2 个阶段中没有找到适配的方法,那么在允许自动装拆箱以及可变长参数的情况下选取重载方法。
- 如果 Java 编译器在同一个阶段中找到了多个适配的方法,那么它会在其中选择一个
最为贴切
的,而决定贴切程度的一个关键就是形式参数类型的继承关系
。
在开头的例子中,当传入 null 时,它既可以匹配第一个方法中声明为 Object 的形式参数,也可以匹配第二个方法中声明为 String 的形式参数。由于 String 是 Object 的子类,因此 Java编译器会认为第二个方法更为贴切。
重载也可以作用于这个类所继承而来的方法。
1.2 重写
众所周知,Java 是一门面向对象的编程语言,它的一个重要特性便是多态
。而方法重写,正是多态最重要的一种体现方式:它允许子类在继承父类部分功能的同时,拥有自己独特的行为。
继承时的特点
如果子类定义了与父类中非私有方法同名的方法,而且这两个方法的参数类型相同,那么这两个方法之间又是什么关系呢?
- 如果这两个方法都是静态的,那么子类中的方法
隐藏了父类中的方法
。 - 如果这两个方法都不是静态的,且都不是私有的,那么子类的方法重写了父类中的方法。
1.3 JVM 的静态绑定和动态绑定
Java 虚拟机识别方法的关键在于类名、方法名以及方法描述符(method descriptor)。前面两个就不做过多的解释了。至于方法描述符,它是由方法的参数类型以及返回类型所构成。在同一个类中,如果同时出现多个名字相同且描述符也相同的方法,那么 Java 虚拟机会在类的验证阶段报错。
可以看到,Java 虚拟机与 Java 语言不同,它并不限制名字与参数类型相同,但返回类型不同的方法出现在同一个类中,对于调用这些方法的字节码来说,由于字节码所附带的方法描述符包含了返回类型
,因此 Java 虚拟机能够准确地识别目标方法。
为什么呢?
理解:你在写java代码的时候是不带方法描述符的。所以编译器不知道调用方法的返回类型信息,也就只能通过方法名和参数来确定了
由于对重载方法的区分在编译阶段已经完成
,我们可以认为 Java 虚拟机不存在重载这一概念。因此,在某些文章中,重载也被称为静态绑定(static binding),或者编译时多态(compiletime polymorphism);而重写则被称为动态绑定(dynamic binding)。
重载加重写
上面说法在 Java 虚拟机语境下并非完全正确。这是因为某个类中的重载方法可能被它的子类所重写
,因此 Java 编译器会将所有对非私有实例方法
的调用编译为需要动态绑定的类型
。
方法的符号引用解析结果
在 class 文件中,Java 编译器会用符号引用指代目标方法。在执行调用指令
(jvm规范要求:只要执行前解析就可以了)前,它所附带的符号引用需要被解析成实际引用。对于可以静态绑定的方法调用而言,实际引用为目标方法的指针。对于需要动态绑定的方法调用而言,实际引用为辅助动态绑定的信息
。
1.4 调用相关的字节码指令
具体来说,Java 字节码中与调用相关的指令共有五种。
- invokestatic:用于调用
静态
方法。 - invokespecial:用于调用
私有实例方法、构造器
,以及使用 super 关键字调用父类
的实例方法或构造器,和所实现接口的默认方法
。 - invokevirtual:用于调用
非私有实例方法
。 - invokeinterface:用于调用
接口方法
。 - invokedynamic:用于调用
动态方法
。
final修饰的方法是可以直接确定的,可以看作静态绑定。
二 虚方法
Java 里所有非私有实例方法调用都会被编译成invokevirtual 指令(可能会被子类重写
),而接口方法调用都会被编译成invokeinterface(接口是抽象方法
) 指令。这两种指令,均属于Java 虚拟机中的虚方法调用
。
动态绑定
在绝大多数情况下,Java 虚拟机需要根据调用者的动态类型,来确定虚方法调用的目标方法。这个过程我们称之为动态绑定。那么,相对于静态绑定的非虚方法调用来说,虚方法调用更加耗时。
静态绑定
在Java 虚拟机中,静态绑定包括用于调用静态方法的invokestatic 指令,和用于调用构造器、私有实例方法以及超类非私有实例方法的invokespecial 指令。如果虚方法调用指向一个标记为final
的方法,那么Java 虚拟机也可以静态绑定该虚方法调用的目标方法
方法表
Java 虚拟机中采取了一种用空间换取时间的策略来实现动态绑定。它为每个类生成一张方法表
,用以快速定位目标方法。那么方法表具体是怎样实现的呢?
java 虚拟机的动态绑定是通过方法表这一数据结构(数组
)来实现的。
- 子类方法表中包含父类方法表中的所有方法;
- 子类方法在方法表中的索引值,与它所重写的父类方法的索引值相同
在解析虚方法调用时,Java 虚拟机会记录下所声明的目标方法的索引值
(不同调用类对应的具体方法可能不一样,需要运行时确定
),并且在运行过程中根据这个索引值查找具体的目标方法。
三 JVM是如何实现反射的
在 Web 开发中,我们经常能够接触到各种可配置的通用框架。为了保证框架的可扩展性
,它们往往借助 Java 的反射机制,根据配置文件来加载不同的类。举例来说,Spring 框架的依赖反转(IoC
),便是依赖于反射机制。
缺点:反射性能开销大的
3.1 反射调用的实现
查阅Method.invoke 的源代码,那么你会发现,它实际上委派给 MethodAccessor
来处理。MethodAccessor 是一个接口,它有两个已有的具体实现:一个通过本地方法来实现反射调用,另一个则使用了委派模式。为了方便记忆,我便用**“本地实现”和“委派实现”**来指代这两者。
每个 Method 实例的第一次反射调用都会生成一个委派实现
,它所委派的具体实现便是一个本地实现
。
- 本地实现非常容易理解。当进入了 Java 虚拟机内部之后,我们便拥有了
Method 实例所指向方法
的具体地址。这时候,反射调用无非就是将传入的参数准备好,然后调用进入目标方法。 - 为什么反射调用还要采取委派实现作为中间层?直接交给本地实现不可以么?
Java 的反射调用机制还设立了另一种动态生成字节码
的实现(下称动态实现),直接使用 invoke 指令来调用目标方法。之所以采用委派实现,便是为了能够
在本地实现以及动态实现中切换
。
动态实现和本地实现
动态实现和本地实现相比,其运行效率要快上 20 倍 。这是因为动态实现无需经过 Java 到C++ 再到 Java 的切换,但由于生成字节码十分耗时,仅调用一次的
话,反而是本地实现要快上 3 到 4 倍 。
考虑到许多反射调用仅会执行一次,Java 虚拟机设置了一个阈值
15(可以通过 -Dsun.reflect.inflationThreshold
= 来调整),当某个反射调用的调用次数在 15 之下时,采用本地实现;当达到 15 时,便开始动态生成字节码(jvm自动生产
),并将委派实现的委派对象切换至动态实现,这个过程我们称之为 Inflation
。
反射调用的 Inflation 机制是可以通过参数(-Dsun.reflect.noInflation=true)来关闭的。这样一来,在反射调用一开始便会直接生成动态实现,而不会使用委派实现或者本地实现。
3.2 反射调用的开销
Class方法调用
Class.forName,Class.getMethod 以及 Method.invoke三个操作。其中,Class.forName 会调用本地方法
,Class.getMethod 则会遍历该类
的公有方法。如果没有匹配到,它还将遍历父类
的公有方法。可想而知,这两个操作都非常费时。
值得注意的是,以 getMethod 为代表的查找方法操作,会返回查找得到结果的一份拷贝
。因此,我们应当避免在热点代码中使用返回 Method 数组的 getMethods 或者getDeclaredMethods 方法,以减少不必要的堆空间消耗。
invoke反射调用
方法的反射调用会带来不少性能开销,原因主要有三个:变长参数方法导致的 Object
数组(每次调用都会创建数组对象),基本类型的自动装箱、拆箱
,还有最重要的方法内联。
四 方法句柄
这个没看懂,简单记录下
是 invokedynamic (5个指令之一)底层机制的基石
- 方法句柄是一个
强类型的、能够被直接执行的引用
。它仅关心所指向方法的参数类型以及返回类型
(所以和动态语言有关,能用就可以),而不关心方法所在的类以及方法名。 - 方法句柄的权限检查发生在
创建过程
中,相较于反射调用节省
了调用时反复权限检查的开销。 - 方法句柄可以通过 invokeExact 以及 invoke 来调用。其中,invokeExact 要求传入的参数和所指向方法的描述符严格匹配。
- 方法句柄还支持增删改参数的操作,这些操作是通过
生成另一个充当适配器
的方法句柄来实现的。 - 方法句柄的调用和反射调用一样,都是间接调用,同样会面临
无法内联
的问题。
五 invokedynamic指令
也没看懂,简单记录下
invokedynamic 是 Java 7 引入的一条新指令
,用以支持动态语言的方法调用。具体来说,它将调用点(CallSite)
抽象成一个 Java 类,并且将原本由 Java 虚拟机控制的方法调用
以及方法链接
暴露给了应用程序。在运行过程中,每一条 invokedynamic 指令将捆绑一个调用点,并且会调用该调用点所链接的方法句柄。
invokedymaic 指令抽象出调用点
的概念,并且将调用该调用点所链接的方法句柄。在第一次执行 invokedynamic 指令时,Java 虚拟机将执行它所对应的启动方法,生成并且绑定一个调用点。之后如果再次执行该指令,Java 虚拟机则直接调用已经绑定了的调用点所链接的方法
。
Lambda 表达式到函数式接口的转换是通过 invokedynamic 指令来实现的。该invokedynamic 指令对应的启动方法将通过 ASM 生成一个适配器类
。对于没有捕获其他变量的 Lambda 表达式,该 invokedynamic 指令始终返回同一个适配器类的实例。对于捕获了其他变量的 Lambda 表达式,每次
执行 invokedynamic 指令将新建`一个适配器类实例。
不管是捕获型的还是未捕获型的 Lambda 表达式,它们的性能上限
皆可以达到直接调用的性能。其中,捕获型 Lambda 表达式借助了即时编译器中的逃逸分析,来避免实际的新建适配器类实例的操作。