Java类加载器的双亲委派机制

Java 的类加载器采用双亲委派机制解决类的加载问题。在类加载器体系中,除了启动类加载器外,每个类加载器都有自己的父类加载器。这些类加载器都是「啃老族」,对于类加载这件事,总是先请求自己的父类加载器去处理,如果父类加载器处理不了,自己再去执行类加载操作。

类加载器

在 Java 的类加载器体系中,系统提供了 3 种类加载器:启动类加载器、扩展类加载器和应用程序类加载器,它们的关系如下图所示

Java类加载器

  • 启动类加载器(Bootstrap ClassLoader)

    这是最顶层的类加载器,使用 C++ 语言实现,是 JVM 的一部分,它负责加载 JAVA_HOME/lib 目录或者 -Xbootclasspath 路径中的类库,并且只加载特定文件名的类库文件(如 rt.jar),不能定制。

  • 扩展类加载器(Extension ClassLoader)

    它是由 sun.misc.Launcher$ExtClassLoader 实现的,负责加载 JAVA_HOME/lib/ext 目录或者 java.ext.dirs 指定路径中的类库。

  • 应用程序类加载器(Application ClassLoader)

    它是由 sun.misc.Launcher$AppClassLoader 实现的,负责加载用户类路径(ClassPath)上的类库。可以通过 ClassLoader.getSystemClassLoader() 获取到该类加载器,因此也把它叫做系统类加载器

双亲委派机制

如上面所讲,类加载器是按层次组织起来的,每个类加载器都有自己的职责。在加载类的时候,它们是按照双亲委派机制实现职责划分的。

什么是双亲委派机制

每个类加载器在加载类的时候,并不是先尝试自己加载,而是先把加载工作交给 parent 类加载器。如果 parent 类能够加载成功,则直接返回已加载的类。否则,才会尝试自己去读取并加载类。

比如 java.lang.Object 位于 rt.jar 中,由 BootStrap ClassLoader(启动类加载器)进行加载。当使用 Application ClassLoader(应用程序类加载器)该类时,它会首先委托 Extension ClassLoader(扩展类加载器)进行加载,而 Extension ClassLoader 则会委托 Bootstrap ClassLoader 进行加载,加载成功并返回结果。

同样,如果是用户定义的类,按照上述顺序加载后,最终会由 Application ClassLoader 加载成功。

类加载器的结构

类加载器是按照父子结构组织起来的,注意这里并不是使用的类继承的方式,而是每一个类加载器持有一个 parent 属性指向它的父级的类加载器。

所有的类加载器都继承自 ClassLoader 类。ClassLoader 类封装了 parent 属性,同时提供了一个 getParent 方法用于获取父级类加载器,因此可以通过循环调用此方法打印出类加载器结构。

执行如下代码:

1
2
3
4
5
6
7
8
9
public class ClassLoaderTree {
public static void main(String[] args) {
ClassLoader classLoader = ClassLoaderTree.class.getClassLoader();
while (classLoader != null) {
System.out.println(classLoader.toString());
classLoader = classLoader.getParent();
}
}
}

输出结果为:

1
2
sun.misc.Launcher$AppClassLoader@18b4aac2
sun.misc.Launcher$ExtClassLoader@1761e840

可以看到依次打印出了应用程序类加载器、扩展类加载器。

然而这里并没有看到启动类加载器,也就是说通过 ExtClassLoader 获取到的 parent 类加载器为空。这是因为启动类加载器是使用 c++ 实现的,在 java 中并没有对应的对象,所以不能通过 getParent() 方法获取到。

为什么使用双亲委派?

上面讲到了类加载器的职责划分,每个类加载器只加载自己管理范围的类。这是因为在 java 中,即使是同一个类,在被不同的类加载器加载后也是相等的。为了确保类型的一致性,需要类加载器按照一定的规则划分职责,确保不管按照什么顺序加载,最终的结果都是一致的。

java 提供了很多的基础类库,用户通过定义自己的类以实现各种功能。双亲委派机制将系统中的类分成了不同的层级,分别交由对应的类加载器进行加载。不管使用何种类加载器,基础类一定是由基础的类加载器加载完成的。正如上面提到的 java.lang.Object ,即使是使用 Application ClassLoader 来加载,最终一定是由 BootStrap ClassLoader 加载完成的。这种机制确保了内存中的类的一致性。

双亲委派的实现

双亲委派机制是通过 ClassLoader 类的 loadClass 方法实现的,它是一个模板方法,定义了类加载器的执行顺序。为方便查看,这里对其进行了一些简化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 检查类是否已加载过
Class<?> c = findLoadedClass(name);
if (c == null) {
try {
if (parent != null) {
// 使用父级类加载器进行加载
c = parent.loadClass(name, false);
} else {
// 对于ExtClassLoader,父级类加载器为BootstrapClassLoader
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// do nothing
}

if (c == null) {
// 自己加载
c = findClass(name);
}
}

return c;

主要流程为:

  1. 检查是否已经加载过(加载过的类可以直接使用)
  2. 父级类加载器不为空,使用父级类加载器加载
  3. 父级类加载器为空,说明当前是 ExtClassLoader,则使用 BootstrapClassLoader 加载
  4. 加载结果仍为空,则尝试自己加载

由此可见,如果重写了 loadClass 方法,可以实现不同于双亲委派的类加载机制。

自定义类加载器

loadClass 定义了加载的机制,findClass 则是定义了具体的类加载实现,比如从文件系统加载、从网络加载、从压缩包加载等。

ClassLoaderfindClass 默认未实现任何类型的加载,只抛出了一个 ClassNotFoundException 异常,若要自定义类加载器则需重写该方法。

1
2
3
protected Class<?> findClass(String name) throws ClassNotFoundException {
throw new ClassNotFoundException(name);
}

通常该方法实现可分为如下两部分

  1. 根据类的名字从指定位置加载类的内容,将其转换为字节数组
  2. 将字节数组解析为类对象

第一步的实现依赖于具体的类加载器,比如从文件系统加载文件为字节流、从网络加载字节流等。

第二步是一个通用的流程,因此 ClassLoader 提供了一个模板方法 defineClass ,只需将加载好的字节数组交给该方法解析即可。

如下是一个从文件系统加载类的示例,可供参考

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
public class FileSystemClassLoader extends ClassLoader {

private String rootDir;

public FileSystemClassLoader(String rootDir) {
this.rootDir = rootDir;
}

@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
// 加载类并转换为字节数组
byte[] classData = getClassData(name);
if (classData == null) {
throw new ClassNotFoundException();
}

// 解析字节数组为类对象
return defineClass(name, classData, 0, classData.length);
}

private byte[] getClassData(String className) {
String path = classNameToPath(className);
try {
InputStream ins = new FileInputStream(path);
ByteArrayOutputStream baos = new ByteArrayOutputStream();

int bufferSize = 4096;
byte[] buffer = new byte[bufferSize];

int bytesNumRead;
while ((bytesNumRead = ins.read(buffer)) != -1) {
baos.write(buffer, 0, bytesNumRead);
}

return baos.toByteArray();
} catch (IOException e) {
e.printStackTrace();
}
return null;
}

private String classNameToPath(String className) {
String fullName = rootDir + File.separatorChar + className.replace('.', File.separatorChar) + ".class";
System.out.println(fullName);
return fullName;
}
}

破坏双亲委派机制

双亲委派机制虽然解决了类的一致性,看似很完美,但实际上还是存在一些未能解决的问题。

一个类加载问题

一个经常提到的例子是 java.sql.Driver ,这是一个在 rt.jar 里的接口,它定义了用于操作数据库的 sql 驱动规范,每个数据库驱动都需要实现该接口。而 java.sql.DriverManager 用来加载实现 java.sql.Driver 接口的数据库驱动类。

这样就提供了一个可扩展的机制,只需要更换驱动的实现类即可切换到新的数据库,非常符合面向接口编程的思想。

这里面存在一个类加载的问题:java.sql.DriverManagerjava.sql.Driver 均定义在 rt.jar 中,是由 Bootstrap ClassLoader 加载的。而厂商提供的驱动类定义在第三方的 jar 包中,Bootstrap ClassLoader 并不能够加载,这就需要有另外一种机制来解决。

委托 Thread Context ClassLoader 加载

要解决厂商提供 java.sql.Driver 子类的加载问题,一个可行的办法就是将加载工作交给 Application ClassLoader 或是厂商自定义的类加载器。这里就用到了 Thread Context ClassLoader,也就是线程上下文类加载器。

线程上下文类加载器是保存在线程对象中的加载器,它默认会从父线程中继承类加载器,如果没有设置过则默认为 Application ClassLoader 。 通过线程上下文类加载器可以将类加载工作委托给特定的类加载器,从而解决上面的问题。

具体的加载封装在 ServiceLoader 类里,加载 Driver 驱动类的代码如下

  1. 通过 ServiceLoader.load 加载 Driver 的实现类,得到一个延迟加载数组
  2. 通过迭代器遍历数组,触发驱动类加载
1
2
3
4
5
6
7
// 加载Driver驱动类
ServiceLoader<Driver> loadedDrivers = ServiceLoader.load(Driver.class);
Iterator<Driver> driversIterator = loadedDrivers.iterator();

while(driversIterator.hasNext()) {
driversIterator.next();
}

load 方法的实现如下,线程上下文加载器是在这里传进去的

1
2
3
// 取得线程上下文类加载器
ClassLoader cl = Thread.currentThread().getContextClassLoader();
return ServiceLoader.load(service, cl);

最终的加载过程如下,注意这里对代码进行了简化,以方便查看

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
private S nextService() {
Class<?> c = null;
try {
// 使用线程上下文类加载器加载指定类
c = Class.forName(nextName, false, loader);
} catch (ClassNotFoundException x) {
}

try {
// 创建类示例
S p = service.cast(c.newInstance());
providers.put(cn, p);
return p;
} catch (Throwable x) {
}
}

spi 机制

上面提到的解决方法即是 spi 类加载机制。spi 全称为 service provider interface,可翻译为「服务提供方接口」。这种机制通常用于提供扩展能力,在开源框架中很常见。

比如框架针对某个模块提供了 interface 接口规范,框架本身提供了默认实现,通过 ServiceLoader 来加载具体实现。框架用户可根据接口规范定义自己的实现方式,然后按照 ServiceLoader 规范注册自定义实现,从而实现自定义模块功能。

其他场景

除了 spi 外还有一些违背双亲委派机制的场景,比如 Tomcat 针对 Servlet 规范自定义了类加载机制:先加载 Web 应用目录下的类,然后加载其他目录的类,它的加载流程为

  1. 检查 Tomcat 类加载器是否已经加载过
  2. 检查 AppClassLoader 是否已经加载过
  3. 尝试用 ExtClassLoader 加载,防止覆盖 java 的基础类
  4. 尝试在本地目录搜索并加载(findClass)
  5. 尝试用 AppClassLoader 加载
  6. 都没加载到则加载失败

注意 4 和 5 与 ClassLoader 默认的加载顺序相反。

另外要注意的是在尝试自己加载前先执行了 ExtClassLoader 加载,目的是防止覆盖 java 的基础类。

参考

周志明. 深入理解Java虚拟机