Java继承与多态的底层机制:虚方法表如何工作?栈帧如何传递?
本文作者:
CodeStats Java Web 造轮者,自研 IoC 与 Tomcat,手写底层打通整套体系。硬核源码解析 + 全栈实战,用代码诠释框架底层原理,以自研驱动技术成长。
📌 从字节码指令看面向对象底层实现,这篇文章会彻底改变你对Java执行机制的理解。欢迎点赞、收藏、关注!
📖 目录
-
从一条指令说起:JVM类加载五阶段宏观纵览
-
问题一:类初始化 vs 对象初始化——从
<clinit>和<init>指令看区别 -
问题二:继承是如何实现的?——从字节码指令看父类优先、虚方法调用
-
问题三:方法虚拟表(vtable)是什么?——多态的底层指令支持
-
问题四:“链接”到底做什么?会加载所有依赖类吗?——从解析指令看延迟加载
-
问题五:JVM程序计数器 vs CPU程序计数器——为什么指令地址不是简单+1?
-
问题六:main方法由哪个线程执行?——从
invokestatic看主线程启动 -
程序执行的内核:栈帧压栈/出栈,返回指令如何实现方法退出?
-
一张图回顾完整指令流
-
总结:从指令理解面向对象——你写的每一行代码都对应着JVM的精密协作
1. 从一条指令说起:JVM类加载五阶段宏观纵览
当你写下 new Person(),JVM并不是直接执行 Person 的构造方法。它背后经历了:
加载 → 验证 → 准备 → 解析 → 初始化 → 创建对象 → 执行 <init>
其中 验证+准备+解析 = 链接。
核心思想:按需加载、延迟解析、父类优先、指令驱动。
今天我们从字节码指令的角度,重新理解面向对象的两大核心:初始化 和 继承。
2. 问题一:类初始化 vs 对象初始化——从<clinit>和<init>指令看区别
先说结论:类初始化 ≠ 对象初始化
| 对比项 | 类初始化 | 对象初始化 |
|---|---|---|
| 执行的方法 | <clinit>() |
<init>() |
| 触发指令 | new、getstatic、putstatic、invokestatic 等主动使用类时 |
new 指令后,调用 <init> |
| 执行次数 | 整个JVM生命周期只一次 | 每次 new 一次 |
| 主要内容 | 静态变量赋值、静态代码块 | 实例变量赋值、代码块、构造器 |
| 父类顺序 | <clinit> 先父后子 |
<init> 先父后子 |
从字节码指令看区别
用下面这个类:
java
class Father {
static int a = 10;
int b = 20;
Father() { b = 30; }
}
类初始化时,JVM会生成 <clinit> 方法,字节码类似:
assembly
0: bipush 10 2: putstatic #2 // 给 Father.a 赋值
对象初始化时,new 指令后会自动调用 <init> 方法,字节码类似:
assembly
0: aload_0 1: invokespecial #3 // 调用 Object.<init> 4: aload_0 5: bipush 20 7: putfield #4 // 给 b 赋值为 20 10: aload_0 11: bipush 30 13: putfield #4 // 构造器里 b = 30
关键:
<clinit>由JVM在类加载的初始化阶段调用一次;<init>由new指令触发,每次创建对象都会执行。
3. 问题二:继承是如何实现的?——从字节码指令看父类优先、虚方法调用
继承的第一个体现:<clinit> 和 <init> 的父类优先
JVM保证:
-
执行子类的
<clinit>前,先执行父类的<clinit> -
执行子类的
<init>前,第一条指令必须是invokespecial调用父类的<init>
子类构造器字节码示例(子类没有显式调用super):
assembly
0: aload_0 1: invokespecial #1 // 调用 Father.<init> 4: ... 子类自己的实例初始化
继承的第二个体现:方法调用指令的差异
Java中有三种方法调用指令:
-
invokestatic:调用静态方法(不涉及继承多态) -
invokespecial:调用构造器、私有方法、父类方法(静态绑定) -
invokevirtual:调用实例方法(动态分派,支持多态)
当我们写 Father f = new Son(); f.say();,f.say() 编译成 invokevirtual #2(#2 指向 Father.say 方法符号)。
运行时,JVM不会直接调用 Father.say,而是通过虚拟表找到实际对象 Son 的 say 方法。
4. 问题三:方法虚拟表(vtable)是什么?——多态的底层指令支持
虚拟表定义
方法虚拟表(vtable) 是每个类在方法区中存储的一个数组,数组元素是方法入口地址(直接引用)。
它记录了该类所有可被子类重写的实例方法的最终实现地址。
虚拟表如何支持 invokevirtual
当执行 invokevirtual 时,JVM:
-
从栈顶拿到实际对象的引用
-
根据对象头中的类型指针,找到方法区中该类的虚拟表
-
根据方法在虚拟表中的索引,取出方法入口地址
-
跳转执行
例:Father 有方法 say() 和 eat(),虚拟表索引:say→0,eat→1。
Son 重写了 say(),则 Son 的虚拟表中索引0指向 Son.say,索引1仍指向 Father.eat。
为什么虚拟表能实现多态?
因为 invokevirtual 指令不绑定到具体类,而是运行时根据对象实际类型查表。
这就是动态分派(晚期绑定)的底层实现。
5. 问题四:“链接”到底做什么?会加载所有依赖类吗?——从解析指令看延迟加载
链接 = 验证 + 准备 + 解析。
-
验证:检查字节码合法性,比如
invokevirtual的参数类型是否匹配。 -
准备:静态变量分配内存并设默认零值。
-
解析:将常量池中的符号引用(如
#2表示Father.say)转换为直接引用(实际方法地址)。
关键:解析阶段并不会加载所有依赖类。
JVM采用懒加载:只有当执行到 new、invokestatic、invokevirtual 等指令时,才会触发相关类的加载、验证、准备、解析(如果有必要)。
例如:
java
public class Main {
public static void main(String[] args) {
if (false) {
new RareClass(); // 永远不会执行,RareClass 不会被加载
}
}
}
指令视角:
new RareClass字节码虽然存在,但因为条件永远 false,JVM 不会执行这条指令,所以 RareClass 的类加载永远不会发生。
6. 问题五:JVM程序计数器 vs CPU程序计数器——为什么指令地址不是简单+1?
| 对比项 | JVM程序计数器 | CPU程序计数器 |
|---|---|---|
| 存储内容 | 当前执行字节码指令的偏移量(0, 1, 3 ...) | 当前执行机器指令的内存地址 |
| 更新方式 | 根据当前指令长度(1/2/3字节)累加 | 大多数架构自动+1(固定指令长度) |
| 跳转修改 | 被分支、循环、异常、goto、return 等直接覆盖 |
被 jmp、call 等修改 |
为什么不是简单+1?
因为JVM字节码指令长度可变:
-
iconst_1→ 1字节 -
bipush 100→ 2字节 -
invokestatic #2→ 3字节
如果统一 +1,执行完 bipush 100 后 PC 会指向 100 指令的第二个字节,那根本不是合法指令。
正确的做法:PC += 当前指令长度。
解释器执行完一条指令后,根据当前指令的操作码(opcode)查到长度,然后累加。
7. 问题六:main方法由哪个线程执行?——从invokestatic看主线程启动
-
JVM启动后,创建一个主线程(Main Thread)
-
主线程调用
invokestatic main执行main方法 -
main方法栈帧被压入主线程的虚拟机栈
assembly
public static void main(String[] args) Code: 0: getstatic ... // 第一条指令
此时,程序计数器指向 0,主线程开始执行。
JVM进程退出条件:所有非守护线程执行完毕。如果 main 方法创建了其他用户线程且未结束,JVM不会退出。
8. 程序执行的内核:栈帧压栈/出栈,返回指令如何实现方法退出?
栈帧结构
每个方法调用对应一个栈帧,包含:
-
局部变量表
-
操作数栈
-
动态链接(指向常量池当前方法的引用)
-
返回地址
指令循环(自洽模型)
text
PC -> 读取指令 -> 执行(操作数栈压/弹) -> 更新PC(+指令长度) -> 下一指令
方法调用的压栈与出栈
-
调用
invokespecial/invokevirtual/invokestatic时:-
创建被调用方法的栈帧
-
压入当前线程的虚拟机栈
-
PC 指向被调用方法的第一条指令
-
-
方法返回时,执行
ireturn、areturn、return等返回指令:-
将返回值(如果有)压入调用者的操作数栈
-
弹出当前栈帧
-
PC 恢复为调用者的返回地址(即调用指令的下一条指令的地址)
-
返回指令是如何知道返回地址的?
调用方法时,JVM 会将调用指令的下一条指令的 PC 值保存在被调用者栈帧的“返回地址”字段。return指令会读取这个地址并赋值给 PC。
这就是压栈出栈实现方法嵌套、递归、返回的底层机制。
9. 一张图回顾完整指令流
10. 总结:从指令理解面向对象——你写的每一行代码都对应着JVM的精密协作
-
类初始化:
<clinit>只一次,由主动使用类的指令触发 -
对象初始化:
<init>多次,由new+invokespecial完成 -
继承:父类的
<clinit>和<init>先于子类执行,通过invokespecial保证 -
多态:
invokevirtual+ 虚拟表 = 动态分派 -
链接:解析指令延迟加载依赖类,而不是一次性全加载
-
程序计数器:按指令长度灵活累加,不是简单+1
-
main线程:由JVM启动,调用
invokestatic main执行 -
方法调用:栈帧压栈/出栈,返回指令恢复PC
面向对象的三大特性(封装、继承、多态)在JVM指令层面都有精确的对应实现。
理解这些,你就等于拿到了阅读任何Java框架源码、解决诡异线上问题的钥匙。
👍 如果这篇文章帮你从指令层面打通了JVM,请点赞让更多人看到
📥 收藏起来,面试前翻一翻
👀 关注我,一起死磕底层原理,不辜负每一行代码
下一篇预告: 从 invokedynamic 看 Lambda 表达式和 Java 函数式编程的底层实现!
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐



所有评论(0)