HotSpot VM运行时

1.1 调用Java方法的不同种方式

这一篇文章来总结一下Java的几种调用方法的方式,调用方法总共可以归纳为如下7种:

undefined

如果要从字节码指令的角度看,有如下方法调用相关的字节码指令:

undefined

上图中前5个字节码指令是常用的,后面2个是为了更好的实现调用功能,在HotSpot VM内部扩展出来的,最后一个_fast_invokevfinal对x86平台上无用,所以不用关注。这些字节码指令的描述如下表所示。

字节码指令 描述
invokestatic 调用静态方法
invokespecial 调用私有方法、构造方法及super关键字调用父类方法
invokevirtual 调用所有虚方法
invokeinterface 调用接口方法
invokedynamic 调用动态方法,如lambda调用
invokehandle (HotSpot VM内部扩展) MethodHandle调用
_fast_invokefinal (HotSpot VM内部扩展) 调用Java方法,x86平台上不可用

下面以Java调用方式的角度来介绍,在介绍调用方式时会简单介绍实现原理,同时也会对介绍到涉及的字节码指令。

invokestatic、invokespecial、invokevirtual与invokeinterface所涉及到的方法调用有一个共同的特点:目标方法一定需要在编译期确定。编译后4种指令的参数都指定了目标方法所在的类和签名以供运行时链接、动态分派。也就是说,JVM在运行时直接解析、链接、动态分派硬编码指定的目标方法。

invokedynamic是为了实现动态类型语言而增加的,invokedynamic指令通过回调机制来获取需要调用的目标方法。即先调用业务自定义回调方法做方法决策(解析、链接),再调用其返回的目标方法

1.1.1 直接调用

在Java方法调用中,直调是除内联之外性能最好的调用方式,只需要一次跳转即可到达目标方法体。这样的方法有:

  • invokestatic调用的静态方法
  • invokespecial调用私有方法、构造方法及 super关键字调用的父类方法
  • invokevirtual调用的final修饰的方法

如上这些方法调用会在类加载的时候就可以把符号引用解析为该方法的直接引用。不需要在运行时去完成。

1.1.2 反射调用

举个反射调用的例子,如下:

// 1.反射得到表示类的Class对象
Class<?> clazz = Class.forName("cn.hotspotvm.User");
// 2.获取静态方法
Method staticMethod = clazz.getDeclaredMethod("staticMethod");
// 3.执行获取到的静态方法
staticMethod.invoke(clazz);

invoke()方法的执行流程如下:

  • 查找方法:当通过 java.lang.reflect.Method 对象调用invoke()方法时,HotSpot首先确认该方法是否存在并可以访问。这包括检查方法的访问权限、方法签名是否匹配等。
  • 安全检查:如果方法是私有的或受保护的,还需要进行访问权限的安全检查。如果当前调用者没有足够的权限访问这个方法,将抛出 IllegalAccessException。
  • 参数转换和适配:invoke()方法接受一个对象实例和一组参数,需要将这些参数转换成对应方法签名所需要的类型,并且进行必要的类型检查和装箱拆箱操作。
  • 方法调用:在小于阈值(默认为15次)的情况下,Java 反射实际上是通过JNI调用到JVM内部的 native 方法。这个 native 方法负责完成真正的动态方法调用。阈值大于15次时,通过ASM框架动态生成类并加载到虚拟机中,这个类会将反射调用变为方法表达式调用模式,也就是虚方法会通过方法表、虚方法表进行查找和调用;对于非虚方法或者静态方法,JVM 会直接调用相应的方法实现。
  • 异常处理:在执行方法的过程中,如果出现任何异常,JVM 会捕获并将异常包装成InvocationTargetException抛出,应用程序可以通过这个异常获取到原始异常信息。
  • 返回结果:如果方法正常执行完毕,invoke()方法会返回方法的执行结果,或者如果方法返回类型是 void,则不返回任何值。

通过这种方式,Java 反射的invoke()方法能够打破编译时的绑定,实现运行时动态调用对象的方法,提供了极大的灵活性,但也带来了运行时性能损耗和安全隐患(如破坏封装性、违反访问控制等)。

通过动态类生成的方式会让反射调用变为直接调用。也就是变成了如下调用表达式:

User.staticMethod() 反射的代价与规避策略:

缺陷 解决方案
性能损耗(比直接调用慢50倍) 缓存反射对象
破坏封装性 严格权限控制 + SecurityManager
兼容性问题 配合接口使用 + 防御性编程
模块化限制(JDK9+) 在module-info中声明opens

1.1.3 MethodHandle调用

之前只依靠符号引用来确定调用的目标方法,现在可通过动态来确定目标方法的机制,称为"方法句柄"(MethodHandle)。举个例子,如下:

class Util {
    public static int compare(int a, int b) {
        return a - b;
    }

    public static void main(String[] args) throws Throwable {
        // lookup()方法在指定的类中查找符合条件的方法句柄
        MethodHandles.Lookup lookup = MethodHandles.lookup();
        MethodType methodType = MethodType.methodType(
                int.class, // 返回类型
                new Class[]{int.class, int.class}); // 参数类型

        // 三个方法findStatic()、findVirtual()、findSpecial()对应了字节码
        // invokestatic、invokevirtual/invokeinterface和invokespecial
        MethodHandle methodHandle = MethodHandles.lookup()
                .findStatic(
                        Util.class, // 方法的引用类
                        "compare", // 方法名称
                        methodType); // 调用者的类
        int result = (int) methodHandle.invoke(2, 3);
        System.out.println(result);
    }
}

生成的字节码如下:

// int result = (int) methodHandle.invoke(2, 3);
invokevirtual #11 <java/lang/invoke/MethodHandle.invoke : (II)I>

在类加载时期,HotSpot会扫描类中定义的全部方法,对方法的字节码进行重写,其中会将调用MethodHandle.invoke()方法的invokevirtual指令重写为invokehandle指令,相关的实现原理也比较复杂,在后面将会专门介绍。

方法句柄可以像传递函数指针一样在Java中传递。

// C/C++实现
void sort(int list[],int size,int (*compare)(int,int))

// Java一般实现
void sort(List list,Comparator c)

// Java的MethodHandle实现
void sort(List list,MethodHandle compare)

在C/C++实例中,因为有函数指针,所以能将函数当参数传递,但是在Java方法中是没有办法这样做的,现在有了MethodHandle后就可以直接将方法当参数传递了。 

1.1.4 Lambda调用

Lambda表达式语言特性引入Java语言后,赋予了Java语言更便捷的函数式编程,举个例子如下:

// 函数式接口是只有一个抽象方法的接口
public interface Runnable {
  public abstract void run();
}
// 一般调用方式
Runnable run1 = new Runnable() {
   @Override
   public void run() {
     System.out.println("匿名内部类");
   }
};
// Lambda调用方式
Runnable run2 = () -> {
   System.out.println("lambda表达式");
};

函数式编程的实现基础就是invokedynam字节码指令,这个invokedynamic是为了实现动态类型语言的,而动态类型语言只有在运行期才能确定方法的接收者类型。

1.1.5 JNI API

在native方法的实现体中调用Java方法

public native void myMethod(String); 

对应的C/C++实现如下:

#include <jni.h>
#include <stdio.h>

JNIEXPORT void JNICALL
Java_MyClass_myMethod(JNIEnv *env, jobject obj, jstring message) {
    // 获取类对象和方法唯一id
    jclass clazz = (*env)->GetObjectClass(env, obj);
    jmethodID methodId = (*env)->GetMethodID(env, clazz, "myMethod", "(Ljava/lang/String;)V");
    // 调用Java方法
    (*env)->CallVoidMethod(env, obj, methodId, message);
}

JNI中定义了许多的函数,通过这些函数来操作Java对象、Java字段以及Java方法等,其实这种调用也是一种模拟调用。

1.1.6 内联

内联简单来说,就是将被调用方法内容复制到调用点中,这样可以省去方法调用相关开销,主要有几个方面:

  • 设置要传递的参数
  • 查找要调用的精确方法
  • 为新的调用栈帧创建新的运行时数据结构
  • 将控制转移到新方法
  • 可能需要向调用者返回一个结果

内联可拓宽其它优化的范围,包括:

  • 逃逸分析
  • 死代码消除
  • 循环展开
  • 锁消除

内联拓宽死代码消除优化可以举个例子,如下:

public static void foo(Object obj){
    if (obj != null) {
        System.out.println("do something");
    }
}

public static void testInline(String[] args) {
    Object obj = null;
    foo(obj);
}

我们已经介绍了许多的调用方法,其中的内联调用开销是最小的,那么哪些调用方式能内联呢,我们通过如下表格总结一下:

调用模式 C1能否内联 C2能否内联 解释执行
直接调用 ×
反射 ×
MethodHandle ×
Lambda ×
动态分派 ×
JNI API × × ×

这个表格中的总结并不严谨,我们将大部分情况下能内联时就打✓,只有少部分情况下能内联时打x。如上解释执行一定不内联是确定的,而JNI API实际上只有很少量的方法可以内联,也就是通过C1或C2编写intrinsic可实现内联优化,而动态分派应该在大多数情况下还是能内联的。

MethodHandle以及反射都可以内联,也就是通过生成类然后加载的方式实现高效调用,对于 JIT 编译器来说,动态加载甚至动态生成的代码都是“源代码”,都可以进行内联优化,这些是静态AOT做不到的。

在Java中,有一些方法总是会被内联:

  • 自动拆箱总被内联

  • 指令指定:-XX:CompileCommand中的inline指令指定的方法(c++)

  • 注解指定:@ForceInline注解的方法

  • 实现intrinsic的方法,如MethodHandle.invoke()是native方法也可以被内联 为了让代码内联,我们可以在写程序时多用如下方式写代码:

  • 使用final修饰符

  • 使用静态方法

  • 方法体实现小

  • 使用@ForceInline注解(JDK 9及以上),仅一个提示而已

  • 剥离复态为单态或双态 关于内联的具体实现在后面将会详细介绍。

1.1.7 动态分派

在C++中,默认的是非虚方法,如果需要动态分派,需要使用关键字virtual明确指出,但是Java方法默认就是虚方法,这也就意味着,Java中的虚方法要比C++中多了许多,虚调用的开销通常要比直接调用和内联大,所以为了优化这些虚调用,Java针对虚调用进行了许多的优化。 关于动态分派举个例子,如下:

class Object{...}

class Person extends Object{
  public void eat(){...}
  public void sleep(){...}
}

class Girl extends Person{
     public String toString(){...}
     public void sing(){...}
}

动态分派表如下图所示。

undefined

如果使用动态分派,那么需要3次间接查找:

(1)从对象找到Klass实例

(2)从Klass实例找到vtable里的Method实例

(3)从Method实例里找到解释或编译执行的入口

由于动态分派会有多个实现版本,所以无法直接进行内联,要想进行内联,需要想一个办法,让调用的目标版本只有一个即可,也就是转换为直接调用。

即时编译器将动态绑定的虚方法转化为直接调用,才能进行方法内联,这样的过程叫虚方法的去虚化。去虚化的方式有三种,如下:

根据字节码生成的IR图确定调用者类型的过程叫基于类型推导的完全去虚化,通过数据流分析推导出调用者的动态类型,从而确定具体的目标方法。 根据JVM中已加载的类找到接口的唯一实现的过程叫基于类层次分析的完全去虚化 根据编译时收集的类型profile,依次匹配方法调用者的动态类型与profile中的类型

参考资料:

  

posted @ 2025-08-19 11:19  CharyGao  阅读(21)  评论(0)    收藏  举报