深入解析 JVM 类加载机制:从字节码到运行时对象

43 阅读27分钟

一、概述:为什么需要类加载?

Java 语言的核心特性之一是"一次编写,到处运行",这背后的关键在于 Java 虚拟机(JVM)和其类加载机制。当我们编写好 Java 代码并将其编译为 .class 字节码文件后,这些静态的字节码需要被加载到 JVM 中才能变为可执行的动态对象。类加载就是这个转换过程的核心环节。

理解类加载机制能帮助我们:

  • 深入理解 Java 动态扩展机制(如 SPI、热部署等技术原理)
  • 优化程序性能,理解哪些阶段耗时及如何调整参数优化
  • 解决实际开发中遇到的 ClassNotFoundExceptionNoSuchMethodErrorIllegalAccessError 等异常
  • 实现高级技巧,如编写自定义类加载器实现模块化、代码加密等功能

类加载的完整生命周期包括加载、验证、准备、解析、初始化、使用和卸载七个阶段。其中前五个阶段是类加载的核心过程,下面我们将详细解析每个阶段。

二、加载 (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)会把程序代码存到数据库里
// 自定义类加载器示例:从特定路径加载类
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 直接在生产线上"组装"出来的

规则

  1. 如果数组的元素类型是引用类型(比如 String[]Object[]),那么 JVM 会先去加载这个元素类型。这个数组类会被标记为与加载该元素类型的类加载器关联。
  2. 如果数组的元素类型是基本类型(比如 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 验证时只需要对照检查这些预先生成的记录即可,大大提高了效率。

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 方法)

目的:确保解析动作能够正常执行。如果失败,会抛出 NoSuchFieldErrorNoSuchMethodErrorIllegalAccessError 等异常。

总结与要点

验证阶段核心问题比喻失败后果
1. 文件格式验证"这是一个合法的Class文件吗?"海关检查护照抛出 VerifyError
2. 元数据验证"这个类的描述信息自洽吗?"HR背景调查抛出 VerifyError
3. 字节码验证"这个类的方法逻辑正确吗?"剧本审查抛出 VerifyError
4. 符号引用验证"这个类能访问到它需要的所有资源吗?"项目资源确认抛出 IncompatibleClassChangeError 等

重要提示

  1. 非常耗时:验证阶段是类加载过程中工作量最大、最耗性能的部分之一。
  2. 可以关闭:如果确认所有代码都是可靠且反复验证过的(例如生产环境),可以使用 -Xverify:none 参数来关闭大部分验证措施,以显著提高类加载速度。但这会带来安全风险。
  3. 设计演进:验证规则在《Java虚拟机规范》中变得越来越具体和复杂,体现了对安全性日益增长的要求。

总而言之,验证阶段是 JVM 抵御恶意代码的第一道也是最重要的一道防线,它通过层层递进的严格检查,确保了后续操作的基础安全。

四、准备 (Preparation) - "赋默认值"阶段

核心思想

准备阶段是 JVM 为类变量(static 变量)  "分配房间并给每个房间贴上默认值标签"的阶段。此时尚未执行任何Java代码,所以程序员指定的值还不会被赋予。

一个比喻:布置新房

想象一下,JVM 正在为一个新来的"类"布置它的静态区域(方法区)。

  1. 加载阶段:已经确定了这个"类"需要多大的静态空间(有多少个 static 变量)。
  2. 准备阶段:JVM 开始分配这些空间,并在每个空间里放上一个默认的初始值
  3. 初始化阶段:后面才会执行程序员编写的 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的零值)尚未被显式初始化。

基本数据类型的零值表

数据类型零值
intbyteshortchar0
long0L
float0.0f
double0.0d
booleanfalse
reference (引用类型)null

特殊情况:常量 (static final)

有一种特殊情况,它打破了"准备阶段总是赋零值"的规则

规则:如果类字段的字段属性表中存在 ConstantValue 属性,那么在准备阶段,变量值就会直接被初始化为 ConstantValue 属性所指定的值,而不是零值。

何时生成 ConstantValue 属性?
当变量同时被 static 和 final 修饰,并且它的值是编译期常量时,编译器 (javac) 会为它生成 ConstantValue 属性。

例子

public static final int CONST_VALUE = 123; // 编译期常量

对于这行代码,在准备阶段结束后,CONST_VALUE 的值就是 123,而不是 0

为什么?
因为 123 是一个编译期就能确定的常量,JVM 认为没有必要先赋零值,再在初始化阶段改为 123。直接在准备阶段一步到位,更高效。

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 堆 中。
    • 但"方法区"这个逻辑概念依然存在,所以我们从逻辑上仍然说类变量属于方法区。

总结

准备阶段是一个承上启下的简单阶段,它只做两件事:

  1. 分配内存:为 static 变量在方法区(逻辑上)分配空间。
  2. 赋系统初始值:为这些变量赋上对应数据类型的零值 (0falsenull 等)。

记住那个例外:被 static final 修饰的编译期常量,会在准备阶段直接赋值为代码中写的值。

这个过程完成后,类变量就都有了"默认值",等待着在初始化阶段被程序员写的代码赋予"真正的值"。

五、解析 (Resolution) - "查地址"阶段

核心思想

解析阶段是 JVM 的  "查地址"  阶段。它的任务非常明确:将常量池中的符号引用(一个名字)替换为直接引用(一个具体的地址或句柄)。

核心概念:符号引用 vs. 直接引用

理解这两个概念是理解解析阶段的关键。

特性符号引用 (Symbolic Reference)直接引用 (Direct Reference)
是什么一个名字、一个描述一个指针、一个偏移量、一个句柄
内容用文本形式描述目标(如 java/lang/Object直接指向目标在内存中的位置
例子像通讯录里的  "张三"像张三的  "手机号码"  或  "家庭住址"
与内存的关系无关。它只是一个字符串,不关心目标是否已加载到内存。紧密相关。直接指向内存中的具体位置,目标必须已存在。
特点统一:所有JVM实现的Class文件中的符号引用格式都是一样的。不统一:不同JVM实现的内存布局不同,翻译出的直接引用也不同。

简单比喻

  • 编译时:你的代码里写 user.getName()。编译器只知道你要调用一个叫 getName 的方法,它把这个方法名(符号引用)写在Class文件的常量池里。
  • 解析时:JVM 在加载类后,需要真正执行 user.getName() 了。这时,它就去常量池找到 getName 这个名字(符号引用),然后查表,找到这个方法在内存中的实际入口地址(直接引用),并将常量池中的记录替换成这个地址。以后每次调用,就直接使用这个地址,不再需要查找。

解析的时机

《Java虚拟机规范》没有严格规定解析发生的确切时间,只要求在执行某些特定字节码指令(如 getfieldinvokevirtualnew 等)之前,必须先对它们用到的符号引用进行解析

因此,JVM 有两种策略:

  1. eager resolution (急切解析) :在类加载完成后,立刻解析所有符号引用。
  2. lazy resolution (懒惰解析) :等到第一次使用某个符号引用时,才去解析它。

现在的主流JVM(如HotSpot)默认使用懒惰解析,这可以提升性能,避免加载一个类时就去解析它所有可能还不会用到的其他类。

解析的内容(四大类)

解析动作主要针对类或接口、字段、类方法、接口方法等符号引用进行。其核心逻辑可以概括为:先解析所有者,再在其基础上查找目标成员

1. 类或接口的解析 (从 CONSTANT_Class_info 解析)

目标:将类似 java/lang/Object 这样的符号引用,解析为JVM内部表示该类的数据结构(如Klass)的直接引用。

步骤

  1. 加载:如果符号引用代表的是一个普通类(非数组),JVM 会将这个全限定名交给当前类的类加载器去加载这个类。这个过程会触发该类自身的加载、验证、准备等阶段。
  2. 权限检查:检查当前类 D 是否有权访问这个被解析的类 C。如果没有(例如,C 不是 public 且也不和 D 在同一个包内),则抛出 IllegalAccessError
// 类解析示例
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

步骤

  1. 解析所有者:先解析字段所属的类或接口的符号引用(即先完成上一步的类解析)。

  2. 字段查找:在成功解析出的类或接口 C 中,按以下顺序自下而上地查找匹配的字段:

    • 步骤1:在 C 自身中查找。
    • 步骤2:如果 C 实现了接口,会从上至下递归搜索它的所有接口。
    • 步骤3:如果 C 不是 Object,则自下而上地递归搜索它的父类。
  3. 如果找到,返回字段的直接引用;如果找不到,抛出 NoSuchFieldError

  4. 权限检查:检查当前类是否有权访问该字段(如不能访问 private 字段),否则抛出 IllegalAccessError

3. 方法解析 (从 CONSTANT_Methodref_info 解析)

目标:解析一个的方法(非接口方法)。

步骤

  1. 解析所有者 & 合法性检查:解析方法所属的类 C。如果发现 C 是一个接口,直接抛出 IncompatibleClassChangeError(因为 invokevirtual 指令不能调用接口方法)。

  2. 方法查找:在类 C 中查找:

    • 步骤1:在 C 自身中查找。
    • 步骤2:在 C 的父类中递归查找。
    • 步骤3:在 C 实现的接口列表中查找(这一步不会找到具体方法,只会用于错误检查)。如果在这里找到,说明 C 是一个抽象类但没有实现接口的方法,抛出 AbstractMethodError
  3. 找到则返回直接引用,否则抛出 NoSuchMethodError

  4. 权限检查:检查访问权限,失败则抛出 IllegalAccessError

// 方法解析示例
public class MethodResolutionExample {
    public void callMethod() {
        // 这里会触发对toString()方法的解析
        // 1. 解析当前类 -> Object类
        // 2. 在Object类中查找toString方法
        // 3. 检查访问权限(public方法,可访问)
        // 4. 将符号引用替换为直接引用
        String str = toString();
    }
}

4. 接口方法解析 (从 CONSTANT_InterfaceMethodref_info 解析)

目标:解析一个接口的方法。

步骤

  1. 解析所有者 & 合法性检查:解析方法所属的接口 C。如果发现 C 是一个,直接抛出 IncompatibleClassChangeError

  2. 方法查找

    • 步骤1:在接口 C 自身中查找。
    • 步骤2:在接口 C 的父接口中递归查找,直到 Object 类。
  3. 找到则返回直接引用,否则抛出 NoSuchMethodError

  4. 权限检查:在 JDK 9 之前,接口方法都是 public,无需检查。JDK 9 引入私有静态方法后,也需要进行权限检查。

缓存

为了提升性能,除 invokedynamic 指令外,解析结果会被缓存。一旦一个符号引用被成功解析,下次再遇到它时就会直接使用缓存的直接引用,避免重复解析。

特殊的 invokedynamic 指令

invokedynamic 是为动态语言(如 JavaScript)支持而设计的,它的解析逻辑是 "一次解析,仅一次有效" 。它的解析结果不会被缓存供其他 invokedynamic 指令使用,因为每次调用都可能是动态的、不同的。

总结

解析阶段是连接符号世界和现实世界的桥梁。它通过一系列精心设计的步骤,将Class文件中的文本名字(符号引用)转换为JVM内存中的具体地址(直接引用),同时确保了Java语言的安全性(权限检查)和一致性(继承规则)。这个过程是Java实现动态扩展多态特性的底层基石。

六、初始化 (Initialization) - "执行构造代码"阶段

核心思想

初始化阶段是类加载的最后一步,也是真正开始执行程序员编写的 Java 代码的一步。在这一步,JVM 会将静态变量和静态代码块中你写的逻辑付诸实施。

你可以把它想象成一个设备的最终启动和自检程序。之前加载、验证、准备阶段只是把设备(类)运进工厂、拆箱、检查零件、装上货架(赋零值)。而现在,要按下电源开关,执行制造商(程序员)设定的启动指令了。

主角:<clinit>() 方法

初始化阶段就是执行一个叫做  <clinit>()  方法的过程。这个方法不是程序员手写的,而是由 javac 编译器自动生成的。

  • <clinit>  代表 class initialization
  • 它是由编译器自动收集类中的所有静态变量的赋值语句静态代码块 (static {} 块) 中的语句合并而成的。
  • 收集的顺序就是这些语句在源文件中出现的顺序

举个例子

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

例子

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>() 方法中包含一个耗时极长的操作(比如一个死循环),或者由于某些原因卡住了,那么所有其他试图初始化这个类的线程都会被无限期地阻塞在那里,从而导致系统瘫痪。

static class DeadLoopClass {
    static {
        if (true) { // 为了骗过编译器的静态检查
            System.out.println("线程" + Thread.currentThread() + "开始初始化...");
            while (true) {} // 死循环!
        }
    }
}
// 如果两个线程同时尝试初始化 DeadLoopClass,一个会进去死循环,另一个会永远阻塞等待。

初始化触发时机("主动引用")

只有当类被"主动引用"时,才会触发初始化:

  1. 遇到 newgetstaticputstaticinvokestatic 字节码指令时。
  2. 使用 java.lang.reflect 包的方法对类进行反射调用时。
  3. 初始化一个类时,如果其父类还未初始化,则先触发父类的初始化。
  4. 虚拟机启动时,需指定一个包含 main() 方法的主类,虚拟机会先初始化这个主类。
  5. 使用 JDK 7 的动态语言支持时,如果一个 java.lang.invoke.MethodHandle 实例最后的解析结果为 REF_getStatic, REF_putStatic, REF_invokeStatic 的方法句柄,并且这个句柄所对应的类没有进行过初始化。

总结与比喻

概念比喻
准备阶段给新房间配好家具并贴上"空"的标签(赋零值)。
初始化阶段按照主人的吩咐布置房间:把书放进书柜(i = 1),把画挂上墙(静态块中的操作)。执行 <clinit>() 方法。
<clinit>() 方法房间的布置清单,由管家(编译器)根据主人的吩咐(源码)自动生成。
父类优先布置豪宅前,必须先把它所在的整个庄园都布置好
非法前向引用清单上要求"把花瓶放在第5号桌子上",但此时第5号桌子还没运到房间裡(变量还未赋值),所以你无法描述它看起来怎么样(无法访问其值)。
线程安全房间一次只允许一个管家进去布置,其他管家必须在门口排队等候,等他布置完后,大家就都知道房间已经准备好了,无需再进去。

初始化阶段是类加载过程中开发人员最能直接施加影响的阶段,你写的静态赋值和静态代码块就在这里执行。理解它的顺序规则和线程安全特性,对于编写正确、高效的多线程程序至关重要。要特别小心在静态初始化块中编写可能引起阻塞或死锁的代码。

七、总结

JVM 的类加载过程是一个严谨而精妙的系统,它将静态的字节码文件转变为运行时动态的 Java 对象。五个阶段环环相扣:

  1. 加载是"找数据",通过灵活的类加载器获取字节流。
  2. 验证是"保安全",构筑坚固的安全防线。
  3. 准备是"建空间并清零",为类变量分配空间并赋零值。
  4. 解析是"查地址",将符号引用转换为直接引用。
  5. 初始化是"赋真值",执行静态代码和赋值,完成类的构造。

理解这个过程,不仅能让我们更深入地理解 Java 程序的运行原理,更能为我们在实践中解决复杂问题、进行性能优化和实现高级特性提供坚实的理论基础。