修炼内功JVM-虚拟机视角的方法调用

43次阅读

共计 8805 个字符,预计需要花费 23 分钟才能阅读完成。

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

『我们写的 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");
    }
}

查看其字节码

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

#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 的字节码

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

查看 MethodImpl 字节码

可以看到,父类方法的调用则被编译为 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 字节码

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

再查看 MethodImpl 字节码

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

查看字节码

可以看到,虽然 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 的实际类型),查看字节码

两次 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),使用虚方法表(接口方法表) 索引来代替元数据查找以提高性能

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

动态调用

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

在第一次执行 invokedynamic 时,JVM 虚拟机会调用该指令所对应的启动方法 (`BootstrapMethod) 来生成调用点,并绑定至该 invokedynamic 指令中,之后的运行中虚拟机会直接调用绑定的调用点所链接的方法句柄

字节码中,启动方法由方法句柄来指定(`INDY_BootstrapMethod),该句柄指向一个返回类型为调用点的静态方法(BootstrapMethod),该方法必须接受三个固定的参数,分别为 Lookup 示例、指代目标方法名的字符串及该调用点能够链接的方法句柄类型

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

查看字节码

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

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

正文完
 0