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

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

虚拟机视角的方法调用

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

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

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

编译代码segmentfault

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,查看常量池ide

method_invoke_2

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

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

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

方法调用指令

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

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

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

而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原理-到底是不是匿名类的语法糖


订阅号