【JVM | 第二篇】—— 类加载器 & 双亲委派模型

在 Java 世界里,类加载器(ClassLoader)是 JVM 的核心组件之一,它负责将编译后的.class字节码文件加载到内存中,并转换为java.lang.Class对象。而加载的方式则是双亲委派机制。

接下来会围绕类的加载器双亲委派机制这两方面讲解。

一. 类加载器

1.1 什么是类加载器

类加载器是 JVM 的一个重要组成部分,它的核心职责是根据类的全限定名(如java.lang.String),找到对应的字节码文件,并将其加载到内存中,生成对应的Class对象

简单来说,类加载器就像是 JVM 的 "搬运工",负责把磁盘上或网络上的字节码 "搬" 到内存里,让 JVM 能够执行这些代码。

1.2 类加载器的加载对象

类加载器加载的对象是Java 字节码文件(.class 文件),但不仅仅局限于本地磁盘上的文件。理论上,任何可以转换为字节数组的数据源都可以作为类加载器的加载来源,包括:

  • 本地文件系统中的.class文件
  • JAR 包或 ZIP 包中的.class文件
  • 网络传输的字节流(如从 HTTP 服务器下载)
  • 数据库中存储的字节数据
  • 动态生成的字节码(如 ASM、CGLIB 等框架)

1.3 JDK 中的类加载器体系

从 JDK 9 开始,Java 对类加载器体系进行了重大调整,将原来的 "启动类加载器 - 扩展类加载器 - 应用程序类加载器" 三层结构,改为 "启动类加载器 - 平台类加载器 - 应用程序类加载器" 三层结构。

下面是 JDK 9 及以后版本的类加载器体系:

|-----------------------------------|----------|-------------|-----------------------------------------------|-----------------------------------------------|
| 类加载器名称 | 实现语言 | 父加载器 | 加载范围 | 示例 |
| 启动类加载器(Bootstrap ClassLoader) | C++ | null | JDK 核心类库(JAVA_HOME/jmods目录下的java.base等模块) | java.lang.*java.util.*java.io.*等 |
| 平台类加载器(Platform ClassLoader) | Java | Bootstrap | JDK 平台模块(除java.base外的其他内置模块) | java.sql.*java.xml.*jdk.management.*等 |
| 应用程序类加载器(Application ClassLoader) | Java | Platform | 应用程序 classpath 下的类 | 我们自己编写的业务代码、Maven/Gradle 引入的第三方依赖 |
| 自定义类加载器(Custom ClassLoader) | Java | Application | 自定义来源的类 | 加密的字节码文件、网络下载的类、热部署的类 |

1.4 类加载器示例代码

让我们通过一个简单的例子,看看不同类是由哪个类加载器加载的:

java 复制代码
public class ClassLoaderDemo {
    public static void main(String[] args) {
        // 1. 启动类加载器加载的类(输出null,因为是C++实现的)
        System.out.println("String类的加载器:" + String.class.getClassLoader());
        
        // 2. 平台类加载器加载的类
        System.out.println("Driver类的加载器:" + java.sql.Driver.class.getClassLoader());
        
        // 3. 应用程序类加载器加载的类
        System.out.println("当前类的加载器:" + ClassLoaderDemo.class.getClassLoader());
        
        // 4. 获取系统类加载器(即应用程序类加载器)
        ClassLoader systemClassLoader = ClassLoader.getSystemClassLoader();
        System.out.println("系统类加载器:" + systemClassLoader);
        
        // 5. 查看类加载器的父子关系
        System.out.println("应用程序类加载器的父加载器:" + systemClassLoader.getParent());
        System.out.println("平台类加载器的父加载器:" + systemClassLoader.getParent().getParent());
    }
}
java 复制代码
String类的加载器:null
Driver类的加载器:jdk.internal.loader.ClassLoaders$PlatformClassLoader@6d06d69c
当前类的加载器:jdk.internal.loader.ClassLoaders$AppClassLoader@24d46ca6
系统类加载器:jdk.internal.loader.ClassLoaders$AppClassLoader@24d46ca6
应用程序类加载器的父加载器:jdk.internal.loader.ClassLoaders$PlatformClassLoader@6d06d69c
平台类加载器的父加载器:null

启动类加载器(Bootstrap ClassLoader)不是一个 Java 对象,而是 JVM 的 C++ 原生实现 。它在 Java 层面没有对应的java.lang.ClassLoader实例,因此所有指向它的引用都会显示为null

二、双亲委派模型:类加载的 "规矩"

2.1 什么是双亲委派模型

双亲委派模型(Parent Delegation Model)是 Java 类加载器的核心设计原则,它定义了类加载器之间的协作方式

核心思想 :当一个类加载器收到类加载请求时,它首先不会自己尝试加载这个类,而是将请求委托给父类加载器 去完成。这个委托过程会沿着类加载器的层级结构递归向上传递,直到到达最顶层的启动类加载器。只有当父类加载器无法完成加载请求时,子类加载器才会尝试自己加载。

简单来说,就是 "儿子先问爸爸,爸爸问爷爷,爷爷都加载不了,儿子再自己干"。

2.2 双亲委派模型的完整执行流程

让我们通过一个具体的例子,看看加载**com.example.MyClass**的完整流程:

  1. 检查是否已加载 :应用程序类加载器首先检查自己的缓存中是否已经加载过com.example.MyClass,如果已经加载,直接返回该 Class 对象。
  2. 委托给父类加载器:如果没有加载过,应用程序类加载器将请求委托给它的父加载器 ------ 平台类加载器。
  3. 继续向上委托:平台类加载器同样先检查自己的缓存,如果没有加载过,再将请求委托给它的父加载器 ------ 启动类加载器。
  4. 顶层加载器尝试加载 :启动类加载器在自己的加载范围(JDK 核心类库)中查找com.example.MyClass,显然找不到,于是返回加载失败。
  5. 子加载器尝试加载:平台类加载器收到父加载器的失败反馈后,在自己的加载范围(JDK 平台模块)中查找,仍然找不到,返回加载失败。
  6. 最终加载 :应用程序类加载器收到父加载器的失败反馈后,在自己的加载范围(应用程序 classpath)中查找,找到对应的.class文件,加载并返回 Class 对象。

2.3 源码分析:ClassLoader.loadClass ()

双亲委派模型的核心逻辑就实现在java.lang.ClassLoader类的loadClass()方法中:

java 复制代码
protected Class<?> loadClass(String name, boolean resolve)
    throws ClassNotFoundException
{
    // 1. 加锁,保证线程安全
    synchronized (getClassLoadingLock(name)) {
        // 2. 首先检查该类是否已经被加载过
        Class<?> c = findLoadedClass(name);
        if (c == null) {
            long t0 = System.nanoTime();
            try {
                // 3. 如果有父加载器,委托给父加载器加载
                if (parent != null) {
                    c = parent.loadClass(name, false);
                } else {
                    // 4. 如果没有父加载器(说明是启动类加载器),调用启动类加载器加载
                    c = findBootstrapClassOrNull(name);
                }
            } catch (ClassNotFoundException e) {
                // 5. 父加载器抛出ClassNotFoundException,说明父加载器无法加载
            }

            if (c == null) {
                // 6. 父加载器无法加载,调用自己的findClass方法尝试加载
                long t1 = System.nanoTime();
                c = findClass(name);

                // 记录加载信息
                PerfCounter.getParentDelegationTime().addTime(t1 - t0);
                PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
                PerfCounter.getFindClasses().increment();
            }
        }
        // 7. 如果需要解析,进行类的链接操作
        if (resolve) {
            resolveClass(c);
        }
        return c;
    }
}

从源码可以清晰地看到双亲委派的逻辑:

  • 先检查缓存
  • 再委托父类
  • 父类加载失败,自己再加载

2.4 经典例子:为什么自定义 java.lang.String 不会被加载

这是面试中最常问的一个问题,也是双亲委派模型安全性的最好体现。

假设我们在自己的项目中创建了一个java.lang.String类:

java 复制代码
package java.lang;

public class String {
    static {
        System.out.println("我是自定义的String类!");
    }
    
    public String() {
        System.out.println("自定义String类的构造方法被调用了!");
    }
}

然后在主类中使用这个类:

java 复制代码
public class TestCustomString {
    public static void main(String[] args) {
        java.lang.String str = new java.lang.String();
        System.out.println(str.getClass().getClassLoader());
    }
}

运行结果:

java 复制代码
null

为什么没有输出我们自定义的内容?

因为当我们创建java.lang.String对象时,类加载请求会被委托给启动类加载器。启动类加载器在 JDK 核心类库中找到了官方的java.lang.String类并加载了它,根本不会加载我们自定义的那个类。

这就是双亲委派模型的安全保障:Java 核心类库永远由启动类加载器加载,防止恶意代码篡改核心 API

2.5 双亲委派模型的优缺点

优点

  1. 避免类的重复加载

    • 父加载器已经加载过的类,子加载器不会再重复加载,节省了内存空间,提高了加载效率。
    • 保证了同一个类在 JVM 中只有一个实例。
  2. 保护程序安全,防止核心 API 被篡改

    • 如上面的例子所示,恶意代码无法替换 Java 核心类库中的类,保证了 Java 平台的安全性。
    • 防止 "类欺骗" 攻击,确保核心类的完整性。
  3. 类的层次划分清晰

    • 不同层级的类加载器负责加载不同范围的类,职责明确,便于管理。

缺点

  1. 灵活性不足

    • 检查类是否加载的过程是单向的,父加载器无法看到子加载器加载的类。
    • 这在某些场景下会带来问题,比如 SPI 机制。
  2. 无法满足复杂的类隔离需求

    • 在大型应用或框架中,可能需要加载不同版本的同一个类,而双亲委派模型要求一个类只能被一个类加载器加载。

三、打破双亲委派模型

虽然双亲委派模型有很多优点,但它并不是一个强制的模型,而是一个推荐的模型。在某些特殊场景下,我们需要打破双亲委派模型来满足特定的需求。

3.1 为什么需要打破双亲委派模型

常见的需要打破双亲委派模型的场景:

  1. SPI 机制(服务提供者接口)

    • 如 JDBC、JNDI 等,接口由 JDK 核心类库定义(由启动类加载器加载),但实现类由第三方提供(由应用程序类加载器加载)。
    • 按照双亲委派模型,启动类加载器加载的接口无法看到应用程序类加载器加载的实现类。
  2. Web 容器的应用隔离

    • 如 Tomcat、Jetty 等 Web 容器,需要在同一个 JVM 中部署多个 Web 应用,每个应用可能依赖不同版本的同一个库。
    • 如果使用标准的双亲委派模型,所有应用会共享同一个类加载器,导致版本冲突。
  3. 热部署与动态加载

    • 如 Spring Boot 的热部署、JRebel 等工具,需要在不重启 JVM 的情况下,重新加载修改后的类。
    • 这需要创建新的类加载器来加载新的字节码,替换旧的类加载器。
  4. 插件化开发

    • 如 OSGi、Eclipse 插件系统等,需要实现插件的动态安装、卸载和更新。
    • 每个插件都有自己独立的类加载器,实现类的隔离。

3.2 打破双亲委派模型的三种主要方法

方法一:重写 loadClass () 方法

这是最直接、最彻底的打破方式。通过重写ClassLoader类的loadClass()方法,改变类加载的顺序,让子类加载器先尝试自己加载,加载失败再委托给父类加载器。

代码示例

java 复制代码
public class CustomClassLoader extends ClassLoader {
    private String classPath;

    public CustomClassLoader(String classPath) {
        this.classPath = classPath;
    }

    @Override
    protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
        synchronized (getClassLoadingLock(name)) {
            // 1. 检查是否已经加载过
            Class<?> c = findLoadedClass(name);
            if (c == null) {
                // 2. 先尝试自己加载
                try {
                    c = findClass(name);
                } catch (ClassNotFoundException e) {
                    // 3. 自己加载失败,再委托给父类加载器
                    if (getParent() != null) {
                        c = getParent().loadClass(name);
                    } else {
                        c = findBootstrapClassOrNull(name);
                    }
                }
            }
            if (resolve) {
                resolveClass(c);
            }
            return c;
        }
    }

    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        // 从指定路径读取类文件的字节码
        byte[] classData = loadClassData(name);
        if (classData == null) {
            throw new ClassNotFoundException();
        }
        // 将字节码转换为Class对象
        return defineClass(name, classData, 0, classData.length);
    }

    private byte[] loadClassData(String className) {
        // 将全限定名转换为文件路径
        String fileName = classPath + "/" + className.replace('.', '/') + ".class";
        try (InputStream is = new FileInputStream(fileName);
             ByteArrayOutputStream bos = new ByteArrayOutputStream()) {
            byte[] buffer = new byte[1024];
            int len;
            while ((len = is.read(buffer)) != -1) {
                bos.write(buffer, 0, len);
            }
            return bos.toByteArray();
        } catch (IOException e) {
            e.printStackTrace();
            return null;
        }
    }
}

使用示例

java 复制代码
public class BreakParentDelegationDemo {
    public static void main(String[] args) throws Exception {
        // 创建自定义类加载器
        CustomClassLoader classLoader1 = new CustomClassLoader("/path/to/classes1");
        CustomClassLoader classLoader2 = new CustomClassLoader("/path/to/classes2");

        // 用不同的类加载器加载同一个类
        Class<?> clazz1 = classLoader1.loadClass("com.example.MyClass");
        Class<?> clazz2 = classLoader2.loadClass("com.example.MyClass");

        // 验证两个Class对象不是同一个
        System.out.println("clazz1 == clazz2: " + (clazz1 == clazz2));
        System.out.println("clazz1的类加载器:" + clazz1.getClassLoader());
        System.out.println("clazz2的类加载器:" + clazz2.getClassLoader());
    }
}

运行结果:

java 复制代码
clazz1 == clazz2: false
clazz1的类加载器:com.example.CustomClassLoader@1b6d3586
clazz2的类加载器:com.example.CustomClassLoader@4554617c

可以看到,同一个类被两个不同的类加载器加载,生成了两个不同的 Class 对象,这在标准的双亲委派模型中是不可能的。

方法二:使用线程上下文类加载器

线程上下文类加载器(Thread Context ClassLoader)是 Java 为了解决 SPI 问题而引入的一种机制。

每个线程都有一个上下文类加载器,可以通过Thread.currentThread().getContextClassLoader()获取和设置。默认情况下,线程的上下文类加载器就是应用程序类加载器。

工作原理

  • 启动类加载器加载的 SPI 接口(如java.sql.Driver),通过线程上下文类加载器来加载第三方的实现类。
  • 这样就打破了双亲委派模型的单向性,让父加载器能够看到子加载器加载的类。

JDBC 中的应用示例

java 复制代码
// DriverManager是由启动类加载器加载的
public class DriverManager {
    static {
        // 加载JDBC驱动
        loadInitialDrivers();
    }

    private static void loadInitialDrivers() {
        // 获取线程上下文类加载器(默认是应用程序类加载器)
        ClassLoader cl = Thread.currentThread().getContextClassLoader();
        
        // 使用上下文类加载器加载META-INF/services/java.sql.Driver文件中配置的驱动类
        ServiceLoader<Driver> loadedDrivers = ServiceLoader.load(Driver.class, cl);
        // ...
    }
}

方法三:破坏类加载器的父子关系

通过自定义类加载器的父子关系,或者直接使用defineClass()方法来加载类,绕过正常的委派流程。

这种方式比较底层,一般不推荐使用,除非你非常清楚自己在做什么。

四、总结

类加载器和双亲委派模型是 JVM 的核心机制,理解它们对于深入学习 Java、排查类加载相关的问题以及开发框架都至关重要。

核心要点回顾

  1. 类加载器负责将字节码加载到内存中生成 Class 对象,JDK 9 及以后有三层内置类加载器:启动类加载器、平台类加载器和应用程序类加载器。

  2. 双亲委派模型的核心是 "先委托父类,再自己加载",它保证了类的唯一性和 Java 平台的安全性。

  3. 双亲委派模型的优点:避免类重复加载、防止核心 API 被篡改、职责清晰。

  4. 双亲委派模型的缺点:灵活性不足、无法满足复杂的类隔离需求。

  5. 打破双亲委派模型的方法 :重写loadClass()方法、使用线程上下文类加载器、破坏父子关系。

  6. 常见的打破场景:SPI 机制、Web 容器的应用隔离、热部署、插件化开发。

需要注意的是,打破双亲委派模型是一把双刃剑,它可以解决特定场景下的问题,但也可能带来类版本冲突、内存泄漏等问题。在实际开发中,我们应该根据具体需求,谨慎使用。

相关推荐
Nyarlathotep011314 小时前
自动内存管理(3):HotSpot中垃圾收集的实现
jvm·后端
仍然.15 小时前
浅谈JVM
jvm
小江的记录本1 天前
【JVM虚拟机】垃圾回收GC:四种引用类型:强引用、软引用、弱引用、虚引用(附《思维导图》+《面试高频考点清单》)
java·jvm·spring boot·后端·python·spring·面试
Access开发易登软件1 天前
Access 和 SQLite,根本不在一个赛道上
java·jvm·数据库·sqlite·excel·vba·access开发
枫叶林FYL1 天前
项目十:事件溯源仓储管理系统(WMS)
jvm·数据库·oracle
小江的记录本1 天前
【JVM虚拟机】垃圾回收GC:垃圾判定算法:引用计数法、可达性分析算法(附《思维导图》+《面试高频考点清单》)
java·jvm·后端·python·算法·spring·面试
Byron__1 天前
JVM垃圾回收与调优核心面试笔记(引用计数/GC算法/CMS/G1/参数调优)
java·jvm·笔记·面试
jameslogo1 天前
JVM入门
jvm
一只小白0002 天前
【JVM | 第一篇】—— JVM内存区域详解
jvm