类加载器
类加载器(ClassLoader)说白了只是个"搬运工",负责把磁盘上的 .class 字节码文件拉进内存;而加载、验证、准备、解析、初始化,则是 JVM 拿到文件后,在内部进行的"组装、安检和激活"过程。
类加载的生命周期
加载,验证、准备、解析(连接),初始化
🛡️ 1. 验证(Verification)------ 安全检查
- 一句话总结: 检查拉进来的
.class文件是不是合法的,有没有坏人恶意篡改。 - 为什么要这一步? 字节码文件不一定非要用 Java 编译器(javac)生成,任何人都可以用二进制编辑器手动写一个
.class文件。如果没有验证,里面写了破坏 JVM 内存、攻击系统的恶意代码,JVM 直接运行就瘫痪了。 - 具体查什么?
- 文件格式验证: 检查开头是不是魔数
0xCAFEBABE(咖啡宝贝),版本号是否在当前 JVM 接受范围内。 - 元数据验证: 检查语法,这个类有没有父类?是不是继承了被
final修饰的类? - 字节码验证: 保证程序语义是合法的,比如不会出现"把一个对象强转成毫无关系的另一个类"这种离谱操作。
- 符号引用验证: 后面解析阶段会用,确保能根据名字找到对应的类、方法和字段。
- 文件格式验证: 检查开头是不是魔数
储备阶段:2. 准备(Preparation)------ 分配内存,赋零值
-
一句话总结: 为类的静态变量(static 变量)在方法区分配内存,并设置默认初始值。
-
核心细节(面试常考点):
此时赋的值是"零值"(如
0、0.0、null、false),而不是你在代码里写的那个值! -
举个栗子:
假设你的类里写了这一行:
Javapublic static int value = 123;在准备阶段 过完后,
value在内存里的值是0,而不是123!真正的123要等到初始化阶段才会赋值。 -
特殊情况(常量):
如果是被
final修饰的常量:Javapublic static final int value = 123;因为有了
final,它是不可变的。在编译时 javac 就为它生成了ConstantValue属性,所以在准备阶段 ,value就会直接被赋值为123。
🔗 3. 解析(Resolution)------ 符号引用转直接引用
-
一句话总结: 把常量池内的"名字(字符串标签)"换成真正的"内存地址指针"。
-
什么叫符号引用(Symbolic References)?
你在写代码或者写字节码时,调用一个方法
com.user.OrderService.string()。此时 JVM 内部并不知道这个方法具体在内存的哪个地方,它只能用一串字符串(符号)来暂时代替:"喏,我以后要调用这个名字的方法"。 -
什么叫直接引用(Direct References)?
解析阶段开始后,JVM 在内存里一查,找到了
OrderService.string()对应的真实内存起始地址(比如0x7fff1234)。然后把之前的字符串名字,替换成这个真实的内存指针。 -
解析的对象: 类或接口、字段、类方法、接口方法等。
🚀 4. 初始化(Initialization)------ 真正执行 Java 代码
-
一句话总结: 这是类加载的最后一步,JVM 开始真正执行你在类里写的 Java 赋值语句和静态代码块。
-
核心底层:
初始化阶段,本质上就是 JVM 自动收集类里所有静态变量的赋值动作 和静态代码块(
static {}) ,融合成一个叫做<clinit>()(Class Initialize)的方法,然后去执行它。 -
再看刚才的栗子:
Javapublic static int value = 123;到了初始化 阶段,JVM 执行
<clinit>()方法,value的值才真正从0变成了123。 -
触发初始化的时机(主动引用):
类不是平白无故初始化的,只有遇到以下情况(部分常见情况)才会触发:
- 使用
new关键字实例化对象。 - 读取或设置一个类的静态字段(被 final 修饰的常量除外)。
- 调用一个类的静态方法。
- 使用
Class.forName("...")反射加载类。 - 虚拟机启动时,包含
main()方法那个主类会被率先初始化。
- 使用
💡 总结通关图谱
为了让你在学接下来的"三种类加载器"时完全不晕,记住这个连贯的故事:
- 加载: 搬运工(ClassLoader)把
.class二进制流读进内存。 - 验证: 安检员上场,看看文件有没有被下毒(防篡改)。
- 准备: 木匠在内存方法区盖好静态变量的"毛坯房",里面先刷上白墙(赋零值
0)。 - 解析: 导航仪把代码里的"路标字符串"换成精细的"GPS 经纬度(内存直接引用)"。
- 初始化: 装修队进场,把精装家具搬进去,按照你的代码意图给静态变量正式赋值(执行
<clinit>())。
这五步走完,这个类在 JVM 里就彻底"活了",接下来你就可以愉快地 new 它的对象实例了。把这套流程焊死在脑子里,再去学双亲委派 和三大类加载器,逻辑就会顺畅得不可思议!
三大类加载器
| 名称 | 加载哪的类 | 说明 |
|---|---|---|
| Bootstrap ClassLoader(启动类加载器) | JAVA_HOME/jre/lib 目录下的核心类库(如 rt.jar、charsets.jar 等 JDK 核心类) |
JVM 底层 C++ 实现,无 Java 类实例,Java 代码中无法直接访问 ,getClassLoader() 会返回 null |
| Extension ClassLoader(扩展类加载器) | JAVA_HOME/jre/lib/ext 目录下的扩展 jar 包 |
父加载器为 Bootstrap ClassLoader,Java 代码中获取其上级加载器时会显示为 null |
| Application ClassLoader(应用 / 系统类加载器) | 项目 classpath 路径下的自定义类、第三方依赖 jar 包 |
父加载器为 Extension ClassLoader,是 Java 代码中默认的类加载器 |
| 自定义类加载器 | 自定义路径(如网络、加密文件、特殊目录等)下的类 | 父加载器默认指定为 Application ClassLoader,可实现自定义类加载逻辑 |
1. 启动类加载器 (Bootstrap ClassLoader) ------ "始祖级大佬"
- 它在哪、加载啥: 负责加载 Java 核心类库,也就是你配置的
JAVA_HOME/jre/lib目录下的核心 jar 包(比如最关键的rt.jar,里面躺着java.lang.Object、java.lang.String、java.util.HashMap等)。 - 底层硬核秘密(面试爱考):
- 它不是用 Java 语言写的! 它是用 C/C++ 写的,嵌套在 JVM 内核里面。
- 它没有实例: 因为它不是一个 Java 对象,所以你在 Java 代码里如果尝试去获取它,返回的结果永远是
null。 - 举个栗子: 如果你执行
String.class.getClassLoader(),你会发现打印出来的是null。表里说明写的"显示为 null"就是这个意思。
2. 扩展类加载器 (Extension ClassLoader) ------ "皇家护卫"
- 它在哪、加载啥: 负责加载 Java 的扩展类库,对应的目录是
JAVA_HOME/jre/lib/ext。这里面放的是一些官方自带但不是最核心的扩展工具 jar 包。 - 底层秘密: 它是由 Java 语言编写的(具体类名是
sun.misc.Launcher$ExtClassLoader)。表里写着"上级为 Bootstrap",意思是它的逻辑父加载器是 Bootstrap。
3. 应用程序类加载器 (Application ClassLoader) ------ "搬砖主力军"
- 它在哪、加载啥: 负责加载
classpath(类路径) 下的所有类。说白了,你在项目里自己写的代码、引入的第三方 Maven 依赖(如 Spring、MyBatis、或者是各类 jar 包),全部都是由它来负责加载进内存的。 - 底层秘密: 它也是 Java 写的(
sun.misc.Launcher$AppClassLoader)。因为平时我们绝大多数类都是它加载的,所以它也叫系统类加载器(System ClassLoader)。如果你写一个自己的类User.class.getClassLoader(),打印出来的就是它。
4. 自定义类加载器 (Custom ClassLoader) ------ "特种兵"
- 为什么需要它: 官方的前三个加载器只能去本地固定的磁盘目录或环境变量 里加载明文的
.class文件。如果我的业务场景很特殊呢?- 比如:我的字节码文件是加密过的,防止别人反编译,需要加载时在内存里先解密。
- 比如:我的
.class文件不在本地,而是存在远端服务器或数据库里,需要通过 网络网络请求 读进来。想加载非classpath随意路径中的类文件。
- 怎么做: 继承
java.lang.ClassLoader类,重写findClass()方法,你就能自己手写一个属于你的类加载器。
⚠️ 盯紧图里最右侧的"说明":这是在给双亲委派埋伏笔!
注意看图里写的:
- Extension 的上级是 Bootstrap
- Application 的上级是 Extension
- 自定义类加载器的上级是 Application
🚨 纠错警示灯:这里的"上级"绝对不是面向对象里的"继承(extends)"关系!
在 JVM 源码里,它们之间既没有 AppClassLoader extends ExtClassLoader,也没有 ExtClassLoader extends Bootstrap。 它们是通过组合(Combination)关系来维持组合的。
也就是说,每个 ClassLoader 实例里面都有一个成员变量叫 parent,AppClassLoader 的 parent 属性指向了 ExtClassLoader 实例。
这种"逐级引向上级"的链条,就是接下来你要学的 双亲委派模型(Parent Delegation Model) 的核心骨架!
双亲委派模式
就是调用类加载器的loadClass方法时, 查找类的规则。
java
protect Class<?> loadClass(Stirng name, boolean resolve) throws ClassNotFoundException {
synchronized (getClassLoadingLock(name)) {
// 1.检查该类是否已经加载
Class<?> c = findLoadedClass(name);
if (c == null) {
long t0 = System.nanoTime();
try {
if (parent != null) {
// 2.有上级的话, 委派上级 loadClass
c = parent.loadClass(name, false);
} else {
// 3.没有上级了(ExtClassLoader), 则委派 BootstrapClassLoader
c = findBootstrapClassOrNull(name);
}
}catch (){}
if (c == null) {
long t1 = System.nanoTime();
// 4.每一层找不到,调用findClass 方法(每个类加载器自己扩展)来加载
c = findClass(anme);
}
}
}
}
线程上下文类加载器
线程上下文类加载器(Thread Context ClassLoader) ,以及它如何用来打破双亲委派模型。
一个很好的引子:在老版本的 JDBC 中,我们需要手动写 Class.forName("com.mysql.jdbc.Driver") 来加载驱动;但在 JDBC 4.0 之后,即使不写这行代码,MySQL 驱动也能被正确加载。
这背后其实隐藏着一个著名的设计矛盾,也就是 SPI(Service Provider Interface)机制。
1. 痛点:双亲委派模型的"死穴"
在正常情况下,双亲委派模型要求:如果一个类由某个类加载器加载,那么它里面引用的其他类,默认也会用同一个类加载器去加载。
DriverManager的身份 :它是 JDK 核心类库的一部分(位于java.sql包下),因此它是由最顶层的Bootstrap ClassLoader(启动类加载器) 加载的。- MySQL 驱动的身份 :它是第三方厂商提供的 Jar 包(位于
classpath下),原本应该由底层的App ClassLoader(系统类加载器) 来加载。
当 DriverManager 初始化并尝试去加载各个厂商实现的驱动时,矛盾就来了:
DriverManager(由 Bootstrap 加载)想要调用底层的com.mysql.jdbc.Driver(在 classpath 中,Bootstrap 根本找不到、也管不着)。
这就是双亲委派模型的局限性:核心类库(顶层加载器)无法直接访问用户代码(底层加载器)。
2. 破局者:线程上下文类加载器(Thread Context ClassLoader)
为了解决这个"套娃"死结,Java 引入了线程上下文类加载器。它相当于给顶层加载器开了一个"后门",允许顶层代码"逆向"调用底层的加载器。
虽然图片在 loadInitialDrivers() 这里戛然而止,如果我们追进这个方法的源码,就会发现它的核心实现逻辑如下:
Java
private static void loadInitialDrivers() {
// 1. 使用 ServiceLoader 机制加载驱动
ServiceLoader<Driver> loadedDrivers = ServiceLoader.load(Driver.class);
Iterator<Driver> driversIterator = loadedDrivers.iterator();
try{
while(driversIterator.hasNext()) {
driversIterator.next(); // 这里会触发驱动类的加载和注册
}
} catch(Throwable t) {
// Do nothing
}
}
当我们再进一步点进 ServiceLoader.load(Driver.class) 的源码时,狐狸尾巴就露出来了:
Java
public static <S> ServiceLoader<S> load(Class<S> service) {
// 核心:获取了当前线程的上下文类加载器!(是当前线程使用的类加载器,默认就是Application classloader)
ClassLoader cl = Thread.currentThread().getContextClassLoader();
return ServiceLoader.load(service, cl);
}
3. 核心总结
- 默认委派失败 :
DriverManager是被BootstrapClassLoader加载的,它在自己的管辖范围内(JDK 核心库)找不到引入的 MySQL 驱动包。 - 借刀杀人(打破委派) :每个 Java 线程在创建时,默认都会把
AppClassLoader设置为自己的ContextClassLoader(上下文类加载器)。 - 成功加载 :
DriverManager巧妙地通过Thread.currentThread().getContextClassLoader()拿到了这个AppClassLoader,并强行用它去加载了classpath下的 MySQL 驱动。
这种由高层加载器委托底层加载器 去加载类的行为,本质上打破了双亲委派模型那种只能"自底向上委派"的固有规则。
自定义类加载器
一、 为什么要自定义类加载器?
图片中列举了三个核心场景,它们在实际开发(尤其是中间件和服务器开发)中非常经典:
- 加载非
classpath随意路径中的类文件- 大白话 :默认的
AppClassLoader只能加载项目环境变量classpath路径下的类。如果你想从网络上(比如 RPC 远程传输的字节码)、数据库里、或者服务器的某个特定目录(如图片中的E:\myclasspath)动态加载.class文件,默认的加载器就无能为力了。
- 大白话 :默认的
- 通过接口解耦(常用于框架设计、插件化)
- 大白话 :比如做插件化开发(OSGi 架构)。主程序只定义接口,具体的实现类由不同的自定义类加载器动态从插件包里加载,实现热插拔和完美解耦。
- 类隔离(不同应用的同名类都可以加载,不冲突)
- 大白话 :这是 Tomcat 等 Web 容器 的看家本领。假设一个 Tomcat 里面同时运行了两个 Web 应用,应用 A 用的是 Spring 4.0,应用 B 用的是 Spring 5.0。如果不做隔离,由于全限定名完全一样,JVM 只会加载其中一个,另一个应用直接崩溃。Tomcat 通过为每个 Web 应用分配一个独立的自定义类加载器,完美实现了同名类的物理隔离。
二、 核心步骤与底层源码的闭环
写自定义类加载器的"黄金法则":
1.继承ClassLoader 父类
2.要遵从双亲委派机制,重写findClass方法
■ 注意不是重写loadClass方法,否则不会走双亲委派机制
3.读取类文件的字节码
4.调用父类的defineClass方法来加载类
5.使用者调用该类加载器的loadClass 方法
特别是第 2 步,点出了无数初学者最容易踩的坑。
为什么是重写 findClass,而不是 loadClass?
还记得你在双亲委派模式看到的 loadClass 源码吗?它的逻辑是:
- 检查是否加载过。
- 委派给父类加载器(实现双亲委派机制)。
- 如果父类加载器找不到,才调用
findClass。
关键点 :JDK 的设计者已经把双亲委派的模板流程在
loadClass方法里写死了(这就是模板方法模式)。
- 如果你重写了
loadClass:你就把双亲委派机制给无意间破坏了。- 如果你重写
findClass:当父类加载器找不到这个类时,JVM 才会乖乖调用你重写的findClass去你指定的路径(比如E:\myclasspath)读入字节码。这样既实现了自定义加载,又完美保留了双亲委派机制。
三、 自定义类加载器标准代码模板
下面是一个严格按照图片 5 个步骤实现的标准代码模板:
Java
import java.io.ByteArrayOutputStream;
import java.io.FileInputStream;
import java.io.IOException;
// 步骤 1:继承 ClassLoader 父类
public class MyClassLoader extends ClassLoader {
private String classPath;
public MyClassLoader(String classPath) {
this.classPath = classPath;
}
// 步骤 2:重写 findClass 方法(遵循双亲委派)
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
// 步骤 3:读取类文件的字节码
byte[] data = loadClassData(name);
if (data == null) {
throw new ClassNotFoundException();
}
// 步骤 4:调用父类的 defineClass 方法,将字节数组转化为 Class 对象
return defineClass(name, data, 0, data.length);
}
// 步骤 3 的具体实现:从物理磁盘读取 .class 文件为字节数组
private byte[] loadClassData(String className) {
// 将包名 com.example.User 转换为路径 com/example/User.class
String fileName = classPath + className.replace('.', '/') + ".class";
try (FileInputStream ins = new FileInputStream(fileName);
ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
int b;
while ((b = ins.read()) != -1) {
baos.write(b);
}
return baos.toByteArray();
} catch (IOException e) {
e.printStackTrace();
}
return null;
}
}
步骤 5:使用者如何调用?
Java
public class Test {
public static void main(String[] args) throws Exception {
// 1. 创建自定义类加载器,指定去 E:\myclasspath 找类
MyClassLoader loader = new MyClassLoader("E:/myclasspath/");
// 2. 调用 loadClass 方法(它内部会遵从双亲委派,最终触发我们重写的 findClass)
Class<?> clazz = loader.loadClass("com.example.MyMapImplementation");
// 3. 实例化对象并使用
Object instance = clazz.getDeclaredConstructor().newInstance();
System.out.println("类加载器是:" + clazz.getClassLoader());
// 输出将是:MyClassLoader@xxxxx
}
}