深入拆解Java虚拟机笔记(2)JVM中的重载和重写/虚方法/反射机制

一 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 编译器会根据所传入参数的声明类型(注意与实际类型区分)来选取重载方法。选取的过程共分为三个阶段:

  1. 在不考虑对基本类型自动装拆箱(auto-boxing,auto-unboxing),以及可变长参数的情况下选取重载方法;
  2. 如果在第 1 个阶段中没有找到适配的方法,那么在允许自动装拆箱,但不允许可变长参数的情况下选取重载方法;
  3. 如果在第 2 个阶段中没有找到适配的方法,那么在允许自动装拆箱以及可变长参数的情况下选取重载方法。
  4. 如果 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 字节码中与调用相关的指令共有五种。

  1. invokestatic:用于调用静态方法。
  2. invokespecial:用于调用私有实例方法、构造器,以及使用 super 关键字调用父类的实例方法或构造器,和所实现接口的默认方法
  3. invokevirtual:用于调用非私有实例方法
  4. invokeinterface:用于调用接口方法
  5. invokedynamic:用于调用动态方法

final修饰的方法是可以直接确定的,可以看作静态绑定。

二 虚方法

Java 里所有非私有实例方法调用都会被编译成invokevirtual 指令(可能会被子类重写),而接口方法调用都会被编译成invokeinterface(接口是抽象方法) 指令。这两种指令,均属于Java 虚拟机中的虚方法调用

动态绑定

在绝大多数情况下,Java 虚拟机需要根据调用者的动态类型,来确定虚方法调用的目标方法。这个过程我们称之为动态绑定。那么,相对于静态绑定的非虚方法调用来说,虚方法调用更加耗时。

静态绑定

在Java 虚拟机中,静态绑定包括用于调用静态方法的invokestatic 指令,和用于调用构造器、私有实例方法以及超类非私有实例方法的invokespecial 指令。如果虚方法调用指向一个标记为final的方法,那么Java 虚拟机也可以静态绑定该虚方法调用的目标方法

方法表

Java 虚拟机中采取了一种用空间换取时间的策略来实现动态绑定。它为每个类生成一张方法表,用以快速定位目标方法。那么方法表具体是怎样实现的呢?

java 虚拟机的动态绑定是通过方法表这一数据结构(数组)来实现的。

  1. 子类方法表中包含父类方法表中的所有方法;
  2. 子类方法在方法表中的索引值,与它所重写的父类方法的索引值相同

解析虚方法调用时,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 表达式借助了即时编译器中的逃逸分析,来避免实际的新建适配器类实例的操作。

发布了107 篇原创文章 · 获赞 1 · 访问量 3949

猜你喜欢

转载自blog.csdn.net/m0_38060977/article/details/104273103