字节码的"变身记":从.class文件到运行时对象
一、类加载阶段
rust
.class文件 -> 加载(Loading) -> 链接(Linking) -> 初始化 -> 使用 -> 卸载
^
验证>准备>解析
前两篇我们完成了:
解码:拆解了.class文件的二进制结构(CAFEBABE、常量池、方法表...)
定位:追踪了ClassLoader如何找到目标字节码(双亲委派、SPI机制)
那么问题来了:找到的字节码如何"活"起来,变成我们能new出来的对象?
让我们通过一个具体例子,追踪类的完整加载过程:
arduino
// Main.java
public class Main {
public static void main(String[] args) {
System.out.println("开始加载Student");
Student student = new Student("张三", 20);
System.out.println(student);
}
}
// Person.java
class Person {
static {
System.out.println("Person静态块执行");
}
static final String SPECIES = "人类"; // 准备阶段赋值
static int population = 0; // 准备阶段:0,初始化:0
String name;
Person(String name) {
this.name = name;
population++;
}
}
// Student.java
class Student extends Person {
static {
System.out.println("Student静态块执行");
}
static final int MAX_AGE = 25; // 准备阶段赋值
static int studentCount = 0; // 准备阶段:0
int score;
Student(String name, int score) {
super(name);
this.score = score;
studentCount++;
}
}
执行流程:
markdown
1. 执行"java Main"
↓
2. 加载Main类(触发原因:主类)
↓
3. 初始化Main类,执行main()方法
↓
4. main()第一行:打印"开始加载Student"
↓
5. main()第二行:new Student("张三", 20)
↓
6. 检查Student类是否加载 → 未加载
↓
7. 加载Student类(Loading阶段)
↓
【关键步骤】在加载Student时,JVM发现其父类Person未加载
↓
8. 先加载父类Person
- 读取Person.class二进制
- 创建Person的Klass(元空间)
- 递归检查Person的父类(Object)并加载
↓
9. 然后加载Student类
- 读取Student.class二进制
- 创建Student的Klass(元空间)
- 建立继承关系:Student.klass.super = Person.klass
↓
10. 链接Person类(Linking阶段)
- 验证Person字节码
- 准备Person的静态变量:
SPECIES = "人类"(final,直接赋值)
population = 0(默认值)
- 解析:延迟进行
↓
11. 链接Student类(Linking阶段)
- 验证Student字节码
- 准备Student的静态变量:
MAX_AGE = 25(final,直接赋值)
studentCount = 0(默认值)
- 解析:延迟进行
↓
12. 【注意】此时两个类都已加载和链接,但都未初始化
↓
13. 初始化Student类前,发现父类Person未初始化(JVM检查)
↓
14. 初始化Person类(父类优先)
- 执行Person.<clinit>()
- 打印"Person静态块执行"
- population初始化为0(其实还是0,因为默认值就是0)
↓
15. 初始化Student类
- 执行Student.<clinit>()
- 打印"Student静态块执行"
- studentCount初始化为0
↓
16. 创建Student实例
- 调用Student.<init>构造器
- 先调用super() → Person.<init>
- population++ (1)
- studentCount++ (1)
Loading阶段的核心问题
放哪里? JVM加载后的Klass、Class分别数据存放在JVM规定内存的哪块区域?
首先需要对JVM内存区域划分有个大致的概念,参考以下草绘图
图中两处⭐️标记出了class在内存中的存放位置。
1. Meta Space(元空间)⭐️
存放内容:Klass信息
-
Klass :HotSpot虚拟机内部用来表示Java类的C++对象
-
特点:
- JVM内部使用的数据结构
- 包含完整的类元信息(vtable、方法代码、布局信息等)
- 分配在本地内存(Native Memory),不在Java堆中
- 在Loading阶段创建
2. Java Heap(堆)⭐️
存放内容:Class对象
-
java.lang.Class:Java程序运行时使用的类对象
-
特点:
- Java标准的反射API入口
- 作为锁对象(
synchronized(MyClass.class)) - 分配在Java堆中,参与GC
- 注意 :不是在Loading阶段创建,而是在Initialization阶段创建
当Klass对象在本地内存中被创建完成即代表Loading阶段完成,下一个阶段------Linking(验证、准备、解析)------将为类的"活动"做好最后的安全检查和资源准备。
Linking阶段的核心问题
Verification检查文件内容各项是否符合JVM规范
markdown
1. 格式验证:魔数、版本、常量池格式
2. 元数据验证:继承规则、final约束
3. 字节码验证:类型安全、控制流
4. 符号验证:引用存在性
Preparation为静态变量分配内存(元数据区),设置默认值
java
//比如各类型赋默认值
static int x = 5; // 准备阶段:x = 0
static boolean a = true; //a = false
static Object o = new Object(); //o = null
//特殊处理:final常量准备阶段即赋值
//准备阶段会直接赋值通常需要符合:1.final修饰,2.基本类型,3.编译阶段能确定的值(常量/常量表达式)
static final int y = 10; // 准备阶段:y = 10(常量)
Resolution解析引用
将符号引用转换为直接引用的过程,HotSpot默认采用延迟解析策略------只有在第一次实际使用时才进行解析和绑定,解析结果会缓存起来供后续使用,这显著提升了启动性能和减少了内存占用。
shell
字节码中的符号引用如前文中提到的
Constant Pool:
#1. Methodref #...
#2 Fieldref #...
#3. NameAndType #...
...
直接引用是"可以直接被CPU使用的内存地址信息" ,它可能是指针、偏移量或索引,但最终目的都是避免运行时的查找过程。
Initialzation阶段的核心问题
- 加载的最终阶段,让类可以正式投入使用:
- 创建Class对象(堆内)
- 执行
<clinit>()方法 - 建立Klass与Class对象的双向引用
<clinit>() 方法是由 Java 编译器自动生成的类初始化方法 ,它负责处理所有静态成员的初始化逻辑。(如果类不存在静态变量和静态代码块,则JVM不会处理)
-
<clinit>()具体做了些什么?- 静态变量的最终赋值
css在准备阶段已经为静态变量赋了默认值比如 static int a = 5; 此时a = 0 -> a = 5;- 静态代码块执行
arduinostatic { // do smt }
相当于是Class存在于JVM内部的构造函数,只负责静态部分,保证一次且仅一次执行,这也是为什么静态代码中不能访问实例成员--因为此时可能还没有任何实例被创建。
二、运行时对象实例
对象创建位置与内存分配
大多数情况下,新创建的对象都会在Young Gen - Eden区分配,针对大对象,基于不同的收集器,会有不同的分配策略。
CMS收集器 (Concurrent Mark-Sweep)
· 大对象直接进入Old Gen 参数:-XX:PretenureSizeThreshold=3M;任何大小超过这个参数阈值的对象,将直接在老年代分配,避免大对象在eden区创建导致空间不足以及Eden -> Survivor之间的复制。
CMS老年代是使用并发标记-清除算法,不涉及对象复制,直接将大对象分配在老年代可以避免昂贵的复制开销和触发YoungGC对年轻代造成影响。
·风险点 如果老年代空间不足,或因为大对象生命周期很短,会引发频繁且不必要的fgc。
G1收集器(Garbage-First)
·大对象有专门区域:Humongous Region 如果一个对象大小超过单个Region大小的50%, 就会被定义为大对象。
大对象不在Eden Region分配,分配时,G1会尝试找到连续的、空闲的Region 来存放,这些被大对象占用的Region即Humongous Region。
因为G1是将堆划分成N个固定大小的Region进行管理,没有物理上的Young/Old Gen之分,所以为大对象专门设计了管理区域,避免碎片化。
-XX:G1HeapRegionSize决定了Region的大小,也决定了大对象的定义阈值,比如(-XX:G1HeapRegionSize=2M)即超过1M的对象就会进入Humongous Region。
对象的组成结构
运行时对象在堆中的结构由三部分组成:
- 对象头:Mark Word/Klass指针/数组长度(如果是数组对象)
- 实例数据
- 缓存行填充(确保缓存行对齐,大小保持8的倍数)
主要关注对象头⭐️
Mark Word
Mark Word 是 Java 对象头的一部分,存储了对象的运行时状态信息。其结构在不同状态下会复用相同的 32/64 位空间,以节省内存,以64位为例:
- 无锁状态

- Lock(2bits): 01
- biased_lock(1bit): 0
- age(4bits):对象分代年龄0~15
- identity_hashcode(31bits):仅调用hashcode()后会生成,对象哈希值
- cms_free(1bit):CMS垃圾收集标记
- unused(25bits):未使用空间
仅无锁状态下才会在Mark Word中存储哈希值,线程获取锁时默认切换偏向锁状态
- 偏向锁状态

- Lock(2bits):01
- biased_lock(1bit):1(标记为偏向状态)
- age(4bits):0~15
- thread ID(54bits):持有偏向锁的线程ID
- epoch(2bits):偏向版本号(线程访问时优先检查biased标记,若biased_lock ==1,则检查epoch,若对象epoch==Klass.epoch代表偏向有效,才会去检查threadID是否相等,若epoch != Klass.epoch,则当前线程会尝试CAS重偏向替换threadID,若CAS失败则会升级为轻量锁)
- unused(2bits): 未使用空间
通过无锁和偏向锁对比可以看出,无锁状态有31bits空间存储hashcode,而偏向锁优化时,需要54bits空间来存放持有的线程ID,此时hashcode与threadID互斥,先获取hashcode则无法进入偏向锁状态,先进入偏向锁状态再获取hashcode则会强行撤销偏向锁状态进入无锁状态(后续再发生竞争则会直接进入重量锁状态)
- 轻量级锁状态

- Lock(2bits):00(代表轻量锁状态)
- prt_to_lock_record(62bits):指向栈帧中锁记录的指针
轻量级锁的加锁过程便是通过CAS尝试将无锁/偏向锁状态的对象头替换为指向锁记录的指针无锁状态可直接获得锁,偏向锁状态会先尝试偏向锁撤销,退回无锁状态再尝试替换对象头,成功则获取轻量级锁,失败则膨胀为重量级锁 在轻量锁状态获取hashcode同样会直接膨胀为重量锁
- 重量级锁

- Lock(2bits):10
- prt_to_monitor(62bits):指向ObjectMonitor的指针
重量级锁状态会创建ObjectMonitor实例,其中存储了锁关键信息,如:owner,原始对象头,竞争队列,等待队列,重入次数,等待线程数,以及hashcode等信息到这一步才算真正到达了系统层面的线程竞争,通过操作系统原语实现线程调度/上下文切换/CAS自旋等复杂机制
总结
从.class文件到运行时对象,JVM通过类加载机制(加载-链接-初始化)在元空间创建Klass结构、在堆中创建Class对象,最终实例化时在堆中分配对象内存,其对象头(Mark Word)会根据使用情况动态变化锁状态(无锁/偏向锁/轻量锁/重量锁),整个过程体现了延迟加载、分层初始化、空间复用等优化设计,让字节码"活"成真正的Java对象。