类加载过程是怎样的?什么是双亲委派机制?
简化版
类从被使用到卸载经历七个阶段,其中加载 → 验证 → 准备 → 解析 → 初始化是核心。双亲委派指:类加载请求先逐层向上交给父加载器,父加载器加载不了才由子加载器自己加载,用来保证核心类库不被篡改、同一个类在 JVM 里唯一。
详细版
类的生命周期:加载、验证、准备、解析、初始化、使用、卸载。前五个是把 .class 变成可用类型的关键:
- 加载:拿到类的二进制字节流,把静态结构转成方法区的运行时数据,并在堆里生成一个
Class对象作为访问入口。 - 验证:校验字节码合法、不会危害虚拟机安全。
- 准备:给静态变量分配内存并赋零值(不是代码里写的初值)。
- 解析:把常量池里的符号引用替换成直接引用。
- 初始化:执行
<clinit>,真正给静态变量赋代码里的值、执行静态代码块。
双亲委派的加载器层级:Bootstrap(核心类库)→ JDK 8 的 Extension 或 JDK 9+ 的 Platform → Application(classpath/module path)。典型父优先流程会先向上委派,父加载不到才尝试自己查找,从而保护核心类并减少同一命名空间中的重复定义。
完整版教学
一、五个阶段各自在干什么
加载(Loading):通过类的全限定名获取字节流(可以来自 jar、网络、动态生成等),在方法区建立类的运行时数据结构,并在堆中创建对应的 java.lang.Class 对象——它是你反射、getClass() 拿到的那个入口。
验证(Verification):确保字节码符合规范、没有恶意或错误(比如跳转指令不会跳到方法外、类型转换合法)。这是 Java「即使加载外部字节码也相对安全」的基础。
准备(Preparation):给类的静态变量在方法区分配内存并设零值。注意:
public static int a = 10;
准备阶段后 a == 0,赋值成 10 是在初始化阶段做的。但如果是:
public static final int b = 10; // 常量
b 在准备阶段就直接是 10(编译期常量,放进常量池)。
解析(Resolution):把常量池里的符号引用(用名字描述的引用,如 “java/lang/String”)换成直接引用(指向内存的指针/偏移)。
初始化(Initialization):执行类构造器 <clinit>()——它由编译器把所有静态变量赋值和静态代码块按源码顺序合并而成。JVM 保证 <clinit>() 在多线程下只执行一次且线程安全(这也是「静态内部类单例」线程安全的原理)。
二、什么时候会触发初始化
类被「加载」不等于被「初始化」。只有主动使用才会初始化,典型场景:new 对象、读写静态字段(非编译期常量)、调用静态方法、反射 Class.forName、初始化子类时先初始化父类、作为程序入口的主类。
反例(不会触发初始化):通过子类访问父类的静态字段、用类名定义数组 new SomeClass[10]、访问编译期常量。这类「被动引用」是常见的面试陷阱题。
三、双亲委派机制到底解决什么
三层加载器:
- Bootstrap ClassLoader:C++ 实现,加载
JAVA_HOME/lib下的核心类库(java.*)。 - Extension / Platform ClassLoader:JDK 8 的 Extension Loader 负责扩展机制;JDK 9+ 改为 Platform Loader,服务模块化后的平台类,两者不能当作完全相同的目录加载器。
- Application ClassLoader:加载 classpath 上你自己的类。
加载一个类时,加载器先把请求委派给父加载器,一路向上到 Bootstrap;父加载器能加载就用父加载器的结果,加载不了才自己来。带来两个保证:
- 安全:正常父优先体系会先使用 Bootstrap 已定义的核心 String;JVM 还禁止普通类加载器定义
java.*等受保护包。 - 一致性:同一加载器命名空间优先复用父层定义,避免核心类型被应用副本替代;不同定义加载器仍可产生同名但不同身份的类。
关键点:JVM 判断「两个类是否相等」,看的是全限定名 + 加载它的类加载器。同一个
.class被两个不同加载器加载,就是两个不相等的类。
四、什么时候需要「打破」双亲委派
双亲委派不是铁律,有正当理由可以打破:
- JDBC / SPI:
DriverManager(核心库,Bootstrap 加载)需要加载第三方厂商的Driver(在 classpath,Application 加载)。父加载器加载不了子加载器的类,于是用**线程上下文类加载器(ContextClassLoader)**反向委派给子加载器。 - Tomcat 等容器:每个 web 应用要相互隔离、还要能加载同名不同版本的库,于是每个应用一个
WebappClassLoader,优先自己加载(打破向上委派)来实现隔离。 - 热部署 / OSGi:用自定义加载器实现类的替换和模块化。
五、类身份、加载器命名空间与卸载
JVM 判断两个类是否相同,不只比较全限定名,还同时比较定义它们的类加载器。两个独立插件加载器即使都加载 com.example.Api,得到的也是两个不同类型,强制转换会抛 ClassCastException。这正是应用服务器和插件系统实现版本隔离的基础。
类身份 = 二元组(定义类加载器, 全限定类名)
LoaderA + com.example.Api ≠ LoaderB + com.example.Api
| 加载器层级(常见 HotSpot/JDK) | 典型加载内容 | 版本边界 |
|---|---|---|
| Bootstrap | java.base 等核心类 | 由虚拟机实现,Java 中常显示为 null |
| Platform | 平台模块 | JDK 9 起替代早期 Extension 概念 |
| Application | classpath/module-path 应用类 | 通常由系统类加载器承担 |
| 自定义加载器 | 插件、热部署、隔离依赖 | 应谨慎处理委派与资源释放 |
类卸载要求定义它的加载器可回收、该加载器定义的类不再被引用,并且对应 Class 对象也不可达。仅把某个业务对象设为 null 不足以卸载类;线程上下文加载器、静态缓存和 ThreadLocal 都可能让插件加载器长期存活。
六、常见误区与追问
- 误区:双亲委派要求所有加载器都必须继承同一个父类。 它描述的是委派查找流程,不等同 Java 继承关系;Bootstrap 甚至不是普通 Java 对象。
- 误区:
Class.forName只加载类而不会初始化。 常用单参数重载默认会初始化;需要控制时使用带 initialize 参数的重载。 - 误区:同名同字节码的类一定能互相强转。 定义类加载器不同,JVM 类型身份就不同。
- 追问:准备阶段会执行
static赋值表达式吗? 通常只分配静态字段并设零值,显式初始化主要在<clinit>初始化阶段执行;编译期常量有特殊处理。 - 追问:为什么 SPI 常用线程上下文类加载器? 父加载器定义的框架接口需要反向发现子层应用实现,单纯父委派无法向下查找。
- 追问:如何排查类加载器泄漏? 观察类加载器数量、类直方图和 heap dump 中到加载器的 GC Roots 路径,重点检查线程、缓存和 ThreadLocal。
记忆钩子:加载流程回答“类怎样进入 JVM”,类身份还要再加一句——谁加载的,同样重要。
七、加强记忆
记忆时把整题串成“进、验、备、解、初”五步:加载把字节流变成运行时类,验证守安全,准备给静态字段设初始零值,解析连接符号引用,初始化才执行 <clinit>。再用“先问父层、父层不会才自己找”记住典型父优先委派,但 SPI、容器和插件隔离会在受控场景调整查找方向。类型身份始终是“定义类加载器 + 全限定名”,所以同名字节码不保证是同一个类型。最后记住类卸载不是清掉一个实例,而是整个定义加载器及其类图都不再可达。