本文作者:

CodeStats Java Web 造轮者,自研 IoC 与 Tomcat,手写底层打通整套体系。硬核源码解析 + 全栈实战,用代码诠释框架底层原理,以自研驱动技术成长。

📌 从字节码指令看面向对象底层实现,这篇文章会彻底改变你对Java执行机制的理解。欢迎点赞、收藏、关注


📖 目录

  1. 从一条指令说起:JVM类加载五阶段宏观纵览

  2. 问题一:类初始化 vs 对象初始化——从<clinit><init>指令看区别

  3. 问题二:继承是如何实现的?——从字节码指令看父类优先、虚方法调用

  4. 问题三:方法虚拟表(vtable)是什么?——多态的底层指令支持

  5. 问题四:“链接”到底做什么?会加载所有依赖类吗?——从解析指令看延迟加载

  6. 问题五:JVM程序计数器 vs CPU程序计数器——为什么指令地址不是简单+1?

  7. 问题六:main方法由哪个线程执行?——从invokestatic看主线程启动

  8. 程序执行的内核:栈帧压栈/出栈,返回指令如何实现方法退出?

  9. 一张图回顾完整指令流

  10. 总结:从指令理解面向对象——你写的每一行代码都对应着JVM的精密协作


1. 从一条指令说起:JVM类加载五阶段宏观纵览

当你写下 new Person(),JVM并不是直接执行 Person 的构造方法。它背后经历了:

加载 → 验证 → 准备 → 解析 → 初始化 → 创建对象 → 执行 <init>

其中 验证+准备+解析 = 链接
核心思想:按需加载、延迟解析、父类优先、指令驱动。

今天我们从字节码指令的角度,重新理解面向对象的两大核心:初始化 和 继承


2. 问题一:类初始化 vs 对象初始化——从<clinit><init>指令看区别

先说结论:类初始化 ≠ 对象初始化

对比项 类初始化 对象初始化
执行的方法 <clinit>() <init>()
触发指令 newgetstaticputstaticinvokestatic 等主动使用类 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:

  1. 从栈顶拿到实际对象的引用

  2. 根据对象头中的类型指针,找到方法区中该类的虚拟表

  3. 根据方法在虚拟表中的索引,取出方法入口地址

  4. 跳转执行

例:Father 有方法 say() 和 eat(),虚拟表索引:say→0,eat→1。
Son 重写了 say(),则 Son 的虚拟表中索引0指向 Son.say,索引1仍指向 Father.eat。

为什么虚拟表能实现多态?

因为 invokevirtual 指令不绑定到具体类,而是运行时根据对象实际类型查表
这就是动态分派(晚期绑定)的底层实现。


5. 问题四:“链接”到底做什么?会加载所有依赖类吗?——从解析指令看延迟加载

链接 = 验证 + 准备 + 解析。

  • 验证:检查字节码合法性,比如 invokevirtual 的参数类型是否匹配。

  • 准备:静态变量分配内存并设默认零值。

  • 解析:将常量池中的符号引用(如 #2 表示 Father.say)转换为直接引用(实际方法地址)。

关键:解析阶段并不会加载所有依赖类
JVM采用懒加载:只有当执行到 newinvokestaticinvokevirtual 等指令时,才会触发相关类的加载、验证、准备、解析(如果有必要)。

例如:

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(固定指令长度)
跳转修改 被分支、循环、异常、gotoreturn 等直接覆盖 被 jmpcall 等修改

为什么不是简单+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 时:

    1. 创建被调用方法的栈帧

    2. 压入当前线程的虚拟机栈

    3. PC 指向被调用方法的第一条指令

  • 方法返回时,执行 ireturnareturnreturn 等返回指令:

    1. 将返回值(如果有)压入调用者的操作数栈

    2. 弹出当前栈帧

    3. 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 函数式编程的底层实现!

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐