
Klass模型前言Java程序能够运行的核心逻辑从“静态”到“动态”的搬运工Klass模型.class 文件转化成内存里的 Klass 模型时机从文件到内存Klass 模型的构造InstanceKlass 的内部构成静态变量static fields为什么存放在堆里的 Class 对象中InstanceKlass 与 Class 镜像的内存布局关系偏移量Offset的奥秘非静态变量的布局vtable虚函数表虚函数表vtable的内存足迹vtable 的构建过程为什么这样设计Klass 模型的“快速访问”设计快速类型检查的秘密Primary Slots前言本文旨在记录近期研读Java源码的学习心得与疑难问题。由于个人理解水平有限文中内容难免存在疏漏恳请读者不吝指正。Java程序能够运行的核心逻辑在 Java 虚拟机JVM中.class文件静态的字节码向Klass 模型内存中的元数据的转化是 Java 程序能够运行的核心逻辑。下面一步步探索这个从“文件”到“运行时对象”的蜕变之旅。这个过程通常包括以下几个核心环节加载阶段Loading类加载器ClassLoader如何读取二进制流并生成内存中的 C 数据结构。Klass 模型结构在元空间Metaspace中InstanceKlass到底长什么样它如何存储方法、字段和常量池链接与初始化Linking Initialization验证Verification、准备Preparation和解析Resolution是如何把抽象的符号引用变成具体的内存地址的。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**的触发条件。常见的场景包括使用new关键字实例化对象时。访问静态成员读取或设置一个类的静态字段被final修饰的常量除外它们有时在编译期就解析了。调用静态方法比如运行main方法所在的类。反射调用例如执行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对象中统一的垃圾回收GC 静态变量往往持有对普通 Java 对象的引用。如果把这些引用存在元空间非堆内存垃圾回收器在扫描引用链时就需要额外处理跨空间的复杂度。将它们放在堆内的Class对象中可以让 GC 像处理普通对象一样处理静态引用的可达性。解耦与安全性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说明classInstanceKlass:publicKlass{// 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]// ...Arrayu2*_fields;}协作逻辑当 JVM 执行读取静态变量的字节码时它会进行以下“寻址”操作定位基础地址找到堆内存中对应的java.lang.Class镜像对象的起始内存地址。获取偏移量从元空间的InstanceKlass中查到该字段对应的offset。计算目标地址目标内存地址 Class 镜像基址offset。通过这种设计InstanceKlass就像是一张静态的工程图纸而堆中的对象包括Class镜像则是根据图纸建造出来的动态实体。非静态变量的布局我们刚刚聊完了“方法”在Klass里的组织方式vtable。现在回过头来看实例变量Non-static fields。既然InstanceKlass是模板它记录了字段的偏移量。那么请你想一想如果类B继承自类A在内存中一个B对象的实例里是父类A定义的字段在前还是子类B定义的字段在前这种顺序有什么特殊的考量吗在 HotSpot JVM 的默认分配策略中父类定义的字段总是排在子类字段之前。为什么要“父类在前”这种设计主要有两层深意内存平滑转换Cast 在 Java 中子类对象可以被向上转型为父类引用例如A obj new B();。如果父类字段排在前面那么对于 JVM 来说无论这个对象实际是A还是B父类字段相对于对象起始地址的**偏移量Offset**都是一模一样的。这使得访问父类成员的指令不需要根据子类的不同而改变。继承的逻辑完整性 对象在实例化时必须先完成父类的初始化。将父类字段放在前面符合“先有父后有子”的构造逻辑。细化 InstanceKlass 的字段布局虽然大原则是“父类在前”但 JVM 为了节省空间还会进行字段重排Field Reordering。在InstanceKlass转化为内存模型时它会尽量让相同宽度的数据类型排在一起例如所有的long/double放在一起所有的int放在一起以减少由于**内存对齐Memory Alignment**产生的空隙Padding。vtable虚函数表在内存布局中vtable是最值得关注的部分。它是为了提高方法调用效率而设计的。当代码执行invokevirtual调用虚方法时JVM 不会去一层层查找父类而是直接查这个vtable。举个例子如果类B继承自类A那么B的InstanceKlass里的vtable会先完整拷贝一份A的vtable然后如果B重写了某个方法就替换掉对应的地址。虚函数表vtable的内存足迹既然我们聊到了内存布局还有一个非常关键的部分直接影响了 Java 的执行性能那就是vtableVirtual Method Table。它并不直接存在于InstanceKlass对象头部的固定字段里而是紧跟在InstanceKlass对象体之后的连续内存区域中。在.class文件转化为Klass模型时JVM 会计算出这个类需要多少个 vtable 条目。设想一下如果类B继承自类A且两者都定义了一些方法。你认为B的InstanceKlass里的 vtable 是一张全新的表还是包含了父类A的信息为什么 JVM 要这么设计虽然从内存分配的角度来看B确实拥有一块独立的连续内存空间来存放它的vtable但从内容上看它并不是“从零开始”的全新创作而是一场**“继承与重写”的艺术**。vtable 的构建过程为了实现多态JVM 在内存中构建B的vtable时遵循以下逻辑复制父类首先JVM 会将父类A的vtable内容完整地拷贝到B的vtable区域。方法重写Override如果B重写了A中的某个虚方法JVM 就会把B的vtable中对应位置的函数地址替换为B自己方法的入口地址。追加新方法如果B定义了父类中没有的新虚方法这些新方法的地址会按顺序追加在vtable的末尾。为什么这样设计这种设计被称为偏移量一致性Offset Consistency。假设类A有一个方法test()它在A的vtable中的索引是5。因为B拷贝了A的布局test()在B的vtable中的索引也一定是5。当 CPU 执行到一段需要调用test()的代码时它根本不需要关心当前对象到底是A还是B它只需要找到对象所属的Klass。直接取出vtable中索引为 5的地址。跳转执行。这就是 Java 实现方法快速动态绑定的秘密。Klass 模型的“快速访问”设计Java 是一门高度依赖“类型检查”的语言比如instanceof操作或类型转换。如果每次检查都要去遍历整个继承链从B找到A再找到Object效率会非常低。如果你是 JVM 设计者为了让obj instanceof A这种操作在极短时间内完成你会不会在InstanceKlass里额外存一点什么东西方便直接判断这个类有哪些“祖先”为了实现极其快速的类型检查JVM 在InstanceKlass中设计了一个非常精妙的结构Primary Slot主槽位也常被称为Super Helpers。快速类型检查的秘密Primary Slots在InstanceKlass的内存布局中不仅记录了直接父类还维护了一个固定长度的数组通常是 8 个槽位记录了该类在继承树中的“祖先”位置固定数组的第 0 位永远是Object第 1 位是顶层父类以此类推直到当前类本身。O ( 1 ) O(1)O(1)时间复杂度当执行obj instanceof A时JVM 知道类A在继承树的深度比如深度为 2。它只需要去obj所属Klass的Primary Slot数组的第 2 个位置看一眼地址是否等于A的地址。Secondary Supers如果继承链太长超过 8 层或者是接口Interface类型JVM 则会求助于一个名为_secondary_supers的列表那里的查询速度会稍微慢一点。源码解析主要的布局定义在src/share/vm/oops/klass.hpp文件中。你会发现_primary_supers是一个固定大小的数组而_secondary_supers是一个可变长度的数组ArrayKlass**。Klass 类中的定义// 路径: src/share/vm/oops/klass.hppclassKlass:publicMetadata{// ... 其他成员 ...// 这里的布局是性能的关键enum{// 这里的 8 就是你提到的 8 个槽位包含当前类本身primary_super_limit8};// Primary Supers 数组记录继承体系中的父类Klass*_primary_supers[primary_super_limit];// Secondary Supers记录接口Interface或者超过 8 层深度的父类ArrayKlass**_secondary_supers;// 记录当前类在继承树中的深度juint _super_check_offset;// ...};核心逻辑is_subtype_ofJVM 如何利用这些槽位进行快速判断呢核心逻辑位于src/share/vm/oops/klass.cpp。当执行obj instanceof A时本质上是在调用下面的逻辑。请注意 JVM 是如何巧妙利用_super_check_offset来实现O ( 1 ) O(1)O(1)查找的。// 路径: src/share/vm/oops/klass.cppboolKlass::is_subtype_of(Klass*k)const{// 第一步快速检查 offset// 如果 k 是当前类的 Primary Super那么它的地址一定在 _primary_supers 的特定位置juint offsetk-super_check_offset();// 通过 offset 直接定位并对比地址if(this-super_at_offset(offset)k){returntrue;}// 第二步如果 offset 指向的是 secondary_super 的槽位则进入慢速查找elseif(offset!in_bytes(secondary_super_cache_offset())){returnfalse;}else{// 扫描 _secondary_supers 列表returnsearch_secondary_supers(k);}}为什么接口Interface通常在 Secondary Supers 中既然 Primary Slots 这么快为什么不把接口也放进去单继承 vs 多实现Java 的类继承是单继承的这使得继承链是线性且深度可预测的。我们可以很轻松地规定Object永远在深度 0Base在深度 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记录上次命中的结果