Java 类加载的过程是怎样的?
面试考察点
-
流程完整性:类加载到底分几步?每一步干了什么?能不能把加载 → 链接 → 初始化这条线从头讲到尾,而不是背个名字就完事。
-
细节掌握度:链接阶段还细分为验证、准备、解析三个子步骤,这里面的区别你能说清楚吗?
static变量什么时候分配内存?什么时候赋初始值?什么时候赋真正值?这三个时间点搞混的人特别多。 -
实战关联:类加载机制跟双亲委派模型、自定义类加载器、SPI 机制这些都有关系,面试官很容易从这里追问下去。
核心答案
类加载分 5 个阶段:加载 → 验证 → 准备 → 解析 → 初始化。其中验证、准备、解析三步合起来叫 "链接"(Linking)。
| 阶段 | 干了什么 | 一句话概括 |
|---|---|---|
| 加载(Loading) | 把 .class 文件的二进制数据读进内存 | 找到文件,读进来 |
| 验证(Verification) | 检查字节码格式是否合法、有没有安全问题 | 安检 |
| 准备(Preparation) | 为 static 变量分配内存,赋零值 | 分座位,先坐好 |
| 解析(Resolution) | 把常量池里的符号引用替换成直接引用 | 把名字翻译成地址 |
| 初始化(Initialization) | 执行 <clinit>() 方法,赋真正值 | 正式开工 |
还有个容易被忽略的第六步:卸载(Unloading),类被 GC 回收。不过这块面试基本不问,知道有这回事就行。
深度解析
一、全流程图
整个流程是顺序的:加载 → 链接(验证 → 准备 → 解析)→ 初始化。不过 "解析" 这一步在某些情况下可以在初始化之后才开始(支持 Java 的运行时绑定,也就是多态)。
下面逐个拆开讲。
二、加载(Loading)
这一步做三件事:
- 通过类的全限定名找到对应的
.class文件 - 把二进制数据读进内存,存到方法区(JDK 8 以后叫 Metaspace)
- 在堆里生成一个对应的
java.lang.Class对象,作为方法区数据的访问入口
注意,"加载" 这个词在日常聊天中经常泛指整个类加载过程,但在 JVM 规范里它只是第一步。面试时别搞混。
.class 文件不一定来自磁盘,还可以从 JAR/WAR 包读取、通过网络下载、甚至运行时动态生成(动态代理就是这么干的)。
三、验证(Verification)
这一步是安全检查。JVM 怕字节码被人篡改或者编译器有 bug,所以要验一遍。验证的内容包括:
- 文件格式:是不是以魔数
0xCAFEBABE开头?版本号对不对? - 元数据:有没有继承一个
final类?有没有实现不存在的接口? - 字节码:指令跳转有没有越界?类型转换合不合法?
- 符号引用:引用的类、方法、字段存不存在?
其中格式验证在加载阶段就会开始,不是非要等到链接。
这里有个实际踩坑的例子。我之前有个同事,本地用的 JDK 11 编译,部署到线上 JDK 8 的环境,直接报 UnsupportedClassVersionError。这个错误就是在验证阶段抛出来的——JVM 发现字节码的版本号比自己高,拒绝加载。
四、准备(准备)
这一步给 static 变量分配内存,并赋零值。
重点来了:赋的是零值,不是你代码里写的那个值。
public class Demo {
// 准备阶段:value 被赋值为 0(不是 123)
// 初始化阶段:value 才被赋值为 123
private static int value = 123;
// 准备阶段:CONSTANT 被赋值为 123(因为被 final 修饰)
// 编译时 javac 会把常量值存到字节码的 ConstantValue 属性中
private static final int CONSTANT = 123;
}
两种情况要区分清楚:
| 变量类型 | 准备阶段 | 初始化阶段 |
|---|---|---|
static int value = 123 | 赋 0 | 赋 123 |
static final int X = 123 | 直接赋 123(跳过初始化) | 不再赋值 |
static final 修饰的常量(基本类型和 String)在准备阶段就会赋真实值,因为 javac 编译时就把值写进了 ConstantValue 属性。这个区别面试官特别爱追问。
五、解析(Resolution)
把常量池里的符号引用替换成直接引用。
什么意思?.class 文件里引用其他类的时候,用的不是内存地址,而是一串文本描述(比如 com/example/Demo.method:()V),这叫符号引用。等真正要调用的时候,JVM 需要知道这个方法在内存里的具体位置,这个具体位置就是直接引用(指针、偏移量之类的)。
符号引用 → 直接引用的转换不一定在 "解析" 这一步全部完成。Java 支持多态,有些引用要等到实际使用时才能确定指向哪个具体方法,所以解析可以推迟到运行时。
六、初始化(Initialization)
这一步才真正执行你的 Java 代码。
JVM 会自动生成一个 <clinit>() 方法(类构造器,不是实例构造器 <init>()),把所有 static 变量的赋值和 static {} 块合并在一起,按源码顺序执行。
public class Demo {
static {
// 这个代码块会被编入 <clinit>() 方法
System.out.println("静态代码块执行");
}
private static int value = initValue();
private static int initValue() {
// 这个方法调用也会被编入 <clinit>() 方法
System.out.println("静态变量赋值");
return 123;
}
}
有几点容易忽略:
<clinit>()会被synchronized加锁。如果多个线程同时触发类初始化,只有一个线程执行<clinit>(),其他线程阻塞等待。这也是为什么静态代码块里写多线程初始化逻辑是安全的。- 如果一个类没有
static变量和static块,编译器不会生成<clinit>()方法。 <clinit>()不需要显式调用父类的<clinit>(),JVM 会保证父类的初始化先于子类。但<init>()(构造函数)必须手动调用super()。
七、触发初始化的时机
不是所有类被加载了就会初始化。以下情况会触发初始化(也叫 "主动引用"):
new一个对象- 调用类的
static方法 - 访问类的
static字段(final常量除外) - 反射调用
Class.forName() - 初始化一个类的子类(父类会先初始化)
- JVM 启动时的主类(含
main方法的那个)
除此之外,引用类但不触发初始化的情况叫 "被动引用",比如通过子类引用父类的 static 变量,只会初始化父类,不会初始化子类。
面试高频追问
类加载器和双亲委派模型了解吗?
类加载器分四层:Bootstrap → Extension → Application → 自定义。双亲委派的核心逻辑是:先让父加载器去加载,父加载器搞不定才自己来。好处是避免同一个类被不同加载器重复加载,也保护了核心 API 不被篡改。
Thread.currentThread().getContextClassLoader() 这玩意儿在 SPI 场景下特别重要。JDBC、JNDI 这些核心接口在 rt.jar 里,由 Bootstrap 加载器加载,但具体实现(比如 MySQL 驱动)在应用 classpath 下,Bootstrap 找不到。所以需要线程上下文类加载器来 "打破" 双亲委派。
有没有什么场景需要打破双亲委派?
有。Tomcat 就是典型:一个 Tomcat 部署多个 Web 应用,每个应用依赖不同版本的同一个库,必须隔离。所以 Tomcat 每个 WebApp 用独立的类加载器,先自己加载,加载不到再交给父加载器,正好反过来。SPI 机制(JDBC、JNDI)也需要打破双亲委派。
Class.forName() 和 ClassLoader.loadClass() 有什么区别?
Class.forName() 默认会执行完整的加载 + 链接 + 初始化。ClassLoader.loadClass() 只做加载,不触发初始化。所以 JDBC 注册驱动用的是 Class.forName("com.mysql.cj.jdbc.Driver"),因为它需要触发 Driver 类的 static 块来完成注册。
常见面试变体
- "Java 类的生命周期是怎样的?"
- "
<clinit>()和<init>()有什么区别?" - "什么情况下会触发类的初始化?"
- "谈谈你对双亲委派模型的理解"
总结
类加载五步走:加载 → 验证 → 准备 → 解析 → 初始化。面试时重点把 "准备阶段赋零值 vs 初始化阶段赋真实值" 这对区别讲清楚,再顺带提一下 static final 常量在准备阶段就直接赋值的特例。要是面试官追问双亲委派和自定义类加载器,那就算赚到了——说明前面答得不错,面试官在拔高。
