云计算百科
云计算领域专业知识百科平台

深入理解 Java 类加载机制与双亲委派模型

深入理解 Java 类加载机制与双亲委派模型

在 Java 的世界里,有一句著名的口号:“一次编写,到处运行(Write Once, Run Anywhere)”。支撑这一特性的核心基石之一,就是 JVM 的类加载机制。

与 C/C++ 需要在编译时进行链接不同,Java 将类的加载、连接和初始化放在了运行期。这种动态加载机制赋予了 Java 极大的灵活性(如反射、热部署、动态代理等)。

本文将详细拆解类的生命周期,深入剖析类加载的五个阶段,并重点讲解大名鼎鼎的双亲委派模型。


一、类的生命周期

一个 Java 类从被加载到 JVM 内存中,到最终被卸载出内存,它的生命周期共分为 7 个阶段:

  • 加载(Loading)
  • 连接(Linking)
    • 验证(Verification)
    • 准备(Preparation)
    • 解析(Resolution)
  • 初始化(Initialization)
  • 使用(Using)
  • 卸载(Unloading)
  • 其中,加载、验证、准备、初始化和卸载这五个阶段的顺序是确定的。而解析阶段在某些情况下可以在初始化阶段之后再开始,这是为了支持 Java 的动态绑定(晚期绑定)特性。


    二、类加载的详细过程与代码示例

    1. 加载(Loading)

    在这个阶段,JVM 需要完成三件事情:

  • 通过类的全限定名获取定义此类的二进制字节流。
  • 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
  • 在内存中生成一个代表该类的 java.lang.Class 对象。
  • 2. 验证(Verification)

    确保 Class 文件的字节流中包含的信息符合当前虚拟机的要求,保证虚拟机的安全(如检查魔数 0xCAFEBABE、版本号等)。

    3. 准备(Preparation)

    为类变量(被 static 修饰的变量)分配内存并设置零值。

    • 注意:这里不包含实例变量。
    • 注意:如果是 final 修饰的常量,JVM 会在准备阶段直接将其赋值为真实值。

    4. 解析(Resolution)

    将常量池内的符号引用替换为直接引用的过程。

    5. 初始化(Initialization)

    初始化阶段是执行类构造器 <clinit>() 方法的过程。在这个阶段,JVM 才真正开始执行类中定义的 Java 程序代码(如静态变量的真实赋值、静态代码块)。

    🔍 【代码示例:准备阶段 vs 初始化阶段】

    为了直观理解准备阶段和初始化阶段的区别,请看下面这段代码:

    public class ClassInitDemo {
    // 1. 准备阶段:分配内存,赋零值(0)
    // 2. 初始化阶段:赋真实值(10)
    public static int value = 10;

    // 1. 准备阶段:因为是 final 常量,直接赋真实值(20)
    public static final int CONST_VALUE = 20;

    // 静态代码块在初始化阶段执行
    static {
    System.out.println("ClassInitDemo 正在初始化…");
    value = 30; // 覆盖之前的 10
    }
    }

    在类加载的准备阶段,value 会被赋值为 0,CONST_VALUE 直接赋值为 20。
    在初始化阶段,执行 <clinit>() 方法,value 会先被赋值为 10,然后执行静态代码块,打印提示信息,最后 value 被改为 30。

    🔍 【代码示例:什么时候会触发初始化?】

    JVM 严格规定了只有主动引用才会触发初始化。我们可以通过被动引用的例子来验证。

    场景一:通过子类引用父类的静态字段

    class Parent {
    static { System.out.println("Parent 初始化"); }
    public static int value = 100;
    }

    class Child extends Parent {
    static { System.out.println("Child 初始化"); }
    }

    public class Test {
    public static void main(String[] args) {
    // 只输出 "Parent 初始化",不会输出 "Child 初始化"
    // 因为对于静态字段,只有直接定义这个字段的类才会被初始化
    System.out.println(Child.value);
    }
    }

    场景二:通过数组定义来引用类

    public class Test {
    public static void main(String[] args) {
    // 不会触发 Parent 的初始化,只会生成一个 [LParent; 的数组类型对象
    Parent[] parents = new Parent[10];
    }
    }

    场景三:引用 static final 常量

    class Constants {
    static { System.out.println("Constants 初始化"); }
    public static final String HELLO = "Hello World";
    }

    public class Test {
    public static void main(String[] args) {
    // 不会触发 Constants 初始化
    // 因为在编译阶段,HELLO 已经存入 Test 类的常量池中,属于编译期优化
    System.out.println(Constants.HELLO);
    }
    }


    三、类加载器(ClassLoader)

    类加载器负责将 .class 文件加载到 JVM 中。对于任意一个类,都需要由加载它的类加载器和这个类本身一同确立其在 JVM 中的唯一性。

    JVM 提供了以下三种系统提供的类加载器:

  • 启动类加载器(Bootstrap ClassLoader):
    • C++ 实现,加载 <JAVA_HOME>\\lib 目录下的核心类库(如 rt.jar)。
    • 无法被 Java 程序直接引用(在代码中获取会返回 null)。
  • 扩展类加载器(Extension ClassLoader):
    • Java 实现,加载 <JAVA_HOME>\\lib\\ext 目录下的类库。
  • 应用程序类加载器(Application ClassLoader):
    • Java 实现,默认的类加载器,负责加载用户 ClassPath 上的类。

  • 四、双亲委派模型(Parent Delegation Model)

    1. 什么是双亲委派模型?

    双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应当有自己的父类加载器。

    工作流程:
    当一个类加载器收到类加载的请求时,它不会自己先去尝试加载,而是把这个请求委派给父类加载器去完成。每一层都是如此。
    只有当父类加载器反馈自己无法完成这个加载请求时,子加载器才会尝试自己去加载。

    流程图:

    自定义类加载器
    |
    (委派请求) —> 应用程序类加载器
    |
    (委派请求) —> 扩展类加载器
    |
    (委派请求) —> 启动类加载器
    |
    [找到] <———————————- (尝试加载)
    | |
    [加载成功] [找不到]
    |
    [子加载器尝试加载] <—————————– (回退)

    🔍 【代码示例:查看类加载器的委派层级】

    我们可以通过 getClassLoader() 方法来观察类的加载器层级:

    public class ClassLoaderDemo {
    public static void main(String[] args) {
    // 1. String 类由启动类加载器加载,Java 中获取不到,返回 null
    ClassLoader loader1 = String.class.getClassLoader();
    System.out.println("String 的类加载器: " + loader1); // 输出: null

    // 2. 我们自己写的类,由应用程序类加载器加载
    ClassLoader loader2 = ClassLoaderDemo.class.getClassLoader();
    System.out.println("ClassLoaderDemo 的类加载器: " + loader2);
    // 输出: sun.misc.Launcher$AppClassLoader@…

    // 3. 获取应用程序类加载器的父加载器(扩展类加载器)
    System.out.println("AppClassLoader 的父加载器: " + loader2.getParent());
    // 输出: sun.misc.Launcher$ExtClassLoader@…

    // 4. 扩展类加载器的父加载器是启动类加载器,获取不到
    System.out.println("ExtClassLoader 的父加载器: " + loader2.getParent().getParent());
    // 输出: null
    }
    }

    2. 双亲委派模型的优点

  • 保证核心类的安全性。
  • 避免类的重复加载。
  • 🔍 【代码示例:验证双亲委派的安全机制】

    假设我们在自己的项目中,恶意或出于好奇,创建了一个与 JDK 核心类同名的类 java.lang.String:

    package java.lang;

    public class String {
    static {
    System.out.println("我是黑客自己写的 String 类!");
    }

    public static void main(String[] args) {
    System.out.println("尝试执行自定义的 String 类");
    }
    }

    如果你尝试运行这个 main 方法,程序会报错(或者根本加载不到我们写的这个类)。
    原因就在于双亲委派模型:当 AppClassLoader 收到加载 java.lang.String 的请求时,它会一路委派给 Bootstrap ClassLoader。Bootstrap 发现这是核心类,直接加载了 JDK 真正的 String 类,我们写的这个假冒类根本没有机会被加载。这极大地保证了 JVM 的安全。


    3. 如何打破双亲委派模型?(进阶)

    双亲委派模型并不是一个强制性的约束,在以下场景中,它的机制被“打破”了:

  • SPI 机制(如 JDBC):
    • 核心接口 java.sql.Driver 由 Bootstrap ClassLoader 加载。但具体的数据库驱动(如 com.mysql.cj.jdbc.Driver)由 AppClassLoader 加载。
    • 为了解决父加载器无法看到子加载器加载的类的问题,引入了线程上下文类加载器(Thread Context ClassLoader),由父加载器“逆向委派”给子加载器去加载。
  • Tomcat 等 Web 容器:
    • Tomcat 为了实现应用隔离(不同 Web 应用依赖不同版本的库)和热部署,采用了破坏双亲委派的自定义类加载器架构。它优先加载本地 Web 应用目录下的类,只有在找不到时才委派给父类加载器。

  • 五、总结

    维度核心要点
    加载过程 加载 -> 验证 -> 准备 -> 解析 -> 初始化
    准备阶段 为 static 变量分配内存并赋零值(final 常量直接赋真实值)
    初始化 执行 <clinit>(),包括静态变量赋值和静态代码块
    类加载器 Bootstrap、Extension、Application
    双亲委派 收到请求先委派给父类,父类无法加载再由子类加载
    双亲委派优点 保证核心类库安全、避免类重复加载
    打破双亲委派 线程上下文类加载器(SPI)、Tomcat 等自定义容器

    掌握类加载过程与双亲委派模型,不仅能帮你从容应对面试,更能让你在排查 ClassNotFoundException、NoClassDefFoundError、ClassCastException(同一个类被不同加载器加载导致不相等)等疑难杂症时,做到对症下药。

    赞(0)
    未经允许不得转载:网硕互联帮助中心 » 深入理解 Java 类加载机制与双亲委派模型
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!