csdn.net/md/?articleId=159419058)
前言
在前三篇文章中,我们深入探讨了对象的内存布局,看到了对象头中的Klass指针指向方法区中的InstanceKlass。但有一个根本问题一直没有回答:
方法区中的InstanceKlass是从哪来的?
答案就是类加载机制。
当我们写下
new User()时,JVM是如何找到User类的?.class文件是如何变成方法区中的Klass的?双亲委派模型又是什么?今天,我们就来揭开类加载的神秘面纱。理解类加载,你将能回答:
- 静态变量什么时候赋值?
- 为什么
Class.forName()和ClassLoader.loadClass()行为不同?- Tomcat为什么能实现应用隔离?
下一篇,我们将在此基础上,深入方法区的实现演进------为什么JDK 8要移除永久代?
一、类加载的五个阶段
JVM的类加载过程分为五个阶段:
┌─────────────────────────────────────────────────────────────────────┐
│ 类加载的五个阶段 │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐
│ │ 加载 │ → │ 验证 │ → │ 准备 │ → │ 解析 │ → │ 初始化 │
│ │ Loading │ │Verification│ │Preparation│ │Resolution│ │Initialization│
│ └─────────┘ └─────────┘ └─────────┘ └─────────┘ └─────────┘
│ │
│ └───────────────────────┐ ┌────────────────────────┘ │
│ ↓ ↓ │
│ 连接(Linking)阶段 │
│ │
└─────────────────────────────────────────────────────────────────────┘
注意:解析阶段的时机比较特殊,它可以在初始化之后再进行(为了支持动态绑定)。但JVM规范允许实现选择在类加载的解析阶段执行,或首次使用时解析。
二、阶段一:加载(Loading)
2.1 目标
将类的二进制字节流加载到内存中,生成方法区的Klass结构和堆中的Class对象。
java
// 加载的三种方式
public class LoadingExample {
// 方式1:通过类的全限定名加载(会触发初始化)
Class<?> clazz1 = Class.forName("com.example.User");
// 方式2:通过类加载器加载(不会触发初始化)
ClassLoader loader = Thread.currentThread().getContextClassLoader();
Class<?> clazz2 = loader.loadClass("com.example.User");
// 方式3:通过对象获取(类已加载)
User user = new User();
Class<?> clazz3 = user.getClass();
}
2.2 加载阶段做了什么?
步骤1:获取类的二进制字节流
├─ 从.class文件读取
├─ 从JAR包读取
├─ 从网络读取(如Applet)
├─ 动态生成(如CGLib代理、JDK动态代理)
└─ 从数据库读取(较少见)
步骤2:将字节流转换为方法区的运行时数据结构(InstanceKlass)
├─ 类名、父类名、接口名
├─ 字段信息(名称、类型、修饰符、偏移量)
├─ 方法信息(名称、返回值、参数、字节码、异常表)
├─ 常量池
└─ 注解信息
步骤3:在堆中生成java.lang.Class对象
└─ 作为方法区Klass的Java层入口(klass->java_mirror())
2.3 加载阶段的产物
加载完成后,方法区中就有了该类的完整元数据:
方法区地址:0x7f6488c00000
┌─────────────────────────────────────────────────────────────┐
│ User类的InstanceKlass │
├─────────────────────────────────────────────────────────────┤
│ 类元数据 │
│ ├─ 类名: "User" │
│ ├─ 父类: Object (0x7f6488b00000) │
│ ├─ 字段: id (int, 偏移量12) │
│ └─ 字段: name (String, 偏移量16) │
├─────────────────────────────────────────────────────────────┤
│ 运行时常量池 (地址: 0x7f6488c00100) │
│ ├─ #1: Class Object (0x7f6488b00000) │
│ ├─ #2: Field id │
│ └─ #3: Method <init> │
├─────────────────────────────────────────────────────────────┤
│ 方法表 (地址: 0x7f6488c00800) │
│ ├─ <init> → Method对象 (0x7f6488c00200) │
│ ├─ setName → Method对象 (0x7f6488c00300) │
│ └─ getName → Method对象 (0x7f6488c00400) │
└─────────────────────────────────────────────────────────────┘
堆中:
┌─────────────────────────────────────────────────────────────┐
│ java.lang.Class对象 (User.class) │
│ ├─ 对象头 │
│ └─ _klass指针 → 0x7f6488c00000 (指向InstanceKlass) │
└─────────────────────────────────────────────────────────────┘
三、阶段二:验证(Verification)
3.1 目标
确保.class文件的字节流符合JVM规范,不会危害JVM安全。
3.2 验证的四个步骤
java
// 验证阶段会检查什么?
// 1. 文件格式验证
// - 是否以0xCAFEBABE开头(魔数)
// - 主次版本号是否在当前JVM支持范围内
// - 常量池是否有不被支持的常量类型
// - 常量池中的索引是否指向有效位置
// 2. 元数据验证
// - 类是否有父类(除了Object)
// - 是否继承了final类
// - 是否实现了final方法
// - 字段、方法是否与父类冲突
// 3. 字节码验证(最复杂)
// - 操作数栈的数据类型是否与指令匹配
// - 跳转指令是否指向合法位置
// - 方法调用的参数类型是否正确
// - 局部变量表是否被正确初始化
// 4. 符号引用验证
// - 通过符号引用能否找到对应的类、字段、方法
// - 访问权限是否合法(private、protected等)
// - 类、字段、方法是否存在
3.3 验证失败的例子
java
// 手动修改.class文件,把某个指令的操作数改错
// JVM会抛出 VerifyError
Exception in thread "main" java.lang.VerifyError:
Bad type on operand stack
为什么要验证? 防止恶意代码通过篡改.class文件破坏JVM。
四、阶段三:准备(Preparation)
4.1 目标
为类的静态变量分配内存,并设置默认初始值。
java
public class PreparationExample {
// 准备阶段后:value = 0(不是100!)
public static int value = 100;
// 准备阶段后:flag = false
public static boolean flag = true;
// 准备阶段后:str = null
public static String str = "hello";
// 准备阶段后:常量直接赋值(特殊情况)
public static final int CONSTANT = 100; // 直接赋值为100
}
4.2 为什么不是代码中的值?
因为赋值指令是putstatic,需要在类初始化阶段(<clinit>)执行。准备阶段只是分配内存并设为默认零值。
| 类型 | 默认零值 |
|---|---|
| int | 0 |
| long | 0L |
| boolean | false |
| 引用类型 | null |
4.3 final静态变量的特殊性
java
public static final int MAX = 100;
// final静态变量在准备阶段就赋值,因为它的值永远不会变
// 编译器会在常量池中直接存储这个值
五、阶段四:解析(Resolution)
5.1 目标
将常量池中的符号引用 替换为直接引用。
这是理解类加载的关键环节,我们在前面的文章中详细讲过:
符号引用 → 直接引用
示例:
常量池 #2 = "User" → #2 = 0x7f6488c00000 (Klass地址)
常量池 #3 = "User.<init>" → #3 = 0x7f6488c00200 (Method对象地址)
常量池 #4 = "User.id" → #4 = 12 (字段偏移量)
5.2 解析的时机
JVM规范允许两种实现方式:
| 方式 | 说明 | 优点 | 缺点 |
|---|---|---|---|
| 加载时解析 | 在类加载的连接阶段就完成所有解析 | 提前发现问题 | 可能解析一些永远不用的符号引用 |
| 使用时解析 | 在首次使用时才解析(延迟解析) | 节省内存,支持动态绑定 | 首次使用时有额外开销 |
HotSpot采用混合策略:部分解析在加载时完成,部分在首次使用时完成。
5.3 解析的具体内容
cpp
// HotSpot源码中的解析过程(简化)
void ConstantPool::resolve_class(int index) {
// 1. 从常量池获取符号引用
Symbol* class_name = symbol_at(index); // "User"
// 2. 通过类加载器加载类
Klass* klass = class_loader->load_class(class_name);
// 3. 将符号引用替换为直接引用
klass_at_put(index, klass); // 存入Klass地址
}
六、阶段五:初始化(Initialization)
6.1 目标
执行类的构造器方法 <clinit>,为静态变量赋值为代码中指定的值,并执行静态代码块。
java
public class InitializationExample {
// 1. 静态变量赋值
public static int value = 100; // <clinit>中执行 putstatic
// 2. 静态代码块
static {
System.out.println("静态代码块执行");
value = 200;
}
// 3. 静态常量(已经在准备阶段完成,不在这里)
public static final int CONSTANT = 300;
}
6.2 <clinit>方法的生成
编译器会将所有静态变量赋值和静态代码块合并成一个<clinit>方法:
java
// 编译后的伪代码
public static void <clinit>() {
value = 100;
System.out.println("静态代码块执行");
value = 200;
}
6.3 触发初始化的条件(主动引用)
| 场景 | 示例 | 说明 |
|---|---|---|
new实例化 |
new User() |
创建对象实例 |
| 访问静态变量 | User.count |
读取或赋值(final常量除外) |
| 调用静态方法 | User.getCount() |
调用静态方法 |
| 反射调用 | Class.forName("User") |
反射获取类 |
| 子类初始化触发父类初始化 | new Child() |
先初始化父类 |
| 启动类(main方法所在类) | public static void main |
程序入口 |
6.4 被动引用(不会触发初始化)
java
public class PassiveReference {
public static void main(String[] args) {
// 1. 通过子类访问父类静态变量(只触发父类初始化)
System.out.println(Child.value); // 父类初始化,子类不初始化
// 2. 通过数组定义(不触发初始化)
User[] users = new User[10]; // User类不会被初始化
// 3. 访问静态常量(编译期就放入常量池)
System.out.println(User.CONSTANT); // User类不会被初始化
// 4. 通过类字面量获取Class对象(不触发初始化)
Class<?> clazz = User.class; // User类不会被初始化
}
}
七、类加载器(ClassLoader)
7.1 什么是类加载器?
类加载器 是负责执行加载阶段的Java对象。每个类加载器都有一个父类加载器(组合关系,不是继承)。
java
// 获取类加载器
public class ClassLoaderDemo {
public static void main(String[] args) {
// 1. 启动类加载器(null表示无法获取,因为它是C++实现的)
System.out.println(String.class.getClassLoader()); // null
// 2. 扩展类加载器(JDK 9+ 改名为PlatformClassLoader)
System.out.println(com.sun.nio.zipfs.ZipFileSystem.class.getClassLoader());
// jdk.internal.loader.ClassLoaders$PlatformClassLoader
// 3. 应用类加载器
System.out.println(ClassLoaderDemo.class.getClassLoader());
// jdk.internal.loader.ClassLoaders$AppClassLoader
}
}
7.2 三种内置类加载器
┌─────────────────────────────────────────────────────────────────────┐
│ 类加载器层级结构 │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────┐ │
│ │ 启动类加载器 │ │
│ │ BootstrapClassLoader│ │
│ │ (C++实现,null) │ │
│ └──────────┬──────────┘ │
│ │ 父加载器 │
│ ┌──────────▼──────────┐ │
│ │ 扩展类加载器 │ │
│ │ PlatformClassLoader│ ← JDK 9+ 改名 │
│ │ (JDK 8: ExtClassLoader)│ │
│ └──────────┬──────────┘ │
│ │ 父加载器 │
│ ┌──────────▼──────────┐ │
│ │ 应用类加载器 │ │
│ │ AppClassLoader │ │
│ └─────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘
| 类加载器 | 加载路径 | 实现语言 | 特点 |
|---|---|---|---|
| 启动类加载器 | JAVA_HOME/lib 核心类库(rt.jar等) |
C++ | JVM启动时创建,无法直接获取 |
| 扩展类加载器 | JAVA_HOME/lib/ext 或 java.ext.dirs |
Java | JDK 9+改名PlatformClassLoader |
| 应用类加载器 | CLASSPATH 环境变量 |
Java | 加载用户类,默认的类加载器 |
八、双亲委派模型(Parents Delegation Model)
8.1 定义
双亲委派模型:当一个类加载器收到类加载请求时,它不会自己先尝试加载,而是把请求委派给父加载器去完成。只有当父加载器无法加载时,才尝试自己加载。
java
// ClassLoader.loadClass() 源码(简化版)
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
// 1. 检查是否已经加载过
Class<?> c = findLoadedClass(name);
if (c == null) {
try {
// 2. 委派给父加载器
if (parent != null) {
c = parent.loadClass(name, false);
} else {
// 3. 没有父加载器,交给启动类加载器
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// 4. 父加载器加载失败,自己加载
c = findClass(name);
}
}
return c;
}
8.2 流程图
用户请求加载 "com.example.User"
↓
应用类加载器 AppClassLoader
↓ 委派给父加载器
扩展类加载器 PlatformClassLoader
↓ 委派给父加载器
启动类加载器 BootstrapClassLoader
↓ 在 JAVA_HOME/lib 中查找
├─ 找到了 java.lang.String → 返回
└─ 没找到 com.example.User
↓ 回到扩展类加载器
扩展类加载器在 lib/ext 中查找
├─ 找到了 → 返回
└─ 没找到
↓ 回到应用类加载器
应用类加载器在 CLASSPATH 中查找
└─ 找到了 com.example.User → 返回
8.3 为什么要设计双亲委派?
核心目的:保证核心类库的安全和唯一性。
java
// 假设没有双亲委派,黑客可以自己写一个 java.lang.String
package java.lang;
public class String {
public String() {
// 恶意代码
System.exit(0);
}
}
双亲委派如何阻止:
- 用户代码请求加载
java.lang.String - 应用类加载器委派给父加载器
- 最终由启动类加载器加载JVM自带的
String - 用户自定义的
String永远不会被加载
好处:
- 避免重复加载:同一个类只会被加载一次
- 安全性:防止核心类库被篡改
- 隔离性:不同类加载器加载的类属于不同的命名空间
九、打破双亲委派
9.1 什么时候需要打破?
某些场景需要打破双亲委派模型:
场景1:JDBC驱动加载
java
// JDBC使用线程上下文类加载器打破双亲委派
public class DriverManager {
private static final ClassLoader callerClassLoader =
Thread.currentThread().getContextClassLoader();
// 使用线程上下文类加载器加载JDBC驱动
Class<?> driverClass = Class.forName(driverClassName, true, callerClassLoader);
}
为什么需要打破?因为DriverManager由启动类加载器加载,但JDBC驱动在CLASSPATH中(应用类加载器才能加载),如果不打破,启动类加载器找不到驱动。
场景2:Tomcat等Web容器
┌─────────────────────────────────────────────────────────────────────┐
│ Tomcat类加载器结构 │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────┐ │
│ │ 启动类加载器 │ │
│ └──────────┬──────────┘ │
│ │ │
│ ┌──────────▼──────────┐ │
│ │ 扩展类加载器 │ │
│ └──────────┬──────────┘ │
│ │ │
│ ┌──────────▼──────────┐ │
│ │ 应用类加载器 │ │
│ └──────────┬──────────┘ │
│ │ │
│ ┌──────────▼──────────┐ │
│ │ Common ClassLoader │ ← Tomcat公共类库 │
│ └──────────┬──────────┘ │
│ ┌──────────────┼──────────────┐ │
│ ▼ ▼ ▼ │
│ ┌────────────┐ ┌────────────┐ ┌────────────┐ │
│ │ Webapp 1 │ │ Webapp 2 │ │ Webapp 3 │ │
│ │ ClassLoader│ │ ClassLoader│ │ ClassLoader│ │
│ └────────────┘ └────────────┘ └────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘
每个Web应用有自己的类加载器,实现应用隔离。一个应用中的com.example.User和另一个应用中的同名类不会冲突。
9.2 如何打破?
重写loadClass方法(而不是findClass):
java
public class BreakParentDelegationClassLoader extends ClassLoader {
@Override
public Class<?> loadClass(String name) throws ClassNotFoundException {
// 1. 先检查是否已经加载
Class<?> c = findLoadedClass(name);
if (c != null) return c;
// 2. 对于核心类库,还是让父加载器加载
if (name.startsWith("java.") || name.startsWith("javax.")) {
return super.loadClass(name);
}
// 3. 对于其他类,自己先加载(打破双亲委派)
try {
c = findClass(name);
if (c != null) return c;
} catch (ClassNotFoundException e) {
// 自己加载失败,再委派给父加载器
return super.loadClass(name);
}
return super.loadClass(name);
}
}
十、自定义类加载器
10.1 什么时候需要自定义?
- 加密解密:对.class文件进行加密,防止反编译
- 从非标准源加载:从数据库、网络等地方加载类
- 热部署:实现类的动态替换
- 字节码增强:在加载时修改字节码(如AOP)
10.2 如何自定义?
java
public class CustomClassLoader extends ClassLoader {
private String classPath;
public CustomClassLoader(String classPath) {
this.classPath = classPath;
}
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
try {
// 1. 获取.class文件的字节码
byte[] classData = loadClassData(name);
if (classData == null) {
throw new ClassNotFoundException();
}
// 2. 调用defineClass将字节码转换为Class对象
return defineClass(name, classData, 0, classData.length);
} catch (IOException e) {
throw new ClassNotFoundException("Failed to load class: " + name, e);
}
}
private byte[] loadClassData(String className) throws IOException {
// 将类名转换为文件路径
String fileName = className.replace('.', '/') + ".class";
String fullPath = classPath + "/" + fileName;
// 读取字节码文件
try (InputStream is = new FileInputStream(fullPath);
ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
byte[] buffer = new byte[1024];
int len;
while ((len = is.read(buffer)) != -1) {
baos.write(buffer, 0, len);
}
return baos.toByteArray();
}
}
}
10.3 使用自定义类加载器
java
public class CustomClassLoaderDemo {
public static void main(String[] args) throws Exception {
// 创建自定义类加载器
CustomClassLoader loader = new CustomClassLoader("/path/to/classes");
// 加载类
Class<?> clazz = loader.loadClass("com.example.User");
// 创建实例
Object obj = clazz.getDeclaredConstructor().newInstance();
// 验证类加载器
System.out.println("类加载器: " + clazz.getClassLoader());
// 输出: CustomClassLoader@...
// 同一个类被不同加载器加载,不是同一个类
CustomClassLoader loader2 = new CustomClassLoader("/path/to/classes");
Class<?> clazz2 = loader2.loadClass("com.example.User");
System.out.println(clazz == clazz2); // false
}
}
十一、常见面试题
Q1:Class.forName() 和 ClassLoader.loadClass() 有什么区别?
| 方法 | 是否初始化 | 是否触发静态代码块 | 类加载器 |
|---|---|---|---|
Class.forName() |
✅ 是 | ✅ 执行 | 当前线程的上下文类加载器 |
ClassLoader.loadClass() |
❌ 否 | ❌ 不执行 | 指定的类加载器 |
java
// Class.forName() 源码
public static Class<?> forName(String className) throws ClassNotFoundException {
return forName(className, true, getCallerClassLoader());
}
// 第二个参数 initialize=true 表示会执行初始化
Q2:同一个类被不同类加载器加载,是同一个类吗?
答 :不是。JVM中,类的唯一标识是 类加载器 + 类全限定名。
java
ClassLoader loader1 = new CustomClassLoader();
ClassLoader loader2 = new CustomClassLoader();
Class<?> clazz1 = loader1.loadClass("com.example.User");
Class<?> clazz2 = loader2.loadClass("com.example.User");
System.out.println(clazz1 == clazz2); // false
System.out.println(clazz1.equals(clazz2)); // false
Q3:Tomcat为什么要打破双亲委派?
答:Tomcat需要实现应用隔离和热部署:
- 每个Web应用应该有自己的类加载器
- 同一个类在不同应用中应该是独立的
- 如果使用双亲委派,所有应用共享同一个类加载器,无法实现隔离
- 同时Tomcat需要支持热部署,需要能够卸载和重新加载类
Q4:静态代码块什么时候执行?
答 :在类初始化阶段执行,且只执行一次。触发条件包括:new实例化、访问静态变量(final常量除外)、调用静态方法、反射调用等。
Q5:<clinit>方法和<init>方法有什么区别?
| 对比 | <clinit> |
<init> |
|---|---|---|
| 执行时机 | 类初始化时 | 对象实例化时 |
| 作用 | 初始化静态变量、执行静态代码块 | 初始化实例变量、执行构造方法 |
| 执行次数 | 一次 | 每次new一次 |
| 是否有锁 | 有(线程安全) | 无 |
十二、总结
12.1 类加载的五个阶段
| 阶段 | 核心任务 | 产物 |
|---|---|---|
| 加载 | 从.class文件获取字节流 | InstanceKlass + Class对象 |
| 验证 | 校验字节流是否符合JVM规范 | 确保安全 |
| 准备 | 为静态变量分配内存,设置默认零值 | 静态变量初始值 |
| 解析 | 将符号引用替换为直接引用 | 直接引用 |
| 初始化 | 执行<clinit>方法 |
静态变量赋值为代码中的值 |
12.2 类加载器与双亲委派
| 概念 | 核心内容 |
|---|---|
| 启动类加载器 | 加载核心类库,C++实现,null |
| 扩展类加载器 | 加载lib/ext下的类,JDK 9+改名PlatformClassLoader |
| 应用类加载器 | 加载CLASSPATH下的用户类 |
| 双亲委派 | 先委派给父加载器,父加载器失败再自己加载 |
| 打破双亲委派 | 重写loadClass方法,如JDBC、Tomcat |
12.3 面试金句
如果面试官问你"类加载机制",你可以这样回答:
"类加载分为加载、验证、准备、解析、初始化五个阶段。加载阶段将.class文件转换为方法区的InstanceKlass结构和堆中的Class对象;验证阶段确保字节码安全;准备阶段为静态变量分配默认值;解析阶段将符号引用转换为直接引用;初始化阶段执行静态变量赋值和静态代码块。类加载器采用双亲委派模型,子加载器先委派给父加载器,父加载器加载失败才自己尝试,这样做是为了保证核心类库的安全和唯一性。Tomcat等容器为了实现应用隔离和热部署,会打破双亲委派。"
下篇预告
理解了类加载的五阶段和双亲委派模型,我们知道类的元数据最终存储在方法区中。但方法区本身也在进化:从JDK 7的永久代到JDK 8的元空间,为什么要变?这一变化带来了什么好处?
下一篇《方法区的进化------永久代到元空间,为什么要变?》将为你揭晓答案。
如果你觉得本文有帮助,欢迎点赞、评论、转发!