返回首页面试题

面试必备:JVM 类加载机制深度解析

2026年03月25日8 min read

面试必备:JVM 类加载机制深度解析

类加载的完整过程

源代码 (.java)
        ↓ 编译
字节码文件 (.class)
        ↓ 类加载
Class 对象(存在于方法区/元空间)
        ↓
运行时使用

完整七步

加载 → 验证 → 准备 → 解析 → 初始化 → 使用 → 卸载
  ↓       ↓       ↓       ↓       ↓
 ①      ②       ③       ④       ⑤

第一步:加载(Loading)

做什么

  1. 通过类的全限定名获取类的二进制字节流
  2. 把字节流转化为方法区的运行时数据结构
  3. 在堆中生成 java.lang.Class 对象

类的来源

  • 本地磁盘的 .class 文件
  • JAR 包
  • 网络下载
  • 动态生成(ASM、CGlib)
  • 数据库

第二步:验证(Verification)

确保字节码是安全的、正确的。

验证阶段检查:
├── 文件格式验证    → 魔数、版本号
├── 元数据验证      → 语法检查(是否有父类、是否继承了 final 类)
├── 字节码验证      → 指令是否正确(不会跳到方法外)
└── 符号引用验证    → 引用的类/字段/方法是否存在

第三步:准备(Preparation)

给类变量(static)分配内存并设置零值

public class User {
    static int count = 10;      // 在准备阶段:count = 0
    static final int MAX = 100; // 常量:在准备阶段:MAX = 100(编译期确定)
}

注意

  • static int:初始值是 0,不是 10
  • static final int:初始值是 100(编译时就确定)

第四步:解析(Resolution)

符号引用替换为直接引用

// 符号引用(编译时不知道实际地址)
"Lcom/User;.name" → Ljava/lang/String;
"sayHello()"      → method ref sayHello

// 直接引用(运行时知道内存地址)
#1 = String @0x1234
#2 = Method @0x5678

第五步:初始化(Initialization)

执行 <clinit> 构造器,执行静态变量的赋值和静态代码块

public class User {
    static int a = 10;          // ①
    static {
        a = 20;                 // ②
    }
}
// 初始化顺序:a = 10 → a = 20 → a = 20

第六步 & 第七步:使用 & 卸载

正常使用,直到类不再被需要时被卸载


类初始化的时机(主动引用 vs 被动引用)

主动引用(会立即触发初始化)

// ① new 对象
new User();

// ② 访问类的静态变量(不是 final)
int a = User.count;  // 触发

// ③ 调用类的静态方法
User.say();          // 触发

// ④ 反射
Class.forName("com.User");

// ⑤ 初始化子类,父类先初始化
class Son extends User { }  // User 先初始化

// ⑥ 启动类(main 方法所在类)
public class Test {
    public static void main(String[] args) { }
}

被动引用(不触发初始化)

// ① 访问 static final 常量(编译期确定)
int b = User.MAX;  // MAX 是 static final,不会触发

// ② 通过数组定义
User[] users = new User[10];  // 不会触发

// ③ 子类访问父类的 static 字段
class Son extends User {
    static {
        System.out.println("Son 初始化");  // 不执行
    }
}
Son.count;  // 触发的是 User,不是 Son

双亲委派模型(重点!)

什么是双亲委派?

当类加载器收到加载请求时,先让父类加载器处理,父类处理不了才自己处理

类加载请求
      │
      ▼
┌─────────────────────────────────────────┐
│         Bootstrap ClassLoader            │ ← C++ 实现,无法访问
│         (启动类加载器)                    │
│  JAVA_HOME/jre/lib/*.jar                │
└─────────────────────────────────────────┘
      ↑ 委派
      │
┌─────────────────────────────────────────┐
│         Extension ClassLoader            │
│         (扩展类加载器)                   │
│  JAVA_HOME/jre/lib/ext/*.jar            │
└─────────────────────────────────────────┘
      ↑ 委派
      │
┌─────────────────────────────────────────┐
│         Application ClassLoader          │
│         (应用程序类加载器)               │
│  classpath 中的类                        │
└─────────────────────────────────────────┘
      ↑ 委派
      │
┌─────────────────────────────────────────┐
│         Custom ClassLoader(自定义)      │
│         (自定义类加载器)                  │
└─────────────────────────────────────────┘

工作流程(伪代码)

class ClassLoader {
    protected Class<?> loadClass(String name, boolean resolve) {
        // ① 先检查是否已加载
        Class<?> c = findLoadedClass(name);
        if (c != null) return c;

        // ② 委托给父类加载器
        try {
            ClassLoader parent = this.parent;
            if (parent != null) {
                c = parent.loadClass(name, false);
            } else {
                // 没有父类,委托给 Bootstrap
                c = findBootstrapClassOrNull(name);
            }
        } catch (ClassNotFoundException e) { }

        // ③ 父类加载不了,自己加载
        if (c == null) {
            c = findClass(name);
        }
        return c;
    }
}

为什么需要双亲委派?

核心目的:安全 + 防止重复加载

场景:用户自定义了一个 java.lang.String

如果没有双亲委派:
├─ ApplicationClassLoader 加载
├─ ExtensionClassLoader 加载
├─ BootstrapClassLoader 加载
└─ 三种不同的 String 类存在!

如果有双亲委派:
├─ Application → 委托给父
├─ Extension   → 委托给父
├─ Bootstrap  → 加载 rt.jar 中的 String(官方版)
└─ 用户自定义的 String 永远不会被加载!

三种系统类加载器

加载器负责目录优先级
Bootstrapjre/lib最高
Extensionjre/lib/ext
Applicationclasspath最低

自定义类加载器

public class MyClassLoader extends ClassLoader {
    
    @Override
    protected Class<?> findClass(String name) {
        // 读取 .class 文件
        String path = "D:/classes/" + name.replace('.', '/') + ".class";
        byte[] data = Files.readAllBytes(Paths.get(path));
        
        // 调用父类方法定义类
        return defineClass(name, data, 0, data.length);
    }
}

// 使用
ClassLoader loader = new MyClassLoader();
Class<?> clazz = loader.loadClass("com.example.User");

使用场景

  • 热部署(Tomcat)
  • 加密保护(.class 文件加密)
  • 动态生成类(ORM 框架)

面试高频问题

Q1:为什么重写 loadClass 就不走双亲委派了?

// 如果自定义类加载器重写了这个方法
@Override
public Class<?> loadClass(String name) throws ClassNotFoundException {
    // 直接自己加载,不委托父类
    return findClass(name);
}

则每个类都会尝试自己加载,破坏了双亲委派,安全机制失效。

Q2:Tomcat 为什么打破双亲委派?

Tomcat 需求:
├── 部署多个 web 应用
├── 每个应用可能使用不同版本的同名类
└── 隔离不同应用的类

Tomcat 的类加载顺序(打破双亲委派):

  1. Bootstrap → Extension → common(双亲委派)
  2. WebApp1 → WebApp2(每个应用独立加载)

Q3:类的卸载条件?

一个类被卸载需要满足:

  1. 该类所有实例已被回收
  2. 该类的 ClassLoader 已被回收
  3. 该类的 Class 对象不再被引用

总结

类加载过程:加载 → 验证 → 准备 → 解析 → 初始化

双亲委派:先让父类加载,父类处理不了才自己加载

目的:安全 + 防止类重复加载

打破场景:Tomcat 热部署、自定义加密类加载

加载器层次:
Bootstrap → Extension → Application → 自定义
   高优先级 ←————————————————→ 低优先级

评论区