【022】JVM 运行时数据区与对象创建

前面我们聊过 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)

加载阶段完成三件事:

  1. 通过类的全限定名获取类的二进制字节流
  2. 将字节流转化为方法区的运行时数据结构
  3. 在堆中生成一个 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(自己加载)

双亲委派的好处

  1. 保证类的唯一性(不会加载用户自定义的 java.lang.String)
  2. 保证安全性(核心类库不会被篡改)
  3. 避免重复加载

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。

相关推荐
Xpower 171 小时前
OpenClaw Token 优化的技术方案与实践:OpenSpace 自进化 Skill 引擎
java·开发语言·人工智能
虎子_layor1 小时前
Headless Chrome 该退休了?Obscura 正在给 AI Agent 换浏览器底座
前端·人工智能·后端
李日灐1 小时前
<4>Linux 权限:从 Shell 核心原理 到 权限体系的底层逻辑 详解
linux·运维·服务器·开发语言·后端·面试·权限
Victor3562 小时前
MongoDB(100)如何解决性能瓶颈?
后端
神奇小汤圆2 小时前
面试官:“线上突然大量报错,你先查什么?” 我:“先查今天谁发了版” 面试官:......
后端
阿Y加油吧2 小时前
算法二刷复盘:LeetCode 79 单词搜索 & 131 分割回文串(Java 回溯精讲)
java·算法·leetcode
Victor3562 小时前
MongoDB(99)如何处理MongoDB中的孤立数据?
后端
-凌凌漆-2 小时前
【QML】qml和C++中同时使用单例模式
java·c++·单例模式
掘金者阿豪2 小时前
时序数据库选型避坑指南:为什么我们最终选择了IoTDB
后端