类加载机制

面试官问:"Java 类的加载过程是怎样的?"

候选人小张说:"分三步:加载、链接、初始化。"面试官追问:"那链接里面有哪些步骤?"

小张停顿了一下,说:"验证和准备..."面试官:"还有呢?"

小张说不全了。面试官继续追问:"什么是主动使用和被动使用?"

小张彻底卡住...

这道题看似简单,但 80% 的候选人会在第二层追问上翻车——链接里的解析这一步到底是干什么的,初始化时机到底是什么,主动使用和被动使用的边界在哪里。

一、类加载的全流程 🔴

1.1 七步走:加载到初始化

JVM 的类加载过程分为五个阶段(也有说法是七个阶段,加载被拆得更细):

加载(Loading)

验证(Verification)

准备(Preparation)

解析(Resolution)

初始化(Initialization)

使用(Using)

卸载(Unloading)

其中加载、验证、准备、解析、初始化属于类加载的五个阶段。

1.2 逐阶段解析

阶段一:加载(Loading)

做什么:通过类的全限定名找到类的字节码文件,将字节码文件转化为方法区的运行时数据结构,在堆中生成一个 Class 对象作为访问方法区数据的入口。

// JVM 规范定义了类加载器的职责:
// 1. 通过类的全限定名获取类的二进制字节流
// 2. 将字节流转化为方法区的运行时数据结构
// 3. 在堆中生成 java.lang.Class 对象

// 类的来源可以是:
// - 本地文件系统(大多数情况)
// - JAR/WAR 包
// - 网络(Applet)
// - 动态代理(ProxyGenerator)
// - 数据库
// - 从其他文件格式解析(JSP 编译后的类)

阶段二:验证(Verification)

做什么:确保加载的字节码是合法的、不会危害 JVM 安全。

验证是 JVM 的安全防线,分为四个层级:

验证阶段
  ├── 文件格式验证
  │     └── 魔数(0xCAFEBABE)、版本号、常量池是否有无效类型
  ├── 元数据验证
  │     └── 是否有父类、是否继承了 final 类、是否实现了抽象方法
  ├── 字节码验证
  │     └── 跳转指令是否越界、操作数栈类型是否匹配
  └── 符号引用验证
        └── 能否找到对应的类、方法、字段
⚠️

面试陷阱:有人说"验证阶段可以禁用来提升性能"。生产环境禁止禁用验证,因为加载了被篡改的字节码会导致 JVM 崩溃或安全漏洞。只有在确定字节码来源可信(如预编译的内部模块)时才考虑。

阶段三:准备(Preparation)

做什么:为类变量(static 修饰的变量)分配内存,并设置零值

// 这个阶段的行为
public class Demo {
    // 类变量(准备阶段分配内存,初始化阶段赋值为 10)
    public static int value = 10;

    // 常量(编译时就确定了,直接赋值为 100)
    public static final int CONST = 100;
}

准备阶段的内存分配:

变量类型准备阶段值初始化阶段值
static int a0(零值)10
static final int b100(编译期常量)无需初始化
static Object objnullnew Object()

关键点static final 常量在编译阶段会将值写入字节码的 ConstantPool,准备阶段直接赋值,不需要零值。

阶段四:解析(Resolution)

做什么:将符号引用替换为直接引用。

这是最容易混淆的一个阶段。

符号引用 vs 直接引用

符号引用(Symbolic Reference):
  - 编译阶段不知道对象的具体内存地址
  - 用一组符号来描述引用的目标
  - 例如:com.example.Demo 类的 println 方法
         → CONSTANT_Class #4
         → CONSTANT_Methodref #6.#20

直接引用(Direct Reference):
  - 已经知道对象的具体内存地址
  - 可以是直接指向目标的指针、相对偏移量、句柄
  - 例如:0x0000F7A0(方法区的实际地址)

解析可能发生在初始化之前(大多数情况),也可以在初始化之后(invokedynamic 指令使用)。

阶段五:初始化(Initialization)

做什么:执行类构造器 <clinit>() 方法,为类变量赋予正确的初始值。

<clinit>() vs <init>()

<clinit>()<init>()
所属类构造器实例构造器
来源static 变量赋值语句 + static{}构造函数 + 实例代码块
执行时机类初始化时实例创建时
执行顺序按源文件顺序,父类先于子类父类构造函数先于子类
public class Demo {
    static int a = 1;
    static {
        System.out.println("static block, a = " + a);  // 此时 a = 1
    }

    public static void main(String[] args) {
        System.out.println(Demo.a);  // 触发初始化
    }
}

1.3 初始化的六种触发条件

JVM 规范定义了主动使用(触发初始化)的六种情况:

1. new 实例化
2. 读取或设置 static 字段(final 除外)
3. 调用 static 方法
4. 反射(Class.forName("xxx"))
5. 初始化子类时,先初始化父类
6. 主类(包含 main 方法的类)

被动使用(不触发初始化):

// 被动使用例子一:通过子类引用父类的 static 字段
class Parent {
    static int value = 100;
    static { System.out.println("Parent init"); }
}
class Child extends Parent {}
// 不会触发 Parent 初始化,因为 static 字段是父类的
System.out.println(Child.value);

// 被动使用例子二:数组引用
Parent[] arr = new Parent[10];
// 不会触发 Parent 初始化,触发的是数组类型的初始化

【面试官心理】 这道题我能追问到 P7 级别,是因为类加载机制涉及到 JVM 的底层实现。能说清 <clinit><init> 区别、主动使用和被动使用边界的候选人,说明他对 JVM 的理解不止于表面。

二、类加载器与双亲委派 🟡

2.1 三层类加载器

加载器加载范围路径
Bootstrap ClassLoader核心 Java 类库$JAVA_HOME/jre/lib/
Extension ClassLoaderext 目录下的类$JAVA_HOME/jre/lib/ext/
Application ClassLoader应用 classpathCLASSPATH 环境变量

2.2 双亲委派模型

Application ClassLoader
        ↓ 加载前先问父加载器
Extension ClassLoader
        ↓ 加载前先问父加载器
Bootstrap ClassLoader
        ↓ 如果 Bootstrap 找不到
Extension ClassLoader
        ↓ 如果 Extension 找不到
Application ClassLoader
        ↓ 最后由自己加载

为什么需要双亲委派?

核心目的:安全。确保同一个类不会被不同的加载器加载多次,避免用户自定义的 String 类覆盖核心类库。

// 如果没有双亲委派
// 恶意代码可以定义 java.lang.String 类,覆盖核心类库
// 从而绕过安全检查,执行任意代码

// 双亲委派保证:
// java.lang.String 永远由 Bootstrap ClassLoader 加载
// 用户自定义的同名类不会被加载

2.3 类加载的命名空间

每个类加载器都有自己的命名空间。同一个类,被两个不同的加载器加载,在 JVM 中是两个完全不同的类

// 如果有两个类加载器 L1 和 L2
Class<?> c1 = loader1.loadClass("com.example.Foo");
Class<?> c2 = loader2.loadClass("com.example.Foo");

// c1 != c2,它们不兼容
// 不能将 c1 的实例赋值给 c2 类型
// 不能用 c2 的 Class 对象调用 c1 的方法

三、生产中的类加载问题 🟡

3.1 类加载导致的 Full GC

// 问题代码:每次请求都 new 一个新的 ClassLoader
public Class<?> loadClassFromDB(String className, byte[] bytecode) {
    // 每次都创建新的 URLClassLoader
    URLClassLoader loader = new URLClassLoader(new URL[0]);
    return loader.defineClass(className, bytecode, 0, bytecode.length);
}

问题:大量自定义 ClassLoader 导致 Metaspace 膨胀,触发 Full GC。

3.2 类加载器的内存泄漏

Tomcat 的类加载器泄漏问题(Tomcat 7 时代尤为突出):

  • 每个 Web 应用有一个独立的 ClassLoader
  • 应用关闭时,如果 ClassLoader 引用了其他对象(单例、静态集合),会导致 ClassLoader 无法被回收
  • 多次热部署后,Metaspace 持续增长

排查工具

# 查看 Metaspace 使用
jstat -gc 12345 1000

# 输出:
#  MC: Metaspace 容量 (KB)
#  MU: Metaspace 使用量 (KB)
#  CCSC: 压缩类空间容量
#  CCSU: 压缩类空间使用量
💡

面试加分点:能说出"JDK 8 的 Metaspace 默认无上限,在容器化环境中容易被不规范的代码撑爆,导致节点 FGC。推荐设置 -XX:MaxMetaspaceSize=256m",说明他有生产环境的实际踩坑经验。

四、类加载器层级关系 🟢

4.1 类加载器的代码层级

public class ClassLoaderTest {
    public static void main(String[] args) {
        // 获取当前类的类加载器
        ClassLoader cl = ClassLoaderTest.class.getClassLoader();
        System.out.println(cl);   // AppClassLoader
        System.out.println(cl.getParent());  // ExtClassLoader
        System.out.println(cl.getParent().getParent());  // null (Bootstrap)
    }
}

4.2 自定义类加载器的加载顺序

// 自定义 ClassLoader 的 findClass 模板
class MyClassLoader extends ClassLoader {
    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        // 1. 查找字节码文件
        String path = "d:/classes/" + name.replace('.', '/') + ".class";
        byte[] bytecode = Files.readAllBytes(Paths.get(path));

        // 2. 调用 defineClass 将字节码转化为 Class 对象
        return defineClass(name, bytecode, 0, bytecode.length);
    }
}
⚠️

面试陷阱:有人说"自定义 ClassLoader 必须重写 loadClass"。准确答案是:只需要重写 findClass,因为 loadClass 实现了双亲委派逻辑,重写它会破坏委派机制。除非你有明确的理由要打破双亲委派。

【架构权衡】 类加载机制看似是 JVM 的底层知识,但它直接决定了:

  • OSGi 和 Tomcat 的多模块隔离方案能否正常工作
  • SPI(JDBC、JNDI)能否跨模块加载实现类
  • 热部署和热替换(JRebel)能否在不重启 JVM 的情况下更新代码

理解类加载,是理解 Java 生态隔离机制的前提。