jvm 对 java Class 在 C++ 的映射及实现是 Klass 类, 这对于普通 java 程序员来说颇为陌生, 但如果想搞懂 jvm 的底层原理, 我们就必须仔细学习这个 C++ 类的实现细节 (尤其是 jvm 的多态实现, 其核心机制就存在于该类的数据结构中);
Klass 的继承结构
1 | Klass (顶级基类) |
Klass 的数据结构
Klass
以 InstanceKlass 为例:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37class InstanceKlass : public Klass {
// 头部信息
markOop _mark; // 对象标记字
Klass* _klass; // 指向自身的 Klass 指针
// 类元数据
Symbol* _name; // 类名
Klass* _super; // 父类
Klass* _subklass; // 第一个子类
Klass* _next_sibling; // 下一个兄弟类
// 布局信息
juint _layout_helper; // 对象布局帮助信息
jint _modifier_flags; // 访问修饰符
// 方法信息
Array<Method*>* _methods; // 方法数组
Array<Method*>* _default_methods; // 默认方法(接口)
// 虚方法表
int _vtable_len; // 虚表长度
Method** _vtable; // 虚方法表
// 接口方法表
itableOffsetEntry* _itable; // 接口方法表
// 字段信息
Array<FieldInfo>* _fields; // 字段数组
// 常量池
ConstantPool* _constants; // 常量池
// 其他
ClassLoaderData* _class_loader_data; // 类加载器数据
// ... 其他字段
}
Klass 与 java.lang.Class 的关系
在 JVM (特别是 hotspot 实现) 中, java.lang.Class 类和 C++ Klass 类是 java 类元数据的两种不同表现形式, 分别服务于 Java 层和 JVM 层:
- Klass 是类的物理结构: 表达字节码、方法表等底层数据, 存储在 Metaspace 中;
- java.lang.Class 是类的逻辑镜像: 提供静态变量存储 (作为 Class 对象的附加数据) 和反射能力;
java.lang.Class 对象是 Klass 面向 java 的代理, 每个 Class 对象内部会持有一个指向 jvm Klass 的指针 (_klass 字段), java 的反射操作 (如调用方法) 最终会委托给 Klass 执行;
为什么 static 静态变量不放在 Metaspace?
- Metaspace 的职责限制:
- Metaspace 用于存储 类元数据(如 Klass 结构、方法字节码、常量池等),这些数据与类的结构相关,但与运行时状态无关。
- 静态变量是 类的运行时状态,而非元数据。它们的值可能随程序运行改变,与类元数据的“只读”特性不符。
- 垃圾回收 (GC) 的需求:
- Metaspace 的回收:Metaspace 的垃圾回收(如类卸载)仅发生在类加载器被回收时,频率低且与对象 GC 无关。
- 静态变量的回收:静态变量的生命周期与 类的 Class 对象 绑定。当类加载器被卸载时,Class 对象及其静态变量会被一起回收(通过堆的 GC 机制)。若静态变量放在 Metaspace,会导致 无法通过堆 GC 自动管理,增加内存泄漏风险。
- 性能优化:
- 访问速度: 堆内存的访问速度经过 JVM 深度优化(如 TLAB、逃逸分析等),而 Metaspace 主要用于元数据查询,不适合高频读写。
- 内存局部性:静态变量常被频繁访问(如全局配置),存储在堆中能更好地利用 CPU 缓存。
PermGen vs Metaspace:
在 Java 8 之前的永久代 (PermGen) 中, 静态变量确实曾与类元数据一起存储, 但导致以下问题:
- 永久代大小固定,易引发 OutOfMemoryError: PermGen space;
- 静态变量与元数据混合管理, 增加了 GC 复杂度;
Java 8 的 Metaspace 明确分离了元数据与运行时数据, 静态变量移至堆中, 彻底解决了上述问题;
Method
Method 方法元信息是一块独立的结构, 但它被引用进了 Klass 里, 由 Klass 的相关 methods 字段关联:1
2
3
4
5
6
7class Method {
ConstMethod* _constMethod; // 方法常量信息
MethodData* _method_data; // 方法性能数据 (用于 JIT)
AccessFlags _access_flags; // 访问标志
int _vtable_index; // 虚表索引
// ...
}
Klass 中所有的方法信息均存储于 Method 结构中:
- 对于虚方法、接口方法, Klass 会额外使用 vtable/itable 作为索引去映射 Method 的地址;
- 对于 final / static 方法, 编译期生成的字节码会直接静态绑定方法地址, 直接调用;
多态的原理
多态方法调用指的是在运行时根据对象的实际类型来确定调用哪个方法实现, 而不是在编译时确定; java 中所有非 private、非 static、非 final 的实例方法都是虚方法, 支持多态调用;
逻辑实现
虚方法表 (vtable):
每个类都有一个虚方法表,存储该类所有虚方法的实际入口地址:
1
2
3
4
5
6class Klass {
// ...
int _vtable_len; // 虚表长度
Method** _vtable; // 虚方法表
// ...
}子类的 vtable 会包含父类的 vtable 条目;
- 重写的方法会替换父类对应位置的条目;
- 新方法会添加到 vtable 末尾;
- 由于 java 的类只允许单继承, 于是子类中所复制的父类的 vtable 条目可以和父类的 vtable 条目保持相同的 slot 槽位, 即使新方法向 vtable 末尾追加也不会造成冲突;
接口方法表 (itable):
用于接口方法调用:
1
2
3
4
5class Klass {
// ...
itableOffsetEntry* _itable; // 接口方法表
// ...
}由于 java 的接口允许多实现, itable 比 vtable 更复杂, itable 没法像 vtable 一样保持各个方法 slot 槽位的唯一性, 只能通过接口级别的隔离, 分别去索引各接口内的方法;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17public interface A {
void method1();
}
public interface B {
void method2();
void method3();
}
public class TestCase implements A, B {
void method1() {
}
void method2() {
}
void method3() {
}
}对于以上 case, jvm 生成的 Klass TestCase 的 itable 是一个二级索引表:
1
2
3
4[
A --> [0 --> addr(method1) ]
B --> [0 --> addr(method2), 1 --> addr(method2) ]
]
编译期插桩
方法符号引用生成:
- 编译器生成字节码时, 会为方法调用生成一个符号引用;
- 符号引用包含: 方法名、描述符和所属类信息;
举例如下:1
invokevirtual #5 // Method com/example/Animal.sayHello:()V
上文中的 #5
表示的是该符号所在的常量池索引;
补充: invokevirtual 和 invokeinterface 是多态会涉及到的指令, 除此之外, jvm 还有其余几种方法调用指令类型:
指令 | 用途 | 分派方式 |
---|---|---|
invokevirtual | 虚方法调用 | 动态分派 |
invokeinterface | 接口方法调用 | 动态分派 |
invokespecial | 构造方法、private方法等 | 静态分派 |
invokestatic | 静态方法调用 | 静态分派 |
invokedynamic | 动态语言支持 | 动态分派 |
类加载期构建
当 jvm 加载一个 Class 时, 会真正开始构建 vtable / itable, 分配各方法的 slot 槽位;
运行时解析
当 jvm 执行到 invokevirtual 指令时:
确定接收者对象:
- 从操作数栈弹出对象引用 (方法的接收者);
- 检查对象是否为null (如果是则抛出 NullPointerException);
查找方法:
- 获取对象的实际类型 (通过对象头中的 Klass 指针);
- 从 Klass 结构开始方法查找过程:
- 缓存查找: 首先检查该调用点的缓存 (方法调用缓存 Inline Cache);
- 类方法表查找: 如果缓存未命中, 则通过以下步骤查找:
a. 从对象的实际类开始查找;
b. 找到后更新缓存;
方法执行:
- 获取方法的字节码;
- 设置新的栈帧;
- 执行方法字节码;
性能优化
虚方法调用比静态方法、final 方法调用的开销大, 但是多态方法调用机制是 java 面向对象特性的核心实现, 现代 jvm 通过多种优化技术尽可能地减少了虚方法调用的开销, 在保持灵活性的同时尽可能提高性能:
方法内联缓存 (Inline Cache):
- 在调用点缓存上次成功调用的方法;
- 结构: [receiver klass, method entry];
- 如果下次调用的对象类型匹配缓存, 直接调用缓存方法;
多态内联缓存 (Polymorphic Inline Cache):
- 缓存多个常见类型的调用目标;
- 当类型超过阈值时退化为全局查找;
虚方法内联:
- 对于单态调用点 (总是同一种类型), JIT 会直接内联方法;
- 对于多态调用点, 可能生成条件判断的多分支内联代码;
相关扩展
Metaspace 与方法区的关系
方法区(Method Area) 是一个 逻辑概念, 定义在《Java 虚拟机规范》中, 用于存储类的元数据 (如类结构、方法信息、常量池等);
作为虚拟机规范定义的一部分, 所有 JVM 实现 (如 HotSpot、J9、GraalVM) 都必须提供该区域, 但具体实现方式可以不同; 而 Metaspace 是 HotSpot JVM (Oracle JDK / OpenJDK 默认实现) 在 Java 8 及之后对该区域的物理实现, 其存储内容如下:
- 类元数据(Klass 结构、方法信息、字段信息、注解等)
- 运行时常量池(Runtime Constant Pool)
- 静态变量(static 字段)
- JIT 编译后的代码(部分优化后的方法代码)
- 虚方法表(vtable)的元信息(但方法调用地址仍通过 vtable 动态分派)
需要注意的是, hotspot 对于方法区中的信息, 并不是全都存储在 Metaspace 的, 比如:
- JIT 编译器生成的本地机器码: 理论上属于方法区,但 HotSpot 将其放在了独立的本地区域;
- 所有 intern 字符串的常量池: 逻辑上属于方法区,但 HotSpot 将其放在堆中;
不过反过来看, hotspot Metaspace 中存储的内容, 绝大部分 (基本可以这么认为) 都是 Klass 及由 Klass 关联的对象 (Method、FieldInfo、ConstantPool、ClassLoaderData 等);
抽象类 & 接口性能分析
关键性能指标差异:
比较维度 | 抽象方法调用 | 接口方法调用 |
---|---|---|
查找机制 | 直接 vtable 偏移访问 | 需要遍历 itable |
内存访问次数 | 1 ~ 2 次 | 3 ~ 5 次 |
缓存友好性 | 高(固定偏移) | 低(需要搜索) |
JIT优化潜力 | 更容易内联 | 更难内联 |
多态开销 | 低(单继承) | 高(多实现) |
性能差距的底层原因:
- 内存访问模式:
- vtable:连续内存区域,CPU缓存命中率高
- itable:分散存储,可能引起缓存未命中
- 查找复杂度:
vtable: 可 O(1) 确定方法位置:
1
method = obj->klass->vtable[index]; // 直接计算地址
itable: 需要运行时搜索:
1
2
3
4
5
6for (itable in klass->itables) { // 循环查找
if(itable.interface == target) {
method = itable.methods[index];
break;
}
}
- JIT 优化限制:
- 抽象方法更容易被识别为单态调用(只有一个实现)
- 接口方法更容易形成多态调用(多个实现类),阻碍优化
实际性能测试数据 (纳秒/操作):
调用类型 | Java 8 | Java 11 | Java 17 |
---|---|---|---|
抽象方法调用 | 2.1 | 1.8 | 1.5 |
接口方法调用 | 3.7 | 3.2 | 2.6 |
差值 | +76% | +78% | +73% |
虽然接口调用仍然较慢, 但现代 jvm 已经做了诸多优化:
默认方法优化:
java 8+ 将默认方法放入 vtable, 尽量减少 itable 查找次数;类层次分析 (CHA):
如果确定接口只有单一实现, JIT 会将 invokeinterface 转为 invokevirtual 指令;多态内联缓存:
- 缓存最近几次调用的具体实现;
- 通过条件分支直接跳转;
编程优化建议
需要高性能时:
- 优先使用抽象类而非接口;
- 对高频调用的方法使用 final 修饰;
- 限制接口的实现类数量;
需要灵活性时:
- 仍应优先使用接口设计 API;
- 运行时性能差异在大多数应用中可忽略;
- 清晰的接口设计比微优化更重要;
特殊情况说明, 当以下条件满足时, 接口调用性能可接近抽象类调用:
- 接口只有一个实现类;
- 方法被频繁调用 (触发 JIT 优化);
- 使用 GraalVM 等高级 JVM;
理解这些底层差异有助于在需要极致性能时做出合理选择,但在大多数应用场景中,接口提供的设计灵活性价值远超过其微小的性能开销;