定位 :本文是 JVM 系列的第二篇。面试中"类加载机制"几乎是必考题,很多人背得出"加载→链接→初始化"三步走,但一问双亲委派、一问打破双亲委派就答不上来。本文从生活比喻 出发,结合 JVM 规范逐步拆解类加载的每个阶段,帮你真正理解而不是死记硬背。
官方规范参考:
目录
- [1. 为什么需要了解类加载机制?](#1. 为什么需要了解类加载机制?)
- [2. 类加载全过程全景图](#2. 类加载全过程全景图)
- [3. 加载(Loading)------找到并读取 .class 文件](#3. 加载(Loading)——找到并读取 .class 文件)
- [4. 链接(Linking)------把类数据接入 JVM](#4. 链接(Linking)——把类数据接入 JVM)
- [5. 初始化(Initialization)------执行
<clinit>方法](#5. 初始化(Initialization)——执行 <clinit> 方法) - [6. 类加载器(ClassLoader)------谁负责加载类?](#6. 类加载器(ClassLoader)——谁负责加载类?)
- [7. 双亲委派机制------类加载的核心规则](#7. 双亲委派机制——类加载的核心规则)
- [8. 打破双亲委派------什么时候需要?](#8. 打破双亲委派——什么时候需要?)
- [9. 自定义类加载器实战](#9. 自定义类加载器实战)
- [10. 常见面试题精选](#10. 常见面试题精选)
- [11. 总结](#11. 总结)
1. 为什么需要了解类加载机制?
先从生活说起
你开了一家公司,新员工入职需要经过三个步骤:
新员工入职流程:
1. 报到(加载) → 找到员工档案,读取基本信息
2. 培训(链接) → 验证身份 → 准备工位 → 介绍同事
3. 上岗(初始化) → 执行岗前培训流程,正式开始工作
Java 中的类也一样,.class 文件不是凭空就能用的,需要经过加载→链接→初始化三个阶段才能被 JVM 使用。
不了解会怎样?
| 问题 | 后果 |
|---|---|
| ClassNotFoundException | 不知道类加载器怎么找类,无法排查 |
| ClassCastException | 不同类加载器加载同一个类,类型不兼容 |
| SPI 机制理解 | 不懂打破双亲委派,无法理解 JDBC、Spring 的类加载 |
| 热部署/热加载 | 不懂类加载机制,无法实现代码热更新 |
| 面试必考 | "说说类加载机制和双亲委派"几乎逢面必问 |
2. 类加载全过程全景图
先看全貌,再逐一深入:
┌─────────────────────────────────────────────────────────────────────────┐
│ 类加载全过程 │
│ │
│ ┌───────────────┐ ┌───────────────────────────────────┐ ┌──────────┐│
│ │ 加载(Loading)│ │ 链接(Linking) │ │ 初始化 ││
│ │ │ │ │ │(Init) ││
│ │ 1.找到.class │ │ ┌──────────┐ ┌────────┐ ┌─────┐│ │ ││
│ │ 文件 │ │ │ 验证 │ │ 准备 │ │解析 ││ │ 执行 ││
│ │ 2.读取字节码 │ │ │(Verify) │ │(Prepare)│ │(Res)││ │<clinit> ││
│ │ 3.生成Class │ │ │ │ │ │ │ ││ │ 方法 ││
│ │ 对象 │ │ │ 格式校验 │ │ 分配默认│ │ 符号││ │ ││
│ │ │ │ │ 语义校验 │ │ 零值 │ │ 引用││ │ static ││
│ │ │ │ │ 字节码校验│ │ │ │ 转 ││ │ 变量赋值 ││
│ │ │ │ │ │ │ │ │直接 ││ │ static ││
│ │ │ │ │ │ │ │ │引用 ││ │ 代码块 ││
│ └───────────────┘ │ └──────────┘ └────────┘ └─────┘│ └──────────┘│
│ │
└─────────────────────────────────────────────────────────────────────────┘
三个阶段的核心任务
| 阶段 | 核心任务 | 生活比喻 |
|---|---|---|
| 加载 | 找到 .class 文件,读取字节码,生成 Class 对象 | 找到员工档案 |
| 链接 | 验证合法性、分配内存、解析引用 | 验证身份、准备工位、介绍同事 |
| 初始化 | 执行 static 赋值和 static 代码块 | 岗前培训,正式上岗 |
3. 加载(Loading)------找到并读取 .class 文件
生活比喻
加载就像HR 找到员工档案并录入系统------先找到档案在哪,然后读取内容,最后在系统中创建一条记录。
加载的三个步骤
加载过程:
═══════════════════════════════════════
1. 通过全限定名获取二进制字节流
"com.bit.agents.ParallelizationWorkflow"
→ 去 classpath 中找 ParallelizationWorkflow.class
2. 将字节流所代表的静态存储结构转化为方法区的运行时数据结构
.class 文件的字节码 → 方法区中的类信息
3. 在堆中生成一个 java.lang.Class 对象
→ 作为方法区数据的访问入口
字节流的来源
| 来源 | 说明 | 示例 |
|---|---|---|
| 本地文件 | 从文件系统读取 .class 文件 | 最常见的方式 |
| JAR/WAR 包 | 从压缩包中读取 | Spring Boot 的 fat jar |
| 网络 | 从远程服务器下载 | Applet(已过时) |
| 动态生成 | 运行时动态生成字节码 | 动态代理、CGLib、ASM |
| 数据库 | 从数据库中读取字节码 | 少见但可行 |
Class 对象的作用
java
// Class 对象是访问方法区中类信息的入口
Class<?> clazz = Class.forName("com.bit.agents.ParallelizationWorkflow");
// 通过 Class 对象可以获取类的所有信息
clazz.getName(); // 类名
clazz.getFields(); // 字段
clazz.getMethods(); // 方法
clazz.getConstructors(); // 构造器
clazz.newInstance(); // 创建实例
加载的时机
JVM 规范没有强制约束"何时加载",但规定了"何时必须初始化"(见第5节)。不同的虚拟机实现有不同的策略:
常见的加载策略:
├─ 预加载:JVM 启动时加载核心类(java.lang.* 等)
├─ 按需加载:首次使用时才加载(大多数类的加载方式)
└─ 懒加载:尽可能延迟加载(部分虚拟机的优化策略)
4. 链接(Linking)------把类数据接入 JVM
链接分为三个子阶段:验证 → 准备 → 解析
4.1 验证(Verify)------安全检查
生活比喻
验证就像新员工入职时的身份核验------确认身份证是真的、学历是真的、没有犯罪记录。
验证的四个方面
验证过程:
═══════════════════════════════════════
1. 文件格式验证
├─ 魔数是否为 0xCAFEBABE?
├─ 主次版本号是否在当前 JVM 支持范围内?
├─ 常量池的常量是否有不支持的类型?
└─ 目的:确保输入的字节流能被 JVM 接受
2. 元数据验证
├─ 是否有父类?(除了 Object 都要有)
├─ 父类是否允许继承?(final 类不能被继承)
├─ 是否实现了接口的所有抽象方法?
└─ 目的:对类的元数据进行语义校验
3. 字节码验证
├─ 操作数栈的数据类型与指令代码是否匹配?
├─ 跳转指令是否跳到方法内部?
├─ 类型转换是否安全?
└─ 目的:确保程序语义合法,最复杂的验证阶段
4. 符号引用验证
├─ 符号引用中的类、字段、方法是否存在?
├─ 引用的类是否可访问?(权限检查)
└─ 目的:确保解析动作能正常执行
验证的重要性
java
// 如果没有验证,恶意字节码可能导致 JVM 崩溃
// 例如:以下"伪代码"如果绕过验证,会导致类型混乱
// 将 String 引用当作 Integer 使用
// 跳转到方法外的地址执行代码
// 可以通过 -Xverify:none 关闭验证(仅用于开发调试,生产环境绝对不要关闭)
4.2 准备(Prepare)------分配内存并设置默认值
生活比喻
准备就像给新员工准备工位------桌子椅子摆好,电脑装上,但软件还没配置(都是默认设置)。
准备阶段做了什么
java
public class PrepareDemo {
// 准备阶段:static 变量被分配内存并设置默认零值
// 注意:此时 value = 0,不是 123!
// 123 要到初始化阶段才赋值
private static int value = 123;
// 准备阶段:final static 变量直接赋值(编译期常量)
// 此时 CONSTANT = 456,因为编译期就确定了
private static final int CONSTANT = 456;
// 准备阶段:引用类型默认值是 null
private static String name = "Hello";
// 此时 name = null,"Hello" 在初始化阶段赋值
}
各类型的默认零值
| 类型 | 默认零值 |
|---|---|
int |
0 |
long |
0L |
float |
0.0f |
double |
0.0 |
boolean |
false |
char |
'\u0000' |
byte |
(byte)0 |
short |
(short)0 |
| 引用类型 | null |
4.3 解析(Resolve)------符号引用转为直接引用
生活比喻
解析就像把"张三的工位"变成"3楼A区027号"------从人能理解的名称变成系统可以直接定位的地址。
符号引用 vs 直接引用
符号引用(Symbolic Reference):
→ 用字符串表示的引用,如 "com/bit/agents/ParallelizationWorkflow"
→ 编译时确定,存在 .class 文件的常量池中
→ 人能读懂,但 JVM 找起来需要查表
直接引用(Direct Reference):
→ 直接指向目标的指针、句柄或偏移量
→ 如方法区中类信息的内存地址 0x7F3A2B00
→ JVM 可以直接使用,不需要再查
解析的时机
解析的时机:
═══════════════════════════════════════
JVM 规范没有规定解析必须在初始化之前完成
→ 可以在初始化之后按需解析(懒解析)
解析的动作主要针对:
├─ 类或接口的解析
├─ 字段解析
├─ 方法解析
└─ 接口方法解析
5. 初始化(Initialization)------执行 <clinit> 方法
生活比喻
初始化就像新员工正式上岗------完成岗前培训,执行入职流程,开始真正工作。
什么是 <clinit> 方法?
<clinit> 是编译器自动收集类中所有 static 变量赋值 和 static 代码块合并产生的。
java
public class InitDemo {
// static 变量赋值
private static int value = 123;
// static 代码块
static {
System.out.println("静态初始化块执行");
name = "World";
}
private static String name = "Hello";
// 编译器生成的 <clinit> 方法等价于:
// void <clinit>() {
// value = 123; // static 变量赋值
// System.out.println("静态初始化块执行");
// name = "World"; // static 代码块中的赋值
// name = "Hello"; // static 变量赋值(覆盖上面的)
// }
// 注意:按源代码顺序执行!
}
<clinit> 的关键特点
| 特点 | 说明 |
|---|---|
| 编译器生成 | 不是开发者写的,是编译器自动收集合并的 |
| 按源码顺序 | static 赋值和 static 块按源代码出现顺序执行 |
| 线程安全 | JVM 保证 <clinit> 在多线程下被正确地加锁和同步 |
| 可选的 | 如果类没有 static 赋值和 static 块,就不会生成 <clinit> |
| 不会继承 | 父类的 <clinit> 先执行,但不会被子类覆盖 |
类初始化的触发条件(主动引用)
JVM 规范严格规定了5种必须初始化类的场景:
必须初始化的5种场景(主动引用):
═══════════════════════════════════════
1. new 关键字
new MyObject() → 初始化 MyObject
2. 访问静态变量
MyObject.value → 初始化 MyObject
(注意:访问自己的 static final 常量不会触发初始化)
3. 访问静态方法
MyObject.getMethod() → 初始化 MyObject
4. 反射调用
Class.forName("com.bit.MyObject") → 初始化 MyObject
5. 初始化子类
new ChildObject() → 先初始化父类,再初始化子类
不会触发初始化的场景(被动引用)
java
// 场景1:通过子类引用父类的静态变量,不会初始化子类
class Parent {
static int value = 100;
static { System.out.println("Parent 初始化"); }
}
class Child extends Parent {
static { System.out.println("Child 初始化"); }
}
// Child.value → 只输出 "Parent 初始化",不会输出 "Child 初始化"
// 场景2:通过数组定义引用类,不会初始化类
// Parent[] arr = new Parent[10]; → 不会初始化 Parent
// 场景3:访问编译期常量(static final),不会初始化类
class Constants {
static final int VALUE = 100; // 编译期常量
static final String NAME = "Hello"; // 编译期常量
static { System.out.println("Constants 初始化"); }
}
// Constants.VALUE → 不会输出 "Constants 初始化"
// 因为编译期常量在编译阶段就通过常量传播优化,直接嵌入调用方
初始化的顺序
java
class Father {
static int fatherStatic = print("Father static 变量");
static { System.out.println("Father static 块"); }
{ System.out.println("Father 实例块"); }
Father() { System.out.println("Father 构造方法"); }
}
class Child extends Father {
static int childStatic = print("Child static 变量");
static { System.out.println("Child static 块"); }
{ System.out.println("Child 实例块"); }
Child() { System.out.println("Child 构造方法"); }
}
// new Child() 的输出顺序:
// 1. Father static 变量 ← 父类静态变量
// 2. Father static 块 ← 父类静态块
// 3. Child static 变量 ← 子类静态变量
// 4. Child static 块 ← 子类静态块
// 5. Father 实例块 ← 父类实例块
// 6. Father 构造方法 ← 父类构造方法
// 7. Child 实例块 ← 子类实例块
// 8. Child 构造方法 ← 子类构造方法
// 规律:父类静态 → 子类静态 → 父类实例 → 父类构造 → 子类实例 → 子类构造
6. 类加载器(ClassLoader)------谁负责加载类?
生活比喻
类加载器就像公司的不同 HR 部门,每个部门负责招聘不同层级的员工:
公司招聘体系:
├─ 总部 HR(Bootstrap) → 招高管(核心类库)
├─ 分公司 HR(Extension) → 招中层(扩展类库)
├─ 部门 HR(Application) → 招普通员工(应用类)
└─ 外包公司(Custom) → 招外包人员(自定义类)
类加载器的层次结构
┌─────────────────────────────────────────────────────────────────────────┐
│ 类加载器层次结构 │
│ │
│ ┌───────────────────────────────────────────────────────────────────┐ │
│ │ Bootstrap ClassLoader(启动类加载器) │ │
│ │ │ │
│ │ 加载路径:JAVA_HOME/lib 目录(rt.jar、charsets.jar 等) │ │
│ │ 加载内容:java.lang.*、java.util.* 等核心类 │ │
│ │ 实现语言:C++ 实现,不是 Java 类,在 JVM 内部 │ │
│ │ 获取方式:String.class.getClassLoader() 返回 null │ │
│ └──────────────────────────────┬────────────────────────────────────┘ │
│ │ parent │
│ ┌──────────────────────────────┴────────────────────────────────────┐ │
│ │ Extension ClassLoader(扩展类加载器) │ │
│ │ │ │
│ │ 加载路径:JAVA_HOME/lib/ext 目录 │ │
│ │ 加载内容:javax.* 等扩展类 │ │
│ │ 实现语言:Java 实现,sun.misc.Launcher$ExtClassLoader │ │
│ │ (JDK9后改为 Platform ClassLoader) │ │
│ └──────────────────────────────┬────────────────────────────────────┘ │
│ │ parent │
│ ┌──────────────────────────────┴────────────────────────────────────┐ │
│ │ Application ClassLoader(应用类加载器) │ │
│ │ │ │
│ │ 加载路径:classpath(用户类路径) │ │
│ │ 加载内容:你自己写的类、第三方依赖 │ │
│ │ 实现语言:Java 实现,sun.misc.Launcher$AppClassLoader │ │
│ │ 获取方式:ClassLoader.getSystemClassLoader() │ │
│ └──────────────────────────────┬────────────────────────────────────┘ │
│ │ parent │
│ ┌──────────────────────────────┴────────────────────────────────────┐ │
│ │ Custom ClassLoader(自定义类加载器) │ │
│ │ │ │
│ │ 加载路径:自定义 │ │
│ │ 加载内容:特殊需求的类(热部署、加密类、隔离加载等) │ │
│ │ 实现方式:继承 java.lang.ClassLoader,重写 findClass() │ │
│ └───────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────┘
验证类加载器
java
public class ClassLoaderDemo {
public static void main(String[] args) {
// String 由 Bootstrap ClassLoader 加载
// Bootstrap 是 C++ 实现,Java 中表示为 null
System.out.println("String 的加载器: " +
String.class.getClassLoader()); // null
// DriverManager 由 Extension ClassLoader 加载
System.out.println("DriverManager 的加载器: " +
java.sql.DriverManager.class.getClassLoader());
// 自己写的类由 Application ClassLoader 加载
System.out.println("自定义类的加载器: " +
ClassLoaderDemo.class.getClassLoader());
// 打印类加载器的父子关系
ClassLoader loader = ClassLoaderDemo.class.getClassLoader();
while (loader != null) {
System.out.println(loader);
loader = loader.getParent();
}
System.out.println("Bootstrap ClassLoader (null)");
// 输出:
// sun.misc.Launcher$AppClassLoader@xxx
// sun.misc.Launcher$ExtClassLoader@xxx
// Bootstrap ClassLoader (null)
}
}
7. 双亲委派机制------类加载的核心规则
生活比喻
双亲委派就像公司的审批流程------员工请假,先问部门经理能不能批,部门经理说"这个要问总监",总监说"这个要问CEO"。从下往上请示,从上往下审批。
双亲委派的工作流程
类加载请求:
═══════════════════════════════════════
Application ClassLoader 收到加载 "java.lang.String" 的请求
│
│ 1. 先委派给父加载器 Extension ClassLoader
▼
Extension ClassLoader 收到请求
│
│ 2. 再委派给父加载器 Bootstrap ClassLoader
▼
Bootstrap ClassLoader 收到请求
│
│ 3. Bootstrap 尝试加载
│ → 找到了!java.lang.String 在 rt.jar 中
│ → 由 Bootstrap ClassLoader 加载完成
▼
加载结果逐层返回,Application ClassLoader 不需要自己加载
如果 Bootstrap 找不到呢?
→ 返回给 Extension,Extension 尝试加载
→ 如果 Extension 也找不到
→ 返回给 Application,Application 尝试加载
→ 如果 Application 也找不到 → ClassNotFoundException
双亲委派的代码实现
java
// ClassLoader.loadClass() 的核心逻辑(简化版)
protected Class<?> loadClass(String name, boolean resolve) {
// 1. 先检查是否已经加载过
synchronized (getClassLoadingLock(name)) {
Class<?> c = findLoadedClass(name);
if (c == null) {
// 2. 没加载过,委派给父加载器
if (parent != null) {
c = parent.loadClass(name, false); // 先让父加载器尝试
} else {
c = findBootstrapClassOrNull(name); // 父为null,用Bootstrap
}
// 3. 父加载器也找不到,自己尝试加载
if (c == null) {
c = findClass(name); // 自己的加载逻辑
}
}
return c;
}
}
双亲委派的好处
| 好处 | 说明 |
|---|---|
| 安全性 | 防止核心类被篡改(你无法自定义一个 java.lang.String) |
| 避免重复加载 | 父加载器已加载的类,子加载器不需要再加载 |
| 层级清晰 | 每个加载器各司其职,不会混乱 |
验证双亲委派的安全性
java
// 尝试自定义 java.lang.String
package java.lang;
public class String {
public static void main(String[] args) {
System.out.println("我是假的 String");
}
}
// 运行结果:
// 错误: 在类 java.lang.String 中找不到 main 方法
// 原因:双亲委派机制确保了加载的是 rt.jar 中的真正的 String
// 你自定义的 String 根本不会被加载!
8. 打破双亲委派------什么时候需要?
生活比喻
双亲委派就像"所有事情都要上级审批",但有时候部门经理需要自己做决定:
- 紧急情况:来不及等上级审批
- 特殊需求:上级的方案不适合本部门
- 隔离需求:不同部门需要各自的规范
为什么要打破双亲委派?
| 场景 | 原因 | 代表 |
|---|---|---|
| SPI 机制 | 接口在核心库,实现在第三方 jar | JDBC、JNDI |
| Tomcat | 不同 Web 应用需要类隔离 | WebApp ClassLoader |
| OSGi | 模块化需要网状加载 | Equinox、Felix |
| 热部署 | 需要重新加载已加载的类 | JRebel、Spring DevTools |
SPI 机制与线程上下文类加载器
这是最经典的打破双亲委派的场景:
JDBC 的问题:
═══════════════════════════════════════
java.sql.DriverManager(Bootstrap 加载)
↓ 需要调用
com.mysql.cj.jdbc.Driver(第三方 jar,Bootstrap 找不到!)
问题:Bootstrap ClassLoader 只加载核心类
它看不到 classpath 下的 MySQL 驱动
解决方案:线程上下文类加载器(Thread Context ClassLoader)
java
// SPI 机制的核心:ServiceLoader
public class DriverManager {
static {
// 使用线程上下文类加载器加载第三方驱动
// 而不是用 Bootstrap ClassLoader
ServiceLoader<Driver> drivers = ServiceLoader.load(Driver.class);
for (Driver driver : drivers) {
// 加载到 MySQL/Oracle 等驱动
}
}
}
// ServiceLoader.load() 使用线程上下文类加载器
public static <S> ServiceLoader<S> load(Class<S> service) {
ClassLoader cl = Thread.currentThread().getContextClassLoader();
return new ServiceLoader<>(service, cl);
}
Tomcat 的类加载器架构
Tomcat 的类加载器架构:
┌─────────────────────────────────────────────────────────┐
│ │
│ Bootstrap ClassLoader │
│ │ │
│ Extension ClassLoader │
│ │ │
│ Application ClassLoader │
│ │ │
│ Common ClassLoader(Tomcat 公共类) │
│ │ │
│ ┌────┴─────────────────────────────┐ │
│ │ │ │
│ WebApp1 ClassLoader WebApp2 ClassLoader │
│ ├─ /WEB-INF/classes/ ├─ /WEB-INF/classes/ │
│ └─ /WEB-INF/lib/ └─ /WEB-INF/lib/ │
│ │
│ 特点:每个 WebApp 有自己的 ClassLoader │
│ → 不同应用可以使用不同版本的同一个类 │
│ → 打破了双亲委派! │
└─────────────────────────────────────────────────────────┘
打破双亲委派的方式
打破双亲委派的常见方式:
═══════════════════════════════════════
1. 重写 loadClass() 方法
→ 不委派给父加载器,自己先加载
→ Tomcat、OSGi 使用这种方式
2. 线程上下文类加载器
→ 父加载器使用子加载器加载类
→ SPI 机制使用这种方式
3. OSGi 网状加载
→ 模块之间平级加载,不是父子关系
→ Equinox、Felix 使用这种方式
9. 自定义类加载器实战
为什么要自定义类加载器?
| 需求 | 说明 |
|---|---|
| 加密解密 | .class 文件加密存储,加载时解密 |
| 热部署 | 不重启应用,重新加载修改后的类 |
| 类隔离 | 不同模块使用不同版本的同一个类 |
| 从非标准来源加载 | 从数据库、网络加载 .class 文件 |
自定义类加载器的步骤
java
import java.io.*;
/**
* 自定义类加载器示例
* 核心原则:重写 findClass(),不要重写 loadClass()
* 重写 loadClass() 会打破双亲委派,除非你确实需要
*/
public class MyClassLoader extends ClassLoader {
private String classPath; // .class 文件存放的路径
public MyClassLoader(String classPath) {
this.classPath = classPath;
}
/**
* 重写 findClass() 方法
* 当父加载器找不到类时,JVM 会调用这个方法
* 这样不会破坏双亲委派机制
*/
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
try {
// 1. 读取 .class 文件的字节码
byte[] classData = loadClassData(name);
if (classData == null) {
throw new ClassNotFoundException(name);
}
// 2. 将字节码转换为 Class 对象
return defineClass(name, classData, 0, classData.length);
} catch (IOException e) {
throw new ClassNotFoundException(name, e);
}
}
/**
* 从文件系统读取 .class 文件的字节码
*/
private byte[] loadClassData(String name) throws IOException {
// 将类名转换为文件路径
// com.bit.agents.MyClass → com/bit/agents/MyClass.class
String path = classPath + "/" + name.replace('.', '/') + ".class";
File file = new File(path);
if (!file.exists()) {
return null;
}
try (InputStream is = new FileInputStream(file);
ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
byte[] buffer = new byte[4096];
int bytesRead;
while ((bytesRead = is.read(buffer)) != -1) {
baos.write(buffer, 0, bytesRead);
}
return baos.toByteArray();
}
}
}
使用自定义类加载器
java
import java.lang.reflect.Method;
public class MyClassLoaderDemo {
public static void main(String[] args) throws Exception {
// 创建自定义类加载器,指定 .class 文件路径
MyClassLoader loader = new MyClassLoader("D:/myclasses");
// 加载类(会走双亲委派流程,父加载器找不到才用自定义的)
Class<?> clazz = loader.loadClass("com.bit.agents.MyClass");
// 创建实例
Object obj = clazz.newInstance();
// 调用方法
Method method = clazz.getMethod("sayHello");
method.invoke(obj);
}
}
热部署示例
java
import java.lang.reflect.Method;
/**
* 简单的热部署示例
* 核心思路:创建新的 ClassLoader 重新加载类
* 注意:同一个 ClassLoader 不能重复加载同一个类
*/
public class HotDeployDemo {
public static void main(String[] args) throws Exception {
while (true) {
// 每次创建新的 ClassLoader
MyClassLoader loader = new MyClassLoader("D:/myclasses");
Class<?> clazz = loader.loadClass("com.bit.HotService");
Object service = clazz.newInstance();
Method method = clazz.getMethod("process");
method.invoke(service);
System.out.println("等待5秒后重新加载...");
Thread.sleep(5000);
// 在这5秒内修改 D:/myclasses/com/bit/HotService.class
// 新的 ClassLoader 会加载修改后的版本
}
}
}
10. 常见面试题精选
Q1:类加载的过程?
回答思路:
类加载分为三个阶段:
1. 加载 --- 找到 .class 文件,读取字节码,在方法区生成类数据,在堆中生成 Class 对象
2. 链接 --- 分为验证(校验字节码合法性)、准备(为 static 变量分配默认零值)、解析(符号引用转直接引用)
3. 初始化 --- 执行 <clinit> 方法,包括 static 变量赋值和 static 代码块
Q2:什么是双亲委派?为什么要双亲委派?
双亲委派:类加载器收到加载请求时,先委派给父加载器尝试加载,
父加载器加载不了才自己加载。
好处:
1. 安全性 --- 防止核心类被篡改(自定义的 java.lang.String 不会被加载)
2. 避免重复加载 --- 父加载器已加载的类,子加载器不需要再加载
3. 层级清晰 --- 每个加载器各司其职
Q3:如何打破双亲委派?
三种方式:
1. 重写 loadClass() --- 不委派给父加载器,自己先加载(Tomcat)
2. 线程上下文类加载器 --- 父加载器使用子加载器加载类(SPI/JDBC)
3. OSGi 网状加载 --- 模块间平级加载,非父子关系
Q4:为什么 JDBC 需要打破双亲委派?
问题:DriverManager 在 rt.jar 中,由 Bootstrap ClassLoader 加载
但 MySQL 驱动在 classpath 下,Bootstrap 看不到
解决:使用线程上下文类加载器(Thread Context ClassLoader)
DriverManager 通过 ServiceLoader.load() 使用
当前线程的上下文类加载器(Application ClassLoader)
来加载 classpath 下的驱动类
本质:父加载器"委托"子加载器去加载类,反向使用类加载器
Q5:<clinit> 和 <init> 的区别?
| 对比 | <clinit> |
<init> |
|---|---|---|
| 名称 | 类初始化方法 | 对象初始化方法 |
| 内容 | static 赋值 + static 块 | 构造方法 |
| 执行次数 | 类加载时执行一次 | 每次 new 都执行 |
| 线程安全 | JVM 保证加锁同步 | 需要开发者保证 |
| 是否有参数 | 无 | 取决于构造方法 |
Q6:new 一个对象时,类加载的完整流程?
new MyObject() 的完整流程:
═══════════════════════════════════════
1. 检查 MyObject 类是否已加载
→ 未加载:触发类加载(加载→链接→初始化)
→ 已加载:跳过
2. 在堆中分配对象内存
3. 将对象内存初始化为零值(实例变量的默认值)
4. 设置对象头(Mark Word、Klass Pointer 等)
5. 执行 <init> 方法(构造方法)
→ 实例变量赋值
→ 实例初始化块
→ 构造方法体
11. 总结
类加载速记口诀
"加链初,双亲委"(加链初,双亲委)
加 --- 加载:找 .class 文件,生成 Class 对象
链 --- 链接:验证 + 准备 + 解析
初 --- 初始化:执行 <clinit>,static 赋值和 static 块
双亲委 --- 双亲委派:先让父加载器加载,加载不了再自己来
关键知识点回顾
| 知识点 | 核心内容 |
|---|---|
| 加载 | 找到字节流,生成方法区数据结构和堆中 Class 对象 |
| 链接-验证 | 文件格式、元数据、字节码、符号引用四重校验 |
| 链接-准备 | 为 static 变量分配默认零值(final static 直接赋值) |
| 链接-解析 | 符号引用转为直接引用 |
| 初始化 | 执行 <clinit>,static 赋值和 static 块按源码顺序执行 |
| 双亲委派 | 先委派父加载器,保证核心类安全和避免重复加载 |
| 打破双亲委派 | SPI(线程上下文类加载器)、Tomcat(重写 loadClass)、OSGi |
| 自定义类加载器 | 重写 findClass(),不要重写 loadClass() |
上一篇:《JVM 内存区域划分详解------从生活比喻到运行时数据区全景图》
下一篇预告:《JVM 垃圾回收机制详解------从对象死亡判定到GC算法全景》