Java 方法调用的底层实现

1,560 阅读16分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第2天,点击查看活动详情

为什么要了解方法调用

我们写的代码,经过编译、经过类加载的各种阶段,进入了 JVM 的运行时数据区。

但作为程序员真正关心是代码的执行,代码的执行其实本质上是方法的执行,站在 JVM 的角度归根到底还是字节码的执行

main 函数是 JVM 指令执行的起点,JVM 会创建 main 线程来执行 main 函数,以触发 JVM 一系列指令的执行,真正地把 JVM 跑起来。

接着,在我们的代码中,就是方法调用方法的过程,所以了解方法在 JVM 中的调用是非常必要的。

方法调用的字节码指令

一个方法的执行是通过调用字节码指令实现的,并且在Class常量池中有类的版本、字段、方法和接口等描述信息。即在Java类尚未加载的时候,方法以字节码的形式存在于Class常量池中。

附:Java字节码指令大全

这样说好像不能信服,我们随便写一个方法,通过jclasslib(一个查看字节码的工具)来查看,如下:

image-20210831232827320

我们知道了方法在哪里,但是怎么调用呢?关于方法调用,Java共提供了5个指令,来调用不同类型的方法:

  • invokestatic, 用来调用静态方法。
  • invokespecial,用来调用私有实例方法、构造器、super关键字等。
  • invokevirtual, 用于调用非私有实例方法,比如 public 和 protected,大多数方法调用属于这一种。
  • invokeinterface ,和invokevirtual类似,但作用于接口类。
  • invokedynamic, 用于调用动态方法。

我们经常说的静态方法,实例方法等,实际上它们有一个比较官方的说法:虚方法以及非虚方法。

非虚方法

什么叫非虚方法?

如果方法在编译期就确定了具体的调用版本,这个版本在运行时是不可变的,这样的方法称为非虚方法。 一般来说包含以下五种:

  • 静态方法(static修饰)

  • 私有方法(private修饰)

  • 父类方法

  • 构造方法

  • final修饰的方法(特例,因为被final修饰的方法就是不可变的方法,但实际还是使用invokevirtual指令),如下

    public final void invokeStatic() {
        System.out.println("invokestatic 调用静态方法");
    }
    

简单来说就是被 invokestaticinvokespecial 指令调用的方法,我们来验证一下。

invokestatic

调用静态方法:

public class InvokeStatic {
​
    public static void invokeStatic() {
        System.out.println("invokestatic 调用静态方法");
    }
​
    public static void main(String[] args) {
        //调用静态方法
        InvokeStatic.invokeStatic();
    }
}

查看字节码:

image-20210831234629358

我们可以看到被在main方法的字节码中有: invokestatic #5

image-20210831234811852

invokestatic 我们知道,调用的是静态方法,#5代表什么呢?我们通过javap -v InvokeStatic.class查看,发现#5后面有个注释,即invokeStatic()方法。

image-20210831235941460

这个方法调用在编译期间就明确以常量池项的形式固化在字节码指令的参数之中了。

image-20210901000103121

invokespecial

调用私有实例方法、构造器、super关键字等。

还是上面的代码,通过实例化:

public class InvokeStatic {
​
    public static void invokeStatic() {
        System.out.println("invokestatic 调用静态方法");
    }
​
    public static void main(String[] args) {
        //实例化
        InvokeStatic invokeStatic = new InvokeStatic();
    }
}

查看字节码:

image-20210901001406290

发现调用的是InvokeStatic.<init><init>其实就是构造方法的字节码。

静态链接

invokestatic 指令加上 invokespecial 指令,就属于静态绑定过程。在上篇文章JVM的类加载中,类的解析阶段是将 JVM 常量池内的符号引用替换为直接引用的过程,即方法在真正运行之前就会有一个可确定的调用版本,并且这个方法的调用版本在运行期是不可变的,也可以这么说编译器可知,运行期不可变这类方法的调用被称为解析。

非虚方法和静态链接的关系

非虚方法的调用会在类的解析阶段将符号引用转化为直接引用,这个过程叫做静态链接。

虚方法

那什么又是虚方法?

很简单,不属于非虚方法的就是虚方法,invokevirtual,即方法在运行时是可变的

invokevirtual

很多时候,JVM 需要根据调用者的动态类型,来确定调用的目标方法,这就是动态绑定的过程。比如上面的invokeStatic()不是static方法,我们发现了 invokevirtual指令。

image-20210901002418801

invokeinterface

因为 invokeinterface 指令跟 invokevirtual 类似,只是作用与接口,所以我们只要熟悉 invokevirtual 即可。

动态链接

对于invokevirtual动态绑定的过程,我们联想到动态连接与虚方法的关系又是什么?

还是在JVM的内存结构中讲到了运行时数据区:

image-20210816161959892

而在线程私有的区域里面,当前线程的虚拟机栈在 JVM 运行过程中存储当前线程运行方法所需的数据,指令、返回地址。而每一个方法回在虚拟机栈中被打包为一个栈帧

image-20210816170617761

栈帧大体都包含四个区域:(局部变量表、操作数栈、动态连接、返回地址)

当时对动态链接的定义是:

每个栈帧都包含一个指向运行时常量池中该栈帧所属性方法的引用,持有这个引用是为了支持方法调用过程中的动态连接。在Class文件的常量池中存有大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用为参数。这些符号引用一部分会在类加载阶段或第一次使用的时候转化为直接引用,这种转化称为静态解析(静态链接)。另外一部分将在每一次的运行期期间转化为直接引用,这部分称为动态连接

PS:现在在回头看是不是很清楚了?

动态连接与虚方法的关系

虚方法会在程序的运行阶段将符号引用转化为直接引用,这个过程叫动态连接。

分派

虚方法中有分派的概念,但分派和链接并不是一个层次的概念,而分派描述的是方法版本确定的过程,即虚拟机如何确定应该执行哪个方法

我们知道 Java 是一门面向对象的程序语言,因为 Java 具备面向对象的 3 个基本特征:继承、封装和多态。分派调用过程将会揭示多态性特征(不会有人学Java不知道多态吧🤣)的一些最基本的体现,如重载和重写在 Java 虚拟机之中是如何实现的。

静态分派

静态分派多见于方法的重载(Overload)。

重载:一个类中允许同时存在一个以上的同名方法,这些方法的参数个数或者类型不同

前面讲了非虚方法,我们知道非虚方法(static方法,构造器等)是不能被复写(@Override)的,所以自然也不会产生子类复写的多态效果。但是可以重载(比如构造器的有参和无参方法)。

这样的话,方法被调用的入口只可能是一个,而且编译器可知,也就是说,jvm需要执行哪个方法是在编译器就已经确定,且在运行期不会变化,举个面试常见的栗子:

来自《深入JAVA虚拟机-JVM高级特性与最佳实践》

/**
 * 静态分派--方法的重载--编译阶段
 */
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) {
        Human man = new Man();
        Human woman = new Woman();
        //输出什么呢?
        StaticDispatch sr = new StaticDispatch();
        sr.sayHello(man);
        sr.sayHello(woman);
    }
}

这段代码main方法执行的结果:

hello,guy!
hello,guy!

查看字节码发现是:

image-20210901111910047

分析

  1. 我们看看这一段代码

    image-20210901102523419

    静态类型Human man = new Man(),其中Human称为变量man的静态类型。

    实际类型Human man = new Man(),其中Man则称为变量man的实际类型。

    而静态类型是在编译期可见的,而实际类型变化的结果在运行期才可确定。

  2. 再看看调用

    image-20210901103044472

    可以看到,调用方法的接受者是确定的,都是sr。在静态分派中,jvm如何确定具体调用哪个目标方法就完全取决于传入参数的数量和数据类型,而且是根据数据的静态类型Human,正因为如此,这两个sayHello方法,最后都调用了public void sayHello(Human human)方法。

因此,我们可以得出静态分派的定义

根据变量的赖静态类型来决定方法执行版本的分派动作,称为静态分派,因此,Java中的方法重载就是静态分派,且静态分派是在编译器就已经完成的了。运行期不会改变,所以也有把静态分派归为类加载的解析范畴的。

相对的,静态类型和实际类型在程序中都可以发生一些变化,区别是静态类型的变化仅仅在使用时发生,变量本身的静态类型不会被改变,并且最终的静态类型是在编译期可知的;而实际类型变化的结果在运行期才可确定,编译器在编译程序的时候并不知道一个对象的实际类型是什么。

怎么理解?我们把main方法中的代码改一下:

image-20210901105758882

结果输出为:

hello,gentleman!
hello,lady!

动态分派

我们已经知道根据变量的静态类型来决定方法的调用的分派动作叫静态分派,那与之对应的根据实际类型来决定方法的分派动作动态分派,动态分派多见于方法的重写(Override)。

重写:在子类中将父类的成员方法的名称保留,重新编写成员方法的实现内容,更改方法的访问权限,修改返回类型的为父类返回类型的子类。

重写也是使用 invokevirtual 指令,这个时候就具备多态性了。invokevirtual 指令有多态查找的机制,该指令运行时,解析过程如下:

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

我们看下面这段代码:

public class DynamicDispatch {
​
    static abstract class Human {
        protected abstract void sayHello();
    }
​
    static class Man extends Human {
        @Override
        protected void sayHello() {
            System.out.println("man say hello");
        }
    }
​
    static class Woman extends Human {
        @Override
        protected void sayHello() {
            System.out.println("woman say hello");
        }
    }
​
    public static void main(String[] args) {
        Human man = new Man();
        Human woman = new Woman();
        man.sayHello();
        woman.sayHello();
    }
}

输出:

man say hello
woman say hello
方法表

动态分派会执行非常频繁的动作,JVM 运行时会频繁的、反复的去搜索元数据,所以 JVM 使用了一种优化手段,这个就是在方法区中建立一个虚方法表

使用虚方法表索引来替代元数据查找以提高性能

在实现上,最常用的手段就是为类在方法区中建立一个虚方法表。虚方法表中存放着各个方法的实际入口地址。如果某个方法在子类中没有被重写,那子类的虚方法表里面的地址入口和父类相同方法的地址入口是一致的,都指向父类的实现入口。如果子类中重写了这个方法,子类方法表中的地址将会替换为指向子类实现版本的入口地址。

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());
    }
}

Son 重写了来自 Father 的全部方法,因此 Son 的方法表没有指向 Father 类型数据的箭头。但是 Son 和 Father 都没有重写来自 Object 的方法,所以它们的方法表中所有从 Object 继承来的方法都指向了 Object 的数据类型。

image-20210901134636100

单分派、多分派

分派中根据宗量,又可以把分派分为单分派和多分派。那什么是宗量呢?

方法的接收者与方法的参数统称为宗量,根据宗量的多少可以将分派分为单分派和多分派。根据一个宗量对方法进行选择叫单分派,根据多于一个宗量对方法进行选择就叫多分派。

  1. Java中的静态分派是单分派还是多分派? 在重载中,调用方法,影响方法调用的因素有两个,一个是方法接收者(即方法调用者),和传入的参数,方法的调用者不同或者方法的传参不同都会调用到不同的方法。所以java中的静态分派属于静态多分派
  2. Java中的动态分派是单分派还是多分派? 在重写中,调用方法,影响方法调用的因素只有一个,就是方法的接收者,也就是方法的实际类型,所以Java的动态分派属于动态单分派

invokedynamic

Java虚拟机的字节码指令集的数量自从Sun公司的第一款Java虚拟机问世至今,二十余年间只新增过一条指令,它就是随着JDK 7的发布的字节码首位新成员——invokedynamic指令。这条新增加的指令是JDK 7的项目目标:实现动态类型语言(Dynamically Typed Language),也是为JDK 8里可以顺利实现Lambda表达式而做的技术储备。

那什么是动态类型语言?在某乎上看到了这样一张图(可能有争议,自行理解)

image-20210901144628256

大致总结如下:

类型概念表现举例
动态类型语言类型的检查是在运行时做的使用变量前不需要声明变量类型JavaScript
静态类型语言类型判断是在运行前做的(如编译阶段)使用变量前需要声明变量类型Java

那既然invokedynamic指令支持动态语言了,Java算不算动态语言?一般来说还是把它定义为静态语言的。和上面介绍的四个指令不同,invokedynamic 并没有确切的接受对象,取而代之的,是一个叫 CallSite 的对象。

Lambda表达式

上面说道Lambda表达式就是动态语言实现的,我们还是来验证一下:

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

我们查看这个类的字节码:

image-20210901150852131

Lambda表达式是通过invokedynamic指令来调用的,而对于invokedynamic 指令的底层,则是使用方法句柄MethodHandle)来实现的。方法句柄是一个能够被执行的引用,它可以指向静态方法和实例方法。

方法句柄

方法句柄是一个强类型的,能够被直接执行的引用。该引用可以指向常规的静态方法或者实例方法,也可以指向构造器或者字段(包括private)。当指向字段时,方法句柄实则指向包含字段访问字节码的虚构方法,语义上等价于目标字段的 getter 或者 setter 方法。

官方文档:docs.oracle.com/javase/7/do…

  • 方法句柄的类型(MethodType)

    是由所指向方法的参数类型以及返回类型组成的。它是用来确认方法句柄是否适配的唯一关键。当使用方法句柄时,我们其实并不关心方法句柄所指向方法的类名或者方法名。

  • Lookup

    MethodHandles.Lookup 可以通过相应的 findxxx 方法得到相应的 MethodHandle,相当于 MethodHandle 的工厂方法。查找对象上的工厂方法对应于方法、构造函数和字段的所有主要用例。如findStatic 相当于得到的是一个 static 方法的句柄(类似于 invokestatic 的作用),findVirtual 找的是普通方法(类似于 invokevirtual 的作用)。

  • invoke

    在得到MethodHandle后就可以进行方法调用了,有三种调用形式:

说了这么多概念,我们来实践一下,当然这只是简单测试,不然Lambda表达式就是我写了。

使用 MethodHandle 调用方法的流程:

  1. 创建 MethodType,获取指定方法的签名(出参和入参)
  2. Lookup 中查找 MethodType 的方法句柄 MethodHandle
  3. 传入方法参数通过 MethodHandle 调用方法
import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodHandles;
import java.lang.invoke.MethodType;

public class MethodHandleDemo {

    static class Bike {
        String who() {
            return "我是自行车";
        }
    }

    static class Animal {
        String who() {
            return "我是动物";
        }
    }

    static class Man extends Animal {
        @Override
        String who() {
            return "我是高级动物-人";
        }
    }

    /**
     * 使用方法句柄方式调用
     */
    String who(Object o) throws Throwable {
        //方法句柄--工厂方法Factory
        MethodHandles.Lookup lookup = MethodHandles.lookup();
        //方法类型表示接受的参数和返回类型(第一个参数是返回参数),这里是toString()的签名
        MethodType methodType = MethodType.methodType(String.class);
        //拿到具体的MethodHandle(findVirtual相当于字节码)
        MethodHandle methodHandle = lookup.findVirtual(o.getClass(), "who", methodType);
        String str = (String) methodHandle.invoke(o);
        return str;
    }

    public static void main(String[] args) throws Throwable {
        //每次送入的实例不一样
        String str = new MethodHandleDemo().who(new Bike());
        System.out.println(str);
        str = new MethodHandleDemo().who(new Animal());
        System.out.println(str);
        str = new MethodHandleDemo().who(new Man());
        System.out.println(str);
    }
}

输出:

我是自行车
我是动物
我是高级动物-人

如果这个例子不懂的话,推荐去看这篇文章,主要讲的就是如何使用:秒懂Java之方法句柄(MethodHandle)

我们想一想,方法句柄甚至可以访问private的方法,那和Java中的反射有什么关系呢?也就是说我上面的代码也可以通过反射来实现。

在Java从最初发布时就支持反射,通过反射可以在运行时获取类型信息,但其有个缺点就是执行速度较慢。于是从Java 7开始提供了另一套API MethodHandle 。其与反射的作用类似,可以在运行时访问类型信息,但是据说其执行效率比反射更高,也被称为Java的 现代化反射

有这样一种说话:使用MethodHandle就像是在用Java来写字节码。

这种说法是有一定道理的,因为MethodHandle里的很多操作都对应着相应的字节码(findXxx)。总的来说,其与反射一样,离应用型程序员日常开发比较远,因为我不懂方法句柄和反射我也能开发,你说是吧。