HICSC
深入理解ClassLoader(2)

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());
    }
}

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

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

// 情景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可以声明模块对其他模块所导出的包。包中的publicprotected类型,以及这些类型的publicprotected成员可以被其他模块所访问。没有声明为导出的包相当于模块中的私有成员,不能被其他模块使用。

模块的依赖关系:使用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以后的三层类加载器的委派关系如下图所示。
JDK9三层类加载器委派关系

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虚拟机(第三版);机械工业出版社;2019-12
Java9模块化遇坑
Java9新特性概述

Comments

Post a Message

人生在世,错别字在所难免,无需纠正。

提交评论