一、概述:为什么需要类加载?
Java 语言的核心特性之一是"一次编写,到处运行",这背后的关键在于 Java 虚拟机(JVM)和其类加载机制。当我们编写好 Java 代码并将其编译为 .class
字节码文件后,这些静态的字节码需要被加载到 JVM 中才能变为可执行的动态对象。类加载就是这个转换过程的核心环节。
理解类加载机制能帮助我们:
- 深入理解 Java 动态扩展机制(如 SPI、热部署等技术原理)
- 优化程序性能,理解哪些阶段耗时及如何调整参数优化
- 解决实际开发中遇到的
ClassNotFoundException
、NoSuchMethodError
、IllegalAccessError
等异常 - 实现高级技巧,如编写自定义类加载器实现模块化、代码加密等功能
类加载的完整生命周期包括加载、验证、准备、解析、初始化、使用和卸载七个阶段。其中前五个阶段是类加载的核心过程,下面我们将详细解析每个阶段。
二、加载 (Loading) - "采购与入库"阶段
核心思想
加载阶段是类加载过程的第一步,它的核心任务非常明确:找到类的字节码,并以 JVM 内部规定的格式把它存起来,同时创建一个访问入口。
将这个阶段比喻为一家公司的采购和入库部门非常贴切:
加载阶段步骤 | 公司比喻 | 技术对应 |
---|---|---|
1. 获取二进制流 | 采购部门寻找货源 | 从JAR、网络、动态生成等处获取字节码 |
2. 转化存储结构 | 入库部门按标准存放 | 将字节流转化为方法区的运行时数据结构 |
3. 生成Class对象 | 创建库存查询目录 | 在Java堆中创建 java.lang.Class 对象 |
详细过程
1. "采购" - 通过类名获取二进制字节流
任务 :根据类的全限定名(如 java.lang.String
),去找到并拿到这个类的"原始产品"------二进制字节流(符合 Class 文件格式的二进制数据)。
关键点 :《Java虚拟机规范》只规定了要拿到什么,但没规定从哪里拿、怎么拿。这个开放性设计是 Java 强大扩展能力的基石。
"采购"渠道的多样性:
- 从本地仓库拿:从 ZIP、JAR、EAR、WAR 等压缩包中读取(最常见的方式)
- 从网络上订货 :从网络上下载(如早期的 Web Applet)
- 自己生产(OEM) :在运行时动态计算生成(动态代理技术是典型例子)
- 由其他原材料加工:由 JSP 文件生成对应的 Class 文件
- 从加密仓库取:从加密的文件中读取,读取时再实时解密(常见代码保护手段)
- 从数据库里读:特定中间件服务器(如 SAP Netweaver)会把程序代码存到数据库里
java
// 自定义类加载器示例:从特定路径加载类
public class CustomClassLoader extends ClassLoader {
private String classPath;
public CustomClassLoader(String classPath) {
this.classPath = classPath;
}
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
// 1. "采购":根据类名找到文件,并读取为字节数组 byte[]
byte[] classData = loadClassData(name);
if (classData == null) {
throw new ClassNotFoundException();
}
// 2. "质检与入库":调用defineClass将字节数组转换为Class对象
// 此方法会完成验证、准备等后续步骤
return defineClass(name, classData, 0, classData.length);
}
private byte[] loadClassData(String className) {
// 实现从特定路径(如加密文件)读取类文件的逻辑
// 将类名转换为文件路径
String path = classNameToPath(className);
try {
InputStream is = new FileInputStream(path);
ByteArrayOutputStream baos = new ByteArrayOutputStream();
int bufferSize = 4096;
byte[] buffer = new byte[bufferSize];
int bytesNumRead;
while ((bytesNumRead = is.read(buffer)) != -1) {
baos.write(buffer, 0, bytesNumRead);
}
return baos.toByteArray();
} catch (IOException e) {
return null;
}
}
private String classNameToPath(String className) {
// 将类名转换为文件路径
return classPath + File.separatorChar +
className.replace('.', File.separatorChar) + ".class";
}
}
2. "质检与入库" - 转化存储结构
任务 :把上一步拿到的"原始产品"(字节流),转换成 JVM 方法区 这个"中央仓库"所能识别的内部数据结构。
比喻:采购回来的货物可能有各种包装(不同来源的字节流),入库部门需要把它们拆包,按照公司仓库(方法区)自己的货架标准和分类方式重新摆放好。
注意 :方法区内部的具体数据结构完全由各个 JVM 实现自行决定,《规范》不做要求。就像不同公司的仓库管理系统可以完全不同。
3. "创建库存目录" - 生成 Class 对象
任务 :在 Java 堆 中创建一个 java.lang.Class
对象。
作用 :这个对象就像仓库的总目录 或访问接口 。程序想要访问方法区中关于这个类的所有元数据信息(比如有哪些方法、哪些字段),都必须通过这个 Class
对象来进行。
比喻:货物已经按规则存入了大型立体仓库(方法区),为了方便大家查找,我们在办公室的电脑(Java 堆)里建了一个数据库条目(Class 对象),通过它就能查到货物在哪、有多少。
两种特殊的"货物":数组 vs 非数组类
非数组类
加载方式 :就是我们上面说的"采购入库"流程。开发人员可以高度控制这个过程 ,通过自定义类加载器(重写 findClass()
方法)来决定如何获取字节流,从而实现热部署、模块化等高级特性。
数组类
加载方式 :数组类比较特殊,它不是通过类加载器"采购"回来的,而是由 JVM 直接在生产线上"组装"出来的。
规则:
- 如果数组的元素类型是引用类型 (比如
String[]
,Object[]
),那么 JVM 会先去加载这个元素类型 。这个数组类会被标记为与加载该元素类型的类加载器关联。 - 如果数组的元素类型是基本类型 (比如
int[]
,boolean[]
),那么 JVM 会直接把数组类标记为与启动类加载器关联。
访问性 :数组的访问权限和它的元素类型一致。int[]
的访问性是 public
。
重要细节
- 交叉进行 :加载阶段和连接阶段(尤其是验证)并不是完全割裂的。为了性能,JVM 可能在拿到一部分字节流后就开始进行文件格式验证(比如检查魔数),但主体上仍然保持"先加载,后连接"的顺序。
- 可控性:在"加载"阶段的"获取二进制字节流"这个动作上,开发人员拥有最大的控制权,这是通过自定义类加载器实现的。
简单来说,"加载"阶段就是 JVM 的物资准备阶段,它为后续的校验、初始化等步骤准备好了最重要的原材料------类的二进制数据,并建立了访问这些数据的基础设施。它的开放性设计为 Java 的繁荣生态奠定了坚实的基础。
三、验证 (Verification) - "严格安检"阶段
核心思想
验证阶段就像是 JVM 的超级严格的安全检查站 。它的唯一目的就是:确保你要加载的 Class 文件是个"良民",而不是一个携带病毒或逻辑炸弹的"黑客",从而保证 JVM 自身的安全。
因为 Class 文件不一定来自 Java 编译器,它可能被篡改或恶意生成,所以 JVM 绝不能信任任何外部传来的字节流,必须进行彻底检查。
安全检查的四个关卡(四大验证)
验证过程非常复杂,但总体上会按顺序通过四个关卡的检查。只有全部通过,Class 才能被成功加载。
1. 文件格式验证 - "文件格式与合法性检查"
检查什么? 检查字节流是否符合 Class 文件格式规范。这是基于二进制字节流本身的检查。
比喻 :就像海关检查护照。官员会看:
- 护照的封面 对不对?(魔数 是不是
0xCAFEBABE
?) - 护照的版本 是否有效?(主次版本号是否支持?)
- 护照里的栏目填写 是否正确无误?(常量池里的常量类型、索引值是否合法?UTF-8编码是否正确?)
目的:保证这个字节流能被正确解析并存入方法区。只有通过此关检查,数据才会被存入方法区,后续检查都将基于方法区内的结构进行,而不再直接操作字节流。
2. 元数据验证 - "语义检查"
检查什么? 对类的元信息 进行语义分析,看是否符合 Java 语言规范。
比喻 :就像公司HR进行背景调查。HR会核实:
- 你的简历上写了你爸的名字(父类),这是真的吗?(除了Object,所有类都应有父类)
- 你爸是最高法院的终身大法官(final 类)吗?如果是,那就不能声称继承了他。
- 你声称掌握所有必要技能(非抽象类),那你是否真的实现了你爸或你接口要求的所有方法?
- 你的经历描述有没有和你爸的简历冲突?(比如重写了父类的 final 方法 ,或者方法重载不符合规则)
目的:保证类的元数据信息没有语义上的矛盾。
3. 字节码验证 - "逻辑检查"(最复杂的一关)
检查什么? 对方法体中的代码进行逻辑校验。这是最复杂、最耗时的部分。
比喻 :就像电影审查部门审核剧本。审查员要确保剧本逻辑通顺,不会导致演员(JVM)在表演时出事故:
- 演员的道具使用是否合理?会不会出现"拿起一把手枪(int类型),却当火箭筒(long类型)来用"这种类型错误?
- 剧本里的跳转指令(如 goto)会不会让演员跳下舞台(跳到方法体之外)?
- 类型转换是否合理?能让一个普通人(子类)扮演超人(父类),但不能让超人(父类)强行扮演一个具体的普通人(子类),更不能让一棵树(无关类)来扮演超人。
著名的"停机问题" :理论上,无法通过程序100%准确地判断另一段程序是否包含所有类型的逻辑错误(就像无法通过算法判断任何程序是否会无限循环下去)。所以,通过字节码验证的程序未必绝对安全,但没通过的一定有问题!
性能优化 - StackMapTable :为了加速这个耗时的过程,JDK 6 之后,编译器(javac)会在编译时预先计算好很多验证信息(记录每个关键点的变量类型和操作数栈状态),并保存在 Class 文件的 StackMapTable
属性中。JVM 验证时只需要对照检查这些预先生成的记录即可,大大提高了效率。
java
public class VerificationExample {
public void method(boolean flag) {
// 字节码验证会分析出,无论走哪个分支,操作数栈在方法返回前都是平衡的
if (flag) {
System.out.println("True");
} else {
System.out.println("False");
}
// 如果这里缺少 return 语句,字节码验证会失败
// 编译器会报错:Missing return statement
}
// 可能引起验证问题的示例
public void problematicMethod() {
// 理论上,这里可能包含验证器无法通过的复杂控制流
// 但现代编译器通常会在编译期就阻止这样的代码
}
}
4. 符号引用验证 - "外部依赖检查"
检查什么? 发生在解析阶段(将符号引用转换为直接引用时)。检查类是否能够成功访问到它所引用的外部类、方法、字段等资源。
比喻 :就像项目启动前的最终资源确认。你要开始一个项目,需要确认:
- 你依赖的其他公司(外部类) 真的存在吗?
- 那家公司的某个部门(方法/字段) 真的存在吗?
- 你有权限访问那个部门的资源吗?(访问权限检查,比如不能访问别人的
private
方法)
目的 :确保解析动作能够正常执行。如果失败,会抛出 NoSuchFieldError
、NoSuchMethodError
、IllegalAccessError
等异常。
总结与要点
验证阶段 | 核心问题 | 比喻 | 失败后果 |
---|---|---|---|
1. 文件格式验证 | "这是一个合法的Class文件吗?" | 海关检查护照 | 抛出 VerifyError |
2. 元数据验证 | "这个类的描述信息自洽吗?" | HR背景调查 | 抛出 VerifyError |
3. 字节码验证 | "这个类的方法逻辑正确吗?" | 剧本审查 | 抛出 VerifyError |
4. 符号引用验证 | "这个类能访问到它需要的所有资源吗?" | 项目资源确认 | 抛出 IncompatibleClassChangeError 等 |
重要提示:
- 非常耗时 :验证阶段是类加载过程中工作量最大、最耗性能的部分之一。
- 可以关闭 :如果确认所有代码都是可靠且反复验证过的(例如生产环境),可以使用
-Xverify:none
参数来关闭大部分验证措施,以显著提高类加载速度。但这会带来安全风险。 - 设计演进:验证规则在《Java虚拟机规范》中变得越来越具体和复杂,体现了对安全性日益增长的要求。
总而言之,验证阶段是 JVM 抵御恶意代码的第一道也是最重要的一道防线,它通过层层递进的严格检查,确保了后续操作的基础安全。
四、准备 (Preparation) - "赋默认值"阶段
核心思想
准备阶段是 JVM 为类变量(static 变量) "分配房间并给每个房间贴上默认值标签"的阶段。此时尚未执行任何Java代码,所以程序员指定的值还不会被赋予。
一个比喻:布置新房
想象一下,JVM 正在为一个新来的"类"布置它的静态区域(方法区)。
- 加载阶段 :已经确定了这个"类"需要多大的静态空间(有多少个
static
变量)。 - 准备阶段 :JVM 开始分配这些空间 ,并在每个空间里放上一个默认的初始值。
- 初始化阶段 :后面才会执行程序员编写的
static
代码块或赋值语句,把这些默认值替换成程序员真正想要的值。
两个关键要点
1. 分配谁?不分配谁?
- 仅分配类变量 (Class Variables) :即被
static
修饰的变量。准备阶段只处理它们。 - 不分配实例变量 (Instance Variables) :没有被
static
修饰的变量。它们要等到将来创建对象实例时,才会随着对象一起在 Java 堆中分配内存和初始化。
比喻:这就像给一个公司布置办公室。
static
变量是公司的公共财产(如前台电话、会议室投影仪)。公司一注册成立(类被加载),这些就要准备好。- 实例变量是员工的个人办公用品 (如员工的电脑、笔记本)。要等员工入职(对象被
new
出来)才会分配。
2. "初始值"是什么?零值!
在准备阶段,JVM 会给类变量赋予一个系统默认的"零值",而不是程序员在代码中写的值。
为什么? 因为此时还没有开始执行任何 Java 方法(包括 <clinit>
类构造器),赋值语句自然也不会执行。
例子与对比:
Java 代码 | 准备阶段后的值 | 原因 |
---|---|---|
public static int value = 123; |
0 (int的零值) |
赋值 123 的 putstatic 指令在 <clinit> 方法中,尚未执行。 |
public static boolean enabled; |
false (boolean的零值) |
尚未被显式初始化。 |
public static Object obj; |
null (reference的零值) |
尚未被显式初始化。 |
基本数据类型的零值表:
数据类型 | 零值 |
---|---|
int , byte , short , char |
0 |
long |
0L |
float |
0.0f |
double |
0.0d |
boolean |
false |
reference (引用类型) |
null |
特殊情况:常量 (static final)
有一种特殊情况,它打破了"准备阶段总是赋零值"的规则。
规则 :如果类字段的字段属性表中存在 ConstantValue
属性,那么在准备阶段,变量值就会直接被初始化为 ConstantValue
属性所指定的值,而不是零值。
何时生成 ConstantValue 属性?
当变量同时被 static
和 final
修饰,并且它的值是编译期常量 时,编译器 (javac
) 会为它生成 ConstantValue
属性。
例子:
java
public static final int CONST_VALUE = 123; // 编译期常量
对于这行代码,在准备阶段结束后,CONST_VALUE
的值就是 123
,而不是 0
。
为什么?
因为 123
是一个编译期就能确定的常量,JVM 认为没有必要先赋零值,再在初始化阶段改为 123
。直接在准备阶段一步到位,更高效。
java
public class PreparationExample {
public static int normalStatic = 123; // 准备阶段后值为 0
public static final int CONST_STATIC = 456;// 准备阶段后值为 456
// 非常量final字段,准备阶段后仍为0
public static final int NON_CONST_STATIC;
static {
NON_CONST_STATIC = 789; // 在初始化阶段才赋值
}
}
内存位置的演变
- 逻辑概念 :类变量在方法区分配。
- 物理实现 :
- 在 JDK 7 及之前,HotSpot 使用永久代来实现方法区,类变量确实在永久代。
- 在 JDK 8 及之后,永久代被移除,类变量随着
Class
对象一起存放在 Java 堆 中。 - 但"方法区"这个逻辑概念依然存在,所以我们从逻辑上仍然说类变量属于方法区。
总结
准备阶段是一个承上启下的简单阶段,它只做两件事:
- 分配内存 :为
static
变量在方法区(逻辑上)分配空间。 - 赋系统初始值 :为这些变量赋上对应数据类型的零值 (
0
,false
,null
等)。
记住那个例外 :被 static final
修饰的编译期常量,会在准备阶段直接赋值为代码中写的值。
这个过程完成后,类变量就都有了"默认值",等待着在初始化阶段被程序员写的代码赋予"真正的值"。
五、解析 (Resolution) - "查地址"阶段
核心思想
解析阶段是 JVM 的 "查地址" 阶段。它的任务非常明确:将常量池中的符号引用 (一个名字)替换为直接引用(一个具体的地址或句柄)。
核心概念:符号引用 vs. 直接引用
理解这两个概念是理解解析阶段的关键。
特性 | 符号引用 (Symbolic Reference) | 直接引用 (Direct Reference) |
---|---|---|
是什么 | 一个名字、一个描述 | 一个指针、一个偏移量、一个句柄 |
内容 | 用文本形式描述目标(如 java/lang/Object ) |
直接指向目标在内存中的位置 |
例子 | 像通讯录里的 "张三" | 像张三的 "手机号码" 或 "家庭住址" |
与内存的关系 | 无关。它只是一个字符串,不关心目标是否已加载到内存。 | 紧密相关。直接指向内存中的具体位置,目标必须已存在。 |
特点 | 统一:所有JVM实现的Class文件中的符号引用格式都是一样的。 | 不统一:不同JVM实现的内存布局不同,翻译出的直接引用也不同。 |
简单比喻:
- 编译时 :你的代码里写
user.getName()
。编译器只知道你要调用一个叫getName
的方法,它把这个方法名(符号引用)写在Class文件的常量池里。 - 解析时 :JVM 在加载类后,需要真正执行
user.getName()
了。这时,它就去常量池找到getName
这个名字(符号引用),然后查表 ,找到这个方法在内存中的实际入口地址(直接引用),并将常量池中的记录替换成这个地址。以后每次调用,就直接使用这个地址,不再需要查找。
解析的时机
《Java虚拟机规范》没有严格规定解析发生的确切时间,只要求在执行某些特定字节码指令(如 getfield
, invokevirtual
, new
等)之前,必须先对它们用到的符号引用进行解析。
因此,JVM 有两种策略:
- eager resolution (急切解析) :在类加载完成后,立刻解析所有符号引用。
- lazy resolution (懒惰解析) :等到第一次使用某个符号引用时,才去解析它。
现在的主流JVM(如HotSpot)默认使用懒惰解析,这可以提升性能,避免加载一个类时就去解析它所有可能还不会用到的其他类。
解析的内容(四大类)
解析动作主要针对类或接口、字段、类方法、接口方法等符号引用进行。其核心逻辑可以概括为:先解析所有者,再在其基础上查找目标成员。
1. 类或接口的解析 (从 CONSTANT_Class_info
解析)
目标 :将类似 java/lang/Object
这样的符号引用,解析为JVM内部表示该类的数据结构(如Klass)的直接引用。
步骤:
- 加载 :如果符号引用代表的是一个普通类(非数组),JVM 会将这个全限定名交给当前类的类加载器去加载这个类。这个过程会触发该类自身的加载、验证、准备等阶段。
- 权限检查 :检查当前类
D
是否有权访问这个被解析的类C
。如果没有(例如,C
不是public
且也不和D
在同一个包内),则抛出IllegalAccessError
。
java
// 类解析示例
public class ClassResolutionExample {
public void createObject() {
// 这里会触发对java.util.ArrayList类的解析
// 1. 检查常量池中的符号引用"java/util/ArrayList"
// 2. 使用当前类加载器加载ArrayList类(如果尚未加载)
// 3. 检查访问权限
// 4. 将符号引用替换为直接引用
java.util.ArrayList list = new java.util.ArrayList();
}
}
2. 字段解析 (从 CONSTANT_Fieldref_info
解析)
目标 :解析一个字段,例如 java/lang/System.out
。
步骤:
- 解析所有者 :先解析字段所属的类或接口的符号引用(即先完成上一步的类解析)。
- 字段查找 :在成功解析出的类或接口
C
中,按以下顺序自下而上 地查找匹配的字段:- 步骤1 :在
C
自身中查找。 - 步骤2 :如果
C
实现了接口,会从上至下递归搜索它的所有接口。 - 步骤3 :如果
C
不是Object
,则自下而上地递归搜索它的父类。
- 步骤1 :在
- 如果找到,返回字段的直接引用;如果找不到,抛出
NoSuchFieldError
。 - 权限检查 :检查当前类是否有权访问该字段(如不能访问
private
字段),否则抛出IllegalAccessError
。
3. 方法解析 (从 CONSTANT_Methodref_info
解析)
目标 :解析一个类的方法(非接口方法)。
步骤:
- 解析所有者 & 合法性检查 :解析方法所属的类
C
。如果发现C
是一个接口 ,直接抛出IncompatibleClassChangeError
(因为invokevirtual
指令不能调用接口方法)。 - 方法查找 :在类
C
中查找:- 步骤1 :在
C
自身中查找。 - 步骤2 :在
C
的父类中递归查找。 - 步骤3 :在
C
实现的接口列表中查找(这一步不会找到具体方法,只会用于错误检查 )。如果在这里找到,说明C
是一个抽象类但没有实现接口的方法,抛出AbstractMethodError
。
- 步骤1 :在
- 找到则返回直接引用,否则抛出
NoSuchMethodError
。 - 权限检查 :检查访问权限,失败则抛出
IllegalAccessError
。
java
// 方法解析示例
public class MethodResolutionExample {
public void callMethod() {
// 这里会触发对toString()方法的解析
// 1. 解析当前类 -> Object类
// 2. 在Object类中查找toString方法
// 3. 检查访问权限(public方法,可访问)
// 4. 将符号引用替换为直接引用
String str = toString();
}
}
4. 接口方法解析 (从 CONSTANT_InterfaceMethodref_info
解析)
目标 :解析一个接口的方法。
步骤:
- 解析所有者 & 合法性检查 :解析方法所属的接口
C
。如果发现C
是一个类 ,直接抛出IncompatibleClassChangeError
。 - 方法查找 :
- 步骤1 :在接口
C
自身中查找。 - 步骤2 :在接口
C
的父接口中递归查找,直到Object
类。
- 步骤1 :在接口
- 找到则返回直接引用,否则抛出
NoSuchMethodError
。 - 权限检查 :在 JDK 9 之前,接口方法都是
public
,无需检查。JDK 9 引入私有静态方法后,也需要进行权限检查。
缓存
为了提升性能,除 invokedynamic
指令外,解析结果会被缓存。一旦一个符号引用被成功解析,下次再遇到它时就会直接使用缓存的直接引用,避免重复解析。
特殊的 invokedynamic
指令
invokedynamic
是为动态语言(如 JavaScript)支持而设计的,它的解析逻辑是**"一次解析,仅一次有效"** 。它的解析结果不会被缓存 供其他 invokedynamic
指令使用,因为每次调用都可能是动态的、不同的。
总结
解析阶段是连接符号世界和现实世界的桥梁。它通过一系列精心设计的步骤,将Class文件中的文本名字(符号引用)转换为JVM内存中的具体地址(直接引用),同时确保了Java语言的安全性(权限检查)和一致性(继承规则)。这个过程是Java实现动态扩展 和多态特性的底层基石。
六、初始化 (Initialization) - "执行构造代码"阶段
核心思想
初始化阶段是类加载的最后一步 ,也是真正开始执行程序员编写的 Java 代码的一步。在这一步,JVM 会将静态变量和静态代码块中你写的逻辑付诸实施。
你可以把它想象成一个设备的最终启动和自检程序。之前加载、验证、准备阶段只是把设备(类)运进工厂、拆箱、检查零件、装上货架(赋零值)。而现在,要按下电源开关,执行制造商(程序员)设定的启动指令了。
主角:<clinit>()
方法
初始化阶段就是执行一个叫做 <clinit>()
方法的过程。这个方法不是程序员手写的,而是由 javac
编译器自动生成的。
<clinit>
代表 class initialization。- 它是由编译器自动收集类中的所有静态变量的赋值语句 和静态代码块 (
static {}
块) 中的语句合并而成的。 - 收集的顺序 就是这些语句在源文件中出现的顺序。
举个例子:
java
public class Test {
static int i = 1; // 赋值语句1
static { // 静态代码块
i = 2;
j = 3;
// System.out.println(j); // 这里如果访问j,就是非法前向引用!
}
static int j = 4; // 另一个赋值语句2
// 编译器生成的 <clinit>() 方法逻辑顺序:
// i = 1;
// i = 2;
// j = 3;
// j = 4;
}
// 最终 i=2, j=4
关键特性与规则
1. 顺序重要性与"非法前向引用"
- 编译器收集语句的顺序就是源码中的顺序。
- 静态代码块中只能访问 定义在它之前的静态变量。
- 对于定义在它之后 的变量,静态代码块可以为其赋值 ,但不能访问其值 (读取)。如果尝试访问,编译器会报"非法前向引用"错误。
- 为什么? 因为虽然
j
的内存空间在准备阶段已经分配好(初始值为0),但在<clinit>()
方法中,j = 4
的赋值操作还没执行。如果你在之前的静态块中读取它,逻辑上是混乱的。
2. 父类优先原则
- JVM 会保证在子类的
<clinit>()
方法执行前,其父类的<clinit>()
方法已经执行完毕。 - 这意味着父类的静态代码块和静态变量赋值会先于子类的执行。
- 因此,整个 JVM 中第一个被执行
<clinit>()
方法的类肯定是java.lang.Object
。
例子:
java
class Parent {
public static int A = 1; // 1. 先执行这个赋值
static {
A = 2; // 2. 再执行这个,A 最终为 2
}
}
class Sub extends Parent {
public static int B = A; // 3. 最后执行这个,B 的值是父类 A 的最终值 2
}
// 输出 Sub.B 的结果是 2
3. 不是必需的
- 如果一个类中没有静态代码块,也没有对静态变量的显式赋值操作(比如只有
static int i;
),那么编译器可以不为这个类生成<clinit>()
方法。
4. 接口的初始化
- 接口也有
<clinit>()
方法(因为接口可以有静态变量,JDK8后还可以有静态方法)。 - 关键区别 :执行一个接口的
<clinit>()
方法并不需要先执行其父接口的<clinit>()
。父接口只有在真正被使用时(如其定义的变量被访问)才会被初始化。 - 一个类在初始化时,不会 自动先去执行它实现的接口的
<clinit>()
方法。
5. 线程安全与同步(极其重要!)
- JVM 会保证 一个类的
<clinit>()
方法在多线程环境中被正确地加锁同步。 - 这意味着 :多个线程如果同时去初始化同一个类,只有一个线程会去执行
<clinit>()
方法,其他所有线程都会被阻塞等待。 - 直到那个活动线程执行完
<clinit>()
方法后,其他线程才会被唤醒,并且不会再重新执行初始化过程。
这个机制会导致一个严重的风险 :
如果你的 <clinit>()
方法中包含一个耗时极长 的操作(比如一个死循环),或者由于某些原因卡住了,那么所有其他试图初始化这个类的线程都会被无限期地阻塞在那里,从而导致系统瘫痪。
java
static class DeadLoopClass {
static {
if (true) { // 为了骗过编译器的静态检查
System.out.println("线程" + Thread.currentThread() + "开始初始化...");
while (true) {} // 死循环!
}
}
}
// 如果两个线程同时尝试初始化 DeadLoopClass,一个会进去死循环,另一个会永远阻塞等待。
初始化触发时机("主动引用")
只有当类被"主动引用"时,才会触发初始化:
- 遇到
new
,getstatic
,putstatic
,invokestatic
字节码指令时。 - 使用
java.lang.reflect
包的方法对类进行反射调用时。 - 初始化一个类时,如果其父类还未初始化,则先触发父类的初始化。
- 虚拟机启动时,需指定一个包含
main()
方法的主类,虚拟机会先初始化这个主类。 - 使用 JDK 7 的动态语言支持时,如果一个
java.lang.invoke.MethodHandle
实例最后的解析结果为 REF_getStatic, REF_putStatic, REF_invokeStatic 的方法句柄,并且这个句柄所对应的类没有进行过初始化。
总结与比喻
概念 | 比喻 |
---|---|
准备阶段 | 给新房间配好家具并贴上"空"的标签(赋零值)。 |
初始化阶段 | 按照主人的吩咐布置房间 :把书放进书柜(i = 1 ),把画挂上墙(静态块中的操作)。执行 <clinit>() 方法。 |
<clinit>() 方法 |
房间的布置清单,由管家(编译器)根据主人的吩咐(源码)自动生成。 |
父类优先 | 布置豪宅前,必须先把它所在的整个庄园都布置好。 |
非法前向引用 | 清单上要求"把花瓶放在第5号桌子上",但此时第5号桌子还没运到房间裡(变量还未赋值),所以你无法描述它看起来怎么样(无法访问其值)。 |
线程安全 | 房间一次只允许一个管家进去布置,其他管家必须在门口排队等候,等他布置完后,大家就都知道房间已经准备好了,无需再进去。 |
初始化阶段是类加载过程中开发人员最能直接施加影响的阶段,你写的静态赋值和静态代码块就在这里执行。理解它的顺序规则和线程安全特性,对于编写正确、高效的多线程程序至关重要。要特别小心在静态初始化块中编写可能引起阻塞或死锁的代码。
七、总结
JVM 的类加载过程是一个严谨而精妙的系统,它将静态的字节码文件转变为运行时动态的 Java 对象。五个阶段环环相扣:
- 加载是"找数据",通过灵活的类加载器获取字节流。
- 验证是"保安全",构筑坚固的安全防线。
- 准备是"建空间并清零",为类变量分配空间并赋零值。
- 解析是"查地址",将符号引用转换为直接引用。
- 初始化是"赋真值",执行静态代码和赋值,完成类的构造。
理解这个过程,不仅能让我们更深入地理解 Java 程序的运行原理,更能为我们在实践中解决复杂问题、进行性能优化和实现高级特性提供坚实的理论基础。