引言:为什么要深入理解类加载
很多 Java 工程师写了多年业务代码,对集合、并发、Spring 等框架使用得炉火纯青,但一被问到「类的加载过程是怎样的」「双亲委派机制为什么这么设计」「什么场景会打破双亲委派」时,往往只能说出一两个名词,再往深处追问就开始含糊。类加载机制是 JVM 的核心支柱之一,它决定了类什么时候被加载进内存、如何被验证和初始化,也直接影响了热部署、字节码增强、依赖隔离、插件化架构等高级特性的实现。
本文将从类加载的生命周期出发,完整梳理加载、验证、准备、解析、初始化等各个阶段,深入剖析类加载器体系与双亲委派模型,并结合 JDK 源码、实战代码和面试高频问题,带你彻底吃透 Java 类加载过程。全文约两万字,建议配合 JDK 源码和调试工具一起阅读。
阅读提示:本文默认读者已经掌握 Java 基础语法,并对 JVM 内存区域有一定了解。文章中的代码均基于 JDK 8 与 JDK 11 的 HotSpot 虚拟机实现进行说明。
一、类加载机制概述
Java 语言最初的设计目标之一就是「一次编写,到处运行」。为了实现这个目标,Java 编译器并不会像 C/C++ 那样把源代码直接编译成与特定硬件平台绑定的本地机器码,而是把.java源文件编译成与平台无关的字节码文件.class。这个.class文件里保存的是 JVM 认识的指令集,真正负责把这些字节码转变为可以在内存中使用、能被执行的数据结构的过程,就是类加载机制要完成的工作。
从更高的视角看,类加载机制回答了三个核心问题:类从哪来、类怎么进来、类何时进来。类可以来自本地磁盘、网络、JAR 包、数据库甚至是运行时动态生成的字节码;类进来之后要经过一系列校验、转换、解析,最终形成方法区(在 JDK 8 及以后是元空间)中可用的Class对象;而类的加载时机则由 JVM 根据「首次主动使用」的原则来决定。
与 C/C++ 的静态链接不同,Java 采用的是动态加载与动态连接机制。程序运行时,哪个类被真正用到了,JVM 才会去加载它,这意味着开发者可以在运行时按需加载类,甚至可以自定义类加载器从任意来源加载类,这也为后续要讲的自定义类加载器、热部署、OSGi 等项目铺平了道路。
二、类加载的生命周期
2.1 类从字节码到可用的完整链路
一个类从磁盘或者网络上的字节码文件,到最终能够在 JVM 中创建对象、调用方法,需要经历一个漫长而严谨的过程。按照《Java 虚拟机规范》的定义,类的完整生命周期包括七个阶段:加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸载(Unloading)。
这七个阶段中,验证、准备、解析三个阶段又可以统称为「连接」(Linking)。它们的先后关系如下表所示:
| 阶段 | 英文名称 | 归属 | 核心工作 |
|---|---|---|---|
| 加载 | Loading | 独立阶段 | 读取字节码,生成 Class 对象 |
| 验证 | Verification | 连接 | 确保字节码安全合法 |
| 准备 | Preparation | 连接 | 为静态变量分配内存并赋零值 |
| 解析 | Resolution | 连接 | 把符号引用替换为直接引用 |
| 初始化 | Initialization | 独立阶段 | 执行类构造器 static 代码 |
| 使用 | Using | 独立阶段 | 程序正常使用该类 |
| 卸载 | Unloading | 独立阶段 | 类被 GC 回收 |
需要注意的是,虽然规范把生命周期描述为一条顺序执行的链路,但实际执行时并不强制要求必须等待一个阶段完全结束后再开始下一个阶段。最典型的就是「解析」阶段,它有时候会在「初始化」之后才执行,这就是 Java 语言运行期绑定的体现。此外,加载、验证、准备、初始化、卸载这五个阶段的顺序是确定的,类是必须按照这个顺序按部就班地「开始」,而解析阶段则可以在初始化之前或之后进行。
2.2 类生命周期的触发时机
JVM 规范并没有明确规定「加载」阶段必须在什么时刻发生,它可以由虚拟机自行决定。但对于「初始化」阶段,规范则做出了严格规定:有且只有六种情况必须立即对类进行初始化,这六种情况也被称为「主动引用」。除此之外的所有引用类的方式都不会触发初始化,被称为「被动引用」。主动引用与被动引用会在后文专章讲解,这里先建立整体认识:类加载并不是一下子完成的,而是在某个触发点到来时,沿着生命周期逐步推进。
还有一个容易被忽视的细节:类加载过程中的「使用」并不是一个可以独立划分时间段的阶段,它是类初始化之后程序执行过程中持续发生的状态。所以平时面试时说的「类的五个阶段」,一般是指加载、验证、准备、解析、初始化这五个可以被观察和分析的阶段。
三、类加载过程详解
3.1 加载阶段:把字节码变成元数据
加载是类加载过程的第一个阶段,它的任务用一句话概括就是:把类的字节码数据从各种来源读取到内存中,并转化为方法区(JDK 8 之前叫永久代,JDK 8 及以后叫元空间)中的运行时数据结构,同时在堆内存中生成一个对应的java.lang.Class对象,作为访问这个类元数据的入口。
具体来说,加载阶段要完成三件事:
获取字节流:通过类的全限定名来获取定义此类的二进制字节流。这里的「获取」来源非常广泛,可以是从 ZIP、JAR、WAR 包中读取,也可以从网络中获取,甚至可以在运行时动态生成。
转化为运行时数据:把这个字节流所代表的静态存储结构,转化为方法区内的运行时数据结构。
生成 Class 对象:在堆中生成一个代表这个类的
java.lang.Class对象,作为方法区中该类数据的外部访问入口。
很多人会把「加载」和「类加载」两个概念混淆。类加载是包含加载、验证、准备、解析、初始化在内的一整套过程,而加载只是其中的第一个阶段。加载阶段所需要的字节流来源,在 JVM 规范中并没有限定必须来自.class文件,这就给了开发者极大的发挥空间。像常见的动态代理技术,就是在运行时通过Proxy直接生成字节码字节数组,再由类加载器加载;像 ASM、CGLIB 等字节码框架,也都是在运行时生成新的类。
对于数组类来说,情况比较特殊:数组类本身不通过类加载器创建,而是由 JVM 在内存中直接动态构造出来。但数组里的元素类型,也就是数组去掉所有维度后的那个类,仍然需要通过类加载器完成加载。比如String[]这个数组类由 JVM 直接创建,而元素类型String则由启动类加载器去加载。
加载阶段完成之后,字节流中的数据本质上还没有真正「安家落户」。此时Class对象虽然在堆中生成了,但代码还不能安全执行,因为字节码可能是被篡改过的、可能是不合法的。所以 JVM 紧接着要进行下一阶段的验证。
3.2 验证阶段:JVM 的安全防护门
验证是连接阶段的第一步,它的目的是确保被加载类的字节流符合 JVM 规范,不会对虚拟机自身的安全造成危害。如果把 JVM 比作一台机器,那么验证阶段就是进料口的一道安检,任何不合规的东西都别想进来。
为什么验证如此重要?因为 Java 语言本身是相对安全的,编译器会阻止很多危险操作,比如数组越界访问会被编译期检查、强制类型转换不合法的会编译报错。但 JVM 并不只接受 Java 编译器产生的字节码,它也可能加载手工编写、甚至恶意构造的字节码。如果不做验证,攻击者完全有可能构造出绕过安全检查的字节码,破坏运行时的类型系统,甚至直接访问 JVM 内部内存。HotSpot 虚拟机历史上就出现过不少通过字节码进行攻击的漏洞,验证阶段正是抵御这类攻击的第一道防线。
验证阶段的工作量很大,大致可以细分为四个子阶段:文件格式验证、元数据验证、字节码验证和符号引用验证。
3.2.1 文件格式验证
文件格式验证发生在把字节流加载到方法区之前的阶段,主要由ClassFileParser这类解析器完成。它检查的是字节流的整体结构是否符合ClassFile结构的规范,具体包括:
字节流是否以魔数
0xCAFEBABE开头。每个合法的.class文件前四个字节都必须是这个魔数,它就像是字节码文件的身份证前缀。主版本号和次版本号是否在当前虚拟机可接受的范围内。高版本 JDK 编译出的类在低版本 JVM 上运行时会在这里被拦下,报出大家熟悉的
UnsupportedClassVersionError。常量池中的常量类型是否合法,常量池索引是否指向正确的常量。
类文件的各个部分(常量池、字段表、方法表、属性表等)长度是否正确,是否有被截断或多余的数据。
只有通过了文件格式验证的字节流,才会被允许进入方法区进行存储。后续的三个验证阶段都是在方法区上进行的,不再直接操作字节流。
3.2.2 元数据验证
元数据验证是对字节码所描述的语义信息进行静态分析,确保它们符合 Java 语言规范的要求。举例来说:
这个类是否有父类(除了
Object之外,所有类都必须有父类)。这个类的父类是否继承了不允许被继承的类,比如
final修饰的类。如果这个类不是抽象类,是否实现了其父类或接口中要求实现的所有抽象方法。
类中的字段和方法是否与父类产生了不允许的矛盾,比如覆盖了父类的
final方法、出现了不符合规则的方法重载等。
元数据验证阶段的目的是对类的元数据进行语义校验,保证不存在不符合 Java 语言规范的元数据信息。这个阶段报错通常是NoSuchMethodError、NoSuchFieldError等在运行期才会暴露的问题实际上早已埋下的定时炸弹。
3.2.3 字节码验证
字节码验证是整个验证过程中最复杂、开销最大的一个阶段,它对类的方法体进行校验,确保方法在运行时不会做出危害虚拟机安全的操作。这一步需要分析数据流和控制流,主要检查:
保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作,不会出现如操作数栈中放了一个
int却按long来处理的情况。保证跳转指令不会跳转到方法体以外的字节码指令上。
保证类型转换始终是有效的,不会出现把父类对象赋值给子类引用而不做检查的情况。
字节码验证是一件非常耗时的事情,因此在 JDK 6 之后,HotSpot 做了一项重要优化:给方法体的Code属性新增了一个名为StackMapTable的属性。这个属性在编译期记录了基本块开头位置的操作数栈和局部变量表中类型状态,验证时只需对照StackMapTable里的记录做一致性检查,而不用去推演整个数据流。这个优化叫作「类型检查验证器」,它极大缩短了验证时间。在 JDK 7 之后,字节码验证已经强制要求基于StackMapTable进行,这意味着如果你用非常古老的字节码生成工具生成了不含该属性的类,在高版本 JVM 上可能直接验证失败。
3.2.4 符号引用验证
符号引用验证发生在解析阶段,把符号引用转换为直接引用的时候。它检查的内容包括:
符号引用中通过字符串描述的全限定名能否找到对应的类。
指定类中是否存在符合方法的字段描述符及简单名称所描述的方法和字段。
符号引用中的类、字段、方法的可访问性(
private、protected、public、默认包访问权限)是否可以被当前类访问。
符号引用验证的作用是保证解析动作能够正常执行,如果无法通过验证,就会抛出IllegalAccessError、NoSuchFieldError、NoSuchMethodError等异常。
值得一提的是,验证阶段虽然重要,但并不是所有类都必须经过完整验证。对于已经被验证过并被反复使用的类,虚拟机可以通过缓存来跳过重复验证。同时在Server模式下也可以使用-Xverify:none参数关闭大部分验证措施以缩短启动时间,不过这种做法牺牲了安全性,生产环境一般不建议轻易使用。
3.3 准备阶段:为静态变量赋零值
准备阶段是连接阶段的第二步,这个阶段正式为类的静态变量分配内存,并设置变量的初始值。这里要特别强调「零值」这个概念,因为这是一个非常高频的面试陷阱。
准备阶段给静态变量设置的是数据类型的零值,而不是代码里写的那个初始值。各数据类型的零值如下表:
| 数据类型 | 零值 |
|---|---|
| byte | (byte) 0 |
| short | (short) 0 |
| int | 0 |
| long | 0L |
| float | 0.0F |
| double | 0.0D |
| char | \u0000 |
| boolean | false |
| 引用类型 | null |
比如下面这段代码:
java
public class PrepareDemo { public static int value = 123; public static final int CONSTANT_VALUE = 456; public static String name = "Java"; }在准备阶段,value会被赋予零值0,而不是123;name会被赋为null,而不是字符串"Java"。真正的123和"Java"要在后面的初始化阶段才会被赋予。
但是这里有一个非常重要的例外:如果一个静态变量被final和static同时修饰,并且它的值在编译期就能确定为字面量常量,那么准备阶段就会直接把它设置为代码中定义的值,而不是零值。上面的CONSTANT_VALUE就属于这种情况,它的值456在准备阶段就已经确定,甚至会被直接内联到使用它的地方。
为什么会这样?因为static final常量在编译时会被记录在类的常量池中,JVM 在准备阶段读取常量池时就可以直接获取它的值。但要注意,如果static final修饰的字段的值不是编译期常量,比如static final int RANDOM = new Random().nextInt();,那么它依然只能在初始化阶段被赋值,准备阶段还是零值。
准备阶段的内存分配主要针对静态变量。在 JDK 7 及之前,类的元数据和静态变量存放在永久代;JDK 8 移除永久代后,类的元数据进入元空间(Metaspace),但静态变量并没有随之搬走,而是仍然存放在堆中,具体来说是存放在这个类所对应的Class对象里。换句话说,静态变量在运行时更像Class对象的实例字段,这也是反射能够直接读取到它们的原因。
另外需要强调的是,准备阶段只处理被static修饰的类变量。实例变量不会在这一阶段分配,它们要等到对象真正被new出来、执行构造方法时,才会随对象一起在堆中分配内存并初始化。
3.4 解析阶段:把符号引用变为直接引用
解析是连接阶段的第三个环节,它的核心任务是把常量池内的符号引用替换为直接引用。
所谓符号引用,就是用一组符号来描述所引用的目标,比如类的全限定名、字段的名称和描述符、方法的名称和签名。它与内存布局无关,只负责在字面上告诉虚拟机「我要引用谁」。而直接引用,则可以理解为能够直接定位到目标的指针、相对偏移量或者方法区的句柄,它与虚拟机运行时的内存布局强相关,拿到它就能立刻找到目标。
解析动作主要针对以下四类符号引用展开:
类或接口的解析:根据全限定名找到对应的类或接口。
字段解析:定位到字段在其类中的具体偏移量,如果解析失败,会抛出
NoSuchFieldError。类方法解析:也就是静态方法,解析为某个类中确定的方法入口。
接口方法解析:解析接口中定义的方法,运行期配合多态完成分派。
解析与初始化之间没有严格的先后顺序。虚拟机可以在类被初始化之前就完成某些符号引用的解析,这种称为静态解析;也可以把解析推迟到实际调用发生时再执行,称为晚期解析。正是这种晚期解析机制,支撑了 Java 语言的重载、重写与运行期多态。例如invokevirtual指令在真正分派方法时,才会根据接收者的实际类型去解析具体的方法版本,这也是动态绑定的体现。
解析失败通常意味着程序结构存在不一致,常见的错误包括NoClassDefFoundError、NoSuchMethodError、IllegalAccessError,它们往往不是编译器能提前发现的,而是类版本不匹配、依赖冲突等问题的表现。
3.5 初始化阶段:真正执行 Java 代码的时刻
初始化是类加载流程的最后一个关键阶段,也是开发者最熟悉的阶段,因为到了这一步,类的静态变量赋值、static代码块才会真正执行。
在初始化阶段,虚拟机会为类生成一个特殊的类构造器方法<clinit>()。这个方法不是我们手写出来的,而是由编译器自动收集类中所有静态变量的赋值动作和所有static代码块中的语句,按它们在源码中出现的顺序合并而成的。<clinit>()只在类第一次被主动初始化时执行一次,并且 JVM 会保证它在多线程环境下被正确加锁和同步,也就是说,如果多个线程同时触发同一个类的初始化,只有一个线程会真正执行<clinit>(),其余线程会被阻塞,直到初始化完成。
举一个典型例子:
java
public class InitDemo { static { value = 10; // System.out.println(value); 这里虽然可以赋值,但不能访问 } private static int value = 20; public static void main(String[] args) { System.out.println(value); // 输出 20 } }这段代码最终输出 20,因为静态变量的赋值动作和静态代码块会按顺序合并进<clinit>():先执行代码块里的value = 10,再执行后面的显式初始化value = 20。同时要注意,静态代码块只能访问出现在它之前的静态变量,对出现在它之后的静态变量只能赋值、不能读取,否则会触发「非法前向引用」的编译错误。
那么,什么样的操作会触发一个类的初始化?规范规定,有且只有六种「主动引用」会立即触发初始化:
使用
new创建对象实例,或者读写一个类型的静态字段(getstatic、putstatic),或者调用一个类型的静态方法(invokestatic)。但访问编译期常量除外。使用
java.lang.reflect包的方法对类型进行反射调用时,如果类还未初始化,则先触发初始化。当初始化一个类时,发现它的父类还没有初始化,则需要先初始化它的父类。
虚拟机启动时加载的主类,也就是包含
main方法的类,会最先被初始化。JDK 7 引入动态语言支持后,如果
MethodHandle解析结果为REF_getStatic、REF_putStatic、REF_invokeStatic等静态句柄,则需要先初始化对应类。如果一个接口定义了
default方法,那么其实现类初始化时,该接口需要先被初始化。
与之相对的是「被动引用」,它不会触发类的初始化。下面三种情况就是典型:
java
public class PassiveReferenceDemo { public static void main(String[] args) { // 1. 通过子类引用父类的静态字段,只会初始化父类,不会初始化子类 System.out.println(SubClass.VALUE); // 2. 通过数组定义来引用一个类,不会触发该类的初始化 Parent[] arr = new Parent[3]; // 3. 访问编译期常量,不会触发定义该常量的类初始化 System.out.println(ConstClass.HELLO); } }四、类加载器体系:谁负责把类搬进来
前面讲的是「类被加载时经历了什么」,接下来要回答「类到底由谁加载」。JVM 中真正负责把字节码加载进内存的角色,就是类加载器(ClassLoader)。
从逻辑上看,类加载器可以分为四个层次:
启动类加载器(Bootstrap ClassLoader):由 C++ 实现,是 JVM 的一部分,负责加载
<JAVA_HOME>/lib目录或者-Xbootclasspath参数指定的核心类库,例如java.lang、java.util等。在 Java 代码中,它的引用通常返回null,因为它是本地实现,没有对应的 Java 对象。扩展类加载器(Extension ClassLoader):由 Java 实现,继承自
ClassLoader,负责加载<JAVA_HOME>/lib/ext目录或者java.ext.dirs指定的扩展类库。JDK 9 引入模块化后,它被平台类加载器(Platform ClassLoader)取代。应用程序类加载器(Application ClassLoader):也称为系统类加载器,负责加载应用 classpath 下的类,是
ClassLoader.getSystemClassLoader()的返回值,也是绝大多数用户代码实际使用的默认加载器。自定义类加载器(Custom ClassLoader):由开发者继承
ClassLoader自行实现,用于从数据库、网络、加密文件等非常规来源加载类。
这些类加载器并不是彼此孤立,而是通过「双亲」关系构成了一条自上而下的链。可以用下面这段代码直观地观察:
java
public class ClassLoaderDemo { public static void main(String[] args) { ClassLoader appClassLoader = ClassLoader.getSystemClassLoader(); System.out.println("应用程序类加载器:" + appClassLoader); ClassLoader extClassLoader = appClassLoader.getParent(); System.out.println("扩展类加载器:" + extClassLoader); ClassLoader bootstrapClassLoader = extClassLoader.getParent(); System.out.println("启动类加载器:" + bootstrapClassLoader); System.out.println("String 的类加载器:" + String.class.getClassLoader()); } }在 JDK 8 中,这段代码的输出大致是三类非空加载器,而String的加载器和最顶层的父加载器都会打印null,这正是因为核心类由启动类加载器加载,它在 Java 层没有对象形态。
五、双亲委派模型:类加载的默认秩序
双亲委派模型,又称父委托模型,指类加载器在尝试自己加载一个类之前,先把请求委派给父类加载器去完成。对每个类加载器来说,真正的逻辑是「先问爸爸,爸爸搞不定,我再上」。
它对应的工作流程可以分为三步:首先检查目标类是否已经加载过,如果加载过就直接返回;否则把加载请求委派给父类加载器;只有当父类加载器反馈无法完成加载时,当前加载器才尝试自己加载。JDK 中这一套逻辑就实现在java.lang.ClassLoader#loadClass方法里:
java
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException { synchronized (getClassLoadingLock(name)) { Class<?> c = findLoadedClass(name); if (c == null) { long t0 = System.nanoTime(); try { if (parent != null) { c = parent.loadClass(name, false); } else { c = findBootstrapClassOrNull(name); } } catch (ClassNotFoundException e) { // 父类加载器无法完成加载,交由当前加载器处理 } if (c == null) { long t1 = System.nanoTime(); c = findClass(name); sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0); sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1); sun.misc.PerfCounter.getFindClasses().increment(); } } if (resolve) { resolveClass(c); } return c; } }沿着父链逐层向上,所有加载请求最终都会到达启动类加载器。只有启动类加载器也找不到目标类时,请求才会一层层返回,让下一级加载器尝试加载。这样的设计至少带来三个直接好处:
避免类的重复加载:类一旦被上层加载器加载,就不会再被下层重复加载,保证了同一个类在 JVM 中有唯一身份。
保护核心库不被篡改:无论应用如何自定义类加载器,
java.lang.String这类核心类都会优先交给启动类加载器加载,防止恶意代码用自己的同名类替换 JDK 核心类。保证类型一致性:同一个类加载器加载的两个同名类才能正常赋值与比较,双亲委派让依赖关系更加稳定,减少了
ClassCastException类冲突问题的出现。
六、打破双亲委派的现实场景
双亲委派是默认的、好用的秩序,但它并不是强制性的。现实中有一部分场景恰恰需要通过打破这个模型才能正常工作。
6.1 JDBC 与线程上下文类加载器
JDBC 是双亲委派失效最常见的一个例子。DriverManager位于 JDK 核心包内,由启动类加载器加载;而具体数据库驱动,例如 MySQL 的com.mysql.cj.jdbc.Driver,则由我们放进 classpath 中的 JAR 包提供,由应用程序类加载器加载。按照双亲委派的逻辑,启动类加载器根本无法「向下」看到由应用程序类加载器加载出来的驱动实现类。
Java 给出的解法是线程上下文类加载器(Thread Context ClassLoader)。DriverManager在初始化时会调用ServiceLoader.load(Driver.class),后者使用当前线程的上下文类加载器来加载驱动实现,相当于让父级加载器反过来向子级加载器借力。线程上下文类加载器默认指向应用程序类加载器,也可以由开发者手动设置,这一机制也被 Spring、MyBatis 等框架广泛使用。
6.2 Tomcat 的 Web 应用类加载
Tomcat 需要在一个 JVM 里同时运行多个 Web 应用,而不同应用可能依赖同一个库的不同版本。如果完全遵守双亲委派,所有类都从同一个上层加载,版本冲突几乎是必然的。
因此 Tomcat 为每个 Web 应用都创建了独立的WebappClassLoader,并调整了加载顺序:先尝试从当前 Web 应用自己的WEB-INF/classes、WEB-INF/lib中加载类,找不到再委派给父加载器。通过这种「儿子优先」的策略,不同应用可以加载各自的类版本,实现应用隔离与热部署。当然,像java.lang这类核心类仍然遵循双亲委派,不会被 Web 应用覆盖。
6.3 OSGi 与热部署
OSGi 是另一个典型的破局者。它把每一个模块称为 Bundle,每个 Bundle 拥有独立的类加载器,模块之间通过Import-Package和Export-Package声明依赖关系,形成一个网状结构而不是简单的父子链。
这种设计使得不同的 Bundle 可以同时存在不同版本的同一个类,也可以在不重启系统的情况下独立安装、卸载和更新模块。代价是类加载关系变得非常复杂,一旦依赖声明不当,很容易陷入「Class 找不到」的泥潭。热部署与字节码增强类框架,本质上也都是通过自定义类加载器绕开默认模型,以达到运行时替换类的目的。
七、实战:手写一个自定义类加载器
理解概念之后,做一个最小可运行的自定义类加载器会有助于加深印象。下面是一个从指定目录加载.class文件的实现:
java
public class CustomClassLoader extends ClassLoader { private String classPath; public CustomClassLoader(String classPath) { this.classPath = classPath; } @Override protected Class<?> findClass(String name) throws ClassNotFoundException { byte[] data = loadClassData(name); if (data == null) { throw new ClassNotFoundException(name); } return defineClass(name, data, 0, data.length); } private byte[] loadClassData(String name) { String path = classPath + name.replace('.', '/') + ".class"; try (InputStream in = new FileInputStream(path); ByteArrayOutputStream out = new ByteArrayOutputStream()) { byte[] buffer = new byte[4096]; int len; while ((len = in.read(buffer)) != -1) { out.write(buffer, 0, len); } return out.toByteArray(); } catch (IOException e) { return null; } } }使用时,只要把编译好的Demo.class放到自定义目录,再通过反射调用即可:
java
public class CustomLoaderTest { public static void main(String[] args) throws Exception { CustomClassLoader loader = new CustomClassLoader("D:/myclasses/"); Class<?> clazz = loader.loadClass("com.example.Demo"); Object obj = clazz.newInstance(); clazz.getMethod("sayHello").invoke(obj); } }这里的关键是findClass内的defineClass方法,它会把字节数组真正转换成 JVM 中的Class对象。如果我们的需求是「隔离同名类的不同版本」,还可以进一步重写loadClass,先自己加载、失败后再交给父加载器,从而打破双亲委派。
八、高频面试问答
Q1:类加载的过程可以分为哪几个阶段?
答:加载、验证、准备、解析、初始化五个核心阶段,外加使用和卸载。其中验证、准备、解析合称连接阶段。
Q2:双亲委派机制的原理是什么,为什么要这么设计?
答:类加载器收到加载请求时先委派给父加载器,父加载器无法完成时才自己加载。这样能避免类重复加载、保护核心类不被篡改、保证类的唯一性和类型一致性。
Q3:什么是主动引用和被动引用?
答:主动引用指会触发类初始化的六种情况,包括 new 对象、访问静态字段或方法、反射、初始化子类时先初始化父类、启动主类、MethodHandle 静态句柄和接口 default 方法;被动引用则不会触发初始化,比如通过子类引用父类静态字段、创建数组、访问编译期常量。
Q4:准备阶段和初始化阶段对静态变量的处理有什么不同?
答:准备阶段为静态变量分配内存并赋零值,但static final且编译期可确定值的常量会直接赋真实值;初始化阶段才执行静态变量的显式赋值和静态代码块。
Q5:有哪些场景会打破双亲委派?
答:JDBC 通过线程上下文类加载器让父加载器使用子加载器加载的驱动;Tomcat 为每个 Web 应用使用独立的类加载器实现应用隔离;OSGi 通过 Bundle 间的网状依赖实现模块热部署;自定义类加载器也可以通过重写loadClass实现逆序加载。
Q6:如何判断两个类是否是同一个类?
答:需要同时满足「类的全限定名相同」和「由同一个类加载器加载」两个条件。不同类加载器加载的同名类在 JVM 中属于不同的类,互相赋值会抛出ClassCastException。
Q7:什么是线程上下文类加载器?
答:它保存在Thread.currentThread().getContextClassLoader()中,默认是应用程序类加载器,可以手动设置。它用于让父加载器能够反向使用子加载器加载的类,是打破双亲委派的常见实现方式。
九、总结
类加载机制是 JVM 中连接字节码与运行时的桥梁。把本文内容提炼成几句话:
类加载的五个核心阶段是加载、验证、准备、解析、初始化,验证、准备、解析合称连接。
加载阶段负责把字节码读进内存并生成
Class对象,来源可以是磁盘、网络、动态生成等。验证阶段分为文件格式、元数据、字节码、符号引用四个子步骤,保护 JVM 不被非法字节码攻击。
准备阶段为静态变量赋零值,
static final编译期常量是例外。初始化阶段执行
<clinit>(),由静态变量赋值和静态代码块按源码顺序合并而成。类加载器分为启动、扩展、应用和自定义四类,通过双亲委派保证类的一致性和核心库安全。
线程上下文类加载器、Tomcat、OSGi 和自定义类加载器是打破双亲委派的典型场景。