深入浅出 JVM 之类加载-类加载器
系统环境:
- JDK 1.8
参考地址:
- JVM 学习笔记
- Java 类加载机制
- Oracle Java 虚拟机规范
- 深入理解Java虚拟机: JVM高级特性与最佳实践(第3版)
深入浅出 JVM 系列文章
- 01.深入浅出 JVM 之 Java 虚拟机
- 02.深入浅出 JVM 之 Class 字节码文件
- 03.深入浅出 JVM 之字节码-常量池常量分析
- 04.深入浅出 JVM 之字节码-指令集简介
- 05.深入浅出 JVM 之类加载-类加载流程
- 06.深入浅出 JVM 之类加载-类加载器
- 07.深入浅出 JVM 之运行时数据区
- 08.深入浅出 JVM 之运行时数据区-堆
- 09.深入浅出 JVM 之运行时数据区-方法区
- 10.深入浅出 JVM 之运行时数据区-虚拟机栈
- 11.深入浅出 JVM 之运行时数据区-程序计数器
- 12.深入浅出 JVM 之垃圾回收-垃圾回收概述与算法
- 13.深入浅出 JVM 之垃圾回收-垃圾回收器
一、什么是类加载器
在 Java 中,类的初始化分为几个阶段: 加载、链接(包括验证、准备和解析)和 初始化。而 类加载器(Class Loader)则是加载阶段中,负责将本地或网络中的指定类的二进制流,加载到 Java 虚拟机中的工具。

二、抽象类 ClassLoader
在 Java 中存在一个类加载器抽象类 ClassLoader,大多数类加载器都是通过继承这个类来实现的类加载功能。以下是 ClassLoader 类的关键部分代码:
public abstract class ClassLoader {
/* * 类加载器的父加载器 */ private final ClassLoader parent;
/** * 根据类的全限定名加载类 * * @param name 类名称 * @return 加载的Class对象 * @throws ClassNotFoundException 没有发现指定类异常 */ public Class<?> loadClass(String name) throws ClassNotFoundException { // 调用loadClass方法加载类,其中设置resolve=false,表示不立即解析类 return loadClass(name, false); }
/** * 根据类的全限定名加载类 * * @param name 类名称 * @param resolve 是否解析这个类,true=解析,false=不解析 * @return 加载的Class对象 * @throws ClassNotFoundException 没有发现指定类异常 */ protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException { synchronized (getClassLoadingLock(name)) { // 检查类是否已经被加载 Class<?> c = findLoadedClass(name); // 如果没有加载过 if (c == null) { // 如果有父类加载器,则委托给父加载器去加载 // 如果没有父类加载器,则判断 Bootstrap 类加载器是否加载过 if (parent != null) { c = parent.loadClass(name, false); } else { c = findBootstrapClassOrNull(name); } // 如果父类加载器都加载失败,则当前类加载器尝试自行加载 if (c == null) { c = findClass(name); } } // 据 resolve 参数决定是否解析类 if (resolve) { resolveClass(c); } return c; } }
/** * 查找并加载指定名称的类 * * @param name 类名称 * @return Class对象 * @throws ClassNotFoundException 没有发现指定类异常 */ protected Class<?> findClass(String name) throws ClassNotFoundException { //1. 根据传入的类名,到在特定目录下去寻找类文件,把字节码文件读入内存 // ... //2. 调用 defineClass 将字节数组转成 Class 对象 return defineClass(buf, off, len); }
/** * 将一个 byte[] 转换为 Class 类的实例 * * @param name 类名称,如果不知道此名称,则该参数为 null * @param b 组成类数据的字节数组 * @param off 类数据的起始偏移量 * @param len 类数据的长度 * @return Class对象 * @throws ClassFormatError 类格式化异常 */ protected final Class<?> defineClass(byte[] b, int off, int len) throws ClassFormatError { ... }
}类中定义的常用的类加载相关的方法:
| 方法名称 | 描述 |
|---|---|
| getParent() | 返回该类加载器的父类加载器 |
| loadClass(String name) | 加载指定名称的类,返回 java.lang.Class 实例 |
| findClass(String name) | 查找指定名称的类,返回 java.lang.Class 实例 |
| findLoadedClass(String name) | 查找已加载的指定名称的类,返回 java.lang.Class 实例 |
| defineClass(String name, byte[] b, int off, int len) | 将字节数组转换为一个 Java 类,返回 java.lang.Class 实例 |
| resolveClass(Class c) | 连接指定的 Java 类 |
三、类加载器类型
在 Java 虚拟机中类加载器主要有三种,分别为:
- 引导类加载器 (BootstrapClassLoader)
- 扩展类加载器 (ExtClassLoader)
- 应用类加载器 (AppClassLoader)
3.1 引导类加载器 (BootstrapClassLoader)
引导类加载器 (Bootstrap Classloader) 是使用 C++ 语言实现的,没有继承 ClassLoader 类,嵌入在 Java 虚拟机内部,Java 程序无法直接操作这个类加载器。它主要用于加载 Java 中的核心类库,如 Java 虚拟机运行所需的一些依赖库。
引导类加载器一般情况下,会加载 JAVA_HOME 目录下的 /lib 文件夹中的 jar 和配置,或者被 -Xbootclasspath 参数所指定的目录中的 jar 和配置。不过出于安全考虑,引导类加载器只能加载包名为 java、javax、sum 等开头的类,然后将这些系统类加载到方法区内,比如:
| 文件 | 描述 |
|---|---|
| charsets.jar | 字符集支持包 |
| net.properties | JVM 网络配置信息 |
| classlist | 该文件内表示是引导类加载器应该加载的类的清单 |
| jsse.jar | 安全套接字拓展包 Java(TM) Secure Socket Extension |
| rt.jar | rt 全称是 runtime,是一个运行环境包,J2SE 相关类的定义都在这个包内 |
| jce.jar | jce.jar 是一组包,它们提供用于加密、密钥生成和协商以及 Message Authentication Code (MAC) 算法的框架和实现 |
3.2 扩展类加载器 (ExtClassLoader)
扩展类加载器 (ExtClassLoader) 是 Java 语言编写的,继承了 ClassLoader 类,根据该类加载器名也可以知道,它主要负责加载 Java 的扩展类库。
扩展类加载器一般情况下,会加载 JAVA_HOME 目录下的 /lib/ext 文件夹中的 jar,或由 java.ext.dirs 系统变量指定目录中的 jar。
3.3 应用类加载器 (AppClassLoader)
应用类加载器 (AppClassLoader) 是 Java 语言编写的,继承了 ClassLoader 类,是应用程序中默认的类加载器。
应用类加载器可以加载 CLASSPATH 变量指定目录下的 jar,并且一般情况下,我们编写的 Java 类大部分都是使用该类加载器完成加载的。
四、双亲委派机制
4.1 双亲委派机制是什么
双亲委派机制是 Java 类加载机制中非常重要的一环,用于确保类加载的安全性和一致性。
双亲委派机制的核心思想就是: 当一个类加载器接收到类加载的请求时,它自己不会先去尝试加载这个类,而是把这个类加载请求委托给父类加载器。每个类加载器都采用相同的方式,直至委托给最顶层的引导类加载器(Bootstrap ClassLoader)为止。如果父类加载器无法加载委托的类时,子类加载器才会尝试自己去加载,如果子类加载器也无法加载该类,就会抛出一个 ClassNotFoundException 异常。

4.2 双亲委派机制的作用
- ① 防止重复加载字节码文件: 将类加载请求先委托给父类,父类加载后子类就不会重复加载该类。所以,双亲委派机制可以防止对某个类重复加载;
- ② 防止核心字节码文件被篡改: 一般情况下引导类加载器会先加载 JVM 核心类库,然后其它加载器才会执行,如果其它加载器要加载一个被篡改的核心字节码文件,会将该文件委托给父类加载器,当委托到引导类加载器时,加载器已经加载过该类,就不会对该类进行重复加载。而且就算能被加载,那么加载它的肯定不是相同的类加载器 (不会是引导类加载器),Java 虚拟机中只认可核心类加载器加载的核心类库,所以,双亲委派机制可以防止核心字节码文件被篡改。
- ③ 简化加载逻辑: 通过委派模式,每个类加载器只需要关注自己负责的那部分类加载逻辑,而不必关心其他类加载器的加载细节,简化了类加载器的实现,降低了系统的复杂度。
考虑到安全因素,我们试想一下,如果不使用这种委托模式,那我们就可以随时使用自定义的 String 类来动态替代 Java 核心 API 中定义的类型,这样会存在非常大的安全隐患。而双亲委托的方式,就可以避免这种情况,因为 String 已经在启动时就被引导类加载器 (BootstrcpClassLoader) 加载,所以用户自定义的 ClassLoader 永远也无法加载一个用户自己自定义的 String 类,除非你改变 JDK 中 ClassLoader 搜索类的默认算法。
4.3 如何判断两个字节码相同
Java 虚拟机在判定两个字节码是否相同时,不仅要判断类名是否相同,还要判断是否由同一个类加载器实例加载的。只有两者同时满足,虚拟机才认为两个字节码相同。如果内容相同,但由不同的类加载器加载,虚拟机也会认为它们是不同的字节码。
五、自定义类加载器
5.1 为什么要自定义类加载器
默认的类加载器只能加载指定目录下的 Jar 和 Class 文件。如果需要加载指定位置的类文件并实现一些自定义逻辑,就需要自定义类加载器。
比如,我要加载网络上的一个 Class 文件,通过动态加载到内存之后,要调用这个类中的方法实现用户自定义的业务逻辑,在这样的情况下,默认的 ClassLoader 就不能满足我们的需求了,所以需要我们自己去定义带有指定业务逻辑的 ClassLoader。
5.2 如何自定义一个类加载器
实现一个自定义类加载器很简单,只需要继承 ClassLoader 抽象类,然后重写里面的 findClass() 方法,就可以实现一个自定义的类加载器。
在默认的情况下,自定义的类加载器还是会遵循双亲委派机制,如果想自己实现的类加载器不遵循双亲委派机制,则需要在重写 findClass() 方法的同时,也要重写 loadClass() 方法。
5.3 实现自定义类加载器示例
(1) 实现自定义类加载器
如下代码所示,是一个自定义类加载器的示例,实现了文件系统类加载器功能:
import java.io.ByteArrayOutputStream;import java.io.File;import java.io.FileInputStream;import java.io.IOException;import java.io.InputStream;
public class FileSystemClassLoader extends ClassLoader {
private String rootDir;
public FileSystemClassLoader(String rootDir) { this.rootDir = rootDir; }
/** * 获取类的字节码 * * 注: 重写 findClass() 方法 */ @Override protected Class<?> findClass(String name) throws ClassNotFoundException { // 实现一些自定义功能 System.out.println("实现一些自定义功能..."); // 获取类的字节数组 byte[] classData = getClassData(name); if (classData == null) { throw new ClassNotFoundException(); } else { 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 = 0; while ((bytesNumRead = ins.read(buffer)) != -1) { baos.write(buffer, 0, bytesNumRead); } return baos.toByteArray(); } catch (IOException e) { e.printStackTrace(); } return new byte[0]; }
/** * 得到类文件的完全路径 * * @param className 类名称 * @return 类文件的完全路径 */ private String classNameToPath(String className) { return rootDir + File.separatorChar + className.replace('.', File.separatorChar) + ".class"; }
}如上所示, 通过继承 ClassLoader 实现了 FileSystemClassLoader 类加载器,重写了里面的 findClass() 方法,而没有重写 ClassLoader 类的 loadClass() 方法,这是因为里面封装了双亲委派机制的实现,如:
- 该方法会首先调用
findLoadedClass()方法来检查该类是否已经被加载过; - 如果没有加载过的话,会调用父类加载器的
loadClass()方法来尝试加载该类; - 如果父类加载器无法加载该类的话,就调用
findClass()方法来查找该类;
因此,为了保证类加载器都正确实现双亲委派机制,在开发自己的类加载器时,只需要重写 findClass() 方法即可。当然,如果不想使用双亲委派机制时,就需要重写 loadClass() 方法。
自定义类加载器 FileSystemClassLoader 的 findClass() 方法首先根据类的全名在硬盘上查找类的 .class 字节代码文件,然后读取该文件内容,最后通过 defineClass() 方法来把这些字节代码转换成 java.lang.Class 类的实例。
(2) 创建用于测试的字节码文件
创建用于测试的 Sample 类,代码如下:
public class Sample {
private Sample instance;
public void setSample(Object instance) { System.out.println("开始初始化对象: " + instance.toString()); this.instance = (Sample) instance; }
}然后对类进行编译,将编译后的 .class 字节码文件放到 D:\\ 盘,方便后续测试。
(3) 使用自定义类加载器
如使用 FileSystemClassLoader 类加载器加载本地文件系统上的类,操作如下:
import java.lang.reflect.Method;
public class ClassIdentity {
public void testClassIdentity() { // 指定加载字节码的目录 String classDataRootPath = "D:\\"; // 使用自定义类加载器进行加载 FileSystemClassLoader fscl = new FileSystemClassLoader(classDataRootPath); // 指定字节码包名(如果编译时的类没有指定包,则直接输入类名Sample) String className = "mydlq.club.Sample"; try { // 加载 Sample 类,使用反射创建 Sample 对象 Class<?> class1 = fscl.loadClass(className); Object obj1 = class1.getDeclaredConstructor().newInstance(); // 使用反射调用方法 setSample(Object instance) 方法 Method sampleMethod = class1.getMethod("setSample", java.lang.Object.class); sampleMethod.invoke(obj1, obj1); } catch (Exception e) { e.printStackTrace(); } }
public static void main(String[] args) { new ClassIdentity().testClassIdentity(); }
}然后点击运行,可以看到控制台会输出如下内容:
实现一些自定义功能...开始初始化对象: mydlq.club.Sample@b684286---END---
