前言

本文旨在记录近期研读Java源码的学习心得与疑难问题。由于个人理解水平有限,文中内容难免存在疏漏,恳请读者不吝指正。


Java程序能够运行的核心逻辑

在 Java 虚拟机(JVM)中,.class 文件(静态的字节码)向 Klass 模型(内存中的元数据)的转化,是 Java 程序能够运行的核心逻辑。下面一步步探索这个从“文件”到“运行时对象”的蜕变之旅。

这个过程通常包括以下几个核心环节:

  1. 加载阶段(Loading) :类加载器(ClassLoader)如何读取二进制流并生成内存中的 C++ 数据结构。

  2. Klass 模型结构 :在元空间(Metaspace)中,InstanceKlass 到底长什么样?它如何存储方法、字段和常量池?

  3. 链接与初始化(Linking & Initialization) :验证(Verification)、准备(Preparation)和解析(Resolution)是如何把抽象的符号引用变成具体的内存地址的。

  4. OOP-Klass 模型体系 体系:理解为什么 JVM 需要区分 Oop(普通对象指针)和 Klass

从“静态”到“动态”的搬运工

当你在代码中引用一个类时,JVM 首先需要找到对应的 .class 文件。这个文件并不是直接被“读入”内存就完事了,JVM 会在内部创建一个名为 InstanceKlass 的 C++ 对象来代表这个类。

在 Java 层面,我们通过 java.lang.Class 对象来访问类信息;但在 JVM 内部(HotSpot),真正干活的是底层 C++ 的 Klass 对象。

Klass模型

.class 文件转化成内存里的 Klass 模型时机

JVM采用了**懒加载(Lazy Loading)**的策略来完成Klass 模型的转换。并不是 JVM 一启动就会把磁盘上成千上万个 .class 文件都转化成 Klass 模型,那样既浪费内存又拖慢启动速度。

只有当一个类“不得不”被使用时,JVM 才会启动这个转化过程。

转化发生的时机

在 JVM 的规范中,这被称为**初始化(Initialization)**的触发条件。常见的场景包括:

  1. 使用 new 关键字:实例化对象时。
  2. 访问静态成员:读取或设置一个类的静态字段(被 final 修饰的常量除外,它们有时在编译期就解析了)。
  3. 调用静态方法:比如运行 main 方法所在的类。
  4. 反射调用:例如执行 Class.forName("com.example.Test")

从文件到内存:Klass 模型的构造

一旦触发了加载,JVM 就会读取 .class 文件的二进制流。这个过程非常像“解压缩并重组”:

  • 常量池(Constant Pool).class 文件里的符号引用(字符串形式的名字)会被放入内存,并逐步解析为真实的内存地址。
  • 方法与字段:类定义的方法字节码、字段描述符等会被存入 InstanceKlass 数据结构中。
  • 元空间(Metaspace):这些 Klass 数据结构最终存放的地方是本地内存(Native Memory)中的元空间,而不是 Java 堆。

为了有条理地深入研究我们可以从以下几个具体的维度探索

  • 内存布局(InstanceKlass 结构) 🏛️:深入查看一个 InstanceKlass 在 C++ 层面到底包含哪些字段(如虚函数表 vtable、接口表 itable)。
  • 符号引用解析(Constant Pool Cache) 🔗:研究 JVM 是如何把字节码里的“字符串名字”变成真正能跳转执行的“内存地址”的。
  • OOP-Klass 的双亲委派与镜像 🪞:探讨为什么我们在 Java 里看到的 java.lang.Class 对象和底层的 Klass 模型不是同一个东西,它们之间如何关联。

InstanceKlass 的内部构成

当 JVM 加载一个 .class 文件时,它会在**元空间(Metaspace)**中创建一个 InstanceKlass 对象。你可以把它想象成一个“上帝视角”的模板,里面记录了类的一切元数据。

在 C++ 源码级别,InstanceKlass 继承自 Klass,它的布局非常紧凑且复杂。为了方便理解,我们可以将其拆解为几个关键部分:

组成部分 描述
Header 包含类的大小、状态(是否已初始化)、访问标志(public/private)等。
Constant Pool 运行时常量池的指针,链接回类中使用的各种符号引用。
Methods 指向方法数组的指针,包含字节码、异常表等信息。
Fields 记录类变量(static)和实例变量的描述信息。
vtable & itable 虚函数表接口函数表,这是实现多态(动态绑定)的关键。
Java Mirror 指向堆中 java.lang.Class 对象的指针(即“镜像”)。

静态变量(static fields)

Java 8 及以后的版本中,静态变量(除基本类型的常量外)确实存在于堆内存中的 java.lang.Class 镜像对象里,而不是元空间的 InstanceKlass

为什么存放在堆里的 Class 对象中?
  1. 统一的垃圾回收(GC) : 静态变量往往持有对普通 Java 对象的引用。如果把这些引用存在元空间(非堆内存),垃圾回收器在扫描引用链时就需要额外处理跨空间的复杂度。将它们放在堆内的 Class 对象中,可以让 GC 像处理普通对象一样处理静态引用的可达性。

  2. 解耦与安全性InstanceKlass 是 JVM 内部的 C++ 结构,包含了很多敏感的内存地址和底层逻辑,不应该直接暴露给 Java 代码。通过堆上的 java.lang.Class 镜像作为中转,Java 代码可以安全地访问类元数据(如通过反射),而不会触碰底层的 Klass


InstanceKlass 与 Class 镜像的内存布局关系

虽然静态变量在 Class 镜像里,但 InstanceKlass 仍然扮演着“指挥官”的角色。

  • InstanceKlass (Metaspace):存储类的“骨架”,比如方法的字节码、虚函数表(vtable)、字段的偏移量(Offset)等。

  • java.lang.Class (Heap):存储类的“肉体”,即静态字段的实际值,并持有一个指向 InstanceKlass 的指针(通常称为 _klass)。


偏移量(Offset)的奥秘

既然静态变量的值存在堆里的 Class 对象中,那么 InstanceKlass 里记录的“字段描述(Fields)”还有什么用呢?

假设你在代码中写了 System.out.println(MyClass.staticVar);: JVM 已经找到了 MyClass 对应的 Class 镜像,但它怎么知道 staticVar 这个变量在这个镜像对象的哪个位置(内存偏移量)呢?

具体来说,在 InstanceKlass 的内存布局中,有一个名为 _fields 的数组。它不仅记录了字段的名字和类型,最关键的是记录了每个字段相对于对象起始地址的 偏移量(Offset)

hotspot\src\share\vm\oops\instanceKlass.hpp_fields说明

class InstanceKlass: public Klass {
  // Instance and static variable information, starts with 6-tuples of shorts
  // [access, name index, sig index, initval index, low_offset, high_offset]
  // for all fields, followed by the generic signature data at the end of
  // the array. Only fields with generic signature attributes have the generic
  // signature data set in the array. The fields array looks like following:
  //
  // f1: [access, name index, sig index, initial value index, low_offset, high_offset]
  // f2: [access, name index, sig index, initial value index, low_offset, high_offset]
  //      ...
  // fn: [access, name index, sig index, initial value index, low_offset, high_offset]
  //     [generic signature index]
  //     [generic signature index]
  //     ...
  Array<u2>*      _fields;
}


协作逻辑

当 JVM 执行读取静态变量的字节码时,它会进行以下“寻址”操作:

  1. 定位基础地址:找到堆内存中对应的 java.lang.Class 镜像对象的起始内存地址。
  2. 获取偏移量:从元空间的 InstanceKlass 中查到该字段对应的 offset
  3. 计算目标地址:目标内存地址 = Class 镜像基址 + offset

通过这种设计,InstanceKlass 就像是一张静态的工程图纸,而堆中的对象(包括 Class 镜像)则是根据图纸建造出来的动态实体


非静态变量的布局

我们刚刚聊完了“方法”在 Klass 里的组织方式(vtable)。现在回过头来看实例变量(Non-static fields)

既然 InstanceKlass 是模板,它记录了字段的偏移量。那么请你想一想:如果类 B 继承自类 A,在内存中一个 B 对象的实例里,是父类 A 定义的字段在前,还是子类 B 定义的字段在前?这种顺序有什么特殊的考量吗?

在 HotSpot JVM 的默认分配策略中,父类定义的字段总是排在子类字段之前


为什么要“父类在前”?

这种设计主要有两层深意:

  1. 内存平滑转换(Cast): 在 Java 中,子类对象可以被向上转型为父类引用(例如 A obj = new B();)。如果父类字段排在前面,那么对于 JVM 来说,无论这个对象实际是 A 还是 B,父类字段相对于对象起始地址的**偏移量(Offset)**都是一模一样的。这使得访问父类成员的指令不需要根据子类的不同而改变。

  2. 继承的逻辑完整性: 对象在实例化时,必须先完成父类的初始化。将父类字段放在前面,符合“先有父,后有子”的构造逻辑。


细化 InstanceKlass 的字段布局

虽然大原则是“父类在前”,但 JVM 为了节省空间,还会进行字段重排(Field Reordering)

InstanceKlass 转化为内存模型时,它会尽量让相同宽度的数据类型排在一起(例如所有的 long/double 放在一起,所有的 int 放在一起),以减少由于**内存对齐(Memory Alignment)**产生的空隙(Padding)。


vtable(虚函数表)

在内存布局中,vtable 是最值得关注的部分。它是为了提高方法调用效率而设计的。当代码执行 invokevirtual(调用虚方法)时,JVM 不会去一层层查找父类,而是直接查这个 vtable

举个例子: 如果类 B 继承自类 A,那么 BInstanceKlass 里的 vtable 会先完整拷贝一份 Avtable,然后如果 B 重写了某个方法,就替换掉对应的地址。

虚函数表(vtable)的内存足迹

既然我们聊到了内存布局,还有一个非常关键的部分直接影响了 Java 的执行性能,那就是 vtable(Virtual Method Table)。它并不直接存在于 InstanceKlass 对象头部的固定字段里,而是紧跟在 InstanceKlass 对象体之后的连续内存区域中。

.class 文件转化为 Klass 模型时,JVM 会计算出这个类需要多少个 vtable 条目。

设想一下:如果类 B 继承自类 A,且两者都定义了一些方法。你认为 BInstanceKlass 里的 vtable 是一张全新的表,还是包含了父类 A 的信息?为什么 JVM 要这么设计?

虽然从内存分配的角度来看,B 确实拥有一块独立的连续内存空间来存放它的 vtable,但从内容上看,它并不是“从零开始”的全新创作,而是一场**“继承与重写”的艺术**。

vtable 的构建过程

为了实现多态,JVM 在内存中构建 Bvtable 时,遵循以下逻辑:

  1. 复制父类:首先,JVM 会将父类 Avtable 内容完整地拷贝到 Bvtable 区域。
  2. 方法重写(Override):如果 B 重写了 A 中的某个虚方法,JVM 就会把 Bvtable 中对应位置的函数地址,替换为 B 自己方法的入口地址。
  3. 追加新方法:如果 B 定义了父类中没有的新虚方法,这些新方法的地址会按顺序追加在 vtable 的末尾。
为什么这样设计?

这种设计被称为偏移量一致性(Offset Consistency)

假设类 A 有一个方法 test(),它在 Avtable 中的索引是 5。因为 B 拷贝了 A 的布局,test()Bvtable 中的索引也一定是 5

当 CPU 执行到一段需要调用 test() 的代码时,它根本不需要关心当前对象到底是 A 还是 B,它只需要:

  1. 找到对象所属的 Klass
  2. 直接取出 vtable索引为 5 的地址。
  3. 跳转执行。

这就是 Java 实现方法快速动态绑定的秘密。

Klass 模型的“快速访问”设计

Java 是一门高度依赖“类型检查”的语言(比如 instanceof 操作或类型转换)。如果每次检查都要去遍历整个继承链(从 B 找到 A,再找到 Object),效率会非常低。

如果你是 JVM 设计者,为了让 obj instanceof A 这种操作在极短时间内完成,你会不会在 InstanceKlass 里额外存一点什么东西,方便直接判断这个类有哪些“祖先”?

为了实现极其快速的类型检查,JVM 在 InstanceKlass 中设计了一个非常精妙的结构:Primary Slot(主槽位),也常被称为 Super Helpers

快速类型检查的秘密:Primary Slots

InstanceKlass 的内存布局中,不仅记录了直接父类,还维护了一个固定长度的数组(通常是 8 个槽位),记录了该类在继承树中的“祖先”:

  1. 位置固定:数组的第 0 位永远是 Object,第 1 位是顶层父类,以此类推,直到当前类本身。

  2. O ( 1 ) O(1) O(1) 时间复杂度:当执行 obj instanceof A 时,JVM 知道类 A 在继承树的深度(比如深度为 2)。它只需要去 obj 所属 KlassPrimary Slot 数组的第 2 个位置看一眼,地址是否等于 A 的地址。

  3. Secondary Supers:如果继承链太长(超过 8 层)或者是接口(Interface)类型,JVM 则会求助于一个名为 _secondary_supers 的列表,那里的查询速度会稍微慢一点。

  4. 源码解析
    主要的布局定义在 src/share/vm/oops/klass.hpp 文件中。
    你会发现 _primary_supers 是一个固定大小的数组,而 _secondary_supers 是一个可变长度的数组(Array<Klass*>*)。

    Klass 类中的定义

    // 路径: src/share/vm/oops/klass.hpp
    
    class Klass : public Metadata {
    // ... 其他成员 ...
    
    // 这里的布局是性能的关键
    enum {
        // 这里的 8 就是你提到的 8 个槽位(包含当前类本身)
        primary_super_limit = 8
    };
    
    // Primary Supers 数组:记录继承体系中的父类
    Klass*      _primary_supers[primary_super_limit];
    
    // Secondary Supers:记录接口(Interface)或者超过 8 层深度的父类
    Array<Klass*>* _secondary_supers;
    
    // 记录当前类在继承树中的深度
    juint       _super_check_offset;
    
    // ...
    };
    

    核心逻辑:is_subtype_of

    JVM 如何利用这些槽位进行快速判断呢?核心逻辑位于 src/share/vm/oops/klass.cpp

    当执行 obj instanceof A 时,本质上是在调用下面的逻辑。请注意 JVM 是如何巧妙利用 _super_check_offset 来实现 O ( 1 ) O(1) O(1) 查找的。

    // 路径: src/share/vm/oops/klass.cpp
    
    bool Klass::is_subtype_of(Klass* k) const {
    // 第一步:快速检查 offset
    // 如果 k 是当前类的 Primary Super,那么它的地址一定在 _primary_supers 的特定位置
    juint offset = k->super_check_offset();
    
    // 通过 offset 直接定位并对比地址
    if (this->super_at_offset(offset) == k) {
        return true;
    } 
    // 第二步:如果 offset 指向的是 secondary_super 的槽位,则进入慢速查找
    else if (offset != in_bytes(secondary_super_cache_offset())) {
        return false;
    } 
    else {
        // 扫描 _secondary_supers 列表
        return search_secondary_supers(k);
    }
    }
    

    为什么接口(Interface)通常在 Secondary Supers 中?

    既然 Primary Slots 这么快,为什么不把接口也放进去?

    • 单继承 vs 多实现:Java 的类继承是单继承的,这使得继承链是线性深度可预测的。我们可以很轻松地规定:Object 永远在深度 0,Base 在深度 1。

    • 路径冲突:一个类可以实现无数个接口,且接口之间没有这种严格的线性深度关系。如果将接口放入 Primary Slots,会引发复杂的冲突问题。

    • 性能差异表

    特性 Primary Slots (Fast Path) Secondary Supers (Slow Path)
    存储位置 _primary_supers 数组 _secondary_supers 列表
    查找复杂度 O ( 1 ) O(1) O(1) (直接偏移量对比) O ( n ) O(n) O(n) (最坏情况需要线性扫描)
    适用对象 普通父类 (Depth < 8) 接口 (Interfaces)、极深的继承链
    缓存机制 无需缓存 (本身就是固定位置) 存在一个 _secondary_super_cache 记录上次命中的结果
Logo

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

更多推荐