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;
}
}
这是合法重写,因为 Double 是 Number 的子类。
但如果父类返回 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
1 是 int。int -> 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
因为 String 比 CharSequence 和 Object 都更具体。
三、从 class 文件看方法调用:符号引用
编译器选好方法之后,class 文件里不会直接保存真实内存地址。因为编译时类还没加载,方法也没有运行时地址。
所以 class 文件会在常量池里保存一种"名字线索",叫符号引用。
例如:
text
Customer.isVIP:()Z
Merchant.actionPrice:(DLCustomer;)Ljava/lang/Number;
它们表达的是:
text
哪个类/接口
哪个方法名
参数是什么
返回值是什么
用 javap -v 可以查看 class 文件详细结构:
bash
javap -v SomeClass.class
-v 是 verbose,表示打印详细信息,包括常量池、字段、方法、字节码指令等。
常量池中可能看到:
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 没有声明这些方法,但所有实现类最终都是对象,因此 toString、hashCode、equals 这类 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;
}
}
这段代码不会报错。
因为 Double 是 Number 的子类,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 方法描述符可能并不完全一致,此时编译器会在子类中生成桥接方法,保证父类视角的调用能够正确转发到子类真实实现。