类加载机制
开篇:.class 文件是怎么变成可运行的代码的?
写完一个 HelloWorld.java,编译器把它变成 HelloWorld.class。然后呢?JVM 要把这个 .class 文件"请"进内存,经过一系列安检、登记、初始化,它才真正能跑起来。
这个过程就是类加载。你可以把它想象成坐飞机:买票(找到 .class 文件)、过安检(验证)、找座位(分配内存)、对号入座(解析引用)、系好安全带准备起飞(初始化)。
在 Java 中,类加载采用的是懒加载策略——除了 JVM 启动时必须加载的基础类(比如 java.lang.Object、java.lang.Class),其他的类都是在第一次被用到时才会加载。同时 Java 还支持动态加载,即在运行时通过程序来加载类,这为 Java 带来了巨大的灵活性。
本文会从类的生命周期讲起,逐步深入到双亲委派模型和它的"破例"场景,帮你建立起一个完整的认知框架。
一、类的生命周期
一个类从出生到消亡,要经历七个阶段:
其中验证、准备、解析三个阶段合称为链接(Linking)。
加载、验证、准备、初始化、卸载这五步的顺序是确定的,而解析阶段有时会在初始化之后才执行——这是为了支持 Java 的动态绑定特性(比如多态调用,在运行时才能确定具体调用哪个实现)。
在这个完整的生命周期里,类加载子系统扮演着"快递员"的角色:它把磁盘上编译好的 .class 文件装载到 JVM 中,变成方法区里的"DNA 元数据模板",之后就可以根据这个模板实例化出无数个相同的对象。
需要注意的是,ClassLoader 只负责 .class 文件的加载,至于加载进来的类能不能运行,那是执行引擎的事情。加载的类信息存放在方法区,方法区中除了类信息外还会存放运行时常量池信息,包括字符串字面量和数字常量。
二、类加载的五个阶段
2.1 加载:找到 .class 文件
加载阶段做三件事:
- 通过全限定名找到 .class 文件的二进制字节流。 来源可以很多样:
- 本地文件系统(最常见)
- 网络获取(Web Applet)
- ZIP/JAR/WAR 压缩包中读取
- 运行时动态生成(动态代理)
- 其他文件生成(JSP)
- 把字节流转换成方法区的运行时数据结构。
- 在内存中生成一个
java.lang.Class对象,作为方法区中这个类的数据访问入口。
一个形象的比喻:加载就像快递员把包裹(.class)从仓库运到你家门口(JVM 方法区),并给你一张取件码(Class 对象)。
2.2 验证:安检通道
验证的目的是确保 .class 文件的字节流符合 JVM 规范,不会危害虚拟机安全。主要包括四类检查:
- 文件格式验证:魔数是不是
0xCAFEBABE?版本号能不能被当前 JVM 处理? - 元数据验证:这个类是否有父类?是否继承了不允许被继承的
final类? - 字节码验证:方法体中的指令是否合法、类型转换是否安全?
- 符号引用验证:引用的类、字段、方法是否真实存在且可访问?
就像过机场安检一样,行李要过 X 光机,人要过安全门,一个都不能少。
2.3 准备:分配内存,设默认值
准备阶段为类变量(static 字段)在方法区中分配内存,并设置零值(默认初始值)。
// 准备阶段:value = 0(int 的零值)
// 要等到初始化阶段才会被赋值为 123
public static int value = 123;注意三个要点:
final static常量在编译期就确定了值,准备阶段直接赋真实值而非零值。- 实例变量不在这里分配——它们随对象一起分配在 Java 堆中。
- 这里只是分配内存和零值初始化,真正的赋值操作要到初始化阶段。
各类型的零值参考:int → 0,long → 0L,boolean → false,float → 0.0f,引用类型 → null。
2.4 解析:符号引用变直接引用
编译期生成的 .class 文件中,对其他类、字段、方法的引用都是符号引用(一组描述目标的符号名称,与内存地址无关,存在编译后的 class 文件中)。解析阶段把它们替换成直接引用(真实的内存地址、偏移量或指针,是运行期间动态生成的)。
打个比方:符号引用就像通讯录里的"张三",直接引用就是张三的真实手机号。打电话之前,你得先把名字翻译成号码。
public class A {
public int x;
}
public class B {
public void foo() {
A a = new A();
a.x = 10;
// 编译期:对 A.x 的引用是符号引用 "A.x"
// 解析后:变成 getstatic 0x1000(直接指向内存地址)
}
}解析的对象包括 7 类符号引用:类或接口、字段、类方法、接口方法、方法类型、方法句柄和访问控制修饰符。
2.5 初始化:执行 <clinit>
初始化是类加载的最后一步,也是 JVM 第一次真正执行你写的 Java 代码的时刻。
这个阶段会执行类构造器 <clinit>() 方法——它由编译器自动收集类中所有 static 变量赋值语句和 static {} 代码块合并而来。注意它和实例构造器 <init>() 不是一回事。
JVM 对 <clinit>() 有三个重要保证:
- 指令按源码顺序执行。
static块和static变量赋值按照它们在源文件中出现的顺序执行。 - 父类的
<clinit>先于子类执行。 所以父类的static块一定比子类先运行。 - 多线程下会被加锁同步。 保证
<clinit>只执行一次,也是"静态内部类实现单例"天然线程安全的根本原因。
什么时候触发初始化?记住五种"主动使用"场景:
new一个对象、读写非final的static字段、调用static方法时(对应字节码new、getstatic、putstatic、invokestatic)。- 对类进行反射调用时(
Class.forName())。 - 初始化子类时,如果父类还没初始化,先初始化父类。
- JVM 启动时,包含
main()方法的主类。 - JDK 7+ 的
MethodHandle解析结果为REF_getStatic/REF_putStatic/REF_invokeStatic的方法句柄,且没有初始化过。
类的加载(读入内存)可能很早就发生了,但初始化一定是在首次主动使用时才触发。静态代码块只会在初始化阶段执行一次。
三、双亲委派模型
3.1 为什么需要双亲委派?
想象一下:如果你自己写一个 java.lang.String 类,放到 classpath 下,JVM 加载了它,那所有用到 String 的代码都会乱套。
双亲委派模型就是为了解决两个核心问题:
- 安全性:防止核心类库(如
java.lang.*)被篡改。自定义String类时,Bootstrap ClassLoader 会先加载rt.jar中的真正String,你的假冒版本根本没有出场机会。这就是所谓的沙箱安全机制。 - 唯一性:保证同一个类在 JVM 中只被加载一次。
Object类无论被哪个类加载器发起加载请求,最终都委派给 Bootstrap ClassLoader,所以全 JVM 只有一个Object。
简单来说:Java 类随着它的类加载器一起,具备了一种带有优先级的层次关系。越基础的类由越上层的加载器加载,越不容易被篡改。
3.2 三层类加载器
| 加载器 | 实现语言 | 加载范围 | 备注 |
|---|---|---|---|
| Bootstrap ClassLoader | C/C++ | java.*、javax.*、sun.* 等核心类 | 获取不到引用,getClassLoader() 返回 null |
| Extension ClassLoader | Java (sun.misc.Launcher$ExtClassLoader) | jre/lib/ext 或 java.ext.dirs 指定目录 | JDK 9 后改名 PlatformClassLoader |
| Application ClassLoader | Java (sun.misc.Launcher$AppClassLoader) | classpath 或 java.class.path 指定路径 | 程序中默认的类加载器 |
从 JVM 的角度看,其实只有两种类加载器:Bootstrap ClassLoader(C++ 实现,JVM 自身的一部分)和所有其他类加载器(Java 实现,继承自 java.lang.ClassLoader)。
这三层之间并不是继承关系,而是组合关系——每个 ClassLoader 内部持有一个 parent 字段指向上一层:
public abstract class ClassLoader {
// 组合,不是继承!
private final ClassLoader parent;
}你可以用下面这段代码亲手验证加载器的层级:
public class ClassLoaderDemo {
public static void main(String[] args) {
// 应用类加载器
ClassLoader app = ClassLoader.getSystemClassLoader();
System.out.println(app);
// jdk.internal.loader.ClassLoaders$AppClassLoader@...
// 扩展/平台类加载器
ClassLoader ext = app.getParent();
System.out.println(ext);
// jdk.internal.loader.ClassLoaders$PlatformClassLoader@...
// Bootstrap 加载器,C++ 实现,Java 中拿不到
System.out.println(ext.getParent());
// null
// 用户自定义类默认由 Application ClassLoader 加载
System.out.println(ClassLoaderDemo.class.getClassLoader());
// jdk.internal.loader.ClassLoaders$AppClassLoader@...
// String 由 Bootstrap 加载
System.out.println(String.class.getClassLoader());
// null
}
}3.3 委派流程
当一个类加载器收到加载请求时:
- 先检查这个类是否已经加载过(缓存思想),加载过就直接返回。
- 没加载过,就把请求委托给父加载器。
- 父加载器也重复上面的步骤,一路向上递归,直到 Bootstrap ClassLoader。
- 如果父加载器在自己的搜索范围内找不到,子加载器才自己尝试加载(调用
findClass())。
核心实现非常简洁:
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException {
synchronized (getClassLoadingLock(name)) {
// 1. 检查是否已加载(缓存)
Class<?> c = findLoadedClass(name);
if (c == null) {
try {
// 2. 委托父加载器
if (parent != null) {
c = parent.loadClass(name, false);
} else {
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) { }
// 3. 父加载器加载失败,自己加载
if (c == null) {
c = findClass(name);
}
}
return c;
}
}总结一下 loadClass 的四步流程:查缓存 → 委派父加载器 → 找不到则用 findClass() 自行加载 → 如果还找不到,通过子类的自定义 findClass() 获取类的二进制字节流,再调用 defineClass() 将字节流转为 Class 对象。
注意
loadClass()方法被synchronized保护,所以类加载过程是线程安全的。
3.4 打破双亲委派的三个经典场景
双亲委派不是铁律。要打破它,重写 loadClass() 方法即可。历史上有三个经典的"破例":
场景一:向前兼容(JDK 1.2 之前)
双亲委派是 JDK 1.2 才引入的,但 ClassLoader 和用户自定义类加载器在 JDK 1.0 就存在了。那时候用户重写 loadClass() 是唯一的选择。为了向前兼容,JDK 1.2 新增了 findClass() 方法,引导用户把自定义逻辑写到 findClass() 里而不是 loadClass() 里,这样就能保证新代码遵守双亲委派。
场景二:SPI 机制
JDBC、JNDI 等核心接口定义在 rt.jar 里(Bootstrap 加载),但具体实现(如 MySQL 驱动)在 classpath 下(Application 加载)。Bootstrap ClassLoader "看不见"这些实现类,怎么办?
Java 引入了线程上下文类加载器(Thread.currentThread().getContextClassLoader()),让父加载器能通过它"借用"子加载器来加载 SPI 实现——相当于"上级找下级帮忙"。这实质上是父类加载器请求子类加载器完成类加载,违背了双亲委派的层次结构,但这是无可奈何的妥协。Java 中所有涉及 SPI 的加载(JNDI、JDBC、JCE、JAXB、JBI 等)基本都采用这种方式。
场景三:Tomcat 类隔离
一个 Web 容器要部署多个应用,不同应用可能依赖同一个第三方库的不同版本(比如 A 应用用 Spring 5,B 应用用 Spring 6)。如果用默认的双亲委派,全限定名相同的类只会被加载一次,版本隔离就无从谈起。所以 Tomcat 给每个 Web 应用分配了独立的类加载器,实现了类级别的隔离。
类似的还有 OSGi:每个 Bundle 有自己的类加载器,形成网状委派结构而非树状,支持模块的热插拔和独立部署。
3.5 破坏双亲委派能重写 String 类吗?
答案是不能。即使你重写了 loadClass() 绕过了双亲委派,最终还是要调用 defineClass() 把字节流转成 Class 对象。而 defineClass() 内部通过 preDefineClass() 检查类名是否以 java. 开头——如果是,直接抛 SecurityException:
private ProtectionDomain preDefineClass(String name, ProtectionDomain pd) {
if ((name != null) && name.startsWith("java.")) {
throw new SecurityException(
"Prohibited package name: " +
name.substring(0, name.lastIndexOf('.')));
}
// ...
}而且在 JVM 的 C++ 层(SystemDictionary::resolve_from_stream)也有同样的校验——检查全限定名是否以 java/ 开头,如果是就抛异常。这是双重保险,从 Java 层和 Native 层同时阻止你篡改核心类。
当然,理论上如果你完全绕过 defineClass(),自己实现字节流到 Class 对象的转换,确实有可能加载一个自定义的 java.lang.String。但这已经不是正常的类加载机制了。
3.6 如何自定义类加载器?
如果你有以下需求,可以自定义类加载器:隔离加载类、修改类加载方式、扩展加载源、防止源码泄露(加密 .class 文件)。
实现步骤:
- 继承
java.lang.ClassLoader。 - 重写
findClass()方法(保持双亲委派),或重写loadClass()方法(打破双亲委派)。 - 在
findClass()中读取字节流,调用defineClass()生成 Class 对象。
JDK 文档中给出了一个经典示例:
class NetworkClassLoader extends ClassLoader {
String host;
int port;
public Class<?> findClass(String name) {
byte[] b = loadClassData(name);
return defineClass(name, b, 0, b.length);
}
private byte[] loadClassData(String name) {
// 从网络连接加载类数据...
}
}如果需求不复杂,可以直接继承 URLClassLoader,省去手写 findClass() 和获取字节流的代码。
四、常见面试题精选
Q1:JVM 中判断两个 Class 对象是否是"同一个类"的条件是什么?
两个条件缺一不可:全限定类名相同 + 加载它们的 ClassLoader 是同一个实例。所以即使两个 .class 文件内容完全一样,如果被不同的 ClassLoader 加载,它们就是"两个不同的类",instanceof 判断会返回 false。JVM 会将类加载器的引用作为类型信息的一部分保存到方法区中。
Q2:类什么时候会被卸载?
需要同时满足三个条件:
- 该类的所有实例都已被 GC 回收。
- 加载该类的 ClassLoader 已被 GC 回收。
- 该类的
Class对象没有在任何地方被引用(无法通过反射访问)。
Bootstrap ClassLoader 永远不会被回收,所以 JDK 核心类永远不会被卸载。能被卸载的通常是 Tomcat、SPI、JSP 等自定义类加载器加载的临时类。类卸载发生在 Full GC 期间,方法区中的类元数据会被回收。JDK 9 的模块化使得类卸载的控制更加精细——整个模块不再被引用时即可被卸载。
Q3:loadClass() 和 findClass() 有什么区别?
loadClass() 实现了双亲委派逻辑——先委托父加载器,父加载器失败后才调用自己的 findClass()。如果你只想自定义加载来源(比如从网络或加密文件加载 .class),重写 findClass() 就够了,双亲委派不会被破坏。如果你想彻底打破双亲委派,则需要重写 loadClass()。
Q4:数组类是怎么加载的?
数组类不是由类加载器创建的,而是由 JVM 运行时根据需要自动创建。数组类的 ClassLoader 与其元素类型的 ClassLoader 相同;如果元素类型是基本类型(如 int[]),则数组类没有类加载器。
Q5:JDK 8 和 JDK 9 的类加载器有什么不同?
JDK 9 引入模块化(Jigsaw),主要变化是:Extension ClassLoader 被重命名为 Platform ClassLoader,负责加载 Java 平台模块系统中的非核心模块(在 module-info.java 中声明的模块)。类加载器之间的关系本质不变(组合而非继承),但加载的内容边界因模块化而更加清晰。
小结
| 阶段 | 做了什么 | 一句话类比 |
|---|---|---|
| 加载 | 找到 .class,读入字节流,生成 Class 对象 | 快递员送货上门 |
| 验证 | 格式、元数据、字节码、符号引用四重检查 | 机场安检 |
| 准备 | 为 static 变量分配内存,赋零值 | 找到座位坐下 |
| 解析 | 符号引用 → 直接引用 | 通讯录名字翻译成手机号 |
| 初始化 | 执行 <clinit>,static 赋值 + static 块 | 系好安全带,飞机起飞 |
类加载器通过双亲委派模型保证了核心类的安全和唯一性,但在 SPI、Tomcat、OSGi 等场景下会被合理地打破。理解类加载机制,不仅是面试必备,更是排查 ClassNotFoundException、NoClassDefFoundError、类冲突等线上问题的基础功夫。