JVM中的klass与Class对象:类加载后内存里到底放了什么?
2026/9/24 21:41:19 网站建设 项目流程

上个月帮朋友做模拟面试,我问了个自认为很基础的问题:“你天天用的HashMap.class和它背后方法区里的类元数据,到底是同一个东西吗?”对方想了一会儿,答:“Class 对象不是存在方法区吗?”这个答案在初级讨论群里非常常见,但它其实把两个层面搅在了一起。在 HotSpot 虚拟机里,方法区存放的是一套叫 klass 的结构体,而 Java 层那个Class对象,是堆里的一个普通对象,只不过它和 klass 之间有特殊的双向关联。这篇文章就从这两个概念出发,把类加载到底往内存里放了什么、klass 长什么样、Class对象怎么和它联动、JDK 8 换成元空间后哪些位置变了,一次讲透。如果你是准备面试或者想精读 JVM,这部分值得反复看。

1. 先分清三样东西:字节码、klass、Class 对象

1.1 经常被混为一谈的“类信息”到底分几层

我观察到一个规律:大部分人刚开始学 JVM 时,脑子里只有一张很模糊的图——“类加载后,类的信息放到方法区”。这个说法不算错,但它把“信息”这个词用得太笼统了,导致后面一遇到细节就卡壳。

实际上一段 Java 代码从磁盘到运行时内存,至少要经历三个不同形态:

形态存在哪里谁能直接看到典型代表
字节码文件磁盘 / 网络 / 内存等人可以用 javap 看Hello.class文件
klass 结构体方法区(HotSpot 里就是元空间)只有 JVM 内部代码能操作InstanceKlassC++ 对象
Class 对象Java 堆开发者可以直接操作Hello.classobj.getClass()

字节码是“设计图纸”,klass 是“施工完成后挂在大楼里的结构图纸”,而Class对象是“你作为访客拿到的那份导览手册”。三者之间有关联,但绝不是同一个东西。

这里要特别强调:Class对象是存活在堆里的一个普通 Java 对象,普通到它也有对象头、也有_klass指针。它唯一的特殊之处在于,这个对象是 JVM 在类加载过程中专门创建的,Java 代码没法直接 new 一个Class实例,构造器是私有的。但这不妨碍它享受堆对象的所有待遇:可以成为 GC Roots 的引用目标、可以被 finalize、可以被 synchronized 锁住。

1.2 方法区是一个“规范”,klass 是 HotSpot 的实现重心

如果你看过《Java 虚拟机规范》,会发现“方法区”是一个逻辑上的概念,规范只规定它存放类信息、常量、静态变量、JIT 编译后的代码等,并没有规定具体怎么实现。HotSpot 对方法区的实现经历了两代:JDK 7 及之前叫永久代(PermGen),JDK 8 开始叫元空间(Metaspace)。

无论叫哪个名字,klass 都是这个方法区里最核心的一类数据。一个 instanceKlass 里除了类名、继承关系、字段表、方法表、常量池引用之外,还会记录对象布局信息、类加载器数据、初始化状态等。你可以把 instanceKlass 理解成 JVM 内部对一个 Java 类的“完整档案”。

但要注意,方法区并不是只放 klass。一个类对应的方法、字段、常量池、注解、类加载器指针等,其实都是单独的元数据对象,分散在元空间里。instanceKlass 只是那个“总入口”,通过它可以索引到其他元数据。这就是为什么很多复杂问题最后都会追溯到 klass——它是读取类全部信息的钥匙。

1.3 类加载的五个阶段,每步在内存里落了什么

类加载不是一眨眼完成的,而是分成加载、验证、准备、解析、初始化五个阶段。每个阶段都会在 klass 或它周边数据结构上留下痕迹:

  • 加载:读 .class 文件,解析出基本结构,在元空间创建 instanceKlass 雏形,并在堆里创建对应的Class对象(也叫 mirror)。数组类没有 .class 文件,JVM 会直接创建对应的 arrayKlass。
  • 验证:做字节码格式、语义、符号引用验证。这阶段不改 klass 主要结构,但会拦截错误的类文件。
  • 准备:为静态字段分配初始值(零值)。注意,这一步的存储位置不是 instanceKlass 内部,而是落在 mirror 对象上,这个细节后面我会细讲。同时,虚方法表 vtable、接口方法表 itable 也在这个阶段构建。
  • 解析:把常量池里的符号引用替换为直接引用,比如把CONSTANT_Class_info里的类名解析成对应的 instanceKlass 指针,把CONSTANT_Methodref_info解析成方法在 vtable 里的下标或者 Method 对象地址。结果会写回运行时常量池。
  • 初始化:执行<clinit>静态代码块和静态变量的赋值语句。instanceKlass 里的_init_state状态字段会从 loaded 一路推进到 initialized,失败则置为 initialization_error。

很多面试题都藏在这几个阶段里,比如“为什么Class.forName会触发初始化,而Hello.class不会”,答案就在最后一步:前者默认要推进到初始化状态,后者在类加载完成后拿到 mirror 就结束了,没有继续往下走。

2. klass 结构体:HotSpot 内部真正的“类”

2.1 为什么 HotSpot 要把对象模型拆成 oop 和 klass 两层

HotSpot 的对象模型在 C++ 层有一个经典二分:oop(ordinary object pointer,普通对象指针)负责描述 Java 对象的实例数据,klass 负责描述类的元数据。二者各有职责。

先看 oop:一个 Java 对象在内存里由对象头(mark word + klass 指针)和实例数据组成。对象头里的 klass 指针指向的是它所属类的 instanceKlass。也就是说,任何一个对象,只要有了这个指针,JVM 就能回答“这个对象是谁创建的”“它有哪些方法可以调”“它有哪些字段”。

再看 klass:它描述的是“类本身”。比如Person类有nameage两个字段,有sayHello()方法,这些东西如果复制到每个 Person 实例上,那内存会爆炸。所以 HotSpot 把“一类对象共享的结构”抽出来单独放一份,每个实例只保存自己的字段值,再通过指针指回那份共享结构。

可以做个类比:klass 是“小区的户型图”,oop 是“每一套真正装修好的房子”。户型图只需要一张,房子可以有很多套,每套房子里只需要贴一张“我属于哪个户型”的标签。这个拆分的收益在大量实例场景下非常明显,否则 100 万个Person对象就要存 100 万份方法表。

2.2 instanceKlass 的关键字段:从 vtable 到 mirror

如果你打开 HotSpot 源码里的instanceKlass.hpp,会发现这个结构体非常大。我这里挑几个最影响理解的字段:

  • _name:类的名字,保存为 Symbol 类型,相当于一个不可变的字符串。
  • _super/_interfaces:指向父类 klass 和接口 klass 的指针,组成了类的继承体系。
  • _fields:字段信息数组,记录了每个字段的名字、类型、访问标志、偏移量。
  • _methods:方法对象数组。注意,这里是真正的Method元数据对象,和后面你要反射拿到的java.lang.reflect.Method是两回事。
  • _constants:指向运行时常量池ConstantPool的指针。
  • _vtable/_itable:虚方法表和接口方法表。这是实现多态分派的核心。子类重写父类方法时,在 vtable 中同一个槽位写入自己的实现地址;调用虚方法时,JVM 根据对象 klass 的 vtable 定位实际方法。
  • _java_mirror:指向堆中java.lang.Class对象的引用。名字叫 mirror,就是“镜像”,因为 Class 对象是 klass 投到 Java 世界的一面镜子。
  • _class_loader_data:记录加载这个类的 ClassLoader 数据,类卸载判定时要靠它。
  • _init_state:类初始化状态机。
  • _layout_helper:存放对象布局快查信息,比如对象是否可变、对齐方式、字段偏移等,方便 JIT 和 GC 快速计算。

有一个很有意思的点:方法表里的 vtable 是为单继承设计的,下标固定;而 itable 是为多接口设计的,所以查找接口方法时要走线性或缓存逻辑,性能比普通虚方法调用差一些。这也是“尽量面向接口编程但不要滥用接口”在 JVM 层的一个现实注脚。

2.3 数组类:arrayKlass、typeArrayKlass、objArrayKlass

很多初学者不知道数组也是有 klass 的。Java 数组没有对应的 .class 文件,是 JVM 在运行时按“元素类型 + 维度”自动合成的。

HotSpot 里数组类分成两条线:

  • typeArrayKlass:基本类型数组,比如int[]byte[]
  • objArrayKlass:引用类型数组,比如String[]Object[]

它们都继承自arrayKlass,arrayKlass 又继承自 klass。也就是说,int[].class拿到的是一个 TypeArrayKlass 对应的 mirror,String[].class拿到的是 ObjArrayKlass 对应的 mirror。

数组类的 klass 结构里需要额外记录元素类型和维度,比如int[][]在 klass 里会有一个指向int[]对应 arrayKlass 的引用,这样才能在运行期正确判断数组兼容性和做强制转换检查。

面试中容易踩坑的点:Integer[].classint[].class是完全不同的两个 klass,前者是引用类型数组,后者是基本类型数组;Object[].class的继承关系也很有意思,数组类的父类在 Java 层是Object,但它并不由 ClassLoader 从磁盘加载,而是 JVM 内部完成。

2.4 klass 为什么要待在方法区,而不是堆里

klass 放在方法区是有意为之。首先是生命周期:一个 klass 的存活周期基本等于加载它的 ClassLoader 的存活周期,这类数据很少像普通对象那样“朝生夕死”,放进堆里会让 GC 频繁扫描大量近乎永久存活的对象,浪费回收成本。其次是内存管理:元空间使用本地内存,不受-Xmx限制,方便了大型应用加载海量类。

但这里也有反面代价:如果不设置-XX:MaxMetaspaceSize,元空间默认只受操作系统可用内存约束。很多线上应用出现“明明堆内存还很空,却报了 OutOfMemoryError”的诡异问题,多半就是元空间涨满或者本地内存被编译缓存等吃掉了。所以给元空间设一个上限,不是为了限制性能,而是为了“尽早暴露问题”。

3. Java 层的 Class 对象:klass 的“投影”

3.1 mirror 机制:双向引用是怎么建立的

klass 和Class对象之间不是单向的“klass -> Class 对象”,而是双向的关联:

  • 从对象出发:任何一个 Java 对象,对象头里的_klass指针指向自己的 instanceKlass。
  • 从 klass 出发:instanceKlass 的_java_mirror字段指向堆里的java.lang.Class对象。
  • 从 Class 对象出发:JVM 内部其实还在 mirror 对象里维护了一个隐藏的 klass 指针。反射 native 方法拿到一个 Class 对象时,就是通过这个隐藏指针反查它到底代表了哪个类。

这个设计形成了一个闭环。你写person.getClass()时,JVM 做的事是:取出 person 对象头里的 klass 指针,得到 Person 的 instanceKlass,再读取_java_mirror,返回堆里的 Class 对象。而当你写Class.forName("Person")时,JVM 先按类名和 ClassLoader 找到 instanceKlass,再把它对应的 mirror 返回给 Java 层。

同一个类在同一个 ClassLoader 命名空间下只对应一个 instanceKlass 和一个 mirror,所以Person.class == person.getClass()永远是 true。但如果你用两个不同的 ClassLoader 加载同一个字节码,内存里会出现两个 instanceKlass 和两个 mirror,这就是后面类卸载、热部署问题的一个伏笔。

3.2 为什么 Class 对象要放在堆里

Class 对象本质上是 Java 层与 JVM 内部世界之间的“交接面”。Java 代码要拿它做反射、做类型判断、做锁;它要参与 GC、要能被软引用和弱引用持有。把它放在堆里是最自然的选择,因为堆的 GC 机制已经非常成熟,不需要为它专门设计一套回收逻辑。

放在堆里还有一个好处:当这个类已经没有实例、也没有反射引用时,mirror 作为普通对象会被 GC 回收;回收之后,instanceKlass 里的_java_mirror就变成了一个需要清理的引用。HotSpot 对这种“klass 与 mirror 相互引用”的场景有专门处理,避免因为互相引用导致永远无法回收。

这就是为什么“Class 对象在方法区”这个说法需要纠正:Class 对象确实是在堆里,方法区里的是 klass 结构体。很多老文章把两者混为一谈,是因为 JDK 7 之前永久代在物理上也在堆里,看起来“类的东西都在方法区”还能自洽;到了 JDK 8,永久代没了,再这么答就明显经不起推敲。

3.3 instanceof、反射、类字面量背后的指针追踪

我们换个角度,看看三种常见操作在 JVM 内部各自走了什么路:

  • obj instanceof Person:JVM 拿到 obj 对象头的_klass,然后沿着_super_interfaces组成的继承体系查找,看是否匹配 Person 的 klass。这个过程完全不需要经过 Java 层 Class 对象,纯 C++ 层完成,所以 instanceof 比反射快得多。
  • clazz.getMethod("sayHello"):JVM 从 mirror 对象反查得到 instanceKlass,再从_methods数组里找到对应的 JVM 内部 Method 元数据,然后包装出一个java.lang.reflect.Method对象返回给 Java 层。反射慢,除了 native 边界,还有这个“每次都要创建新包装对象”的开销。
  • Hello.class:这是类字面量,本质上是编译期就确定的常量,JVM 在解析ldc指令时,从运行时常量池里拿到类的直接引用,再返回对应的 mirror。这一步不会触发初始化。

把这三条路径放在一起会发现一个共性:所有 Java 层操作,最后都要落到 klass 这个“根”上。理解了这一点,你就不会再被“Class 对象里是不是存了方法实现”这种问题绕晕,因为方法的实现地址在 vtable / Method 元数据里,不在 mirror 里。

3.4 谁来决定类是否初始化

类初始化的触发条件常被整理成六个场景:new 对象、调用静态方法或读取静态字段、反射主动调用、初始化子类导致父类初始化、作为启动类、JDK 7 之后 MethodHandle 相关的动态调用。这些条件最终都会去修改 instanceKlass 的_init_state字段。

有一个必须分清的点:创建 mirror 发生在加载阶段,而初始化是加载之后的独立阶段。所以Hello.class虽然能让你拿到 Class 对象,但类可能还没初始化;Class.forName("Hello")如果默认不传 initialize=false,就会强制把类推到 initialized 状态。一个很容易验证的例子是:一个类里有静态代码块static { System.out.println("init"); },你写System.out.println(Hello.class)不会打印,写Class.forName("Hello")会打印。

还有一个细节:读取一个类的final static基本类型常量(编译期常量)不会触发初始化,因为编译器把值直接内联到调用方的常量池里了,根本不需要加载目标类。但如果这个常量是static final的引用类型,比如static final String S = new String("x"),那读取它仍然会触发初始化。这类题目经常出现在面试八股里,理解了 klass 状态机之后就不用死记。

4. 从永久代到元空间:方法区搬家背后的设计考量

4.1 永久代为什么被嫌弃

JDK 7 之前,永久代是方法区的实现,但它有几个与生俱来的问题。

第一,永久代大小是固定的,由-XX:PermSize-XX:MaxPermSize控制。类加载一多,很容易出现java.lang.OutOfMemoryError: PermGen space,而这个溢出不一定是内存真的不够,有时候只是永久代上限设小了。第二,永久代和堆共享物理内存但有一套独立的回收逻辑,导致 GC 复杂度上升。第三,JIT 编译产物、字符串常量池等数据也堆在永久代里,让这块区域的增长路径变得很难预测。动态生成类的框架(CGLIB、Groovy、JSP 编译)一多,永久代就像个无底洞。

还有一个和 klass 相关的痛点:当时字符串常量池也在永久代,String.intern()用多了就会出现永久代溢出。这直接推动了 JDK 7 把字符串常量池挪到堆里,为最终废弃永久代做铺垫。

4.2 元空间:klass 的新家与内存限制

JDK 8 之后,HotSpot 用元空间替代永久代。元空间并不在 Java 堆里,而是使用操作系统本地内存,由 JVM 内部的虚拟内存管理模块负责分配和回收。

klass 结构体、ConstantPool、Method、Field 等类元数据现在都分配在元空间。-Xmx管不到它,想限制只能设置-XX:MaxMetaspaceSize。默认不设置的话,JVM 会根据自己的判断增长,但最终会受物理内存限制。还有一个-XX:MetaspaceSize是用来设置触发类卸载回收的阈值,不是“初始分配大小”,这个参数经常被误解。

实际运营里,我强烈建议生产环境一定要设MaxMetaspaceSize。不是为了限制类加载,而是为了在泄漏发生时尽早暴露。如果不设,元空间可以一直被撑到操作系统内存耗尽,到时候排查的复杂度和损失都会翻倍。

4.3 字符串常量池、静态变量和运行时常量池,各自在哪

这是面试里最容易混的一块,我用一张表把位置说清楚:

数据JDK 7 之前JDK 7JDK 8 及之后
klass 类元数据永久代永久代元空间
字符串常量池(StringTable)永久代
类静态变量实例永久代(随 mirror)堆(随 mirror)堆(随 mirror)
运行时常量池永久代永久代元空间
JIT 编译产物永久代(早期)元空间侧? / CodeCacheCodeCache

这里要特别解释两个词:

  • 运行时常量池:每个类加载后,都会把 Class 文件常量池里的符号信息搬运到一个内存里的 ConstantPool 对象中,这就是运行时常量池,在元空间里。解析阶段会把符号引用换成直接引用,写回这个池子。
  • 字符串常量池(StringTable):它是全局的一张哈希表,存放String对象的引用。JDK 7 之后,它和堆里的 String 对象一起待在堆里。运行时常量池里的CONSTANT_String_info在解析后会指向 StringTable 中的一个字符串实例,但字符串实例本身不在元空间。

还有一个高频考点:类静态变量存哪里。准备阶段会给静态变量分配内存,但物理上这个内存是 Class 对象(mirror)实例数据的一部分,所以静态变量跟着 mirror 走,在堆里。JDK 8 之后再说“static 变量在方法区”就是错的。你可以通过一个现象验证这个结论:大量加载类但不卸载,堆里能看到很多 java.lang.Class 对象,而这些 Class 对象会把自己的字段数据(包括静态变量)一起带走。

4.4 类卸载到底什么时候发生

klass 在元空间里,但元空间不是自动无脑回收的,必须等类卸载。类卸载条件非常严格:

  1. 该类的所有实例都已被回收;
  2. 加载该类的 ClassLoader 实例已经不可达;
  3. 该类对应的 Class 对象已经没有可达的引用(包括反射、类字面量等)。

三个条件缺一不可。系统类加载器加载的类(String、ArrayList 这些)永远满足不了第二条,所以永远不卸载。只有自定义 ClassLoader 加载的类,才有机会被回收。

这也是动态代理、热部署、脚本引擎场景容易踩坑的根本原因:框架把自定义 ClassLoader 一直被业务代码持有,导致第二条永远不成立,于是每次重新部署都生成一大批新的 instanceKlass,旧的那批又卸不掉,Metaspace 就一路涨上去。

排查时可以用jmap -clstats <pid>看每个 ClassLoader 加载的类数量和占用空间,再用jcmd <pid> VM.metaspace看元空间的分区使用情况。如果发现某个 ClassLoader 的类数量持续增长,基本就是引用泄漏。

5. 面试高频点与排查实操:怎么验证你对 klass 的理解

5.1 几个常被默认“对”的八股文陷阱

我整理了四个高频判断,全是面试里容易被带偏的地方:

说法正确性一句话纠正
Class 对象存放在方法区Class 对象在堆,方法区里的是 klass 结构体
对象头里的 klass 指针指向 Class 对象指向 instanceKlass,要再通过 _java_mirror 才到 Class 对象
JDK 8 的方法区就是元空间,完全不在堆里半对元空间是 HotSpot 对方法区的实现,大部分类元数据不在堆;但字符串常量池、静态变量、Class 对象都在堆
一个类只能被加载一次同一个类可以被多个 ClassLoader 分别加载成多个 instanceKlass,同一个 ClassLoader 命名空间内才只加载一次

你会发现这些陷阱都指向同一个根源:没分清 klass 和 mirror 这两层。一旦从“对象模型分成 oop 和 klass”这个角度去理解,上面的判断都能一眼看穿。

5.2 用工具实际看一次 klass 和 mirror

光看理论不够,我建议你实操一次。写一个最简单的类,启动后 sleep 住。然后:

jhsdb clhsdb --pid <pid>

进入交互命令行后,先输入classes,会列出当前 JVM 里已加载的类及其 klass 地址。找到你的类,比如com.example.demo.Person,它前面会有一个地址,类似0x0000000800000000。再输入:

inspect 0x0000000800000000

就能看到这个 instanceKlass 对象的内部字段,包括_name_java_mirror_methods等。如果你把_java_mirror前面的地址复制出来,再inspect一次,会看到它是一个 java.lang.Class 对象。这一步做完,你对“Class 对象在堆、klass 在元空间”的理解就再也不会忘了。

JDK 8 上没有jhsdb的话,可以退而求其次:用jmap -clstats <pid>看类加载统计,用jcmd <pid> GC.class_histogram(新版)看 Class 对象的堆占用。虽然不是直接展示 klass 内部字段,但能帮你建立“Class 对象是堆对象”的直观感受。

5.3 我踩过的两个排查坑

第一个坑是关于 Metaspace OOM 的。有一段时间我负责的服务频繁 OOM,我习惯性先抓 heap dump 分析,结果看到堆里有很多 java.lang.Class 对象,第一反应是“类对象太多了,是不是缓存了 Class”。后来发现,真正的问题是系统里用了一个动态脚本引擎,每次执行脚本都会用一个新的 ClassLoader 加载生成的脚本类,而这些 ClassLoader 一直被会话对象引用,根本无法回收。heap dump 里看到的 Class 对象只是表象,根源是元空间里的 klassen 占满。那次之后我学会了:Metaspace 问题一定要结合jmap -clstatsjcmd VM.metaspace一起看,不能只盯着堆。

第二个坑是对-XX:MaxMetaspaceSize的误解。有次我把 MaxMetaspaceSize 设得很小,以为能控制内存,结果服务一启动就不停 Full GC,因为元空间频繁达到上限,JVM 反复触发类卸载扫描,性能急剧下降。后来才理解,这个参数更像一个“安全阀”,设太紧会引发持续回收;正确的做法是设一个正常业务的 1.5 到 2 倍作为阈值,同时监控它,而不是拿它当内存限制器。

5.4 给后来者的学习路径

如果你想把这块彻底吃透,我的建议分三步走:

第一步,用javap -v反编译一个简单类的字节码,先看 Class 文件常量池,知道.class里到底存了什么。第二步,用本文提到的 jhsdb 实际 inspect 一个 instanceKlass,找到_java_mirror。第三步,打开 OpenJDK 源码,只看两个文件:src/hotspot/share/oops/instanceKlass.hppsrc/hotspot/share/oops/klass.hpp,不用全读,只读字段名和注释。很多字段你一眼就能认出来,因为它们全是你在 Java 层背过的概念。

如果让我只记住一句话,我会说:klass 是类在 JVM 内部的“户籍档案”,Class 对象是这个档案在 Java 世界里的“办事窗口”。窗口可以有很多操作入口,但真正做事的,永远是背后的档案。把这个结构刻进脑子里,再看任何 JVM 问题,都会觉得思路清晰很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询