Java 字节码进阶3:面向对象多态在字节码层面的原理?
大家好,我是此林。
Java 字节码进阶1:深入解析 class 文件结构-CSDN博客
Java 字节码进阶2:为什么 switch 比 if-else 高效(阿里代码规约)?-CSDN博客
前两章,我们深入分析了 class 字节码文件的十大组成,
介绍了阿里代码规约中 switch-case 比 if-else 高效的字节码原理。
今天,我们来深入分析 面向对象三大特性(封装、继承、多态)之一:多态 在字节码层面的原理。
1. new 创建一个对象的时候发生了什么?
在 Java 中,我们通常使用 new 关键字去创建一个对象,例如:
public class Hello {public void test() {Hello h = new Hello();}
}
我们使用 javac Hello.java 编译成 Hello.class
然后使用 javap -v Hello 去查看字节码。
这里,贴出了 test 函数的字节码。
我们发现,一个对象的创建需要三条指令:new、dup、invokespecial 调用 <init> 方法
问:什么是 <init> 方法?
这个 <init> 方法,实际上就是 JVM 中类的实例初始化方法,类的构造方法、非静态变量的初始化、对象初始化代码块都会被编译进这个方法中。比如:
public class Hello {// 初始化非静态变量private int a = 10;// 构造器方法public Hello() {}// 对象初始化代码块{int b = 9;} }
在上面 Hello 这个类中,初始化变量、构造方法、非静态代码块在最终编译后都会统一编译进 <init> 方法。
再看这个图:
创建一个对象时,先调用 new 指令,只是创建了一个类实例的引用,并将这个引用压入操作数栈顶。
接着,使用 invokespecial 调用 <init> 方法才真正调用了构造方法。
问:那中间指令 dup 的作用是什么?
答:dup 其实就是 duplicate 的缩写,意思是复制。
invokespecial 指令调用 <init> 后,会消耗 操作数栈顶 的类实例引用。
那如果想要在 invokespecial 调用后栈顶还有指向新建类实例的引用,就需要 事先在 invokespecial 之前复制一份类对象实例的引用;
否则,调用完 <init> 之后就再也找不到刚刚创建的对象的引用了。
刚刚我们介绍了用 new 关键字去创建一个对象,底层字节码的流程。
我们提到了 <init> 方法是类实例的初始化方法,与之对应的还有个 <clinit> 方法。
<clinit> 方法是类的静态初始化方法,类的静态代码块、静态变量初始化都会被编译进这个方法中,比如:
public class Hello {// 初始化静态变量private static int a = 10;// 静态代码块static {int b = 9;} }
需要注意的是,<clinit> 的调用在四个字节码指令会触发:
new
getstatic
putstatic
invokestatic
比如下面的场景:
创建类对象的实例
访问类的静态方法或静态字段
初始化某个类的子类
2. 方法调用字节码指令
JVM 的方法调用指令都以 invoke 开头,比如:
invokestatic:调用静态方法
invokevirtual:调用实例对象的非私有方法(支持多态的关键)
invokeinterface:调用接口方法(支持多态的关键)
invokespecial:调用实例对象的私有方法、构造器方法、super关键字调用父类的实例方法
invokedynamic:调用动态方法
2.1. invokestatic 指令
invokestatic 用于调用静态方法,也就是 static 修饰的方法。
它要调用的方法在 javac 编译器已经确定,运行期不会改变,属于静态绑定。所以调用 invokestatic 不需要将对象加载到操作数栈,只需要方法参数入栈即可。
这个我们在 Java 字节码进阶2:为什么 switch 比 if-else 高效(阿里代码规约)?-CSDN博客 已经说过:
非静态方法,比如:
public void test(int name) {// ...
}
它方法参数里看起来只有一个参数 name,实际上还会传入一个隐藏的 this 参数(当前实例)。
2.2. invokevirtual 指令 (多态在字节码层面的原理)
invokevirtual 指令用于调用普通实例方法,它调用的目标方法要在运行时才能更具对象实际的类型确定,在编译期无法知道,类似 C++ 中的虚方法。
在调用 invokevirtual 之前,会把 对象引用(this)、方法参数压入操作数栈,
调用结束 对象引用(this)、方法参数出栈,
如果方法有返回值,返回值会进入操作数栈顶。
我们来看一个案例:
class Animal {public void speak() {System.out.println("Animal speaks");}
}class Dog extends Animal {@Overridepublic void speak() {System.out.println("Dog barks");}
}public class Main {public static void main(String[] args) {Animal a = new Dog(); // 向上转型a.speak(); // 多态调用}
}
我们使用 javac Main.java 编译后,使用 javap -c Main 反编译字节码。
可以看到,编译器只知道变量 a
的静态类型是 Animal
,所以字节码里写的是调用 Animal.speak()
。
运行时 JVM 会查 a
的真实类型(这里是 Dog
),通过 虚方法表 vtable 跳转到 Dog.speak()
。
结论:
-
在 源代码层面:我们写的是
a.speak()
。 -
在 字节码层面:表现为
invokevirtual
指令。 -
在 运行时层面:依靠虚方法表完成 动态分派,最终执行
Dog.speak()
。
2.3. invokeinterface 指令 (多态在字节码层面的原理)
vtable 虚方法表用于支持类的继承的多态,而 itable 用于支持多接口的实现。
问:vtable 和 itable 的区别?
答:
vtable(虚方法表)
用于 类的继承体系,存储类及其父类的虚方法实现。invokevirtual
就是通过 vtable 做动态分派。itable(接口方法表)
用于 接口方法的实现查找,当字节码里遇到invokeinterface
指令时,会通过 itable 来找到具体的实现。
问: 为什么需要 itable?
答:因为一个类可能实现 多个接口,而接口的方法签名可能相同,但来源不同。
如果只用 vtable,无法区分同一个方法属于哪个接口。
因此 JVM 在类加载时,会为每个接口建立一个 itable entry,里面保存该接口的方法到类中具体实现方法的映射。
案例:
interface Speakable {void speak();
}class Dog implements Speakable {@Overridepublic void speak() {System.out.println("Dog barks");}
}public class Main {public static void main(String[] args) {Speakable s = new Dog();s.speak(); // 接口多态调用}
}
javap -c Main
结果:
public static void main(java.lang.String[]);Code:0: new #2 // class Dog3: dup4: invokespecial #3 // Method Dog."<init>":()V7: astore_18: aload_19: invokeinterface #4, 1 // InterfaceMethod Speakable.speak:()V14: return
注意这里不是 invokevirtual
,而是 invokeinterface
。
Dog 加载后,JVM 内部大致结构:
Dog.klass├── vtable│ [0] -> Dog.speak() (覆盖 Object 的 toString() 等也会在这)│├── itable│ [Speakable 接口]│ method[0] -> Dog.speak()
当执行:
Speakable s = new Dog();
s.speak();
-
字节码是:
invokeinterface #4, 1
-
JVM 执程:
-
找到
s
的实际类Dog
-
去
Dog
的 itable 查Speakable
这个接口的 entry -
在 entry 里查
speak()
的实现 -
跳转执行
Dog.speak()
-
所以总的来说,itable 会比 vtable 多一层,主要是因为在 Java 里,只支持单继承,但支持实现多个接口。
当然,感兴趣的同学还可以使用 HotSopt Debugger 去查看更深入的内容。
2.4. invokedynamic 指令
invokedynamic
出现的背景:
在 JDK 7 (JSR 292) 中引入,它的设计目标是:
-
给 JVM 加入一个 动态调用机制,让 动态语言(Groovy、JRuby、Jython 等)能更高效运行在 JVM 上。
那什么是动态语言呢?它主要有两个特点:
-
弱类型 / duck typing:方法调用时不依赖静态类型检查,而是运行时决定。
-
方法、属性可以在运行时动态改变。
传统的
invokevirtual
/invokeinterface
在编译期就要决定目标方法签名,而invokedynamic
则允许 方法绑定延迟到运行时(动态绑定),由引导方法 bootstrap method 决定。
所以, invokedynamic 指令初衷是为了支持动态语言(弱类型为主)而设计的。
Java 本身是强类型语言,但从 Java 8 开始,invokedynamic
被大量用在 语法糖的底层实现,而不是仅仅是 “弱类型”,比如:
Lambda 表达式。通过 invokedynamic
调用 LambdaMetafactory.metafactory
生成 Function
对象。
今天的分享就到这里了,相信你一定有所收获!
我是此林,关注我吧,带你看不一样的世界!