前面我们聊过 JVM 内存模型的基础(013 篇),这篇我们来深入细节:Class 文件长什么样?类是怎么加载的?对象是怎么创建的?内存是怎么分配的?
理解这些底层原理,能帮你:
- 更好地理解 Java 程序的运行机制
- 排查 ClassNotFoundException、NoSuchMethodError 等问题
- 理解对象的内存布局,优化内存使用
- 为学习 GC 和 JVM 调优打基础
下面我按「Class 文件结构 → 类加载过程 → 对象创建流程 → 内存分配策略」的顺序往下聊。
1. Class 文件结构 📄
1.1 Class 文件是什么?
Java 源文件(.java)经过 javac 编译后,生成字节码文件(.class)。JVM 加载 Class 文件,解释或编译执行。
java
// User.java
public class User {
private Long id;
private String name;
public void sayHello() {
System.out.println("Hello");
}
}
编译后生成 User.class,包含 JVM 能看懂的字节码指令。
1.2 Class 文件结构
Class 文件是一种二进制文件,结构如下:
text
Class 文件结构:
│
├─ 魔数(Magic):0xCAFEBABE(固定)
├─ 版本号(Version):主版本 + 次版本
├─ 常量池(Constant Pool):字面量、符号引用
├─ 访问标志(Access Flags):public、final 等
├─ 类索引(This Class)
├─ 父类索引(Super Class)
├─ 接口索引(Interfaces)
├─ 字段表(Fields)
├─ 方法表(Methods)
└─ 属性表(Attributes)
1.3 常量池
常量池 是 Class 文件中最复杂的部分,存放字面量 和符号引用:
java
// 源码
public class User {
private String name = "Tom";
}
// 常量池中包含:
// 1. 字符串字面量:"Tom"
// 2. 类名:com/example/User
// 3. 方法名:sayHello
// 4. 字段名:name
// 5. 字段类型:Ljava/lang/String;
1.4 方法表
每个方法包含:
- 访问标志(public、private、static 等)
- 方法名索引
- 描述符(参数类型、返回值类型)
- 属性表(包含字节码)
java
// 方法的字节码示例
public void sayHello();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=2, locals=1, args_size=1
0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #3 // String Hello
5: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return
2. 类加载过程 🔄
2.1 类加载的五个阶段
text
类加载过程:
│
├─ 1. 加载(Loading)
│ └─ 读取 Class 文件,生成 Class 对象
│
├─ 2. 验证(Verification)
│ └─ 验证字节码是否合法
│
├─ 3. 准备(Preparation)
│ └─ 分配内存,初始化静态变量
│
├─ 4. 解析(Resolution)
│ └─ 符号引用 → 直接引用
│
└─ 5. 初始化(Initialization)
└─ 执行静态代码块,初始化静态变量
2.2 加载(Loading)
加载阶段完成三件事:
- 通过类的全限定名获取类的二进制字节流
- 将字节流转化为方法区的运行时数据结构
- 在堆中生成一个
java.lang.Class对象,作为方法区数据的访问入口
java
// 加载阶段,JVM 做了什么:
// 1. 读取 User.class 文件
// 2. 在方法区创建类的数据结构
// 3. 在堆中创建 Class 对象
Class<?> clazz = Class.forName("com.example.User");
2.3 验证(Verification)
验证字节码是否安全、合法:
| 验证阶段 | 作用 |
|---|---|
| 文件格式验证 | 魔数、版本号是否正确 |
| 元数据验证 | 语义分析(如父类是否是类) |
| 字节码验证 | 指令是否合法(如类型是否匹配) |
| 符号引用验证 | 能否找到引用的类/方法/字段 |
2.4 准备(Preparation)
准备 阶段为静态变量分配内存并初始化:
java
public class User {
// 静态变量(准备阶段分配内存)
public static int count = 0;
// 静态常量(准备阶段直接赋值)
public static final int MAX = 100;
// 实例变量(不在这阶段分配)
private String name;
}
注意:
- 静态变量在准备阶段分配内存
- 静态变量在准备阶段赋默认值(0、null、false)
- 静态常量 在准备阶段赋实际值
- 静态变量的初始值在初始化阶段赋值
2.5 解析(Resolution)
解析 阶段将符号引用 转换为直接引用:
java
// 符号引用(Symbolic Reference)
// "java/lang/System.out" - 一个字符串
// 直接引用(Direct Reference)
// 一个指针,指向方法区中的实际对象
解析的时机:
- 静态解析:类加载时解析(大部分方法、字段)
- 动态解析:运行时解析(多态、动态代理)
2.6 初始化(Initialization)
初始化 阶段执行静态代码块 和静态变量赋值:
java
public class User {
public static int count = 0;
static {
// 静态代码块
System.out.println("User 类初始化");
count = 10;
}
}
// 触发初始化:
// 1. new User()
// 2. 访问静态字段
// 3. 调用静态方法
// 4. 反射调用
// 5. 子类初始化(父类先初始化)
3. 类加载器 🎯
3.1 类加载器分类
java
// 三层类加载器
ClassLoader loader = User.class.getClassLoader();
System.out.println(loader); // AppClassLoader(应用类加载器)
System.out.println(loader.getParent()); // ExtClassLoader(扩展类加载器)
System.out.println(loader.getParent().getParent()); // BootstrapClassLoader(引导类加载器,null)
| 类加载器 | 加载路径 | 作用 |
|---|---|---|
| Bootstrap | $JAVA_HOME/lib |
加载 JDK 核心类库 |
| Ext | $JAVA_HOME/lib/ext |
加载 JDK 扩展类库 |
| App | classpath | 加载应用类 |
3.2 双亲委派模型
类加载器 采用双亲委派机制:
text
加载 "java.lang.String":
AppClassLoader
→ ExtClassLoader
→ BootstrapClassLoader(找到!返回)
加载 "com.example.User":
AppClassLoader
→ ExtClassLoader
→ BootstrapClassLoader(找不到)
→ ExtClassLoader(找不到)
→ AppClassLoader(自己加载)
双亲委派的好处:
- 保证类的唯一性(不会加载用户自定义的 java.lang.String)
- 保证安全性(核心类库不会被篡改)
- 避免重复加载
3.3 自定义类加载器
java
public class MyClassLoader extends ClassLoader {
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
String path = "D:/classes/" + name.replace('.', '/') + ".class";
try (InputStream is = new FileInputStream(path);
ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
byte[] buffer = new byte[1024];
int len;
while ((len = is.read(buffer)) != -1) {
baos.write(buffer, 0, len);
}
return defineClass(name, baos.toByteArray(), 0, baos.size());
} catch (IOException e) {
throw new ClassNotFoundException(name, e);
}
}
}
4. 对象创建流程 🏗️
4.1 对象创建的步骤
text
new User() 执行流程:
│
▼
1. 检查类是否已加载
│
▼
2. 分配内存
│
▼
3. 初始化零值
│
▼
4. 设置对象头
│
▼
5. 执行构造函数
4.1.1 检查类是否已加载
java
// JVM 执行的伪代码
Class<?> clazz = loadClass("com.example.User"); // 加载 User 类
4.1.2 分配内存
分配内存有两种方式:
java
// 方式 1:指针碰撞(内存连续)
// 适用场景:垃圾回收器使用标记-整理算法
// 原理:空闲指针移动到对象位置
class PointerBumpAllocation {
private long nextFree = 100; // 空闲指针
public long allocate(int size) {
long addr = nextFree;
nextFree += size;
return addr;
}
}
// 方式 2:空闲列表(内存不连续)
// 适用场景:垃圾回收器使用标记-清除算法
// 原理:维护一个空闲列表
class FreeListAllocation {
private Map<Long, Long> freeList = new HashMap<>();
public long allocate(int size) {
// 找到合适的空闲块
long addr = findFreeBlock(size);
freeList.remove(addr);
return addr;
}
}
4.1.3 初始化零值
java
// 分配内存后,JVM 将内存清零
// 所以实例变量的默认值是 0/null/false
class User {
long id; // 0
String name; // null
boolean active; // false
}
4.1.4 设置对象头
对象头包含:
text
对象头:
│
├─ Mark Word:哈希码、GC 年龄、锁状态
├─ Class Pointer:指向方法区的类元数据
└─ Array Length:(数组才有)数组长度
java
// 对象头信息
class ObjectHeader {
// Mark Word(32/64 位)
// 存储:哈希码、GC 分代年龄、锁状态
// Class Pointer
// 指向方法区中的 Class 对象
// Array Length(数组)
// 数组长度
}
4.1.5 执行构造函数
java
// 最后调用构造函数
class User {
private Long id;
private String name;
public User(Long id, String name) {
this.id = id;
this.name = name;
}
}
// JVM 执行:
// 1. 调用父类构造函数
// 2. 初始化实例变量
// 3. 执行构造函数体
5. 对象内存布局 📊
5.1 对象内存布局三部分
text
对象在堆中的布局:
│
├─ 对象头(Header)
│ ├─ Mark Word:8 字节(64 位 JVM)
│ ├─ Class Pointer:8 字节(64 位 JVM,开启压缩 4 字节)
│ └─ Array Length:4 字节(数组才有)
│
├─ 实例数据(Instance Data)
│ ├─ 父类字段
│ └─ 子类字段
│
└─ 对齐填充(Padding)
└─ 8 字节对齐
5.2 对象头详解
Mark Word(64 位 JVM):
| 状态 | 内容 |
|---|---|
| 无锁 | 哈希码(25) + 分代年龄(4) + 偏向锁(1) + 锁标志(2) |
| 偏向锁 | 线程ID(23) + Epoch(2) + 分代年龄(4) + 偏向锁(1) + 锁标志(2) |
| 轻量级锁 | 指向栈中锁记录的指针(30) + 锁标志(2) |
| 重量级锁 | 指向Monitor的指针(30) + 锁标志(2) |
| GC标记 | 空(30) + 锁标志(2) |
5.3 实例数据
字段排列顺序:
java
class Parent {
long a; // 8 字节
int b; // 4 字节
}
class Child extends Parent {
int c; // 4 字节
Object d; // 8 字节(64 位)
}
// JVM 优化:相同宽度的字段放一起
// 实际排列:a(8) + c(4) + padding(4) + d(8) + b(4) + padding(4) = 32 字节
5.4 对齐填充
JVM 要求对象起始地址是 8 的倍数:
java
// 实例数据 22 字节 → padding 6 → 28 字节
// 实例数据 24 字节 → 无需 padding
6. 内存分配策略 💡
6.1 对象优先在 Eden 分配
java
// 大多数对象在 Eden 区分配
User user = new User(); // 在 Eden 区
6.2 大对象直接进入老年代
java
// 大对象(超过阈值)直接进入老年代
byte[] large = new byte[10 * 1024 * 1024]; // 10MB,大对象
阈值配置:
bash
# 默认阈值:eden 区的一半
-XX:PretenureSizeThreshold=1m
6.3 长期存活对象进入老年代
java
// 经历 15 次 Minor GC 后,进入老年代
// 年龄阈值:-XX:MaxTenuringThreshold=15
6.4 动态对象年龄判断
java
// 如果 Survivor 区中相同年龄的所有对象大小之和 > Survivor 区的一半
// 年龄 >= 该年龄的对象直接进入老年代
7. 常见问题与排查 🔍
7.1 ClassNotFoundException
原因:类加载器找不到 Class 文件
排查:
bash
# 检查 classpath 是否正确
java -cp .:lib/* MyApp
# 检查 jar 包是否包含类
jar -tf app.jar | grep User
7.2 NoSuchMethodError
原因:类的方法签名不匹配
排查:
bash
# 检查方法签名
javap -p com.example.User
7.3 内存分配失败
原因:堆内存不足
排查:
bash
# 调整堆大小
java -Xms512m -Xmx2g MyApp
小结
- Class 文件包含魔数、版本号、常量池、字段表、方法表等
- 类加载分为加载、验证、准备、解析、初始化五个阶段
- 双亲委派保证类的唯一性和安全性
- 对象创建流程:检查类 → 分配内存 → 初始化零值 → 设置对象头 → 执行构造函数
- 对象内存布局包括对象头、实例数据、对齐填充三部分
- 内存分配策略:对象优先在 Eden、大对象直接进老年代、长期存活对象晋升老年代
下一篇(023)预告:GC 入门:分代、常见收集器名词、如何读 GC 日志------Minor GC、Full GC、串行收集器、并行收集器、CMS、G1。