0x05 深入了解JVM虚拟机(JVM方法调用 -Ⅰ)

内容来源:time.geekbang.org/column/arti...

Java 方法调用看起来只是一次普通的 obj.method(arg),但背后其实经历了源码、编译器、class 文件和 JVM 运行时多个层次。

源码层面要区分重载和重写;编译期会确定重载目标;class 文件中会用符号引用记录方法调用;JVM 运行时再把符号引用解析成直接引用,并根据实际对象类型完成动态分派。某些特殊情况下,编译器还会生成桥接方法来维持 Java 的重写语义。

本文就沿着这条主线: 源码规则 -> 编译期选择 -> class 文件记录 -> JVM 解析与分派 -> 桥接方法适配 来梳理 Java 方法调用从源码到 JVM 执行的完整过程。

一、从 Java 源码看方法调用:重载与重写

源码层最重要的两个概念是:重载 overload重写 override

重载指的是:同一个类中,或者子类继承来的方法集合中,存在多个方法名相同,但参数列表不同的方法。

java 复制代码
class Demo {
    void test(int x) {}

    void test(String x) {}

    void test(int x, String y) {}
}

这三个 test 构成重载。参数列表不同可以是参数个数不同、参数类型不同,也可以是参数顺序不同。

但返回类型不能用于区分重载:

java 复制代码
class Demo {
    int get() {
        return 1;
    }

    String get() {
        return "hello";
    }
}

这段代码无法通过编译。虽然返回类型不同,但两个方法都是 get(),方法名和参数列表完全一样,Java 编译器会认为这是重复定义。

重写则发生在父子类之间:子类定义了一个和父类非私有实例方法方法名相同、参数列表相同,并且返回类型、访问权限等满足规则的方法。

java 复制代码
class Parent {
    void say() {
        System.out.println("Parent");
    }
}

class Child extends Parent {
    @Override
    void say() {
        System.out.println("Child");
    }
}

调用:

java 复制代码
Parent p = new Child();
p.say();

输出是:

text 复制代码
Child

虽然变量 p 的声明类型是 Parent,但运行时实际对象是 Child。对于重写方法,Java 会根据运行时对象的真实类型决定调用哪个实现。

返回类型对二者的影响:

  • 重载:返回类型不能作为区分依据。
  • 重写:返回类型必须兼容,可以相同,也可以是协变返回类型。

例如:

java 复制代码
class A {
    Number get() {
        return 1;
    }
}

class B extends A {
    @Override
    Double get() {
        return 1.0;
    }
}

这是合法重写,因为 DoubleNumber 的子类。

但如果父类返回 void,子类改成返回 Boolean,那既不是重载,也不是合法重写,而是编译错误。

二、从编译期看方法调用:重载为什么是静态绑定

重载方法的选择,在编译期就已经完成。

java 复制代码
class Demo {
    static void call(Object obj) {
        System.out.println("Object");
    }

    static void call(String str) {
        System.out.println("String");
    }

    public static void main(String[] args) {
        Object x = "hello";
        call(x);
    }
}

输出是:

text 复制代码
Object

虽然 x 实际指向 String 对象,但它的声明类型是 Object。编译器选择重载方法时,看的是参数的声明类型,不是运行时实际类型。

所以 call(x) 在编译期就被确定为:

java 复制代码
call(Object)

这就是为什么重载也叫静态绑定(编译时多态)

Java 编译器选择重载方法时,会分三个阶段:

  • 第一阶段:不考虑装箱/拆箱,不考虑可变参数。
  • 第二阶段:允许装箱/拆箱,不考虑可变参数。
  • 第三阶段:允许装箱/拆箱,也允许可变参数。

例如:

java 复制代码
class Demo {
    static void f(long x) {
        System.out.println("long");
    }

    static void f(Integer x) {
        System.out.println("Integer");
    }

    static void f(int... x) {
        System.out.println("int...");
    }

    public static void main(String[] args) {
        f(1);
    }
}

输出是:

text 复制代码
long

1intint -> long 是基本类型拓宽转换,第一阶段就能匹配。int -> Integer 属于装箱,要到第二阶段;int... 属于可变参数,要到第三阶段。第一阶段已经找到 f(long),后面就不会再看。

同一阶段如果有多个方法都能匹配,编译器会选择更具体的那个:

java 复制代码
class Demo {
    static void f(Object x) {
        System.out.println("Object");
    }

    static void f(CharSequence x) {
        System.out.println("CharSequence");
    }

    static void f(String x) {
        System.out.println("String");
    }

    public static void main(String[] args) {
        f("hello");
    }
}

输出:

text 复制代码
String

因为 StringCharSequenceObject 都更具体。

三、从 class 文件看方法调用:符号引用

编译器选好方法之后,class 文件里不会直接保存真实内存地址。因为编译时类还没加载,方法也没有运行时地址。

所以 class 文件会在常量池里保存一种"名字线索",叫符号引用

例如:

text 复制代码
Customer.isVIP:()Z
Merchant.actionPrice:(DLCustomer;)Ljava/lang/Number;

它们表达的是:

text 复制代码
哪个类/接口
哪个方法名
参数是什么
返回值是什么

javap -v 可以查看 class 文件详细结构:

bash 复制代码
javap -v SomeClass.class

-vverbose,表示打印详细信息,包括常量池、字段、方法、字节码指令等。

常量池中可能看到:

text 复制代码
#16 = InterfaceMethodref #27.#29 // Customer.isVIP:()Z
#22 = Methodref          #1.#33  // Merchant.actionPrice:()D

这里的 #16#22 不是固定编号,只是当前 class 文件常量池里的索引。换一个类、改一点代码、换一次编译,编号都可能变化。

InterfaceMethodref 表示接口方法符号引用。

Methodref 表示普通类方法符号引用,也就是非接口符号引用。

方法描述符中:

text 复制代码
()Z

表示无参数、返回 boolean

常见编码包括:

text 复制代码
I = int
J = long
D = double
Z = boolean
V = void
Ljava/lang/String; = String

所以:

text 复制代码
Customer.isVIP:()Z

就是:

java 复制代码
boolean isVIP()

符号引用和直接引用区分:

  • 符号引用:class 文件中的名字线索。
  • 直接引用:JVM 运行时能直接定位目标的引用。

也就是说,class 文件里先记"我要找谁",JVM 运行起来之后,再把这个名字解析成可执行目标。

不过,不是 JVM 一启动就解析所有符号引用。更准确地说,是类被加载、链接、解析,或者某个引用第一次被使用时,相关符号引用才会被转换成直接引用。

四、从 JVM 运行期看方法调用:解析与分派

JVM 运行期要把符号引用解析为具体目标。

对于非接口符号引用 Methodref,假设它指向类 C 的方法 m,JVM 查找顺序大致是:

text 复制代码
1. 先在 C 自己里面找。
2. 如果没有,在 C 的父类中继续找,直到 Object。
3. 如果还没有,在 C 实现的接口中找非 private、非 static 方法。

例如:

java 复制代码
class A {
    void m() {
        System.out.println("A.m");
    }
}

class C extends A {
}

如果符号引用是:

text 复制代码
C.m:()V

虽然 C 自己没有 m,但父类 A 有,所以可以解析到 A.m()

接口默认方法也可能参与解析:

java 复制代码
interface I {
    default void m() {
        System.out.println("I.m");
    }
}

class C implements I {
}

如果 C 和父类链上都没有 m,JVM 可以去接口中找 default 方法。这个方法必须是非私有、非静态的,因为它要作为实例方法被调用。

对于接口符号引用 InterfaceMethodref,假设它指向接口 I,查找顺序是:

text 复制代码
1. 先在 I 自己里面找。
2. 如果没有,在 Object 的 public 实例方法中找。
3. 如果还没有,在 I 的父接口中找。

为什么接口引用要去 Object 里找?

因为接口变量也可以调用 Object 的公共方法:

java 复制代码
interface I {}

class C implements I {}

public class Demo {
    public static void main(String[] args) {
        I x = new C();
        x.toString();
        x.hashCode();
        x.equals(new C());
    }
}

虽然 I 没有声明这些方法,但所有实现类最终都是对象,因此 toStringhashCodeequals 这类 Object 公共实例方法仍然可调用。

重写的动态绑定也发生在运行期。

java 复制代码
class Parent {
    void say() {
        System.out.println("Parent");
    }
}

class Child extends Parent {
    @Override
    void say() {
        System.out.println("Child");
    }
}

Parent p = new Child();
p.say();

编译期,p 的声明类型是 Parent,所以编译器生成的是父类视角的方法调用。

运行期,实际对象是 Child,JVM 会根据真实对象类型进行动态分派,最终调用 Child.say()

所以:

  • 重载:编译期看参数声明类型。
  • 重写:运行期看对象实际类型。

五、桥接方法:Java 语义和 JVM 规则之间的适配层

有些情况下,Java 语言认为是重写,但从 JVM 方法描述符角度看,方法并不完全一样。

典型场景有两个:

  • 协变返回类型
  • 泛型擦除

先看协变返回类型:

java 复制代码
interface Customer {
    boolean isVIP();
}

class Merchant {
    public Number actionPrice(double price, Customer customer) {
        return price;
    }
}

class NaiveMerchant extends Merchant {
    @Override
    public Double actionPrice(double price, Customer customer) {
        return price;
    }
}

这段代码不会报错。

因为 DoubleNumber 的子类,Java 允许子类重写方法时返回更具体的类型。

Java 源码层面:

text 复制代码
NaiveMerchant.actionPrice 重写 Merchant.actionPrice

但 JVM 方法描述符层面:

text 复制代码
Merchant.actionPrice:(DLCustomer;)Ljava/lang/Number;
NaiveMerchant.actionPrice:(DLCustomer;)Ljava/lang/Double;

返回类型不同。

当我们写:

java 复制代码
Merchant m = new NaiveMerchant();
Number n = m.actionPrice(100.0, customer);

编译期,m 的声明类型是 Merchant,所以编译器生成的是父类视角的方法调用:

text 复制代码
Merchant.actionPrice(...):Number

运行期,实际对象是 NaiveMerchant。JVM 要在子类里找到能接住这个 Number 版本调用的方法。

于是编译器会在 NaiveMerchant.class 中生成一个桥接方法:

java 复制代码
public Number actionPrice(double price, Customer customer) {
    return this.actionPrice(price, customer);
}

它内部再调用真正的子类方法:

java 复制代码
public Double actionPrice(double price, Customer customer) {
    return price;
}

执行链可以理解为:

text 复制代码
m.actionPrice(...)
  -> 编译期目标是 Merchant.actionPrice(...):Number
  -> 运行期实际对象是 NaiveMerchant
  -> 找到子类中的桥接方法 actionPrice(...):Number
  -> 桥接方法转调真正的 actionPrice(...):Double
  -> 返回 Double,并作为 Number 使用

桥接方法为什么返回 Number

因为它要匹配父类方法的描述符。父类视角要找的是:

text 复制代码
actionPrice(...):Number

所以桥接方法必须也是 Number 版本,才能接住父类视角的调用。

桥接方法为什么生成在子类?

因为运行期实际对象是子类,JVM 要在子类中找到能覆盖父类方法形态的入口。桥接方法的作用就是在子类中补出这个入口。

泛型擦除也会导致桥接方法:

java 复制代码
class Parent<T> {
    T get() {
        return null;
    }
}

class Child extends Parent<String> {
    @Override
    String get() {
        return "hello";
    }
}

Java 语言层面,Child.get() 重写了 Parent.get()

但泛型擦除后,T 会变成 Object

text 复制代码
Parent.get:()Ljava/lang/Object;
Child.get:()Ljava/lang/String;

于是编译器会在 Child 中生成:

java 复制代码
Object get() {
    return this.get();
}

它接住父类视角的 get():Object 调用,再转到真正的 get():String

六、总结

Java 方法调用可以按五层理解:

  • 源码层:区分重载和重写。
  • 编译期:重载由编译器根据参数声明类型决定。
  • class 文件层:调用目标记录为常量池中的符号引用。
  • JVM 运行期:符号引用被解析成直接引用,重写通过动态分派实现。
  • 适配层:桥接方法弥合 Java 语义和 JVM 描述符之间的差异。

本文从源码、编译期、class 文件和 JVM 运行期几个层面梳理了 Java 方法调用的完整过程。重载由编译器在编译期根据参数声明类型确定,因此属于静态绑定;重写则要等到运行期根据对象的实际类型进行动态分派,因此属于动态绑定。编译后的 class 文件不会直接保存方法的真实地址,而是通过常量池中的符号引用记录调用目标,JVM 在运行时再将其解析为直接引用。对于协变返回类型、泛型擦除等场景,Java 语言层面认为是重写,但 JVM 方法描述符可能并不完全一致,此时编译器会在子类中生成桥接方法,保证父类视角的调用能够正确转发到子类真实实现。

相关推荐
宋哥转AI1 小时前
学了Spring AI Graph再看LangGraph,发现API几乎一模一样
java·人工智能·agent
AskHarries1 小时前
Workspace:文件系统、项目上下文和执行边界
java·服务器·前端
摇滚侠1 小时前
JavaWeb 全套教程 Servlet 66-74
java·servlet·tomcat·intellij-idea·jar
Solis程序员2 小时前
滑动窗口热键探测与三级缓存设计
java·spring·缓存
好家伙VCC2 小时前
区块链双向支付通道实战:从签名到结算
java·后端·区块链·asp.net
ss2732 小时前
【入门OJ题解】分苹果问题(Python/Java/C 实现)
java·c语言·python
weikecms2 小时前
美团霸王餐报名API接口
java·开发语言
李白的天不白2 小时前
配置mysql密码
java
何中应2 小时前
Nexus如何上传JAR包
java·maven·jar