【修炼内功】[JVM] 虚拟机视角的方法调用

本文已收录【修炼内功】跃迁之路

虚拟机视角的方法调用

『我们写的Java方法在被编译为class文件后是如何被虚拟机执行的?对于重写或者重载的方法,是在编译阶段就确定具体方法的么?如果不是,虚拟机在运行时又是如何确定具体方法的?』

方法调用不等于方法执行,一切方法调用在class文件中都只是常量池中的符号引用,这需要在类加载的解析阶段甚至到运行期间才能将符号引用转为直接引用,确定目标方法进行执行

在编译过程中编译器并不知道目标方法的具体内存地址,因此编译器会暂时使用符号引用来表示该目标方法

编译代码

public class MethodDescriptor {
    public void printHello() {
        System.out.println("Hello");
    }

    public void printHello(String name) {
        System.out.println("Hello " + name);
    }

    public static void main(String[] args) {
        MethodDescriptor md = new MethodDescriptor();
        md.printHello();
        md.printHello("manerfan");
    }
}

查看其字节码

method_invoke_1

main方法中调用两次不同的printHello方法,对应class文件中均为invokevirtual指令,分别调用常量池中的#12及#14,查看常量池

method_invoke_2

#12及#14对应两个Methodref方法引用,这两个方法引用均为符号引用(使用方法描述符)而并非直接引用

虚拟机识别方法的关键在于类名、方法名及方法描述符(method descriptor),方法描述符由方法的参数类型及返回类型构成

方法名及方法描述符在编译阶段便可以确定,但对于实际类名,一些场景下(如类继承)只有在运行时才可知

方法调用指令

目前Java虚拟机里提供了5中方法调用的字节码指令

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

invokestatic及invokespecial调用的方法(静态方法、构造方法、私有方法、父类方法),均可以在类加载的解析阶段确定唯一的调用版本,从而将符号引用直接解析为该方法的直接引用,这些方法称之为非虚方法

而invokevirtual及invokeinterface调用的方法(final方法除外,下文提到),在解析阶段并不能唯一确定,只有在运行时才能拿到实际的执行类从而确定唯一的调用版本,此时才可以将符号引用转为直接引用,这些方法称之为虚方法

invokedynamic比较特殊,单独分析

简单示意,如下代码

public interface MethodBase {
    String getName();
}

public class BaseMethod implements MethodBase {
    @Override
    public String getName() {
        return "manerfan";
    }

    public void print() {
        System.out.println(getName());
    }
}

public class MethodImpl extends BaseMethod {
    @Override
    public String getName() {
        return "maner-fan";
    }

    @Override
    public void print() {
        System.out.println("Hello " + getName());
    };

    public String getSuperName() {
        return super.getName();
    }

    public static String getDefaultName() {
        return "default";
    }
}

public class MethodDescriptor {
    public static void print(BaseMethod baseMethod) {
        baseMethod.print();
    }

    public static String getName(MethodBase methodBase) {
        return methodBase.getName();
    }

    public static void main(String[] args) {
        MethodImpl.getDefaultName();

        MethodImpl ml = new MethodImpl();
        ml.getSuperName();
        getName(ml);
        print(ml);
    }
}

查看MethodDescriptor的字节码

method_invoke_3

不难发现,接口MethodBase中getName方法的调用均被编译为invokeinterface指令,子类BaseMethod中print方法的调用则被便以为invokevirtual执行,静态方法的调用被编译为invokestatic指令,而构造函数调用则被编译为invokespecial指令

查看MethodImpl字节码

method_invoke_4

可以看到,父类方法的调用则被编译为invokespecial指令

桥接方法

JVM - 类文件结构中有介绍方法的访问标识,其中有两条 ACC_BRIDGE(桥接方法) 及 ACC_SYNTHETIC(编译器生成,不会出现在源码中),而桥接方法便是由编译器生成,且会将桥接方法标记为ACC_BRIDGE及ACC_SYNTHETIC,那什么时候会生成桥接方法?

桥接方法是 JDK 1.5 引入泛型后,为了使Java的泛型方法生成的字节码和 1.5 版本前的字节码相兼容,由编译器自动生成的,就是说一个子类在继承(或实现)一个父类(或接口)的泛型方法时,在子类中明确指定了泛型类型,那么在编译时编译器会自动生成桥接方法(当然还有其他情况会生成桥接方法,这里只是列举了其中一种情况)

public class BaseMethod<T> {
    public void print(T obj) {
        System.out.println("Hello " + obj.toString());
    }
}

public class MethodImpl extends BaseMethod<String> {
    @Override
    public void print(String name) {
        super.print(name);
    };
}

首先查看BaseMethod字节码

method_invoke_5

由于泛型的擦除机制,print的方法描述符入参被标记为(Ljava/lang/Object;)V

再查看MethodImpl字节码

method_invoke_6

MethodImpl只声明了一个print方法,却被编译为两个,一个方法描述符为(Ljava/lang/String;)V,另一个为(Ljava/lang/Object;)V且标记为ACC_BRIDGE ACC_SYNTHETIC

print(java.lang.Object)方法中做了一层类型转换,将入参转为String类型,进而再调用print(java.lang.String)方法

为什么要生成桥接方法

泛型可以保证在编译阶段检查对象类型是否匹配执行的泛型类型,但为了向下兼容(1.5之前),在编译时则会擦除泛型信息,如果不生成桥接方法则会导致字节码中子类方法为print(java.lang.Object)而父类为print(java.lang.String),这样的情况是无法做到向下兼容的

桥接方法的隐患

既然桥接方法是为了向下兼容,那会不会有什么副作用?

public class MethodDescriptor {
    public static void main(String[] args) {
        BaseMethod bm = new MethodImpl();
        bm.print("manerfan");
        bm.print(new Object());
    }
}

查看字节码

method_invoke_7

可以看到,虽然MethodImpl.print方法入参声明为String类型,但实际调用的还是桥接方法print(java.lang.Object)

由于子类的入参为Object,所以编译并不会失败,但从MethodImpl的字节码中可以看到,桥接方法是有一次类型转换的,在将类型转为String之后会调用print(java.lang.String)方法,那如果类型转换失败呢?运行程序可以得到

Hello manerfan
Exception in thread "main" java.lang.ClassCastException: java.lang.Object cannot be cast to java.lang.String
    at MethodImpl.print(MethodImpl.java:1)
    at MethodDescriptor.main(MethodDescriptor.java:5)

所以,由于泛型的擦除机制,会导致某些情况下(如方法桥接)的错误,只有在运行时才可以被发现

对于其他情况,大家可以编写更为具体的代码查看其字节码指令

分派

静态分派

首先看一个重载的例子

public class StaticDispatch {
    static abstract class Animal {
        public abstract void croak();
    }

    static class Dog extends Animal {
        @Override
        public void croak() {
            System.out.println("汪汪叫~");
        }
    }

    static class Duck extends Animal {
        @Override
        public void croak() {
            System.out.println("呱呱叫~");
        }
    }

    public void croak(Animal animal) {
        System.out.println("xx叫~");
    }

    public void croak(Dog dog) {
        dog.croak();
    }

    public void croak(Duck duck) {
        duck.croak();
    }

    public static void main(String[] args) {
        Animal dog = new Dog();
        Animal duck = new Duck();
        StaticDispatch dispatcher = new StaticDispatch();
        dispatcher.croak(dog);
        dispatcher.croak(duck);
    }
}

运行结果

xx叫~
xx叫~

起始并不难理解为什么两次都执行了croak(Animal)的方法,这里要区分变量的静态类型以及变量的实际类型

一个对象的静态类型在编译器是可知的,但并不知道其实际类型是什么,实际类型只有在运行时才可知

编译器在重载时,是通过参数的静态类型(而不是实际类型)作为判定依据以决定使用哪个重载版本的,所有依赖静态类型来定位方法执行版本的分派动作成为静态分派,静态分派发生在编译阶段,因此严格来讲静态分派并不是虚拟机的行为

动态分派

同样,还是上述示例,修改main方法

 public static void main(String[] args) {
     Animal dog = new Duck();
     Animal duck = new Dog();
     dog.croak();
     duck.croak();
 }

运行结果

呱呱叫~
汪汪叫~

显然这里并不能使用静态分派来决定方法的执行版本(编译阶段并不知道dog及duck的实际类型),查看字节码

method_invoke_8

两次croak调用均使用了invokevirtual指令,invokevirtual指令(invokeinterface类似)运行时解析过程大致为

  1. 找到对象实际类型C
  2. 在C常量池中查找方法描述符相符的方法,如果找到则返回方法的直接引用,如果无权访问则抛jaba.lang.IllegalAccessError异常
  3. 如果未找到,则按照继承关系从下到上一次对C的各个父类进行第2步的搜索
  4. 如果均未找到,则抛java.lang.AbstractMethodError异常

实际运行过程中,动态分派是非常频繁的动作,而动态分派的方法版本选择需要在类的方法元数据中进行搜索,处于性能的考虑,类在方法区中均会创建一个虚方法表(virtual method table, vtable)及接口方法表(interface method table, itable),使用虚方法表(接口方法表)索引来代替元数据查找以提高性能

方法表本质上是一个数组,每个数组元素都指向一个当前类机器祖先类中非私有的实力方法

method_invoke_9

动态调用

在JDK1.7以前,4条方法调用指令(invokestatic、invokespecial、invokevirtual、invokeinterface),均与包含目标方法类名、方法名及方法描述符的符号引用绑定,invokestatic及invokespecial的分派逻辑在编译时便确定,invokevirtual及invokeinterface的分配逻辑也由虚拟机在运行时决定,在此之前,JVM虚拟机并不能实现动态语言的一些特性,典型的例子便是鸭子类型(duck typing)

鸭子类型(duck typing)是多态(polymorphism)的一种形式,在这种形式中不管对象属于哪个,也不管声明的具体接口是什么,只要对象实现了相应的方法函数就可以在对象上执行操作
public class StaticDispatch {
    static class Duck {
        public void croak() {
            System.out.println("呱呱叫~");
        }
    }
    
    static class Dog {
        public void croak() {
            System.out.println("学鸭子呱呱叫~");
        }
    }

    public static void duckCroak(Duck duckLike) {
        duckLike.croak();
    }

    public static void main(String[] args) {
        Duck duck = new Duck();
        Dog dog = new Dog();
        duckCroak(duck);
        duckCroak(dog); // 编译错误
    }
}

我们不关心Dog是不是Duck,只要Dog可以像Duck一样croak就可以

方法句柄

Duck Dog croak的问题,我们可以使用反射来解决,也可以使用一种新的、更底层的动态确定目标方法的机制来实现--方法句柄

方法句柄是一个请类型的、能够被直接执行的引用,类似于C/C++中的函数指针,可以指向常规的静态方法或者实力方法,也可以指向构造器或者字段

public class Dispatch {
    static class Duck {
        public void croak() {
            System.out.println("呱呱叫~");
        }
    }

    static class Dog {
        public void croak() {
            System.out.println("学鸭子呱呱叫~");
        }
    }

    public static void duckCroak(MethodHandle duckLike) throws Throwable {
        duckLike.invokeExact();
    }

    public static void main(String[] args) throws Throwable {
        Duck duck = new Duck();
        Dog dog = new Dog();

        MethodType mt = MethodType.methodType(void.class);
        MethodHandle duckCroak = MethodHandles.lookup().findVirtual(duck.getClass(), "croak", mt).bindTo(duck);
        MethodHandle dogCroak = MethodHandles.lookup().findVirtual(dog.getClass(), "croak", mt).bindTo(dog);

        duckCroak(duckCroak);
        duckCroak(dogCroak);
    }
}

这样的事情,使用反射不一样可以实现么?

  1. 本质上讲,Reflection及MethodHandler都是在模拟方法调用,但Reflection是Java代码层次的模拟,MethodHandler是字节码层次的层次,更为底层
  2. Reflection相比MethodHandler包含更多的信息,Reflection是重量级的,MethodHandler是轻量级的

invokedynamic

invokedynamic是Java1.7引入的一条新指令,用以支持动态语言的方法调用,解决原有4条"invoke*"指令方法分派规则固化在虚拟机中的问题,把如何查找目标方法的决定权从虚拟机转嫁到具体用户代码中,使用户拥有更高的自由度

invokedynamic将调用点(CallSite)抽象成一个Java类,并且将原本由Java虚拟机控制的方法调用以及方法链接暴露给了应用程序,在运行过程中,每一条invokedynamic指令将捆绑一个调用点,并且会调用该调用点所链接的方法句柄

在Java8以前,并不能直接通过Java程序编译生成invokedynamic指令,这里写一段代码用以模拟上述过程

public class DynamicDispatch {
    /**
     * 动态调用的方法
     */
    private static void croak(String name) {
        System.out.println(name + " croak");
    }

    public static void main(String[] args) throws Throwable {
        INDY_BootstrapMethod().invokeExact("dog");
    }

    /**
     * 生成启动方法
     */
    private static CallSite BootstrapMethod(MethodHandles.Lookup lookup, String name, MethodType mt) throws Throwable {
        return new ConstantCallSite(lookup.findStatic(DynamicDispatch.class, name, mt));
    }

    /**
     * 生成启动方法的MethodType
     */
    private static MethodType MT_BootstrapMethod() {
        return MethodType.fromMethodDescriptorString(
            "(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;)"
                + "Ljava/lang/invoke/CallSite;",
            null);
    }

    /**
     * 生成启动方法的MethodHandle
     */
    private static MethodHandle MH_BootstrapMethod() throws Throwable {
        return MethodHandles.lookup().findStatic(DynamicDispatch.class, "BootstrapMethod", MT_BootstrapMethod());
    }

    /**
     * 生成调用点,动态调用
     */
    private static MethodHandle INDY_BootstrapMethod() throws Throwable {
        // 生成调用点
        CallSite cs = (CallSite)MH_BootstrapMethod().invokeWithArguments(MethodHandles.lookup(), "croak",
            MethodType.fromMethodDescriptorString("(Ljava/lang/String;)V", null));
        // 动态调用
        return cs.dynamicInvoker();
    }
}

字节码中,启动方法由方法句柄来指定(MH_BootstrapMethod),该句柄指向一个返回类型为调用点的静态方法(BootstrapMethod)

  1. 在第一次执行invokedynamic时,JVM虚拟机会调用该指令所对应的启动方法(BootstrapMethod)来生成调用点
  2. 启动方法(BootstrapMethod)由方法句柄来指定(MH_BootstrapMethod)
  3. 启动方法接受三个固定的参数,分别为 Lookup实例、指代目标方法名的字符串及该调用点能够链接的方法句柄类型
  4. 将调用点绑定至该invokedynamic指令中,之后的运行中虚拟机会直接调用绑定的调用点所链接的方法句柄

Lambda表达式

Java8中的lambda表达式使用的便是invokedynamic指令

public class DynamicDispatch {
    public void croak(Supplier<String> name) {
        System.out.println(name.get() + "croak");
    }

    public static void main(String[] args) throws Throwable {
        new DynamicDispatch().croak(() -> "dog");
    }
}

查看字节码

method_invoke_10

可以看到,lambda表达式会被编译为invokedynamic指令,同时会生成一个私有静态方法lambda$main$0,用以实现lambda表达式内部的逻辑

其实,除了会生成一个静态方法之外,还会额外生成一个内部类,lambda启动方法及调用点的详细介绍请转 Java8 - Lambda原理-究竟是不是匿名类的语法糖


订阅号

阅读 3.7k

推荐阅读
林中小舍
用户专栏

工作中的坑点及经验

51 人关注
41 篇文章
专栏主页