深入理解 ClassLoader

ClassLoader 源码初探

类加载器用于加载 Java 类到虚拟机中,其实现类 java.lang.ClassLoader 是一个抽象类,其职责是通过指定类的完全限定名 ( binary name ),找到或生成这个类对应的字节码,这些字节码中包含类的定义数据,通过字节码就可以构造出一个 java.lang.Class 对象。

每个 Class 对象都包含一个定义它的类加载器的引用,而数组的 Class 对象不是用类加载器创建的,而是在 Java 运行时根据需要自动创建的,如果调用数组的 Class 对象的 getClassLoader() 方法返回的类加载器与其元素类型的类加载器相同,如果数组元素是基本数据类,则没有类加载,返回空。比如:

// C为自定义类
C[] cs = new C[2];
// jdk.internal.loader.ClassLoaders$AppClassLoader@3d4eac69
System.out.println(cs.getClass().getClassLoader());

int[] is = new int[2];
// null
System.out.println(is.getClass().getClassLoader());

应用程序可以继承 ClassLoader 类来扩展虚拟机动态加载类的方式。典型的策略是将类的 binary name 转化为文件名,然后从文件系统中读取对应的 class 文件,下面的代码简单的实现了这一策略。

public class CustomClassLoader extends ClassLoader {

    /**
     * findClass方法用于根据指定类的binaryName来查找并组装成Class对象,
     * 如果实现自定义类加载器必须覆写此方法,由loadClass方法调用
     */
    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        byte[] b = loadClassData(name);
        // 根据字节码生成Class对象,native方法
        return defineClass(name, b, 0, b.length);
    }

    /**
     * 读取class文件
     */
    private byte[] loadClassData(String name) {
        try (InputStream is = new FileInputStream(new File(name + ".class"));
             ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
            int ch = 0;
            while (-1 != (ch = is.read())) {
                baos.write(ch);
            }
            return baos.toByteArray();
        } catch (IOException e) {
            e.printStackTrace();
        }
        return null;
    }

    public static void main(String[] args) throws Exception {
        CustomClassLoader loader = new CustomClassLoader();
        Class<?> clazz = loader.loadClass("com.hicsc.classloader.Demo");
        Object object = clazz.newInstance();
        // com.hicsc.classloader.Demo@5b2133b1
        System.out.println(object.toString());
        // jdk.internal.loader.ClassLoaders$AppClassLoader@3d4eac69
        System.out.println(object.getClass().getClassLoader());
    }
}

运行上面的代码得知,想要加载的类并不是由自定义类加载器加载的,而是由 AppClassLoader 加载的。为什么会这样?去 loadClass() 方法看看:

// 省略与主题无关代码,完整代码请查看:java.lang.ClassLoader.loadClass()
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
    synchronized (getClassLoadingLock(name)) {
        // 首先检查类是否已被加载,如果已被加载,直接返回
        Class<?> c = findLoadedClass(name);
        if (c == null) {
            try {
                // 如果类加载的父加载器不为空,则委托父加载加载
                if (parent != null) {
                    c = parent.loadClass(name, false);
                } else {
                    // 尝试使用 bootstrap class loader 加载
                    c = findBootstrapClassOrNull(name);
                }
            } catch (ClassNotFoundException e) {}
            if (c == null) {
                // 如果bootstrap类加载器和所有的父加载器都不能加载
                // 最后才使用自定义类加载器加载
                c = findClass(name);
            }
        }
        return c;
    }
}

查看 loadClass() 源码可知,在加载类时,会首先把加载请求委托给自己的父加载器,父加载在委托给爷爷加载器,直到没有父亲为止,这就是类的双亲委托机制。而我们写的自定义类加载器的父加载器是哪个呢?由于其继承自 ClassLoader 类,看下 ClassLoader 的无参构造方法:

protected ClassLoader() {
    this(checkCreateClassLoader(), null, getSystemClassLoader());
}

默认是系统类加载器,即:AppClassLoader,与上面运行结果一致。

类加载器的双亲委托机制

类加载器分类

Java 中的类加载器大致可以分为两类,一是系统提供的加载器,另外一类是自定义类加载器。其中,系统提供的类加载主要有以下三个:

  1. 启动类加载器 ( Bootstrap Class Loader ):负责加载存放在 JAVA_HOME/lib/ 目录,或被 -Xbootclasspath 参数所指定路径中存放的,且能够被 JVM 识别的类库。它由 C++ 实现,非 ClassLoader 的子类。
  2. 扩展类加载器 ( Extension Class Loader ):负责加载存放在 JAVA_HOME/lib/ext/ 目录,或被 java.ext.dirs 系统变量所指定的路径中所有的类库。
  3. 系统类加载器 ( Application Class Loader ):负责加载用户类路径 ( ClassPath ) 上所有的类库,由于应用程序类加载器是ClassLoader 类中的 getSystemClassLoader() 方法的返回值,所以有些场合中也称它为”系统类加载器”。如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。

在JDK8以及之前的JDK版本中,可以通过以下代码分别打印3个类加载的加载路径。

System.out.println(System.getProperty("sun.boot.class.path"));
System.out.println(System.getProperty("java.ext.dirs"));
System.out.println(System.getProperty("java.class.path"));

除了启动类加载器之外,所有的类加载器都有一个父加载器。系统类加载器的父加载器是扩展类加载器,而扩展类加载器的父加载器是引导类加载器。对于开发者编写的自定义类加载器来说,其父加载器是加载此类的类加载器。因为自定义加载器的实现类如同其它的 Java 类一样,也是要由类加载器来加载的。一般来说,开发者自定义类加载器的父加载器是系统类加载器。

类加载器通过这种方式组织起来,形成树状结构,树的根结点就是启动类加载器,其大致示意图如下所示,箭头指向其父加载器:
类加载器委派关系示意图

类加载器的双亲委托机制

前面的实例中已经提到,类加载器在尝试加载某个类时,它首先不会自己去尝试加载这个类,而是先把加载请求委托给父加载器,由父加载器先尝试去加载这个类,以此类推,因此,所有的加载请求,最终都会被传送到最顶层的启动类加载器,只有当父加载器反馈自己无法完成这个加载请求时,子加载器才会尝试自己去加载。其大致的流程示意图如下所示。

类加载器双亲委托机制示意图

在回到前面的例子,自定义类加载器加载的 Demo 类在 classpath 目录下,因此,会被 application class loader 加载,而如果我们把类放到非自定义目录下,结果又如何?修改前面的实例代码测试下:

public class CustomClassLoader extends ClassLoader {
      // 增加path参数来指定class文件的路径
    public String path;

    /**
     * findClass方法用于根据指定类的binaryName来查找并组装成Class对象,
     * 如果实现自定义类加载器必须覆写此方法,由loadClass方法调用
     */
    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        byte[] b = loadClassData(name);
        // 根据字节码生成Class对象,native方法
        return defineClass(name, b, 0, b.length);
    }

    /**
     * 读取class文件
     */
    private byte[] loadClassData(String name) {
        // 将类的全路径名中的.替换成/,用来组装class文件的完整路径
        String fileName = name.replace(".", "/");
        try (InputStream is = new FileInputStream(new File(path + fileName + ".class"));
             ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
            int ch = 0;
            while (-1 != (ch = is.read())) {
                baos.write(ch);
            }
            return baos.toByteArray();
        } catch (IOException e) {
            e.printStackTrace();
        }
        return null;
    }

    public static void main(String[] args) throws Exception {
        CustomClassLoader loader = new CustomClassLoader();
        loader.path = "/Users/Moon/Desktop/";
        Class<?> clazz = loader.loadClass("com.hicsc.classloader.Demo");
        Object object = clazz.newInstance();
        System.out.println(object.toString());
        System.out.println(object.getClass().getClassLoader());
    }
}

请在以下两种情况下运行程序:

  • 在运行实例代码前,先运行 Demo 类
  • Demo.class 移动到自己创建的目录中,比如在桌面创建文件夹 /com/hicsc/classloader

先思考一下,再看运行结果:

// 情景1
com.hicsc.classloader.Demo@5b2133b1
jdk.internal.loader.ClassLoaders$AppClassLoader@3d4eac69
// 情景2
com.hicsc.classloader.Demo@33c7353a
com.hicsc.classloader.CustomClassLoader@38af3868

在情景 1 中,classpath 目录下已经有 Demo.class 文件,在加载 Demo 类时,会直接被系统类加载器加载;而情景2中,由于 classpath 中没有 Demo 类,最终会由自定义类加载器从指定路径中加载该类。

再来看另外一个有趣的例子,还是相同的类加载器代码,创建两个自定义类加载器,同时去加载同一个类,看看得到的 Class 对象是否相同:

public static void main(String[] args) throws Exception {
    // 创建loader,加载Demo类
    CustomClassLoader loader = new CustomClassLoader();
    loader.path = "/Users/Moon/Desktop/";
    Class<?> clazz = loader.loadClass("com.hicsc.classloader.Demo");
    Object object = clazz.newInstance();
    // 打印Class对象的hashCode,看看加载的class对象是否一致
    System.out.println("loader1.clazz = " + clazz.hashCode());
    System.out.println(object.getClass().getClassLoader());

    // 创建loader2,加载Demo类
    CustomClassLoader loader2 = new CustomClassLoader();
    loader2.path = "/Users/Moon/Desktop/";
    Class<?> clazz2 = loader2.loadClass("com.hicsc.classloader.Demo");
    Object object2 = clazz2.newInstance();
    System.out.println("loader2.clazz = " + clazz2.hashCode());
    System.out.println(object2.getClass().getClassLoader());
}

还是在情景 1 和 情景 2 中分别运行,正常情况下会得到如下结果:

// 情景1
loader1.clazz = 2001049719
jdk.internal.loader.ClassLoaders$AppClassLoader@3d4eac69
loader2.clazz = 2001049719
jdk.internal.loader.ClassLoaders$AppClassLoader@3d4eac69
// 情景2
loader1.clazz = 1927950199
com.hicsc.classloader.CustomClassLoader@38af3868
loader2.clazz = 989110044
com.hicsc.classloader.CustomClassLoader@33c7353a

在分析结果之前,再看一眼 loadClass 方法的源码:

protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
    synchronized (getClassLoadingLock(name)) {
        // 首先检查类是否已被加载,如果已被加载,直接返回
        Class<?> c = findLoadedClass(name);
        // ......
    }
}

如果一个类已经被加载过了,那么再次加载时,会直接返回已有的 Class 对象。在情景 1 中,不管是 loader1 还是 loader2,都会先把加载请求委托给父加载器,得到的 Class 对象肯定是一致的。而在情景 2 中,loader1 和 loader2 加载同一个类,却得到两个不同的Class 对象,这又是为什么呢?

在运行期,任意一个 Java 类是由加载它的类加载和这个类本身一起共同确立其在 JVM 中的唯一性,每个类加载器,都拥有一个独立的类命名空间。简单来说,比较两个类是否相等,只有在这两个类是由同一个类加载器加载的前提下才有意义,否则,即使这两个类来源于同一个位置的同一个 Class 文件,被同一个 Java 虚拟机加载,只要加载它们的类加载器不同,那么这两个类就必定不相等。

这就是为什么两个不同的类加载器 loader1 和 loader2 去加载同一个 class 文件,却得到两个不同的 Class 对象的原因。

最后,说说类加载为什么要使用双亲委派机制?总结起来也就一句话:类加载器的双亲委托机制可以避免一个类被重复加载,也能够避免 Java 核心类被篡改,造成安全性问题。

JDK9之后的类加载器架构

JDK9 中引入了 Java 模块化系统,JDK 被重新组织成 90 多个模块,Java 应用可以只引入自己所依赖的模块,而不必引入整个 JDK,当然模块化最主要的目标还是为了实现可配置的封装隔离机制:

  • 可配置:提供一种机制来显示的声明模块间的依赖关系,而应用程序可以通过这个依赖路径找到自己所需的所有模块
  • 封装隔离:模块化机制要求模块中的包只有在显式的导出后才可以被其它模块使用,并且其它的模块必须显式地声明它需要这个模块中的包以后才能使用这些包。也就是说,提供方要声明哪些包可以被依赖,而使用方要声明需要哪些包。这种机制可以提高安全性,攻击者能够访问的类越少也就越安全。这也有助于我们思考如何组织代码才能获得更简洁、合理的设计。

模块导出的包:使用 exports 可以声明模块对其他模块所导出的包。包中的 public 和 protected 类型,以及这些类型的 public 和 protected 成员可以被其他模块所访问。没有声明为导出的包相当于模块中的私有成员,不能被其他模块使用。
模块的依赖关系:使用 requires 可以声明模块对其他模块的依赖关系。使用 requires transitive 可以把一个模块依赖声明为传递的。传递的模块依赖可以被依赖当前模块的其他模块所读取。如果一个模块所导出的类型的结构中包含了来自它所依赖的模块的类型,那么对该模块的依赖应该声明为传递的。

JDK9 为了在实现模块化的同时,还兼容以前的 JDK 版本,并没有从根本上改动三层类加载器架构以及双亲委派模型。但为了模块化系统的顺利实施,类加载器仍然发生了一些变动,主要有以下几个方面。

首先,扩展类加载器被平台类加载器 ( Platform Class Loader )取代。既然整个 JDK 都基于模块化进行构建,天然地满足了可扩展的需求,自然也就无需保留 JAVA_HOME/lib/ext/ 目录,此前使用的这个目录和 java.ext.dirs 系统变量来扩展 JDK 功能的机制也就没有继续存在的价值了。类似地,新版本 JDK 中也取消了 JAVA_HOME/jre 目录,因为随时可以组合构建出程序运行所需的 JRE 来,比如只想使用 java.base 模块中的类型,可通过以下命令来构建出一个 JRE:

jlink -p $JAVA_HOME/jmods --add-modules java.base --output jre

其次,平台类加载器和系统类加载器均不再继承 java.net.URLClassLoader,如果有程序直接依赖了这种继承关系,或者依赖了 URLClassLoader 类的特定方法,那代码很可能会在 JDK9 以及更高版本的 JDK 中崩溃。现在启动类加载器、平台类加载器、系统类加载器全都继承于 jdk.internal.loader.BuiltinClassLoader,在 BuiltinClassLoader 中实现了新的模块化架构下类如何从模块中加载的逻辑,以及模块中资源可访问性的处理。

最后,JDK9 中虽然仍然维持着三层类加载器和双亲委派的架构,但类加载的委派关系也发生了变动。当平台及应用程序类加载器收到类加载请求,在委派给父加载器加载前,要先判断该类是否能够归属到某一个系统模块中,如果可以找到这样的归属关系,就要优先委派给负责那个模块的加载器完成加载。在 JDK9 以后的三层类加载器的委派关系如下图所示。
JDK 9 类加载器双亲委托机制示意图
Java 模块化系统了明确规定了三个类加载器各自负责加载的模块,比如启动类加载器负责加载 java.basejava.netjava.prefs 等模块,而平台类加载器负责加载 java.sejava.sql 等模块。

更多关于 JDK9 模块化的内容可参考:深入理解Java虚拟机(第三版) 第 7 章第 5 小节。

类的卸载

当一个类 Sample 被加载、连接和初始化后,它的生命周期就开始了,而当 Sample 类的 Class 对象不再被引用时,Class 对象的生命周期也就结束了,其在方法区中的数据也会被卸载,从而结束 Sample 类的生命周期。

由 Java 虚拟机自带的类加载器所加载的类,在虚拟机的生命周期中,始终不会被卸载。JVM 自带的类加载器包括启动类加载器、扩展类加载器、系统类加载器,JVM 会始终引用这些类加载器,而这些类加载器则会始终引用它们所加载的类的 Class 对象,因此,这些 Class 对象始终是可达的,只有被用户自定义类加载所加载的类才可以被卸载。

来看一个简单的例子:

public static void main(String[] args) throws Exception {
     CustomClassLoader loader = new CustomClassLoader();
     loader.path = "/Users/Moon/Desktop/";
     Class<?> clazz = loader.loadClass("com.hicsc.classloader.Demo");
     Object object = clazz.newInstance();
     System.out.println("loader1.clazz = " + clazz.hashCode());

     loader = new CustomClassLoader();
     loader.path = "/Users/Moon/Desktop/";
     clazz = loader.loadClass("com.hicsc.classloader.Demo");
     object = clazz.newInstance();
     System.out.println("loader1.clazz = " + clazz.hashCode());

     System.gc();
}

在运行时增加 JVM 参数来追踪类卸载信息,在 JDK8 中可以使用 -XX:+TraceClassUnloading,在较新版本的JDK中,这个参数已被废弃,使用参数 -Xlog:class+unload=info 代替,程序运行后输出:

loader1.clazz = 1927950199
loader1.clazz = 989110044
[0.208s][info][class,unload] unloading class com.hicsc.classloader.Demo 0x0000000800b44840

原因也很简单,类加载器、Class 对象等都被新的对象替换,虚拟机就不再持有旧的类加载器的引用,同样地,旧的 Class 对象也就变成不可达对象,当 JVM 执行垃圾回收时,类自然而然的就会被卸载,方法区中的内存也会被回收。

参考资料

周志明著;深入理解Java虚拟机(第三版)
Java9模块化遇坑
Java9新特性概述

深入理解 JVM 系列的第 2 篇,完整目录请移步:深入理解 JVM 系列
本站为非交互式网站,根据相关规定,你无法在本站内进行评论,如对本文有任何疑问,可移步到微信公众号评论
在微信客户端内打开右侧链接即可评论:深入理解 ClassLoader
不遗漏重要文章,欢迎关注本站公众号,手机扫描下方二维码或微信搜索 橙子成绩好
微信公众号二维码

回到首页