Java类加载机制详解

类加载

视频介绍

https://www.bilibili.com/video/BV1a5411g7op

https://www.bilibili.com/video/BV1ZY4y1n7tg

文章介绍

https://mp.weixin.qq.com/s/vFa_n-C2XssixmpOtbfSzA

https://mp.weixin.qq.com/s/MesrMPZDlF4aVSa-bkObLw

https://mp.weixin.qq.com/s/qIjslNrsA2OCWhAAd4k_Eg

一个生动的比喻:做一道菜(红烧肉)

在开始之前,我们先打个比方。你把 Java 程序想象成要做一道“红烧肉”。

  • Java 源代码 (.java 文件) = 菜谱文字版 它只是用人类和 Java 编译器能懂的语言写的步骤,但没法直接执行。
  • 编译后的字节码 (.class 文件) = 准备好的食材包 菜谱 (.java) 被厨师长 (Java 编译器 javac) 处理过了,变成了分门别类、洗净切好的食材包(肉块、香料包等),这些食材包放在冰箱(硬盘)里。它们已经接近可用的状态,但还没下锅。
  • JVM 内存中的 Class 对象 = 下锅烹饪后的成品菜 这才是真正可以“吃”(被 JVM 执行)的东西。我们需要把食材包从冰箱里拿出来,解冻、下锅、烹饪,最终变成一道真正的菜。

“类加载机制”,就是描述 如何从“食材包”(.class 文件)变成“成品菜”(Class 对象) 的这一整套流程和规则。


一、类加载是做什么的?(Why?)

简单来说,Java 程序要运行,需要把编译好的 .class 文件(字节码)加载到内存里,然后才能被虚拟机执行。

但 Java 的厉害之处在于,它不是一次性把所有类都加载进内存,而是 “按需加载” —— 即程序在运行过程中,用到哪个类了,才去加载哪个类。

类加载机制就是负责完成这件事的,它做了三件核心事:

  1. 加载:找到 .class 文件并把它读进来。
  2. 连接:对加载进来的字节码进行校验、准备和解析(稍后细说)。
  3. 初始化:执行类中的静态代码块和给静态变量赋值,让这个类变得“可用”。

二、谁来做这件事?(Who?)

这件事不是由 JVM 亲手做的,而是交给了一个叫做 “类加载器 (ClassLoader)” 的模块。

JVM 自带了三个最重要的类加载器,它们各有分工,形成了一种父子关系(注意:这里的父子是逻辑上的层次关系,不是继承关系):

(上图展示了类加载器的双亲委派模型,下文会解释)

  1. Bootstrap ClassLoader (启动类加载器)
    • 角色:祖师爷,最高领导。
    • 工作:加载 Java 最核心的库,比如 rt.jar(包含 java.lang, java.util 等包)。它是用 C++ 实现的,是 JVM 自身的一部分。
    • 特点:你无法在 Java 代码中直接获取到它的引用。
  2. Extension ClassLoader (扩展类加载器)
    • 角色:大佬的儿子。
    • 工作:加载 JAVA_HOME/lib/ext 目录下的,或者由 java.ext.dirs 系统变量指定的路径下的类库。这些是 Java 的扩展库。
  3. Application ClassLoader (应用程序类加载器)
    • 角色:大佬的孙子,也是最常用的。
    • 工作:加载我们自己写的 Java 程序中的类,也就是 classpath 环境变量或命令行参数 -cp 所指定路径下的类。
    • 别名:也叫 System ClassLoader

除了这三个,我们还可以自定义类加载器。


三、怎么做的?(How?)—— 核心流程:“双亲委派模型”

这是类加载机制最核心、最重要的原则。它的工作流程就像公司审批流程:

“双亲委派模型” (Parents Delegation Model) 规则:

一个类加载器收到加载请求时,它首先不会自己去尝试加载,而是把这个请求向上委托给父加载器去完成。每一层都是如此。只有当父加载器反馈自己无法完成这个加载请求(在自己的搜索范围没找到所需的类)时,子加载器才会尝试自己去加载。

还用做菜比喻: 你想吃红烧肉(加载一个类)。

  1. 你先问你家大厨(Application Loader):“你会做吗?”
  2. 你家大厨说:“我得问我师父(Extension Loader)。”
  3. 师父说:“我得问我师祖(Bootstrap Loader)。”
  4. 师祖(Bootstrap Loader)一看菜名是“红烧肉”,发现自己只会做“宫保鸡丁”(JDK核心类),不会做这个。就把这个结果告诉师父。
  5. 师父(Extension Loader)一看,发现自己只会做“意大利面”(扩展类),也不会做“红烧肉”,就把结果告诉你家大厨。
  6. 你家大厨(Application Loader)发现师祖和师父都不会,终于轮到自己出手了,于是从冰箱(classpath)里拿出“红烧肉食材包”(你写的 .class 文件),开始制作。

这样做的好处(为什么要有这个机制?):

  • 安全:防止核心 Java 类库(如 java.lang.String)被随意替换。比如你自己也写了一个 java.lang.String 类,按照双亲委派,会最终由 Bootstrap 加载器去加载官方的 String 类,而不是你写的那个,这就保证了安全。
  • 稳定:保证了 Java 程序的稳定运行,核心类的类型体系不会被破坏。

四、类加载的详细步骤(What?)

.class 文件到内存中的可用类,具体分为以下 3 个大阶段,5 个小步骤

1. 加载 (Loading)

  • 做什么: 通过类的全限定名(包名+类名)来获取定义此类的二进制字节流(读 .class 文件)。 将这个字节流所代表的静态存储结构转换为方法区的运行时数据结构。 在内存中生成一个代表这个类的 java.lang.Class 对象,作为方法区这个类的各种数据的访问入口。
  • 注意Class 对象是反射机制的基石。

2. 连接 (Linking)

  • ① 验证 (Verification):确保加载的字节流是符合 JVM 规范的,不会危害虚拟机自身安全。比如检查文件格式、语法、字节码是否合法等。这是一道重要的安全防线。
  • ② 准备 (Preparation):为类变量(static 变量)方法区中分配内存并设置初始值(通常是零值)。例如 static int value = 123; 在准备阶段后,value 的值是 0,而不是 123
  • ③ 解析 (Resolution):将常量池内的符号引用替换为直接引用的过程。可以简单理解为将一些模糊的指向(如名字)替换为具体的内存地址指针。这个过程不一定在连接阶段完成,也可能发生在初始化之后。

3. 初始化 (Initialization)

  • 做什么:这才是真正开始执行类中定义的 Java 程序代码(或者说字节码)的阶段。主要是执行 <clinit>() 方法的过程。
  • <clinit>() 方法是什么?它是由编译器自动收集类中的所有类变量的赋值动作静态代码块 (static{}) 中的语句合并产生的。顺序由语句在源文件中出现的顺序决定。
  • 所以,在上面的例子中,static int value = 123; 的赋值操作 123 就是在这个阶段完成的!

总结与记忆技巧

阶段 核心工作 简单理解
加载 找文件,读进来,创建 Class 对象 从冰箱拿食材包
连接-验证 检查字节码是否安全、规范 检查食材是否变质、有毒
连接-准备 给 static 变量分内存,赋“零值” 把肉拿出来解冻(还没调味)
连接-解析 把符号引用变成直接引用 把“香料包”具体化成“八角、桂皮”
初始化 执行 static 代码块和赋值 下锅烹饪,加调料,做成成品菜

触发初始化的时机(什么时候开始做菜?): “菜”不是随时做的,只有在真正要用到的时候才做。比如:

  • new 关键字创建对象时 (new MyClass())
  • 读取或设置一个类的静态字段(被 final static 修饰的常量除外)
  • 调用一个类的静态方法
  • 使用反射 (Class.forName(...))
  • 初始化一个类的子类(会先初始化父类)

代码示例


示例 1:类加载的触发时机

public class ClassLoadDemo {
    static {
        System.out.println("ClassLoadDemo 的静态代码块执行(初始化阶段)");
    }

    public static void main(String[] args) {
        System.out.println("----- 主程序开始 -----");
        // 场景1:主动引用(触发初始化)
        new SubClass(); // 触发子类初始化(会先初始化父类)
    }
}

class SuperClass {
    static {
        System.out.println("SuperClass 的静态代码块执行(父类初始化)");
    }
}

class SubClass extends SuperClass {
    static {
        System.out.println("SubClass 的静态代码块执行(子类初始化)");
    }
}

输出结果:

ClassLoadDemo 的静态代码块执行(初始化阶段)
----- 主程序开始 -----
SuperClass 的静态代码块执行(父类初始化)
SubClass 的静态代码块执行(子类初始化)

关键点:

  1. 主类 ClassLoadDemo 会先被加载并初始化(因为 main 方法是程序入口)。
  2. new SubClass() 时: JVM 发现 SubClass 未被加载,触发类加载流程。 因为 SubClass 继承 SuperClass,所以会先加载并初始化父类(父类优先原则)。 最后初始化子类 SubClass

示例 2:被动引用(不会触发初始化)

public class PassiveReferenceDemo {
    static {
        System.out.println("PassiveReferenceDemo 初始化");
    }

    public static void main(String[] args) {
        System.out.println("----- 主程序开始 -----");
        // 场景1:通过子类引用父类的静态字段(不会触发子类初始化)
        System.out.println(SubClass.value); // value是父类的静态字段
        // 场景2:定义对象数组(不会触发类初始化)
        SuperClass[] arr = new SuperClass[10];
        // 场景3:引用常量(常量在编译期已优化,不会触发初始化)
        System.out.println(SubClass.CONSTANT);
    }
}

class SuperClass {
    static int value = 123;
    static {
        System.out.println("SuperClass 初始化");
    }
}

class SubClass extends SuperClass {
    static final String CONSTANT = "HELLO";
    static {
        System.out.println("SubClass 初始化");
    }
}

输出结果:

PassiveReferenceDemo 初始化
----- 主程序开始 -----
SuperClass 初始化
123
HELLO

关键点:

  1. 被动引用场景(不触发初始化): 通过子类引用父类的静态字段(SubClass.value)→ 只会初始化父类。 定义对象数组(new SuperClass[10])→ 不会初始化类。 引用编译期常量(CONSTANT)→ 直接替换为字面量,不触发初始化。

示例 3:类加载器的层级关系

public class ClassLoaderHierarchy {
    public static void main(String[] args) {
        // 获取当前类的类加载器(Application ClassLoader)
        ClassLoader loader = ClassLoaderHierarchy.class.getClassLoader();
        System.out.println("当前类的加载器:" + loader); // sun.misc.Launcher$AppClassLoader

        // 获取父加载器(Extension ClassLoader)
        ClassLoader parent = loader.getParent();
        System.out.println("父加载器:" + parent); // sun.misc.Launcher$ExtClassLoader

        // 获取祖父加载器(Bootstrap ClassLoader,由C++实现,返回null)
        ClassLoader grandParent = parent.getParent();
        System.out.println("祖父加载器:" + grandParent); // null
    }
}

输出结果(可能因JDK版本不同略有差异):

当前类的加载器:sun.misc.Launcher$AppClassLoader@18b4aac2
父加载器:sun.misc.Launcher$ExtClassLoader@3feba861
祖父加载器:null

关键点:

  1. Bootstrap ClassLoader 是顶级加载器,用 C++ 实现,Java 中无法直接引用,所以返回 null
  2. 类加载器的父子关系是逻辑上的层级,不是继承关系。

示例 4:破坏双亲委派模型(自定义类加载器)

public class CustomClassLoader extends ClassLoader {
    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        // 模拟从非classpath路径加载类(如网络、磁盘其他目录)
        byte[] classData = loadClassData(name); // 需自行实现读取.class文件的逻辑
        if (classData == null) {
            throw new ClassNotFoundException();
        }
        return defineClass(name, classData, 0, classData.length);
    }

    private byte[] loadClassData(String name) {
        // 简化示例:实际应从文件或网络读取.class文件字节流
        return null; // 实际开发需替换为真实逻辑
    }

    public static void main(String[] args) throws Exception {
        CustomClassLoader loader = new CustomClassLoader();
        // 强制让自定义加载器直接加载类(跳过双亲委派)
        Class<?> clazz = loader.loadClass("com.example.MyClass");
        System.out.println("类加载器:" + clazz.getClassLoader());
    }
}

关键点:

  1. 自定义类加载器需继承 ClassLoader 并重写 findClass() 方法。
  2. 破坏双亲委派的场景: 重写 loadClass() 方法(不推荐)。 使用线程上下文类加载器(如 JDBC、JNDI 等场景)。

示例 5:静态变量的准备阶段 vs 初始化阶段

public class StaticFieldDemo {
    static int a = 1;      // 准备阶段:a=0 → 初始化阶段:a=1
    static final int b = 2; // 常量(准备阶段直接赋值)

    static {
        System.out.println("静态代码块执行:a=" + a + ", b=" + b);
    }

    public static void main(String[] args) {
        System.out.println("main方法执行:a=" + a);
    }
}

输出结果:

静态代码块执行:a=1, b=2
main方法执行:a=1

关键点:

  1. 准备阶段a 被赋默认值 0b 因是常量直接赋 2
  2. 初始化阶段:按顺序执行静态赋值和静态代码块,a 被赋值为 1

总结

通过代码示例,可以观察到:

  1. 类加载的触发时机(主动引用 vs 被动引用)。
  2. 双亲委派模型的实际表现(类加载器的层级)。
  3. 静态变量的内存分配流程(准备阶段 → 初始化阶段)。
  4. 自定义类加载器的简单实现(破坏双亲委派)。
← Java反射机制详解 Java动态代理机制详解 →