阅读 457

探秘类加载器和类加载机制

在面向对象编程实践中,我们通过众多的类来组织一个复杂的系统,这些类之间相互关联、调用使他们的关系形成了一个复杂紧密的网络。当系统启动时,出于性能、资源利用多方面的考虑,我们不可能要求 JVM 一次性将全部的类都加载完成,而是只加载能够支持系统顺利启动和运行的类和资源即可。那么在系统运行过程中如果需要使用未在启动时加载的类或资源时该怎么办呢?这就要靠类加载器来完成了。

什么是类加载器

类加载器(ClassLoader)就是在系统运行过程中动态的将字节码文件加载到 JVM 中的工具,基于这个工具的整套类加载流程,我们称作类加载机制。我们在 IDE 中编写的都是源代码文件,以后缀名 .java 的文件形式存在于磁盘上,通过编译后生成后缀名 .class 的字节码文件,ClassLoader 加载的就是这些字节码文件。

有哪些类加载器

Java 默认提供了三个 ClassLoader,分别是 AppClassLoader、ExtClassLoader、BootStrapClassLoader,依次后者分别是前者的「父加载器」。父加载器不是「父类」,三者之间没有继承关系,只是因为类加载的流程使三者之间形成了父子关系,下文会详细讲述。

BootStrapClassLoader

BootStrapClassLoader 也叫「根加载器」,它是脱离 Java 语言,使用 C/C++ 编写的类加载器,所以当你尝试使用 ExtClassLoader 的实例调用 getParent() 方法获取其父加载器时会得到一个 null 值。

// 返回一个 AppClassLoader 的实例
ClassLoader appClassLoader = this.getClass().getClassLoader();
// 返回一个 ExtClassLoader 的实例
ClassLoader extClassLoader = appClassLoader.getParent();
// 返回 null,因为 BootStrapClassLoader 是 C/C++ 编写的,无法在 Java 中获得其实例
ClassLoader bootstrapClassLoader = extClassLoader.getParent();
复制代码

根加载器会默认加载系统变量 sun.boot.class.path 指定的类库(jar 文件和 .class 文件),默认是 $JRE_HOME/lib 下的类库,如 rt.jar、resources.jar 等,具体可以输出该环境变量的值来查看。

String bootClassPath = System.getProperty("sun.boot.class.path");
String[] paths = bootClassPath.split(":");
for (String path : paths) {
    System.out.println(path);
}

// output
// /Library/Java/JavaVirtualMachines/jdk1.8.0_151.jdk/Contents/Home/jre/lib/resources.jar
// /Library/Java/JavaVirtualMachines/jdk1.8.0_151.jdk/Contents/Home/jre/lib/rt.jar
// /Library/Java/JavaVirtualMachines/jdk1.8.0_151.jdk/Contents/Home/jre/lib/sunrsasign.jar
// /Library/Java/JavaVirtualMachines/jdk1.8.0_151.jdk/Contents/Home/jre/lib/jsse.jar
// /Library/Java/JavaVirtualMachines/jdk1.8.0_151.jdk/Contents/Home/jre/lib/jce.jar
// /Library/Java/JavaVirtualMachines/jdk1.8.0_151.jdk/Contents/Home/jre/lib/charsets.jar
// /Library/Java/JavaVirtualMachines/jdk1.8.0_151.jdk/Contents/Home/jre/lib/jfr.jar
// /Library/Java/JavaVirtualMachines/jdk1.8.0_151.jdk/Contents/Home/jre/classes
复制代码

除了加载这些默认的类库外,也可以使用 JVM 参数 -Xbootclasspath/a 来追加额外需要让根加载器加载的类库。比如我们自定义一个 com.ganpengyu.boot.DateUtils 类来让根加载器加载。

package com.ganpengyu.boot;

import java.text.SimpleDateFormat;
import java.util.Date;

public class DateUtils {
    public static void printNow() {
        SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
        System.out.println(sdf.format(new Date()));
    }
}
复制代码

我们将其制作成一个名为 gpy-boot 的 jar 包放到 /Users/yu/Desktop/lib 下,然后写一个测试类去尝试加载 DateUtils。

public class Test {
    public static void main(String[] args) throws Exception {
        Class<?> clz = Class.forName("com.ganpengyu.boot.DateUtils");
        ClassLoader loader = clz.getClassLoader();
        System.out.println(loader == null);
    }
}
复制代码

运行这个测试类:

java -Xbootclasspath/a:/Users/yu/Desktop/lib/gpy-boot.jar -cp /Users/yu/Desktop/lib/gpy-boot.jar:. Test
复制代码

可以看到输出为 true,也就是说加载 com.ganpengyu.boot.DateUtils 的类加载器在 Java 中无法获得其引用,而任何类都必须通过类加载器加载才能被使用,所以推断出这个类是被 BootStrapClassLoader 加载的,也证明了 -Xbootclasspath/a 参数确实可以追加需要被根加载器额外加载的类库。

总之,对于 BootStrapClassLoader 这个根加载器我们需要知道三点:

  1. 根加载器使用 C/C++ 编写,我们无法在 Java 中获得其实例
  2. 根加载器默认加载系统变量 sun.boot.class.path 指定的类库
  3. 可以使用 -Xbootclasspath/a 参数追加根加载器的默认加载类库

ExtClassLoader

ExtClassLoader 也叫「扩展类加载器」,它是一个使用 Java 实现的类加载器(sun.misc.Launcher.ExtClassLoader),用于加载系统所需要的扩展类库。默认加载系统变量 java.ext.dirs 指定位置下的类库,通常是 $JRE_HOME/lib/ext 目录下的类库。

public static void main(String[] args) {
    String extClassPath = System.getProperty("java.ext.dirs");
    String[] paths = extClassPath.split(":");
    for (String path : paths) {
        System.out.println(path);
    }
}

// output
// /Users/leon/Library/Java/Extensions
// /Library/Java/JavaVirtualMachines/jdk1.8.0_151.jdk/Contents/Home/jre/lib/ext
// /Library/Java/Extensions
// /Network/Library/Java/Extensions
// /System/Library/Java/Extensions
// /usr/lib/java
复制代码

我们可以在启动时修改java.ext.dirs 变量的值来修改扩展类加载器的默认类库加载目录,但通常并不建议这样做。如果我们真的有需要扩展类加载器在启动时加载的类库,可以将其放置在默认的加载目录下。总之,对于 ExtClassLoader 这个扩展类加载器我们需要知道两点:

  1. 扩展类加载器是使用 Java 实现的类加载器,我们可以在程序中获得它的实例并使用。
  2. 通常不建议修改java.ext.dirs 参数的值来修改默认加载目录,如有需要,可以将要加载的类库放到这个默认目录下。

AppClassLoader

AppClassLoader 也叫「应用类加载器」,它和 ExtClassLoader 一样,也是使用 Java 实现的类加载器(sun.misc.Launcher.AppClassLoader)。它的作用是加载应用程序 classpath 下所有的类库。这是我们最常打交道的类加载器,我们在程序中调用的很多 getClassLoader() 方法返回的都是它的实例。在我们自定义类加载器时如果没有特别指定,那么我们自定义的类加载器的默认父加载器也是这个应用类加载器。总之,对于 AppClassLoader 这个应用类加载器我们需要知道两点:

  1. 应用类加载器是使用 Java 实现的类加载器,负责加载应用程序 classpath 下的类库。
  2. 应用类加载器是和我们最常打交道的类加载器。
  3. 没有特别指定的情况下,自定义类加载器的父加载器就是应用类加载器。

自定义类加载器

除了上述三种 Java 默认提供的类加载器外,我们还可以通过继承 java.lang.ClassLoader 来自定义一个类加载器。如果在创建自定义类加载器时没有指定父加载器,那么默认使用 AppClassLoader 作为父加载器。关于自定义类加载器的创建和使用,我们会在后面的章节详细讲解。

类加载器的启动顺序

上文已经提到过 BootStrapClassLoader 是一个使用 C/C++ 编写的类加载器,它已经嵌入到了 JVM 的内核之中。当 JVM 启动时,BootStrapClassLoader 也会随之启动并加载核心类库。当核心类库加载完成后,BootStrapClassLoader 会创建 ExtClassLoader 和 AppClassLoader 的实例,两个 Java 实现的类加载器将会加载自己负责路径下的类库,这个过程我们可以在 sun.misc.Launcher 中窥见。

ExtClassLoader 的创建过程

我们将 Launcher 类的构造方法源码精简展示如下:

public Launcher() {
    // 创建 ExtClassLoader
    Launcher.ExtClassLoader var1;
    try {
        var1 = Launcher.ExtClassLoader.getExtClassLoader();
    } catch (IOException var10) {
        throw new InternalError("Could not create extension class loader", var10);
    }
	// 创建 AppClassLoader
    try {
        this.loader = Launcher.AppClassLoader.getAppClassLoader(var1);
    } catch (IOException var9) {
        throw new InternalError("Could not create application class loader", var9);
    }
	// 设置线程上下文类加载器
    Thread.currentThread().setContextClassLoader(this.loader);
    // 创建 SecurityManager

}
复制代码

可以看到当 Launcher 被初始化时就会依次创建 ExtClassLoader 和 AppClassLoader。我们进入 getExtClassLoader() 方法并跟踪创建流程,发现这里又调用了 ExtClassLoader 的构造方法,在这个构造方法里调用了父类的构造方法,这便是 ExtClassLoader 创建的关键步骤,注意这里传入父类构造器的第二个参数为 null。接着我们去查看这个父类构造方法,它位于 java.net.URLClassLoader 类中:

URLClassLoader(URL[] urls, ClassLoader parent,
                          URLStreamHandlerFactory factory)
复制代码

通过这个构造方法的签名和注释我们可以明确的知道,第二个参数 parent 表示的是当前要创建的类加载器的父加载器。结合前面我们提到的 ExtClassLoader 的父加载器是 JVM 内核中 C/C++ 开发的 BootStrapClassLoader,且无法在 Java 中获得这个类加载器的引用,同时每个类加载器又必然有一个父加载器,我们可以反证出,ExtClassLoader 的父加载器就是 BootStrapClassLoader。

AppClassLoader 的创建过程

理清了 ExtClassLoader 的创建过程,我们来看 AppClassLoader 的创建过程就清晰很多了。跟踪 getAppClassLoader() 方法的调用过程,可以看到这个方法本身将 ExtClassLoader 的实例作为参数传入,最后还是调用了 java.net.URLClassLoader 的构造方法,将 ExtClassLoader 的实例作为父构造器 parent 参数值传入。所以这里我们又可以确定,AppClassLoader 的父构造器就是 ExtClassLoader。

怎么加载一个类

将一个 .class 字节码文件加载到 JVM 中成为一个 java.lang.Class 实例需要加载这个类的类加载器及其所有的父级加载器共同参与完成,这主要是遵循「双亲委派原则」。

双亲委派

当我们要加载一个应用程序 classpath 下的自定义类时,AppClassLoader 会首先查看自己是否已经加载过这个类,如果已经加载过则直接返回类的实例,否则将加载任务委托给自己的父加载器 ExtClassLoader。同样,ExtClassLoader 也会先查看自己是否已经加载过这个类,如果已经加载过则直接返回类的实例,否则将加载任务委托给自己的父加载器 BootStrapClassLoader。

BootStrapClassLoader 收到类加载任务时,会首先检查自己是否已经加载过这个类,如果已经加载则直接返回类的实例,否则在自己负责的加载路径下搜索这个类并尝试加载。如果找到了这个类,则执行加载任务并返回类实例,否则将加载任务交给 ExtClassLoader 去执行。

ExtClassLoader 同样也在自己负责的加载路径下搜索这个类并尝试加载。如果找到了这个类,则执行加载任务并返回类实例,否则将加载任务交给 AppClassLoader 去执行。

由于自己的父加载器 ExtClassLoader 和 BootStrapClassLoader 都没能成功加载到这个类,所以最后由 AppClassLoader 来尝试加载。同样,AppClassLoader 会在 classpath 下所有的类库中查找这个类并尝试加载。如果最后还是没有找到这个类,则抛出 ClassNotFoundException 异常。

综上,当类加载器要加载一个类时,如果自己曾经没有加载过这个类,则层层向上委托给父加载器尝试加载。对于 AppClassLoader 而言,它上面有 ExtClassLoader 和 BootStrapClassLoader,所以我们称作「双亲委派」。但是如果我们是使用自定义类加载器来加载类,且这个自定义类加载器的默认父加载器是 AppClassLoader 时,它上面就有三个父加载器,这时再说「双亲」就不太合适了。当然,理解了加载一个类的整个流程,这些名字就无关痛痒了。

为什么需要双亲委派机制

「双亲委派机制」最大的好处是避免自定义类和核心类库冲突。比如我们大量使用的 java.lang.String 类,如果我们自己写的一个 String 类被加载成功,那对于应用系统来说完全是毁灭性的破坏。我们可以尝试着写一个自定义的 String 类,将其包也设置为 java.lang

package java.lang;

public class String {

    private int n;

    public String(int n) {
        this.n = n;
    }

    public String toLowerCase() {
        return new String(this.n + 100);
    }

}
复制代码

我们将其制作成一个 jar 包,命名为 thief-jdk,然后写一个测试类尝试加载 java.lang.String 并使用接收一个 int 类型参数的构造方法创建实例。

import java.lang.reflect.Constructor;

public class Test {

    public static void main(String[] args) throws Exception {
        Class<?> clz = Class.forName("java.lang.String");
        System.out.println(clz.getClassLoader() == null);
        Constructor<?> c = clz.getConstructor(int.class);
        String str = (String) c.newInstance(5);
        str.toLowerCase();
    }
}
复制代码

运行测试程序

java -cp /Users/yu/Desktop/lib/thief/thief-jdk.jar:. Test
复制代码

程序抛出 NoSuchMethodException 异常,因为 JVM 不能够加载我们自定义的 java.lang.String,而是从 BootStrapClassLoader 的缓存中返回了核心类库中的 java.lang.String 的实例,且核心类库中的 String 没有接收 int 类型参数的构造方法。同时我们也看到 Class 实例的类加载器是 null,这也说明了我们拿到的 java.lang.String 的实例确实是由 BootStrapClassLoader 加载的。

总之,「双亲委派」机制的作用就是确保类的唯一性,最直接的例子就是避免我们自定义类和核心类库冲突。

JVM 怎么判断两个类是相同的

「双亲委派」机制用来保证类的唯一性,那么 JVM 通过什么条件来判断唯一性呢?其实很简单,只要两个类的全路径名称一致,且都是同一个类加载器加载,那么就判断这两个类是相同的。如果同一份字节码被不同的两个类加载器加载,那么它们就不会被 JVM 判断为同一个类。

Person 类

public class Person {
    private Person p;
    public void setPerson(Object obj) {
        this.p = (Person) obj;
    }
}
复制代码

setPerson(Object obj) 方法接收一个对象,并将其强制转换为 Person 类型赋值给变量 p。

测试类

import java.lang.reflect.Method;
public class Test {
    public static void main(String[] args) {
        CustomClassLoader classLoader1 = new CustomClassLoader("/Users/yu/Desktop/lib");
        CustomClassLoader classLoader2 = new CustomClassLoader("/Users/yu/Desktop/lib");
        try {
            Class c1 = classLoader1.findClass("Person");
            Object instance1 = c1.newInstance();

            Class c2 = classLoader2.findClass("Person");
            Object instance2 = c2.newInstance();

            Method method = c1.getDeclaredMethod("setPerson", Object.class);
            method.invoke(instance1, instance2);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

复制代码

CustomClassLoader 是一个自定义的类加载器,它将字节码文件加载为字符数组,然后调用 ClassLoader 的 defineClass() 方法创建类的实例,后文会详细讲解怎么自定义类加载器。在测试类中,我们创建了两个类加载器的实例,让他们分别去加载同一份字节码文件,即 Person 类的字节码。然后在实例一上调用 setPerson() 方法将实例二传入,将实例二强制转型为实例一。

运行程序会看到 JVM 抛出了 ClassCastException 异常,异常信息为 Person cannot be cast to Person。从这我们就可以知道,同一份字节码文件,如果使用的类加载器不同,那么 JVM 就会判断他们是不同的类型。

全盘负责

「全盘负责」是类加载的另一个原则。它的意思是如果类 A 是被类加载器 X 加载的,那么在没有显示指定别的类加载器的情况下,类 A 引用的其他所有类都由类加载器 X 负责加载,加载过程遵循「双亲委派」原则。我们编写两个类来验证「全盘负责」原则。

Worker 类

package com.ganpengyu.full;

import com.ganpengyu.boot.DateUtils;

public class Worker {

    public Worker() {
    }
    public void say() {
        DateUtils dateUtils = new DateUtils();
        System.out.println(dateUtils.getClass().getClassLoader() == null);
        dateUtils.printNow();
    }
}

复制代码

DateUtils 类

package com.ganpengyu.boot;

import java.text.SimpleDateFormat;
import java.util.Date;

public class DateUtils {

    public void printNow() {
        SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
        System.out.println(sdf.format(new Date()));
    }
}
复制代码

测试类

import com.ganpengyu.full.Worker;
import java.lang.reflect.Constructor;
public class Test {
    public static void main(String[] args) throws Exception {
        Class<?> clz = Class.forName("com.ganpengyu.full.Worker");
        System.out.println(clz.getClassLoader() == null);
        Worker worker = (Worker) clz.newInstance();
        worker.say();
    }
}
复制代码

运行测试类

java -Xbootclasspath/a:/Users/yu/Desktop/lib/worker.jar Test
复制代码

运行结果

true
true
2018-09-16 22:34:43
复制代码

我们将 Worker 类和 DateUtils 类制作成名为worker 的 jar 包,将其设置为由根加载器加载,这样 Worker 类就必然是被根加载器加载的。然后在 Worker 类的 say() 方法中初始化了 DateUtils 类,然后判断 DateUtils 类是否由根加载器加载。从运行结果看到,Worker 和其引用的 DateUtils 类都被跟加载器加载,符合类加载的「全盘委托」原则。

「全盘委托」原则实际是为「双亲委派」原则提供了保证。如果不遵守「全盘委托」原则,那么同一份字节码可能会被 JVM 加载出多个不同的实例,这就会导致应用系统中对该类引用的混乱,具体可以参考上文「JVM 怎么判断两个类是相同的」这一节的示例。

自定义类加载器

除了使用 JVM 预定义的三种类加载器外,Java 还允许我们自定义类加载器以让我们系统的类加载方式更灵活。要自定义类加载器非常简单,通常只需要三个步骤:

  1. 继承 java.lang.ClassLoader 类,让 JVM 知道这是一个类加载器
  2. 重写 findClass(String name) 方法,告诉 JVM 在使用这个类加载器时应该按什么方式去寻找 .class 文件
  3. 调用 defineClass(String name, byte[] b, int off, int len) 方法,让 JVM 加载上一步读取的 .class 文件
import java.io.*;
import java.lang.reflect.Constructor;
import java.lang.reflect.Method;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;

public class CustomClassLoader extends ClassLoader {
    private String classpath;
    
    public CustomClassLoader(String classpath) {
        this.classpath = classpath;
    }

    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        String classFilePath = getClassFilePath(name);
        byte[] classData = readClassFile(classFilePath);
        return defineClass(name, classData, 0, classData.length);
    }

    public String getClassFilePath(String name) {
        if (name.lastIndexOf(".") == -1) {
            return classpath + "/" + name + ".class";
        } else {
            name = name.replace(".", "/");
            return classpath + "/" + name + ".class";
        }
    }

    public byte[] readClassFile(String filepath) {
        Path path = Paths.get(filepath);
        if (!Files.exists(path)) {
            return null;
        }
        try {
            return Files.readAllBytes(path);
        } catch (IOException e) {
            throw new RuntimeException("Can not read class file into byte array");
        }
    }

    public static void main(String[] args) {
        CustomClassLoader loader = new CustomClassLoader("/Users/leon/Desktop/lib");
        try {
            Class<?> clz = loader.loadClass("com.ganpengyu.demo.Person");
            System.out.println(clz.getClassLoader().toString());

            Constructor<?> c = clz.getConstructor(String.class);
            Object instance = c.newInstance("Leon");
            Method method = clz.getDeclaredMethod("say", null);
            method.invoke(instance, null);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}
复制代码

示例中我们通过继承 java.lang.ClassLoader 创建了一个自定义类加载器,通过构造方法指定这个类加载器的类路径(classpath)。重写 findClass(String name) 方法自定义类加载的方式,其中 getClassFilePath(String filepath) 方法和 readClassFile(String filepath) 方法用于找到指定的 .class 文件并加载成一个字符数组。最后调用 defineClass(String name, byte[] b, int off, int len) 方法完成类的加载。

main() 方法中我们测试加载了一个 Person 类,通过 loadClass(String name) 方法加载一个 Person 类。我们自定义的 findClass(String name) 方法,就是在这里面调用的,我们把这个方法精简展示如下:

protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
    synchronized (getClassLoadingLock(name)) {
        // 先检查是否已经加载过这个类
        Class<?> c = findLoadedClass(name);
        if (c == null) {
            long t0 = System.nanoTime();
            try {
                // 否则的话递归调用父加载器尝试加载
                if (parent != null) {
                    c = parent.loadClass(name, false);
                } else {
                    // 所有父加载器都无法加载,使用根加载器尝试加载
                    c = findBootstrapClassOrNull(name);
                }
            } catch (ClassNotFoundException e) {}
            if (c == null) {
                // 所有父加载器和根加载器都无法加载
                // 使用自定义的 findClass() 方法查找 .class 文件
                c = findClass(name);
            }
        }
        return c;
    }
}
复制代码

可以看到 loadClass(String name) 方法内部是遵循「双亲委派」机制来完成类的加载。在「双亲」都没能成功加载类的情况下才调用我们自定义的 findClass(String name) 方法查找目标类执行加载。

为什么需要自定义类加载器

自定义类加载器的用处有很多,这里简单列举一些常见的场景。

  1. 从任意位置加载类。JVM 预定义的三个类加载器都被限定了自己的类路径,我们可以通过自定义类加载器去加载其他任意位置的类。
  2. 解密类文件。比如我们可以对编译后的类文件进行加密,然后通过自定义类加载器进行解密。当然这种方法实际并没有太大的用处,因为自定义的类加载器也可以被反编译。
  3. 支持更灵活的内存管理。我们可以使用自定义类加载器在运行时卸载已加载的类,从而更高效的利用内存。

就这样吧

类加载器是 Java 中非常核心的技术,本文仅对类加载器进行了较为粗浅的分析,如果需要深入更底层则需要我们打开 JVM 的源码进行研读。「Java 有路勤为径,JVM 无涯苦作舟」,与君共勉。

关注下面的标签,发现更多相似文章
评论