深入探究JVM之方法调用及Lambda表达式实现原理

时间:2023-12-17 15:43:32

@

前言

在最开始讲解JVM内存结构的时候有简单分析过方法的执行原理——每一次方法调用都会生成一个栈帧并压入栈中,方法链的执行就是一个个栈帧弹出栈的过程,本篇就从字节码层面详细分析方法的调用细节。

正文

解析

Java中方法的调用对应字节码有5条指令:

  • invokestatic:用于调用静态方法。
  • invokespecial:用于调用实例构造器<init>方法、私有方法和父类中的方法。
  • invokevirtual:用于调用所有的虚方法。
  • invokeinterface:用于调用接口方法,会在运行时再确定一个实现该接口的对象。
  • invokedynamic:先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法。

invokedynamic与前4条指令不同的是,该指令分派的逻辑是由用户指定,用于支持动态类型语言特性(相关概念后文会详细描述)。

Java中有非虚方法虚方法,前者是指在解析阶段可以确定的唯一的调用版本,如静态方法、构造器方法、父类方法(特指在子类中使用super调用,而不是在客户端使用对象引用调用)、私有方法(即使用invokestatic和invokespecial调用的方法)以及被final修饰的方法(使用invokevirtual调用),这些方法在类加载阶段就会把方法的符号引用解析为直接引用;除此之外的都是虚方法,虚方法则只能在运行期进行分派调用。

分派

分派分为静态动态,同时还会根据宗量数(可以简单理解为影响方法选择的因素,如方法的接收者参数)分为静态单分派静态多分派动态单分派动态多分派

静态分派

静态分派就是指根据静态类型(方法中定义的变量)来决定方法执行版本的分派动作,Java中典型的静态分派就是方法重载。下面先来看段代码示例:

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) {
StaticDispatch sr = new StaticDispatch(); Human man = new Man();
Human woman = new Woman(); sr.sayHello(man);
sr.sayHello(woman);
}
}

下面的结果是否跟你想的是否一样呢?

hello,guy!
hello,guy!

这里全都是调用的参数为Human类型的方法,原因就是在main方法中定义的变量类型都是Human,这个就属于静态类型,而等于后面的对象则属于实际类型,实际类型只能在运行期间获取到,因此编译器在编译阶段时只能根据静态类型选取到对应的方法,所以这里打印的都是"hello,guy!"。

不过不要想当然的认为静态类型就只会匹配到一个唯一的方法,如果有自动拆、装箱,变长参数,向上转型等参数,就可以匹配到多个,不过它们是存在优先级关系的。

动态分派

Java里面的动态分派与它的多态性息息相关,即方法重写,如下面的代码:

public class DynamicDispatch {

    static abstract class Virus{ //病毒
protected abstract void ill();//生病
}
static class Cold extends Virus{
@Override
protected void ill() {
System.out.println("感冒了,好不舒服!");
}
}
static class CoronaVirus extends Virus{//冠状病毒
@Override
protected void ill() {
System.out.println("粘膜感染,空气传播,请带好口罩!");
}
}
public static void main(String[] args) { Virus clod=new Cold();
clod.ill();
clod = new CoronaVirus();
clod.ill();
}
}

这里的输出结果相信大家都清楚,但你是否深入考虑过它的调用细节呢?先来看看字节码:

  public static void main(java.lang.String[]);
Code:
0: new #2 // class ex8/DynamicDispatch$Cold
3: dup
4: invokespecial #3 // Method ex8/DynamicDispatch$Cold."<init>":()V
7: astore_1
8: aload_1
9: invokevirtual #4 // Method ex8/DynamicDispatch$Virus.ill:()V
12: new #5 // class ex8/DynamicDispatch$CoronaVirus
15: dup
16: invokespecial #6 // Method ex8/DynamicDispatch$CoronaVirus."<init>":()V
19: astore_1
20: aload_1
21: invokevirtual #4 // Method ex8/DynamicDispatch$Virus.ill:()V
24: return

可以看到调用方法时都是通过invokevirtual指令调用的,但注释显示两次调用的常量池以及符号引用都是一样的,那为什么就会产生不同的结果呢?在《Java虚拟机规范》中规定了invokevirtual的调用逻辑:

  • 找到操作数栈顶的第一个元素所指向的对象的实际类型,记作C。
  • 如果在类型C中找到与常量中的描述符和简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找过程结束;不通过则返回java.lang.IllegalAccessError异常。
  • 否则,按照继承关系从下往上依次对C的各个父类进行第二步的搜索和验证过程。
  • 如果始终没有找到合适的方法,则抛出java.lang.AbstractMethodError异常.

这里面第一步就是在运行期间找到接收者的实际类型,在真正调用方法时就是根据这个类型进行调用的,所以会产生不同的结果。不过需要注意的是字段不存在多态的概念,即invokevirtual指令对字段是无效的,当子类声明与父类同名的字段时,就会掩盖父类中的字段,如下面的代码:

public class FieldHasNoPolymorphic {
static class Father {
public int money = 1; public Father() {
money = 2;
showMeTheMoney();
} public void showMeTheMoney() {
System.out.println("I am Father, i have $" + money);
}
} static class Son extends Father {
public int money = 3; public Son() {
money = 4;
showMeTheMoney();
} public void showMeTheMoney() {
System.out.println("I am Son, i have $" + money);
}
} public static void main(String[] args) {
Father gay = new Son();
System.out.println("This gay has $" + gay.money);
}
}

输出结果如下:

I am Son, i have $0
I am Son, i have $4
This gay has $2

在创建Son对象时,首先会调用父类的构造器,而父类构造器又调用了showMeTheMoney方法,该方法会调用子类的版本,对应的拿到的字段也是子类中的,而此时子类构造器还没有执行,所以输出的money是0,但最后根据gay的静态类型输出money是2,即没有拿到运行中的实际类型,所以Java中字段是不存在动态分派的。

这里的解释看似合情合理,但仍然有一个问题,调用子类构造器首先会调用父类构造器,也就是说这时候子类还没有初始化完成,那为什么父类就可以调用子类的实例方法呢?这时候可以反编译main的字节码看看:

  public static void main(java.lang.String[]);
Code:
0: new #2 // class ex8/Test$Son
3: dup
4: invokespecial #3 // Method ex8/Test$Son."<init>":()V
7: astore_1
8: getstatic #4 // Field java/lang/System.out:Ljava/io/PrintStream;
11: new #5 // class java/lang/StringBuilder
14: dup
15: invokespecial #6 // Method java/lang/StringBuilder."<init>":()V
18: ldc #7 // String This gay has $
20: invokevirtual #8 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
23: aload_1
24: getfield #9 // Field ex8/Test$Father.money:I
27: invokevirtual #10 // Method java/lang/StringBuilder.append:(I)Ljava/lang/StringBuilder;
30: invokevirtual #11 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
33: invokevirtual #12 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
36: return
}

重点看到第一句,首先就是调用new字节码创建对象并将其压入栈顶,也就是说在调用构造方法之前对象在内存中已经分配好了,所以在父类构造器中可以调用子类的实例方法。

单分派和多分派

Java是一门静态单分派,动态单分派的语言,读者如果充分理解了上文,这里是非常好理解的。再来看一段代码:

public class Dispatch {
static class QQ{}
static class WX{}
public static class Father{
public void hardChoice(QQ arg){
System.out.println("father choose qq");
}
public void hardChoice(WX arg){
System.out.println("father choose weixin");
}
}
public static class Son extends Father{
public void hardChoice(QQ arg){
System.out.println("son choose qq");
}
public void hardChoice(WX arg){
System.out.println("son choose weixin");
}
}
public static void main(String[] args) {
Father father = new Father();
Father son = new Son();
father.hardChoice(new WX());
son.hardChoice(new QQ());
}
}

通过这段代码,我们可以看出,在编译阶段选取方法有两个影响因素:一是需要看静态类型是Father还是Son,二是方法参数。所以Java中静态分派属于静态多分派。而在运行阶段,调用的方法签名是已经确定了的,即不管参数的实际类型是“腾讯QQ”还是“奇瑞QQ”,走的都是hardChoice(QQ arg)方法,唯一的影响就是该方法的实际接收者,所以Java中的动态分派属于动态单分派

动态分派的实现

说了这么多,虚拟机到底是怎么实现动态分派的呢?不可能在整个方法区去搜索寻找,那样效率是非常低的。实际上虚拟机在方法区会为每个类型建立一个虚方法表(支持invokevirtual 指令)以及接口方法表(支持invokeinterface指令),如下图:

深入探究JVM之方法调用及Lambda表达式实现原理

方发表中存的是各个方法的实际入口地址,如果子类没有重写父类中的方法,那么父子类指向同一个地址,否则,子类就会指向自己重写后的方法入口地址。

Lambda表达式的实现原理

java8增加了对Lambda表达式的支持:

    public static void main(String[] args) {
Runnable r = () -> System.out.println("Hello Lambda!");
r.run();
}

上面代码是Lambda表达式最简单的运用,有没有想过它的底层是怎么实现的呢?直接用javap -v命令反编译看看:

 public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=1, locals=2, args_size=1
0: invokedynamic #2, 0 // InvokeDynamic #0:run:()Ljava/lang/Runnable;
5: astore_1
6: aload_1
7: invokeinterface #3, 1 // InterfaceMethod java/lang/Runnable.run:()V
12: return
}
SourceFile: "LambdaDemo.java"
InnerClasses:
public static final #57= #56 of #60; //Lookup=class java/lang/invoke/MethodHandles$Lookup of class java/lang/invoke/MethodHandles
BootstrapMethods:
0: #27 invokestatic java/lang/invoke/LambdaMetafactory.metafactory:(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodHandle;Ljava/lang/invoke/MethodType;)Ljava/lang/invoke/CallSite;
Method arguments:
#28 ()V
#29 invokestatic ex8/LambdaDemo.lambda$main$0:()V
#28 ()V

我删掉了不重要的部分,可以看到Lambda的调用时通过invokedynamic指令实现的,另外从字节码中我们可以看到会生成Bootstrap Method引导方法,该方法存在于BootstrapMethods属性中,这个是JDK1.7新加入的。从这个属性我们可以发现Lambda表达式的最终是通过MethodHandle方法句柄来实现的,虚拟机会执行引导方法并获得返回的CallSite对象,通过这个对象最终调用到我们自己实现的方法上。

Lambda还分为捕获和非捕获,当从表达式外部获取了非静态的变量时,这个表达式就是捕获的,反之就是非捕获的,如下面两个方法:第一个方法就是非捕获的,第二个是捕获的。

    public static void repeatMessage() {
Runnable r = () -> {
System.out.println("Hello Lambda!");
};
} public static void repeatMessage(String msg, int num) {
Runnable r = () -> {
for (int i = 0; i < num; i++) {
System.out.println(msg);
}
};
}

非捕获的比捕获的Lambda表达式性能更高,因为前者只需要计算一次,而后者每次都要重新计算,但无论如何,最差的情况下和内部类性能也是差不多的,所以尽量使用非捕获的Lambda表达式。

关于Lambda的实现就讲解到这,下面主要来看看MethodHandle的使用。

MethodHandle

var arrays = {"abc", new ObjectX(), 123, Dog, Cat, Car..}
for(item in arrays){
item.sayHello();
}

上面这段代码在动态类型语言(类型检查的主体过程是在运行期而不是编译期进行)中是没有什么问题的,但是在Java中实现的话就会产生很多副作用,比如额外的性能开销(数组中每个类型都不一样,就会导致方法内联失去它本来的作用,还会带来更大的负担)。因此JDK1.7新加入invokedynamic指令和java.lang.invoke包,MethodHandle就存在于该包中,这个包的主要目的是在之前单纯依靠符号引用来确定调用的目标方法这条路之外,提供一种新的动态确定目标方法的机制。下面来看看MehtodHandler的使用:

public class MethodHandleDemo {
static class Bike {
String sound() {
return "Bike sound";
}
} static class Animal {
String sound() {
return "Animal sound";
}
} static class Man extends Animal {
@Override
String sound() {
return "Man sound";
} String listen() {
return "listen";
}
} String invoke(Object o, String name) throws Throwable {
//方法句柄
MethodHandles.Lookup lookup = MethodHandles.lookup();
// MethodType:代表“方法类型”,包含了方法的返回值(methodType()的第一个参数)和具体参数(methodType()第二个及以后的参数)。
MethodType methodType = MethodType.methodType(String.class);
// 在指定类中查找符合给定的方法名称、方法类型,并且符合调用权限的方法句柄。
MethodHandle methodHandle = lookup.findVirtual(o.getClass(), name, methodType);
String obj = (String) methodHandle.invoke(o);
return obj;
} public static void main(String[] args) throws Throwable {
String str = new MethodHandleDemo().invoke(new Bike(), "sound");
System.out.println(str);
str = new MethodHandleDemo().invoke(new Animal(), "sound");
System.out.println(str);
str = new MethodHandleDemo().invoke(new Man(), "sound");
System.out.println(str);
str = new MethodHandleDemo().invoke(new Man(), "listen");
System.out.println(str);
}
}

MethodType是用于指定方法的返回类型和参数,然后通过MethodHandles.Lookup模拟字节码的调用,因此对应的有findVirtualfindStaticfindSpecial等方法,这些方法就会返回一个MethodHandle的对象,最终通过这个对象的invoke或者invokeExact方法就能调用实际想要调用的对象方法(这里需要注意的是前者是松散匹配,即可以自动转型,而后者则必须是精确匹配,参数返回值类型都必须一样,否则就会报错)。

通过上面的代码我们知道,在运行中不论实际类型是什么,只要有方法签名以及返回值能对应上,就能调用成功,相当于动态的替换了符号引用中的静态类型部分,也解决了动态语言对方法内联等编译优化的不良影响。

另外我们可以发现MethodHandle在功能和使用上都和反射差不多,但是使用更加简单,也更轻量级,对应的性能也比反射要高。

总结

静态分派和动态分派在Java中都是支持的,并且是静态多分派,动态单分派;深刻理解分派的原理以及方法的分派规则,才能更好的理解程序的运行过程。另外为什么会出现MethodHandle类,它能给我们带来哪些便利,熟悉并掌握可以让我们写出更灵活的程序。