返回文章列表
JVM虚拟机
JVM类加载器双亲委派

04类加载器的详解

03讲解了什么是类的声明周期,本文章将详细讲解类加载器的内容。

类加载器(ClassLoader)是 Java 虚拟机提供给应用程序去获取类和接口字节码数据的技术。它只参与类加载过程中的"字节码获取并加载到内存"这一部分:通过加载字节码数据放入内存转换成 byte[],接下来调用虚拟机底层方法将 byte[] 转换成方法区和堆中的数据。本地接口 JNI(Java Native Interface)允许 Java 调用其他语言编写的方法,在 Hotspot 类加载器中主要用于调用 Java 虚拟机中 C++ 编写的方法。

类加载器的应用场景非常广泛:企业级应用中的 SPI 机制、类的热部署、Tomcat 的类隔离,以及大量面试题中涉及的"什么是双亲委派机制""如何打破双亲委派机制"等,都离不开类加载器;在线上问题排查时,还可以使用 Arthas 不停机解决线上故障。

1.类加载器的分类

总的分为两类:一类是 Java 代码实现的,一类是 Java 虚拟机底层源码实现的。源代码位于 Java 虚拟机的源码中,实现语言与虚拟机底层语言一致,比如 Hotspot 使用 C++;而所有 Java 中实现的类加载器都需要继承抽象类 ClassLoader,JDK 中默认提供了多种处理不同渠道的类加载器,程序员也可以根据需求自己定制。

类加载器分类

类加载器的设计 JDK8 和 8 之后的版本差别较大,JDK8 及之前的版本中默认的类加载器有如下几种:

  • 底层源码实现的启动类加载器 Bootstrap
  • 扩展类加载器 Extension
  • 应用程序类加载器 Application

其中启动类加载器加载 Java 中最核心的类,扩展类加载器允许扩展 Java 中比较通用的类,应用程序类加载器加载应用使用的类。类加载器的详细信息可以在 Arthas 中通过 classloader 命令查看,该命令可以查看 classloader 的继承树、urls、类加载信息,并可以使用 classloader 去 getResource。

1.1 启动类加载器

启动类加载器(Bootstrap ClassLoader)是由 Hotspot 虚拟机提供的、使用 C++ 编写的类加载器。默认加载 Java 安装目录 /jre/lib 下的类文件,比如 rt.jar、tools.jar、resources.jar 等。

通过启动类加载器去加载用户 jar 包有两种方式:

  • 放入 jre/lib 下进行扩展:不推荐,尽可能不要去更改 JDK 安装目录中的内容,即使放进去由于文件名不匹配的问题也不会正常地被加载。
  • 使用参数进行扩展:推荐,使用 -Xbootclasspath/a:jar包目录/jar包名 进行扩展。

1.2 扩展类加载器与应用程序类加载器

扩展类加载器和应用程序类加载器都是 JDK 中提供的、使用 Java 编写的类加载器。它们的源码都位于 sun.misc.Launcher 中,是一个静态内部类,继承自 URLClassLoader,具备通过目录或者指定 jar 包将字节码文件加载到内存中的能力。

其继承体系为:ClassLoader(抽象类,定义了类加载器的具体行为模式,通过 JNI 调用底层的 Java 虚拟机方法)→ SecureClassLoader(使用证书机制提升类加载的安全性)→ URLClassLoader(利用 URL 获取目录下或者指定的 jar 包进行加载,获取其字节码数据)→ Extension / Application。

URLClassLoader继承体系

扩展类加载器(Extension Class Loader)默认加载 Java 安装目录 /jre/lib/ext 下的类文件。和启动类加载器一样,如果想通过扩展类加载器去加载用户 jar 包,建议使用 -Djava.ext.dirs=jar包目录 进行扩展。这种方式会覆盖掉原始目录,可以用 ;(Windows)或 :(macOS/Linux)追加上原始目录。应用程序类加载器(Application Class Loader)则加载 classpath 下的类文件。

Arthas 中类加载器的加载路径可以通过 classloader -c hash值 查看:

arthas classloader加载路径

2.双亲委派机制

在 Java 中如何使用代码的方式去主动加载一个类呢?有两种方式:

方式 1:使用 Class.forName 方法,使用当前类的类加载器去加载指定的类。 方式 2:获取到类加载器,通过类加载器的 loadClass 方法指定某个类加载器加载。

// 方式1:使用当前类的类加载器去加载
Class.forName("com.itheima.my.A");
 
// 方式2:指定某个类加载器去加载
ClassLoader loader = Demo.class.getClassLoader();
loader.loadClass("com.itheima.my.A");

2.1 parent 父类加载器

每个 Java 实现的类加载器中保存了一个成员变量叫"父"(Parent)类加载器,可以理解为它的上级,并不是继承关系。应用程序类加载器的 parent 父类加载器是扩展类加载器,而扩展类加载器的 parent 是空。启动类加载器使用 C++ 编写,没有上级类加载器——parent 是 null 就代表该类的父类是 Bootstrap。

parent父类加载器关系

类加载器的继承关系可以在 Arthas 中通过 classloader -t 查看:

arthas classloader -t

2.2 双亲委派流程

双亲委派机制指的是:自底向上查找是否加载过,再由顶向下进行加载。

具体过程是:在加载一个类时,每个类加载器都会先检查是否已经加载了该类,如果已经加载则直接返回,否则会将加载请求委派给父类加载器。如果 parent 为 null,则会提交给启动类加载器处理。如果所有的父类加载器都无法加载该类,则由当前类加载器自己尝试加载,看上去是自顶向下尝试加载。第二次再去加载相同的类,仍然会向上进行委派,如果某个类加载器加载过就会直接返回。

双亲委派机制流程

以 com.itheima.my.A 这个不在任何加载器目录中的类为例:自底向上查找时,三个类加载器都"没有加载过";自顶向下尝试加载时,启动类加载器和扩展类加载器都"不在我的加载目录中",最终由应用程序类加载器"加载成功"。而 com.itheima.my.B 这个位于 classpath 中的类,则同样由应用程序类加载器加载成功。

2.3 双亲委派机制解决的问题

双亲委派机制解决了三个问题:

  • 如果一个类重复出现在三个类加载器的加载位置,应该由谁来加载?由启动类加载器加载,根据双亲委派机制,它的优先级是最高的。
  • 在自己的项目中去创建一个 java.lang.String 类,会被加载吗?不能,会交由启动类加载器加载在 rt.jar 包中的 String 类。这样可以防止核心类被重写。
  • 这几个类加载器彼此之间存在关系吗?应用类加载器的父类加载器是扩展类加载器,扩展类加载器没有父类加载器,但是会委派给启动类加载器加载。

这样就能 1.避免重复加载,2.保证类加载的安全性——通过双亲委派机制,让顶层的类加载器去加载核心类,避免恶意代码替换 JDK 中的核心类库,比如 java.lang.String,确保核心类库的完整性和安全性;同时避免同一个类被多次加载,上层的类加载器如果加载过类,就会直接返回该类。

3.打破双亲委派机制

打破双亲委派机制主要有以下三种方法:自定义类加载器并重写 loadClass 方法、利用线程上下文类加载器加载类(如 JDBC 和 JNDI 等)、以及历史上 OSGi 框架实现的同级之间委托加载机制。

3.1 自定义类加载器

一个 Tomcat 程序中是可以运行多个 Web 应用的,如果这两个应用中出现了相同限定名的类,比如 Servlet 类,Tomcat 要保证这两个类都能加载并且它们应该是不同的类。如果不打破双亲委派机制,当应用类加载器加载 Web 应用 1 中的 MyServlet 之后,Web 应用 2 中相同限定名的 MyServlet 类就无法被加载了。

Tomcat应用隔离

Tomcat 使用了自定义类加载器来实现应用之间类的隔离,每一个应用会有一个独立的类加载器加载对应的类。

先来分析 ClassLoader 的原理,ClassLoader 中包含了 4 个核心方法,双亲委派机制的核心代码就位于 loadClass 方法中:

ClassLoader核心方法

四个方法分别是:

public Class<?> loadClass(String name)            // 类加载的入口,提供双亲委派机制,内部会调用 findClass
protected Class<?> findClass(String name)         // 由类加载器子类实现,获取二进制数据并调用 defineClass
protected final Class<?> defineClass(String name, byte[] b, int off, int len) // 校验类名,调用虚拟机底层方法将字节码加载到内存
protected final void resolveClass(Class<?> c)     // 执行类生命周期中的连接阶段

打破双亲委派机制的核心就是将 loadClass 中下面这一段代码重新实现:

// parent 等于 null 说明父类加载器是启动类加载器,直接调用 findBootstrapClassOrNull
// 否则调用父类加载器的加载方法
if (parent != null) {
    c = parent.loadClass(name, false);
} else {
    c = findBootstrapClassOrNull(name);
}
// 父类加载器爱莫能助,我来加载!
if (c == null)
    c = findClass(name);

双亲委派核心代码

需要补充说明的是,自定义类加载器默认的父类加载器是 AppClassLoader。以 JDK8 为例,ClassLoader 类中提供了构造方法设置 parent 的内容,这个构造方法由另外一个构造方法调用,其中父类加载器由 getSystemClassLoader 方法设置,该方法返回的就是 AppClassLoader。

正确地去实现一个自定义类加载器的方式是重写 findClass 方法,这样不会破坏双亲委派机制;只有重写 loadClass 方法才会将双亲委派机制的代码去除,从而打破双亲委派机制。

两个自定义类加载器加载相同限定名的类,不会冲突吗?不会冲突,在同一个 Java 虚拟机中,只有相同类加载器 + 相同的类限定名才会被认为是同一个类。可以在 Arthas 中使用 sc -d 类名 的方式查看具体情况。

3.2 线程上下文类加载器

这种方法常用于 Java 自己实现一些底层功能,比如实现 JDBC。

JDBC 中使用了 DriverManager 来管理项目中引入的不同数据库的驱动,比如 mysql 驱动、oracle 驱动。DriverManager 类位于 rt.jar 包中,由启动类加载器加载;而依赖中的 mysql 驱动对应的类,由应用程序类加载器来加载。

这就违反了双亲委派机制:一个已经被父加载器(Bootstrap ClassLoader)成功加载的类(DriverManager),在它的初始化代码(静态块)或方法执行过程中,需要去加载一个具体的 JDBC 驱动类。按照严格的双亲委派规则,这个加载请求应该首先委托给 DriverManager 自己的加载器,也就是 Bootstrap ClassLoader。但 Bootstrap ClassLoader 根本不认识 classpath 下的 MySQL 的 jar 包,所以会加载失败,导致 JDBC 完全无法使用。

JDBC违反双亲委派

那 DriverManager 怎么知道 jar 包中要加载的驱动在哪儿?答案是 DriverManager 使用了 SPI 机制。SPI 全称为 Service Provider Interface,是 JDK 内置的一种服务提供发现机制。其工作原理是:在 ClassPath 路径下的 META-INF/services 文件夹中,以接口的全限定名来命名文件名,对应的文件里面写该接口的实现;然后使用 ServiceLoader 加载实现类。

ServiceLoader<Driver> loadedDrivers = ServiceLoader.load(Driver.class);

SPI机制

SPI 中是如何获取到应用程序类加载器的?答案是 SPI 中使用了线程上下文中保存的类加载器进行类的加载,这个类加载器一般是应用程序类加载器。完整流程如下:

  • 启动类加载器加载 DriverManager。
  • 在初始化 DriverManager 时,通过 SPI 机制加载 jar 包中的 mysql 驱动。
  • SPI 中利用了线程上下文类加载器(应用程序类加载器)去加载类并创建对象。

这种由启动类加载器加载的类,委派应用程序类加载器去加载类的方式,打破了双亲委派机制。

线程上下文类加载器流程

其实"线程上下文类加载器是否算是打破了双亲委派机制"是有争议的,这种说法主要出自周志明的《深入理解Java虚拟机》。也有观点认为 JDBC 只是在 DriverManager 加载完之后,通过初始化阶段触发了驱动类的加载,类的加载依然遵循双亲委派机制,没有真正打破。这个部分我们不细究,感兴趣的读者可以自行查找相关争议的说法,这里我们只讲解什么是线程上下文类加载器。

3.3 OSGi 模块化

历史上,OSGi 模块化框架实现了一套新的类加载器机制,它存在同级之间的类加载器委托加载——允许同级之间委托进行类的加载。OSGi 还使用类加载器实现了热部署的功能,热部署指的是在服务不停止的情况下,动态地更新字节码文件到内存中。

OSGi同级委托

不过 OSGi 已经基本不再使用了,感兴趣的读者可以自行查找资料了解。

4.JDK9之后的类加载器

JDK8 及之前的版本中,扩展类加载器和应用程序类加载器的源码位于 rt.jar 包中的 sun.misc.Launcher.java。

JDK8类加载器

由于 JDK9 引入了 module 的概念,类加载器在设计上发生了很多变化:

  1. 启动类加载器使用 Java 编写,位于 jdk.internal.loader.ClassLoaders 类中。Java 中的 BootClassLoader 继承自 BuiltinClassLoader,实现从模块中找到要加载的字节码资源文件。启动类加载器依然无法通过 Java 代码获取到,返回的仍然是 null,保持了统一。

JDK9启动类加载器

  1. 扩展类加载器被替换成了平台类加载器(Platform Class Loader)。平台类加载器遵循模块化方式加载字节码文件,所以继承关系从 URLClassLoader 变成了 BuiltinClassLoader,BuiltinClassLoader 实现了从模块中加载字节码文件。平台类加载器的存在更多的是为了与老版本的设计方案兼容,自身没有特殊的逻辑。

JDK9平台类加载器