2026/9/24 21:41:28

JVM中的klass与Class对象:类加载后内存里到底放了什么?

JVM中的klass与Class对象:类加载后内存里到底放了什么? 上个月帮朋友做模拟面试我问了个自认为很基础的问题“你天天用的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.class、obj.getClass()字节码是“设计图纸”klass 是“施工完成后挂在大楼里的结构图纸”而Class对象是“你作为访客拿到的那份导览手册”。三者之间有关联但绝不是同一个东西。这里要特别强调Class对象是存活在堆里的一个普通 Java 对象普通到它也有对象头、也有_klass指针。它唯一的特殊之处在于这个对象是 JVM 在类加载过程中专门创建的Java 代码没法直接 new 一个Class实例构造器是私有的。但这不妨碍它享受堆对象的所有待遇可以成为 GC Roots 的引用目标、可以被 finalize、可以被 synchronized 锁住。1.2 方法区是一个“规范”klass 是 HotSpot 的实现重心如果你看过《Java 虚拟机规范》会发现“方法区”是一个逻辑上的概念规范只规定它存放类信息、常量、静态变量、JIT 编译后的代码等并没有规定具体怎么实现。HotSpot 对方法区的实现经历了两代JDK 7 及之前叫永久代PermGenJDK 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 层有一个经典二分oopordinary object pointer普通对象指针负责描述 Java 对象的实例数据klass 负责描述类的元数据。二者各有职责。先看 oop一个 Java 对象在内存里由对象头mark word klass 指针和实例数据组成。对象头里的 klass 指针指向的是它所属类的 instanceKlass。也就是说任何一个对象只要有了这个指针JVM 就能回答“这个对象是谁创建的”“它有哪些方法可以调”“它有哪些字段”。再看 klass它描述的是“类本身”。比如Person类有name和age两个字段有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[]。它们都继承自arrayKlassarrayKlass 又继承自 klass。也就是说int[].class拿到的是一个 TypeArrayKlass 对应的 mirrorString[].class拿到的是 ObjArrayKlass 对应的 mirror。数组类的 klass 结构里需要额外记录元素类型和维度比如int[][]在 klass 里会有一个指向int[]对应 arrayKlass 的引用这样才能在运行期正确判断数组兼容性和做强制转换检查。面试中容易踩坑的点Integer[].class和int[].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 PersonJVM 拿到 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)如果默认不传 initializefalse就会强制把类推到 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 在元空间里但元空间不是自动无脑回收的必须等类卸载。类卸载条件非常严格该类的所有实例都已被回收加载该类的 ClassLoader 实例已经不可达该类对应的 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 -clstats和jcmd 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.hpp和src/hotspot/share/oops/klass.hpp不用全读只读字段名和注释。很多字段你一眼就能认出来因为它们全是你在 Java 层背过的概念。如果让我只记住一句话我会说klass 是类在 JVM 内部的“户籍档案”Class 对象是这个档案在 Java 世界里的“办事窗口”。窗口可以有很多操作入口但真正做事的永远是背后的档案。把这个结构刻进脑子里再看任何 JVM 问题都会觉得思路清晰很多。