JVM 基础 — Java 方法调用、反射调用与异常

前言

在前一篇中简单介绍了Java 字节码中有关方法调用的一些操作码,但是 java 方法的调用往深了讲又有很多的门道,简单的有方法的重写和重载,深入也有方法的动态绑定和静态绑定,那JVM是如果识别和选定方法的呢?还有有些时候我们并不能直接调用某个目标方法,而是要使用一些特别的手段去调用,也就是我们会经常用到的反射,反射不同于常规的方法调用,这货一上来就不走寻常路,一般方法调用都是先 new 一个对象,就像是朋友来家里做客从正门进来,反射是先通过 class 对象找到目标方法然后传入调用实例,这更像是翻墙进来。那这个翻墙进来的它背后的原理又是怎样的呢?假如这个世界是美好的,但一个方法的执行可能没那么顺利,万一发生了异常这个异常又是怎么捕获的呢?

Java 方法调用原理

重写与重载

在 Java 程序里如果一个类出现了多个名字相同且参数类型相同的方法,那么他们是无法通过编译的。在正常情况下,通常会出现方法名相同但是方法参数类型不相同。这种情况称之为重载

重载的方法在编译过程中即可完成方法的识别。Java 编译器会根据方法名和传入的参数的声明类型选取重载方法。选取的过程分为三个阶段:

  1. 在不考虑对基本类型自动装拆箱(auto-boxing, auto-unboxing),已经可变长参数的情况下选取重载方法。(不考虑自动装拆箱,不考虑变长参数
  2. 如果在第 1 个阶段中没有找到适配的方法,那么在允许自动装拆箱,但是不允许可变长参数的情况下选取重载方法。(考虑自动装拆箱,不考虑变长参数
  3. 如果在第 2 个阶段中没有找到适配方法,那么在允许自动装拆箱以及变长参数的情况下选取重载方法。(考虑自动装拆箱,考虑变长参数

如果 Java 编译器在同一阶段找到多个适配方法,它会在其中找到一个最为贴切的。而决定贴切程度的一个关键就是形式参数类型的继承关系。重载也适用子类从父类中继承来的方法,也就是说如果子类中定义了和父类非私有方法中方法名相同且方法的参数类型不同的方法,那么在子类中这两个方法也构成了重载。

Java 是面向对象的语言,其中有个重要的特性就是多态。而方法的重写就是多态的最重要的一种体现形式。通过对方法的重写,允许子类对于不同的动作有自己独特的行为。 在调用重写的方法的过程中,编译器会更具具体的类型调用目标实际的方法。

JVM 的静态绑定和动态绑定

前面说到 Java 编译器通过方法名和方法参数类型识别方法,JVM 是通过方法名和方法描述符去识别方法的,方法描述符包括方法参数类型返回值,注意在JVM的方法描述符中是包括方法的返回值的。所以在一个类中如果出现多个方法名和方法描述符的JVM在类加载的验证阶段就会报错。

由于重载方法在编译期已经确定,所以我们可以认定,在 JVM 层面不存在重载这一概念。因为对于 JVM来说这就是两个方法。所以针对JVM来说在编译期可以直接解析识别的目标方法就是静态绑定(static binding),而动态绑定(dynamic binding)指的是需要在运行期间根据调用者的类型来识别目标方法的情况。所以 Java 编译器会将所有对非私有的实例方法解析为需要动态绑定的类型。

在上一篇字节码中提到了五种关于方法调用操作码

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

public static void main(String[] args) {
HiClass hiClass = new HiClass();
System.out.println(hiClass.hiYou("daiwei"));
GoodNight goodNight = (GoodNight) hiClass;
goodNight.goodNight("daiwei");
System.out.println(Arrays.stream(new int[]{1, 2, 3}).reduce(0, Integer::sum));
}
}

生成的字节码如下(只截取方法部分)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=5, locals=3, args_size=1
0: new #2 // class io/daiwei/jvm/HiClass
3: dup
4: invokespecial #3 // Method io/daiwei/jvm/HiClass."<init>":()V
7: astore_1
8: getstatic #4 // Field java/lang/System.out:Ljava/io/PrintStream;
11: aload_1
12: ldc #5 // String daiwei
14: invokevirtual #6 // Method io/daiwei/jvm/HiClass.hiYou:(Ljava/lang/String;)Ljava/lang/String;
17: invokevirtual #7 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
20: aload_1
21: astore_2
22: aload_2
23: ldc #5 // String daiwei
25: invokeinterface #8, 2 // InterfaceMethod io/daiwei/jvm/GoodNight.goodNight:(Ljava/lang/String;)V
30: getstatic #4 // Field java/lang/System.out:Ljava/io/PrintStream;
33: iconst_3
34: newarray int
36: dup
37: iconst_0
38: iconst_1
39: iastore
40: dup
41: iconst_1
42: iconst_2
43: iastore
44: dup
45: iconst_2
46: iconst_3
47: iastore
48: invokestatic #9 // Method java/util/Arrays.stream:([I)Ljava/util/stream/IntStream;
51: iconst_0
52: invokedynamic #10, 0 // InvokeDynamic #0:applyAsInt:()Ljava/util/function/IntBinaryOperator;
57: invokeinterface #11, 3 // InterfaceMethod java/util/stream/IntStream.reduce:(ILjava/util/function/IntBinaryOperator;)I
62: invokevirtual #12 // Method java/io/PrintStream.println:(I)V
65: return

结合上一篇字节码的知识,这里的字节码不难分析,4: invokespecial 这里是调用构造函数,14: invokevirtual #6 // Method io/daiwei/jvm/HiClass.hiYou 这里是使用 invokevirutal 调用 hiYou 方法。invokeinterface #8, 2 // InterfaceMethod io/daiwei/jvm/GoodNight.goodNight:(Ljava/lang/String;)V 使用 invokeinterface 调用接口方法。52: invokedynamic #10, 0 这里则是 lambda 表达式的 invokedynamic

通常情况下 invokestaticinvokespeical,JVM 能直接识别出目标方法(静态绑定),而 invokevirutalinvokeinterface 则需要在运行时,动态判定目标类型从而确定目标方法(动态绑定)。对于 final 方法可以直接确定目标方法。

调用指令的符号引用

在编译过程中,我们并不知道调用目标方法的内存地址。编译器会暂时用方法的符号引用代替目标方法,也就是我们方法方法名和方法描述符,也就是这个 io/daiwei/jvm/HiClass.hiYou:(Ljava/lang/String;)Ljava/lang/String;

  • io/daiwei/jvm/HiClass.hiYou 目标类名方法名。
  • (Ljava/lang/String;) 参数类型,L代表引用类型 ,这里是引用类型 String。
  • Ljava/lang/String; 返回类型,引用类型 String。

这些方法描述符作为常量信息存在类的常量池中。在使用这些符号引用字节码之前,JVM 会在类加载阶段把它替换为真正的引用。

对于非接口符号引用,假定该符号引用指向类C,则 Java 虚拟机会按照如下步骤查找。(C -> C 父类 -> C 间接实现接口)

  1. 在 C 中查找符合名字及描述符的方法。
  2. 如果没找到,在 C 的父亲类中继续搜索,直至 Object 类。
  3. 如果没找到,在 C 直接或者间接实现的接口中搜索。这一步搜索的方法必须是 非私有,非静态的。

调用接口符号引用,假定该符号引用指向 I ,则 Java 虚拟机会按照如下步骤进行查找。

  1. 在 I 中查找符合名字及描述符的方法。(I -> Object ->I 超类接口)
  2. 如果没有找到,在 Object 类中的公有实例方法中搜索。
  3. 如果没有找到,则在 I 的超接口中搜索。这一步的搜索结果的要求与非接口符号引用步骤 3 的要求一致。

经过上述的解析之后,静态方法会被解析成一个方法的指针,而需要动态绑定的方法则会被解析成一个方法表的索引。

方法表

Java 虚拟机通过一种用空间换时间的方法来实现动态绑定。在类加载的准备阶段,不仅会为静态字段分配内存,还会生成类的方法表。方法表的是一个本质上是一个数组。这个数据就是动态绑定实现的关键,调用 invokevirutal虚方法表(virtual method table,vtable)

调用 invokeinterface接口方法表(interface method table, itable),itable 会稍微复杂些,但是原理是一致的。在这个数组中每个元素都指向一个当前类或者父类中的一个非私有的实例方法。这些方法可以是具体的可执行的方法,也可以是抽象的没有方法体的抽象方法。方法表满足下面两个关键的特质:

  • 子类方法表中包含所有的父类方法表的中的所有方法。
  • 子类方法表中的索引值与他重写父类方法在方法表中的索引值相同。
Person方法表 walk() share() talk()
Chinese方法表 walk() share() talk() chineseDo()

在上面这个表中 Chinese 类是 Person 的子类,在类加载阶段生成方法表,静态方法解析方法引用的时候将具体的方法替换具体方法的指针,对于动态绑定的方法而言,替换的引用则是方法表中的索引值(实际上并不只是索引值)。这样的话,在方法运行阶段只要拿到运行时的对象,根据它的方法表拿到具体的要执行的目标方法,这个过程便是动态绑定。

有了额外的操作,就会有额外的性能消耗,但是在类加载阶段生成方法表,解析方法替换成索引,执行阶段查询目标方法,虽然这个过程的很简单,是否可以看作对JVM 没有太大的影响呢?显然是不能的。所以针对这个情况编译器有两个优化手段内联缓存(inline cache)和方法内联(method inline)。

内联缓存和方法内联

内联缓存是一种加快绑定的方法,它能够缓存动态调用的类型以及该类型对应的目标方法,如果在后面的调用中击中已缓存类型,会直接从缓存中获取目标方法执行。如果没有命中调用类型,则退化为方法表动态绑定。这是一个典型的用空间换换时间的操作。

还有一种优化手段那便是方法内联,简单且高频的方法例如一些 get/set 方法,Java 的即时编译器会直接进行方法内联也就是直接把调用的方法和外面的调用优化成一个完整的方法机器码存储起来。用这种方法来提高执行效率。这里要注意方法内联是真内联,而内联缓存附带内联二字,但是它并没有内联目标方法。这里需要明确的是,任何方法调用除非被内联,否则都会有固定开销。这些开销来源于保存程序在该方法中的执行位置,以及新建、压入和弹出新方法所使用的栈帧,也就是内联缓存是假内联。

Java 反射调用机制

Java 的反射机制可以让代码在运行阶段自省,可以让代码知道运行阶段的某个对象的某些字段和方法,并进行调用,那 Java 的方法反射调用是如何实现的呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@CallerSensitive
public Object invoke(Object obj, Object... args)
throws IllegalAccessException, IllegalArgumentException,
InvocationTargetException
{
if (!override) {
if (!Reflection.quickCheckMemberAccess(clazz, modifiers)) {
Class<?> caller = Reflection.getCallerClass();
checkAccess(caller, clazz, obj, modifiers);
}
}
MethodAccessor ma = methodAccessor; // read volatile
if (ma == null) {
ma = acquireMethodAccessor();
}
return ma.invoke(obj, args);
}

可以看到 invoke 方法的源码,method.invoke(),最终是调用 MethodAccessor.invoke(),而MethodAccessor 又有两个实现类 DeleagetingMethodAccessorNativeMethodAccessor ,一个是使用了委派模式实现调用,另一个则是通过 Native 方法实现反射调用。

在执行反射的时候会优先使用NativeMethodAccessor 直接调用 Native 方法,但是调用 Native 方法要切换成 C++ 执行方法,这个成本是大于一般的方法调用。那我们生成代理每次反射调用我们就去调用我们的代理对象,这样是不是就可以更快了呢?这样只是方法调用变快了,但是生成代理对象是很慢的,那生成代理对象的时间成本又怎么计算呢?所以 JVM 采用了折中的办法,设置阈值,当调用次数超过默认的15次JVM 为其生成代理对象,由 Native 方法调用切换为 Delegate 方法调用,来提高整体的执行性能。以为一切都很美好了,突然我们的代码发生了异常,那一起看看异常是怎么JVM异常是怎么处理吧。

Java 异常处理机制

Java 异常

异常处理中主要组成要素:抛出异常和捕获异常。由于 Java 中没有 goto,所以这两大要素配置共同实现程序流程的非正常转移。抛出异常可以分为两种,一种是显式抛出另外一种是另外一种是隐式抛出。显式抛出指的是我们在程序中使用 new threw 的方式抛出异常,而隐式抛出则是 Java 虚拟机执行到异常情况,无法继续抛出的异常。例如数组越界,除0等。异常操作通常分成下面三块:

  • try 代码块:用来标记需要异常监测的代码。
  • catch 代码块:在异常发生后执行的后续逻辑块。在 Java 中一个 try 块后面可以跟多个 catch 代码块,用来捕捉不同的异常,发生异常后 JVM 会从上到下依次匹配异常处理代码,因此前面的 catch 块不能覆盖后面的 catch 块。
  • finally 代码块:在 try 和 catch 后面的代码块,用来声明一段必会执行的逻辑,它的设计初衷是用来避免因为异常跳过的没有执行的一些清理逻辑。

所以正常流程下来,如果 try 中的异常没有被捕获,会抛出异常并执行 finally 。如果正常流程下来,try 中没有发生异常,而是 finally 代码中出现了异常,那 finally 只能中断并抛出异常。

Java 虚拟机是如何处理异常的?

在编译生成的字节码中,每个方法都会带有一个异常表,异常表中的每一条记录代表一个异常处理器,并且由 from 指针、to 指针、target 执政以及所捕获的类型。指针的值是字节码索引(bytecode index,bci),已经定位字节码。其中 from 指针和 to 指针标志了该异常处理器的监控范围,例如 try 代码所覆盖的范围。target 指针则指向异常处理器的起始位置,例如 catch 代码块的起始位置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
public static void main(String[] args) {
try {
throw new RuntimeException("test");
} catch (Exception e) {
e.printStackTrace();
} finally {
System.out.println("finally!");
}
}

java code~~~

public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=3, locals=3, args_size=1
0: new #2 // class java/lang/RuntimeException
3: dup
4: ldc #3 // String test
6: invokespecial #4 // Method java/lang/RuntimeException."<init>":(Ljava/lang/String;)V
9: athrow
10: astore_1
11: aload_1
12: invokevirtual #6 // Method java/lang/Exception.printStackTrace:()V
15: getstatic #7 // Field java/lang/System.out:Ljava/io/PrintStream;
18: ldc #8 // String finally!
20: invokevirtual #9 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
23: goto 37
26: astore_2
27: getstatic #7 // Field java/lang/System.out:Ljava/io/PrintStream;
30: ldc #8 // String finally!
32: invokevirtual #9 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
35: aload_2
36: athrow
37: return
Exception table:
from to target type
0 10 10 Class java/lang/Exception
0 15 26 any

上面的Exception table就是异常表,如果发生了异常会从异常表中招当前位置是在那个异常的返回,如果匹配到多个则从上到下一个个匹配,并跳转到对应的 target 的位置往下执行,前面提到了 target 的位置是 catch 块(异常处理器)开始的位置。在这里可以看到一个0 15 26 any 的异常记录。看到这个我们可能会疑惑,方法中明明只有一个try catch 块,为什么异常表中会有两条记录,而且最后一个异常类型是 any。其实这里的记录的是 finally块,从target 字节码对效应的代表执行的开始位置不难发现这是 finlly 块的位置。

从上面的字节码还能看到一个细节,就是 finally 块的代码重复了两次。finally 代码块编译比较复杂,当前版本的做法就是在分别在 try 和 catch 的代码出口添加一段 finally 的内容。

那如果在 catch 代码块中还是出现了异常的场景,异常发生的行数还是在 0 15 26 any 的异常记录范围,同样会匹配到 any 也就是 finally 的代码块,这样就保证了不管在什么场景发生了异常都能执行 finally 的内容了,但是如果 finally 里面发生了异常呢?那就没有办法了。finally 会中止,并抛出异常。从字节码分析的角度也符合我们前面的结论。

总结

接着上一篇的 Java 字节码技术,这一小节我们通过运用字节码技术,分析方法的执行过程,以及方法的静态绑定和动态绑定。以及从算法层面探索了动态绑定的实现原理也就是方法表,以及方法表的优化方案内联缓存。当然除了正常的调用方法,我们还是使用反射的方式去进行方法调用。这一小节还通过源码分析的方式深入了反射的实现细节。最后我们一起看了异常这个让程序员喜忧参半的机制,通过创建异常表来对异常的执行路径进行索引,来保证程序的执行流程。我发现在方法这块JVM 很喜欢用”表”去解决一些问题, 方法表,异常表…

昨天和一个朋友聊天,聊到所有不了解的东西都会自带一层神秘感,但是我们真正去了解它之后会发现其实也就那样,都是人能想出来的点子。真正要做的是积累,掌握向上的办法,这样我们才能见招拆招并创造属于我们的未来。

学习资料

  • 极客时间专栏《深入拆解Java虚拟机》JVM是如何执行方法调用的?(上)
  • 极客时间专栏《深入拆解Java虚拟机》JVM是如何执行方法调用的?(下)
  • 极客时间专栏《深入拆解Java虚拟机》JVM是如何实现反射的?
  • 极客时间专栏《深入拆解Java虚拟机》JVM是如何处理异常的?