JVM系列 之 深入探索Java 类加载器

🚀JVM系列 之 深入探索Java 类加载器

Hey 大家好,我是 Shio👋。今天我们将深入探讨 Java 的类加载器,这是 Java 虚拟机(JVM)中一个重要的概念。在上一篇中,我们了解到 Java 类加载分为多个步骤,其中只有加载步骤中的读取二进制流与初始化部分是由上层开发者控制的, 剩下的步骤都由 JVM 掌控 。 这是为了符合面向对象中的开闭原则和封装思想。

📦 类加载器的分类

首先要声明的是,类加载器的分类属于 JVM 规范,是一种抽象的概念。各个 JVM 的实现方式可能不同。在JVM 规范中,类加载器分为启动类加载器非启动类加载器。本节我们只讲最常见和主流 HotSpot:

image-20240105141428134

  • 启动类加载器

    • Bootstrap Class Loader

      • 采用 C 和 C++ 实现,嵌套在 JVM 内部,无法被程序引用。

      • 主要加载 Java 的核心类库,如 JAVA_HOME /lib 路径下的包(rt.jar tool.jar等)或由启动参数指定的核心类库。(-Xbootclasspath/a: jar包目录/jar包名 的类也会被其加载)

      • 只加载包名在白名单中的文件,例如以 javajavax 开头的包。

  • 非启动类加载器

    image-20240106173100183

    • Extension Class Loader
      • sun.misc.Launcher 内部类``ExtensionClassLoader` 实现。
      • 默认加载 JAVA_HOME /lib 路径下的包(-Djava.et.dirs=jar包目录进行扩展, 但会覆盖掉原始目录, 所以要附带原始目录)
      • 用于加载 Java API 的拓展,对 Java 类库提供一些补充能力。
    • Application Class Loader
      • sun.misc.Launcher 内部类 AppClassLoader 实现。
      • 主要加载环境变量 classpath 或系统属性指定路径下的类库。
      • 用于加载上层程序员编写的代码和第三方类库。
    • User Class Loader
      • 用户自定义的类加载器,继承自 java.lang.ClassLoader
      • 允许用户获取任何来源的字节码进行加载。

❓ 问题 能不能用Extension ClassLoader 来加载我自己写的代码 或者说是一些第三方类库?

StackOverFlow 国内镜像访问CLASSPATH vs java.ext.dirs-腾讯云开发者社区-腾讯云 (tencent.com)

直接上结论

你完全可以这么做 但是没有必要 这种操作呢 不符合规范工程项目中的分层和抽象 就像你完全可以把一个项目 所有的代码都写在一个文件中, 但是是不推荐这么做的

🌐 类加载器的命名空间

每个类加载器都有属于自己的命名空间,即使加载了相同限定名的类,JVM 也认为它们是不同的类。这有助于避免不同类加载器间的混乱,提高程序的开发和维护难度。

判断是否是同一个类的标准
在JVM中表示两个class对象是否为同一个类的两个必要条件:

  • 类的完整类名必须一致,包括包名。

  • 加载这个类的ClassLoader(指ClassLoader实例对象)必须相同

换句话说,在JVM中,即使这两个类对象(class对象)来源同一个Class文件,被同一个虚拟机所加载,但只要加载它们的ClassLoader实例对象不同,那么这两个对象也不是相等的。

示例(来源于<<深入理解Java虚拟机>>):


package com.study;

public class Main {

public static void main(String[] args) throws Exception {
ClassLoader myLoader = new ClassLoader() {
@Override
public Class<?> loadClass(String name) throws ClassNotFoundException {
try {
String fileName = name.substring(name.lastIndexOf(".") + 1) + ".class";
InputStream is = getClass().getResourceAsStream(fileName);
if (is == null) {
return super.loadClass(name);
}
byte[] b = new byte[is.available()];
is.read(b);
return defineClass(name, b, 0, b.length);
} catch (IOException e) {
e.printStackTrace();
throw new ClassNotFoundException(name);
}
}
};


Object obj = myLoader.loadClass("com.study.Test").newInstance();
System.out.println(obj.getClass());
System.out.println(obj instanceof com.study.Test);
}

}

结果:

class com.study.Test
false

注意:

​ 上述例子中, 只有当这个限定名类(com.study.Test)与这个Main类在同一个包下时(com.study),返回的结果才会为false,因为 如果这个限定名类与Main类不在同一个包下时,InputStream is = getClass().getResourceAsStream(filename), 不同包下这个方法返回null,然后直接走的super方法,你需要去修改下filename才能找到文件位置,返回的结果才能为true, 而同包的话就可以直接找到

🤝 双亲委派模型

引言:

​ 竟然Java中存在这么多类加载器,那么如何判断一个类到底由谁来加载呢? 有的同学可能会说根据目录划分, 那么如果我把类在每一个目录都复制一份, 那么由谁来加载呢, 解决这个问题就需要引入Java的双亲委模型了

image-20240105145552504

为了保证类加载的安全性(攻击方在提供的ar包中自己实现了一个 java.lang.String, 引入后如何确保核心类库的完整性质和安全性)和避免重复加载 (避免同一个类被多次加载)

JVM 引入了双亲委派模型,即类加载器在收到加载请求时不会立即加载,而是传递父加载器。只有父加载器无法完成加载时,子加载器才会尝试加载。这样确保了每个类只会被加载一次避免了混乱

向上查找 : 保证类不重复加载

向下加载: 以加载优先级进行加载(Bootstrap -> Extension > Application )

image-20240106182648876

Arthas验证

image-20240106184840870

双亲委派模型通过 java.lang.ClassLoaderloadClass 方法实现。该方法首先检查是否已加载,然后传递给父加载器,如果父加载器无法加载,则调用 findClass 方法加载。

🔍源码解析

image-20240106191457783

接下来,我们通过源码来理解双亲委派模型的实现。

image-20240105150139002

在java.lang.ClassLoader中的loadClass方法中,

  • 首先检查类是否已经加载,

  • 如果没有,则启动加载流程。parent变量代表当前ClassLoader的父加载器,这里通过组合而非继承实现父子关系。当**parent为null时,约定为Bootstrap Class Loader**。

    image-20240106184429274

  • 如果parent不为null,将加载请求传递给父加载器。只有父加载器无法完成加载时,子加载器才会尝试自己加载,这是双亲委派的关键。

findClass方法表示如何寻找指定限定名的class,需要各个类加载器自己实现

查找具有指定二进制名称的类。该方法应由遵循委托模式加载类的类加载器实现重载,并将在检查父类加载器是否有请求的类后由 loadClass 方法调用。

image-20240105150638331

Extension Class Loader和Application Class Loader使用了相同的逻辑(父类URLClassLoader提供),将类的限定名转化为文件路径,通过UCP(Unified Class Path)寻找文件资源

image-20240105151929138

找到资源之后, 然后调用defineClass进行类加载的后续流程。defineClass方法被final修饰,表示最终由java.lang.ClassLoader来进行后续操作。

image-20240105151611984

综上可以总结出双亲委派模型的核心代码

if (parent != null) {
c = parent.loadClass(name, false);
}else {
c = findBootstrapClassOrNull(name);
}
if (c == null)
c = findClass(name);

❓ 双亲委派模型的疑问

问题一:不同的类加载器,除了读取二进制流的动作和范围不一样,后续的加载逻辑是否也不一样?根据源码,我们了解到所有非Bootstrap Class Loader都继承自java.util.ClassLoader,都是由这个类的defineClass方法来进行后续处理。

问题二:遇到限定名一样的类,这么多类加载器会不会产生混乱?根据源码,我们知道不同类加载器有不同的加载范围,越核心的类越由上层加载器加载。一旦某个类被加载,被动情况下不会再去加载相同限定名的类,有效避免混乱

问题三: 攻击方在提供的jar包中自己实现了一个 java.lang.String, 双亲委派机制如何保证如何确保核心类库的完整性质和安全性? 双亲委派机制通过向下加载机制保证了 java核心类库中的类, 永远都由先 Bootstrap ClassLoader加载, 攻击方的java.lang.String不会被加载

public class Main1 {
public static void main(String[] args) throws Exception {
// 获取Application ClassLoader
ClassLoader cl = Demo.class.getClassLoader();
System.out.println(cl);
// 使用Application ClassLoader加载jdk核心类库的java.lang.String
Class<?> stringClazz = cl.lloadClass("java.lang.String");
// 由于双亲委派模型的存在, 最终java.lang.String将被委派给BootStrap ClassLoader加载
System.out.println(stringClazz.getClassLoader());
}
}

image-20240106184131302

当然, 即使你自定义类加载器去尝试的话, 以”java.”包名开头的类也会直接抛异常

image-20240106193318357

尽管双亲委派模型在大部分情况下是透明且可靠的,但并非具有强约束力的模型,存在设计缺陷, 在大多数情况下, 双亲委派模型是生效而且好用的。

但在一些情况下,双亲委派模型可以被主动破坏。例如,通过重写loadClass方法,我们可以破坏原有的双亲委派逻辑,导致限定名对应两种不同的情况。(就像上文中证明类加载器命名空间的那个例子, 重写了loadClass方法, 破坏了原有双亲委派模型, 出现了一个限定名对应两种不同Class的情况, 除非有特殊业务场景, 一般不要主动破坏)

🚨 双亲委派模型的破坏

🩸 First Blood

既然JVM推荐并希望开发者遵循双亲委派模型

为什么不将 loadClass 方法设定成 final 来限制重写? 这样上层开发者就不久必须强制遵循双亲委派逻辑了么?

事实上,Java.lang.ClassLoader在Java很早的版本就存在了, 而双亲委派模型是在JDK1.2才引入的

image-20240105153413082

也就说 , 在JDK1.2版本引入双亲委派模型的特性之前就已经存在着大量重写loadClass方法的代码了,而JVM无法拒绝支持这些旧代码,只能采取补救措施。因此,JDK1.2版本后引入了 findClass 方法来推荐用户重写,而不是直接重写 loadClass 方法,这样就依然能够符合双亲委派模型的逻辑。

这算是第一次对双亲委派模型的破坏

🩸 🩸 Double Kill

有了第一次, 自然会有第二次

以JDK操作数据库为例, JDK想要有操作数据库的功能, 而数据库有很多种, 而且会随着时间而增多, JDK不可能把所有数据库的代码一一实现, 那么比较合理的就是 JDK提供一组规范接口, 由不同的数据库厂商实现这组接口即可

那么问题就出现了, JDK自己定义的接口类的加载一定是使用到了上层类加载器(BootStrap ClassLoader), 但当你调用JDK接口时必然会触发第三方类库的动态加载, 而第三方厂商的实现类加载, 使用的是下层类加载器(Application ClassLoader) ,这就不符合自下而而上的委派加载顺序了, 而是出现了上层类加载器放下身段去调用下层类加载器的情况 这就产生了对双亲委派模型的破坏

纠正 :

这种说法最早源于周志明老师的<<深入理解Java虚拟机>>, 实际上自己思考后, SPI机制 没有打破双亲委派机制, 通过currentThread获取ApplicationClassLoader 来加载类, 不仍然还是遵从 向上查找, 向下加载的机制? 所以最我的终观点是 没有打破, 当然下文, 源码解析仍然正确,别跳过塞

image-20240106195642054

示例代码:

public class Main {
public static void main(String[] args) {
String url = "jdbc:mysql://localhost:3306/数据库名?serverTimezone=UTC";
String user = "数据库账号名";
String password = "数据库账号密码";
try {
Connection conn = DriverManager.getConnection(url, user, password);
System.out.println("DriverManager 的ClassLoader为 : " + DriverManager.class.getClassLoader());
Enumeration<Driver> drivers = DriverManager.getDrivers();
while (drivers.hasMoreElements()) {
Driver driver = drivers.nextElement();
System.out.print(driver + " 的ClassLoader为 : ");
System.out.println(driver.getClass().getClassLoader());
}

System.out.println(conn);

} catch (Exception e) {
e.printStackTrace();
}
}
}

执行结果:

image-20240105163920912

null 即 BootStrap ClassLoader

sun.misc.Launcher$AppClassLoader@18b4aac2 即 Application ClassLoader

DriverManager类为例,其类加载器为null,即约定为bootstrap class loader,而DriverManager内部加载的两个Driver的类加载器均为application class loader,这表明bootstrap class loader委托了application class loader加载了来自第三方的类, 这就是SPI对双亲委派模型的破坏。

image-20240105161803714

进一步分析DriverManager的源码可以发现,其主动对第三方Driver类进行加载,扫描所有注册为java.sql.Driver开头的第三方类,然后使用ServiceLoader进行加载,而ServiceLoader内部使用了当前线程context中的类加载器,默认为application class loader,因此这些第三方类也能够被正常加载,也从而破坏了双亲委派模型的约定。

这便是第二次对双亲委派模型的破坏

🩸 🩸 🩸 Triple Kill

随着对模块化和热替换的追求,希望在着程序运行期间, 动态地对部分组件代码进行替换,可想而知这里又会涉及到很多的自由的类加载操作, 所以又将对双亲委派模型的践踏。(存疑)

综上, 尽管双亲委派模型在大部分情况下有效,但在某些场景下可被主动破坏。主要有三次破坏:

  1. 自定义类加载器:用户可以通过自定义类加载器重写 loadClass 方法,破坏默认的委派逻辑。
  2. Java SPI 机制:在服务提供者接口(SPI)中,JDK 会主动加载第三方实现,违反了双亲委派的加载顺序。
  3. 模块化和热替换:随着对模块化和热替换的追求,程序可能会动态地对部分组件代码进行替换,导致对双亲委派模型的践踏。

📚 总结

尽管双亲委派模型被破坏了三次,它在大部分场景下仍然是一个有效、透明的设计。在发现缺陷时,JVM 设计者采取了一些补救措施。总体而言,双亲委派模型是一个不错的设计,如果有更好的设计思路,欢迎分享讨论。

希望通过本文的介绍,大家对 Java 类加载器有了更深入的理解。下一篇我们将继续探讨 Java 中的其他关键概念,敬请期待!如果有任何问题或建议,欢迎留言交流。感谢阅读! 🚀

扩展阅读

打破双亲委派机制的三种方式

  • 自定义ClassLoader并重写loadClass方法, 就可以将双亲委派机制代码去除(如Tomcat通过这种方式实现应用之间的隔离)
  • 利用上下文类加载器加载类, 例如JDBC和JNDI(没有打破)
  • 历史上Osgi框架实现一套新的类加载机制, 允许同级之间委托类加载

Arthas 实现不停机热部署

背景:
小李的团队将代码上线之后,发现存在一个小bug,但是用户急着使用,如果重
新打包再发布需要一个多小时的时间,所以希望能使用arthas尽快的将这个问
题修复。

我们已知 某个类代码逻辑缺陷的情况下, 在不停机的情况下, 对代码进行热更改热部署

在出问题的服务器上部署一个Arthas , 并启动。

// 反编译class文件,然后可以用编辑器进行修改
jad-source-only `类全限定名` >`目录/文件名.java`

// 获取原java文件使用的类加载器的hashcode
sc -d `类全限定名`

// 将修改后的.java文件使用原类加载器编译成class文件
mc -c 类加载器的hashcode `目录/文件名.java` -d 输出目录

// 用retransform命令加载新的class文件
retransform class.文件所在目录/xxx.class
  • 注意事项:

​ 程序重启后, 字节码文件就会恢复, 除非将class文件放入jar包中更新

​ 使用retransform不能添加方法或者字段, 也不能更新正在执行中的方法

JDK9 之后的类加载器变化

image-20240106202658811

JDK9 引入了模块化的概念, 加载类也从之前的按照目录加载, 转变为按照.jmod文件进行加载

image-20240106202843626

即使使用Java实现启动类加载器, 也仍然无法获取启动类加载器

image-20240106203157866

理论上使用了模块化的思想, 扩展就不需要使用ExtensionClassLoader了, 而为了兼容还是将其保留

image-20240106202940432

常见面试题

image-20240106203451263

image-20240106203517200

image-20240106203547091

image-20240106203640068

参考文献: