从源码入手,手把手带你剖析Java类加载的双亲委派

文章首发于博客:布袋青年,原文链接直达:Java类加载器剖析

Java 的类加载作为核心特性之一拥有众多的特性,理解和掌握类的加载方式和机制能够更好的了解程序的执行流程,本篇文章将详细介绍 Java 的类加载机制与其相关必备知识。

下面就让我们直接开始吧

一、文件编译

在介绍类加载器之前先介绍一下 Java 文件的基本编译与执行命令以供后续使用。

1. Class编译

  • 文件编译

    通过 javac 命令将 .java 文件编译为 .class 文件,默认输出到当前目录。

    javac MyBean.java  
    
  • 编码处理

    Java 文件中包含中文等编码时,在编译时需要指定编码格式,否则将会编译异常。

    javac -encoding utf-8 MyBean.java  
    
  • 包名处理

    默认 javac 命令编译的文件是不包含包路径的,通过参数 -d 编译生成的文件将在同级目录创建包名层级文件夹。其中 . 表示保存至当前目录下,可根据需要指定目录。

    javac -d . MyBean.java  
    
  • 指定版本

    当编程环境中存在多个 JDK 版本时,可以通过输入完整路径从而指定 JDK 编译版本。

    "C:\Program Files\Java\jdk1.8.0_202\bin\javac" -d <target_path> MyBean.java  
    

2. 文件执行

  • Class运行

    Class 文件运行较为简单,直接通过 java + 文件名 即可,需要注意的是若文件时编译设置了包名则运行时同样需要指定包名。

    # 运行不含包名 class 文件
    java MyBean  
    
    # 运行含包名 class 文件
    java xyz.ibudai.MyBean  
    
  • Jar运行

    运行 Jar 文件与 运行 Class 文件类似,只需添加 -jar 参数即可。

    java -jar jar-name.jar
    

二、类加载器

1. 基本类别

Java 中类加载器分为以下四类,各类加载器说明如下:

加载器 描述
BootstrapClassLoader 顶层加载器,主要负责加载核心的类库(java.lang.*等)。
ExtClassLoader 主要负责加载 jre/lib/ext 目录下的一些扩展的 JAR 包。
AppClassLoader 主要负责加载应用程序的主函数 main 等。
CustomClassLoader 自定义类加载器,即我们继承 ClassLoad 而自定义加载器,属于最底层加载器。

2. 加载方式

Java 中加载类可以共有 Class.forName()Classloader.laodClass() 两种方式,下面分别进行介绍。

  • Class.forName()

    通过 Class.forName() 方式加载不仅会将当前类加载进内存并且会初始化对象,可在需被加载的类中添加 static 静态块打印提示,当使用 Class.forName() 加载类时可以发现静态块代码将会被执行,说明此时创建了对象。

    若想实现类加载但不创建对象可通过 Class.forName(className, false, classLoader) 方法加载,其中第二个参数用于指定是否创建对象,第三个参数指定类加载器。

    public void initDemo2() {
        String className = "xyz.ibudai.bean.User";
        ClassLoader classLoader = Thread.currentThread().getContextClassLoader();
        try {
            Class<?> clazz1 = Class.forName(className);
            Class<?> clazz2 = Class.forName(className, false, classLoader);
            System.out.println("clazz1 loader: " + clazz1.getClassLoader());
            System.out.println("clazz2 loader: " + clazz2.getClassLoader());
        } catch (ClassNotFoundException e) {
            e.printStackTrace();
        }
    }
    
  • Classloader.laodClass()

    通过 laodClass() 方式加载并不会解析类只实现加载并不会创建相应对象,只有当对象被引用时才会初始化。

    public void initDemo2() {
        String className = "xyz.ibudai.bean.User";
        ClassLoader classLoader = Thread.currentThread().getContextClassLoader();
        try {
            Class<?> clazz3 = classLoader.loadClass(className);
            System.out.println("clazz3 loader: " + clazz3.getClassLoader());
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
    

需要注意一点 java.lang.String 等核心类库是在 JVM 启动时加载的,它们位于 Java 虚拟机的核心类库中,并由 Bootstrap ClassLoader 加载。因此在获取这些类的类加载器时,结果通常为 null ,需要注意的是 null 表示类加载器未知或者无法确定,而非没有类加载器。

三、类加载机制

1. 加载机制

类加载器负责从不同的源(例如文件系统、网络、内存等)加载类的字节码,如最常见的 .class 文件,将其转换为 Java 对象使得程序可以使用这些类。

类加载器同时还负责解析类的依赖关系,即查找并加载被当前类所依赖的其他类,以及确定每个类应该由哪个类加载器来加载。

Java 类加载机制具有以下特点:

  • 懒加载: 只有在需要使用类时才会加载该类,以节省内存和加载时间。
  • 双亲委派: 类加载器会按照层次结构来加载类,即先委托父类加载器加载,如果父类加载器无法加载则再交给自己来加载。
  • 缓存机制: 已经加载过的类会被缓存,避免重复加载同一类。
  • 破坏双亲委派机制: 允许用户自定义类加载器来加载类,从而可以实现一些自定义的类加载策略,例如热部署、插件化等。

2. 加载过程

所谓类加载过程,简单一句话而言即将编译完成的 class 文件通过特定的方式载入 JVM 虚拟机内存中,之后应该就可以在虚拟机内存中进行读取。

类加载器将字节码文件加载到内存中主要经历三个阶段 加载 -> 连接 -> 实例化

注意的是 .class 并不是一次性全部加载到内存中,而是在 Java 应用程序需要的时候才会加载。也就是说当 JVM 请求一个类进行加载的时候,类加载器就会尝试查找定位这个类,当查找对应的类之后将他的完全限定类定义加载到运行时数据区中。

四、双亲委派

双亲委派机制是类加载中非常重要的一类加载机制,通过该机制保证了类的安全完整性。

默认 JDK 中使用的即双亲委派机制,你可以在 java.lang 包下找到 ClassLoader 类,下面就从源码的角度分析双亲委派机制。

1. 方法描述

在解析核心源码之前先过一遍下面这个表格,其中各方法在后面将会涉及到,在此简单描述其作用功能,后续不作详细描述。

方法 作用
getClassLoadingLock() 防止类被同时加载。
findLoadedClass() 判断类是否加载过,若加载过则返回加载器否则返回 null。
findBootstrapClassOrNull() 委托父加载器进行加载,不存父加载器返回 null。
findClass() 根据完整类名读取 .class 文件并调用 defineClass() 查找对应的 java.lang.Class 对象。
defineClass() 将 byte 字节流转换为 java.lang.Class 对象,字节流可以来源 .class 文件等途径。
resolveClass() 在类加载过程中字节码文件被加载到 JVM 中并不会立即转换成 java.lang.Class 对象。只有在使用这个类时才会调用 resolveClass() 进行转化,才能够进行创建实例等操作。

2. 源码解读

在类加载实现中最为核心的即为 loadClass() 方法,其通过 synchronized 关键字保证类不会被重复加载,下面为 JDKloadClass() 的源码,在关键处我都提供了注释说明。

其中 parent 即为当前加载器的父级加载器。

protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
    synchronized (getClassLoadingLock(name)) {
        // 1. 判断类是否已经加载过
        Class<?> c = findLoadedClass(name);
        // 1.1 若不为空表明已加载则返回
        if (c == null) {
            try {
                // 2. 未加载 -> 判断是否存在父加载器
                if (parent != null) {
                    // 2.1 存在 -> 递归调用直至获取顶层父加载器
                    c = parent.loadClass(name);
                } else {
                    // 2.2 不存在(已达顶层加载器) -> 委托父类进行加载
                    c = findBootstrapClassOrNull(name);
                }
            } catch (ClassNotFoundException e) {
                // ClassNotFoundException thrown if class not found
            }

            // 3. 没有父加载器可以加载类 -> 由类自身实现加载
            if (c == null) {
                c = findClass(name);
            }
        }
        return c;
    }
}

五、自定义加载

自定义类加载器最常见的分为两种,遵循双亲委派机制与打破双亲委派机制,这里主要讨论后者。

1. 父类继承

新建自定义加载器类 CustomClassLoader 并继承 ClassLoader ,在类中定义构造方法与默认 class 文件存放路径等基本信息。构造函数中的 super()super(parent) 用于指定当前加载器的父级加载器,前者默认获取当前上下文的类加载器为父加载器,在绝大多数情况下为 AppClassLoader

在实际应用中通常只有特定类需要实现自定义加载,因此这里定义了集合 targetList 存放需要实现自定义加载的目标类,不在此集合中类则使用默认的加载方法。

public class CustomClassLoader extends ClassLoader {

    private final Path classDir;

    private static final Path DEFAULT_CLASS_DIR = Paths.get("E:\\Workspace\\Class");

    private List<String> targetList = new ArrayList<>();

    static {
        targetList.add("xyz.ibudai.bean.TestA");
        targetList.add("xyz.ibudai.bean.TestB");
    }

    /**
     * 使用默认的信息
     */
    public CustomClassLoader() {
        super();
        this.classDir = DEFAULT_CLASS_DIR;
    }

    /**
     * 指定文件目录与父类加载器
     */
    public CustomClassLoader(String path, ClassLoader parent) {
        super(parent);
        this.classDir = Paths.get(path);
    }
}

2. loadClass()

接下来也是自定义类加载的重点,在 JDK 中类的加载核心流程由 loadClass() 方法控制,想要打破默认的双亲委派机制就必须重写该方法。

重写的 loadClass() 其具象化的加载实现流程如下:

  • 检查当前类是否已经被加载过?

    • 若已加载,则返回结果。
    • 若未加载,则进入下一步。
  • 加载类是否在目标集合中?

    • 若是,则实现自定义 .class 文件读取加载。
    • 若否,则调用父加载器的 loadClass() 加载重试,即仍按双亲委派机制。
  • 判断自定义加载是否成功?

    • 若是,则返回结果。
    • 若否,则调用父加载器的 loadClass() 加载重试,即仍按双亲委派机制。

上述逻辑流程相对应的代码实现如下:

@Override
public Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
    synchronized (getClassLoadingLock(name)) {
        Class<?> c = findLoadedClass(name);
        if (c != null) {
            // 若已加载则返回
            return c;
        }
        if(targetList.contains(name)) {
            try {
                // 若在目标集合则自定义加载
                c = customFindClass(name);
            } catch (ClassNotFoundException ignored) {
                // 加载异常重新委派父类加载
                c = super.loadClass(name, resolve);
            }
        }
        if (c == null) {
            // 加载不成功重新委派父类加载
            c = super.loadClass(name, resolve);
        }
        return c;
    }
}

3. findClass()

在类的加载中 loadClass() 用于控制类的加载流程,而 findClass() 则用于控制具体的 .class 文件加载实现,即如何将 .class 装载进 JVM 中。

这里我选择的是新建 customFindClass() 方法而并非重写 findClass() 目的在于防止当自定义类加载失败时重新调用父类的 loadClass() 时其在调用 findClass() 时发生混乱冲突。

customFindClass() 中实现了将编译后的类文件载入 JVM 中,即从本地读取编译后的 .class 文件转为字节数据并通过 defineClass() 查找对应的 java.lang.Class 对象。

其中 name 为类文件的完整类名,例如:xyz.ibudai.bean.TestA

private Class<?> customFindClass(String name) throws ClassNotFoundException {
    // 读取 class 的二进制数据
    byte[] classBytes = this.readClassBytes(name);
    if (classBytes == null || classBytes.length == 0) {
        throw new ClassNotFoundException("The class byte " + name + " is empty.");
    }
    // 调用 defineClass 方法定义 class
    return this.defineClass(name, classBytes, 0, classBytes.length);
}

/**
 * 将 class 文件转为字节数组以供后续内存载入
 */
private byte[] readClassBytes(String name) throws ClassNotFoundException {
    // 将包名分符转换为文件路径分隔符
    String classPath = name.replace(".", "/");
    Path classFullPath = classDir.resolve(Paths.get(classPath + ".class"));
    if (!classFullPath.toFile().exists()) {
        throw new ClassNotFoundException("Class file " + classFullPath + " doesn't exists.");
    }
    try (ByteArrayOutputStream out = new ByteArrayOutputStream()) {
        Files.copy(classFullPath, out);
        return out.toByteArray();
    } catch (IOException e) {
        throw new ClassNotFoundException("Read class " + classFullPath + " file to byte error.");
    }
}

4. 测试示例

完成上述的示例之后通过一个示例验证一下我们类加载效果。

新建测试类 TestA 并通过 java -d . TestA.java 命令将其编译为 .class 文件,将编译完成的带包名层级结构的 .class 文件移动至 CustomClassLoader 类中定义的默认目录下。

public class TestA {
    public void sayHello() {
        System.out.println("Hello world!");
    }
}

完成上述操作后编写相应的测试示例,分别通过默认的类加载与 CustomClassLoader 两种方式实现 TestA 类的加载,并分别输出了 TestA 的具体加载类信息。

相对应的测试示例代码如下:

@Test
public void loadTest() throws Exception {
    // 默认类加载器加载类
    Class<?> clazz1 = Class.forName("xyz.ibudai.bean.TestA");
    System.out.println("Loader-1: " + clazz1.getClassLoader());

    // 自定义类加载器加载类
    CustomClassLoader myLoader = new CustomClassLoader();
    Thread.currentThread().setContextClassLoader(myLoader);
    Class<?> clazz2 = myLoader.loadClass("xyz.ibudai.bean.TestA");
    System.out.println("Loader-2: " + clazz2.getClassLoader());
}

上述程序运行的结果如下,从结果可以看出通过 CustomClassLoader 我们成功实现了 TestA 的自定义加载。

Loader-1: sun.misc.Launcher$AppClassLoader@18b4aac2
Loader-2: xyz.ibudai.loader.CustomClassLoader@1b9e1916

六、进阶操作

1. 上下文加载器

在之前的示例中通过了 Thread.currentThread().getContextClassLoader() 获取了当前上下文的类加载,那么上下文类加载究竟有何用处?

Java 程序启动时会将核心类交由 Bootstrap Classloader 加载,但存在一部分服务提供者接口 (SPI) 只定义的规范接口,如最常见的 JDBC 其具体的实现都是由各大数据库厂商自行实现。而这部分接口实现类显然无法被系统类加载器加载(系统类加载只会加载核心包,不会加载第三方来源包),而默认的类加载机制(双亲委派机制)并不支持父加载器向下委派,上下文加载器正是为此而生,通过子委派(由当前线程上下文加载器加载 SPI 实现类)的方式打破了双亲委派机制。

Java 应用运行时的初始线程的上下文类加载器是系统类加载器(AppClassLoader),线程的上下文类加载器的获取与设置通过 Thread.currentThread().getContextClassLoader()Thread.currentThread().setContextClassLoader() 方法即可,一个线程若没有指定则默认继承其父线程上下文类加载器。

2. 动态加载

Java 启动时会将相关的类进行加载,而通过 URLClassLoader 即可实现程序运行期间的动态类加载。

URLClassLoader 继承于 ClassLoader,其可以通过 URL 从资源文件从加载类,如下示例中即通过 URLClassLoader 加载 JAR 驱动包。

public static ClassLoader getClassLoader(String driverPath) {
    // 获取当前线程上下文加载器
    ClassLoader parent = Thread.currentThread().getContextClassLoader();
    URL[] urls;
    try {
        File driver = new File(driverPath);
        // 驱动包不存在抛出异常
        if (!driver.exists()) {
            throw new FileNotFoundException();
        }
        // File 转 URL 资源格式
        list.add(driver.toURI().toURL());
        urls = list.toArray(new URL[0]);
    } catch (Exception e) {
        throw new RuntimeException(e);
    }
    return new URLClassLoader(urls, parent);
}

3. 类的卸载

虽然通过 URLClassLoader 可实现类的动态加载,但 Java 中并没有提供显式的方式实现类的卸载,想要卸载一个已经加载的类只能通过垃圾回收的方式实现。

一个类若想通过垃圾回收卸载则需要满足以下三个条件:

  • 当前类不存在任何实例,即所有对象实例都已被销毁。
  • 当前类不存在引用(Reference)。
  • 当前类的类加载已被垃圾回收(GC)。

七、类隔离

在上面我们介绍了类的加载方式并且重点分析了双亲委派机制,下面介绍一下类加载的应用场景。

1. 基本介绍

与之前提到线程上下文加载器类似(子线程默认继承父线程加载器),一个类的引用类同样都将由该应用类的类加载器加载,即若在 ClassA 中引用了 ClassB ,则 ClassB 也将会由 ClassA 的类加载进行加载,这也正是能够类隔离的实现原理之一。

那么类隔离的作用是究竟是什么呢?正如上述所言,类的加载具有引用传递性,而一个模块中一个类只会加载一次,在类加载时通过 findLoadedClass() 判断是否加载,如是则跳过加载,也就是 JVM 虚拟机中每个类有且仅有一份。

2. 示例介绍

根据上述的介绍当程序需要依赖同一个类的多个版本时该方式将会产生大量的问题。

如工程中存在两个模块 module-amodule-b ,二者分别依赖了 module-c1.02.0 两个版本,但由于 module-amodule-b 同属一个工程都由 AppClassLoader 进行加载,最终在 JVM 仅会加载 module-c 的一个版本并非两个版本共同加载。

image.png

根据 Maven 中先定义先导入顺序,若 module-a 定义在前则最终加载的 module-c 则为 1.0 版本,此时若 module-b 引用了 2.0 中新特性在运行时则会抛出 ClassNotFoundException

Java 中同一个类由不同的类加载器加载对于 JVM 而言是两个不同的类,因此针对上述情况我们只要自定义类加载并将 module-amodule-b 分别通过两个不同的类加载(这里不同的类加载并不是指不同的类加载类别,只需由两个不同的实例对象即可)进行加载即可。如上述我们自定义实现了类加载 CustomClassLoader ,我们通过其两个实例 loader1loader2 分别对 module-amodule-b 进行加载类加载引用继承原理 module-c 将被加载分别加载两次,从而实现多版本的兼容隔离加载。

image.png

需要注意这里的 CustomClassLoader1CustomClassLoader2 所对应的自定义类加载器必须重写 loadClass() 方法并破坏其双亲委派机制,否则仍会逐层向上代理最终加载类的加载器都将为系统类加载器。

参考内容

  1. 《Java高并发编程详解-多线程与架构设计》
全部评论

相关推荐

点赞 收藏 评论
分享
牛客网
牛客企业服务