本节摘要:类从字节流到可执行标本要过加载、验证、准备、解析、初始化五道工序。本节逐段拆解各阶段的具体动作,重点讲验证环节如何拦截畸形与恶意字节码(这原是安全模型的地基),并用实验钉死"类初始化何时触发"这条最容易记错的规则。
上一章的支柱页把流水线画在了总图上,本节沿工序逐站讲解。为什么值得逐站走一遍?因为线上"怪象"的谜底常常藏在某个具体阶段:静态字段取到了零值——发生在准备阶段;改了常量却没有重新编译依赖方——发生在解析阶段;类明明在 classpath 上却报找不到——加载阶段的委派机制在起作用。工序不清,现象就玄学。

加载阶段做三件事:通过类的全限定名拿到定义此类的二进制字节流;把字节流的静态结构转成方法区的运行时数据结构;在堆里生成一个 Class 对象,作为方法区这份数据的对外入口。"按名取流"这步最有想象力:字节流可以来自文件系统、jar 包、网络、数据库,甚至运行时现编(第三章第三节讲的就是这条路)。字节流来源的开放性,正是验证环节必须存在的原因——不能假设送上门的字节码都是 javac 规规矩矩编出来的。
字节码校验是 JVM 安全模型的地基工序,目的只有一个:保证送进来的字节码不会危害虚拟机自身。四道关卡层层设防:
文件格式验证先查魔数(每个 class 文件都以十六进制 CAFEBABE 开头)与版本号,不合规直接拒收;元数据验证做语义检查——这个类有没有父类、是否继承了不允许继承的类;字节码验证最严格,对方法体做数据流与控制流分析,确保跳转指令不会跳到方法体之外、操作数栈不会上下溢出、类型转换合法;符号引用验证在解析阶段兜底,确认引用的类、字段、方法真实存在且可访问。
想直观感受一下,可以手工把一个正常 class 文件改坏一个字节再加载:
$ javac Hello.java $ printf '\x00' | dd of=Hello.class bs=1 seek=7 count=1 conv=notrunc $ java Hello Error: A JNI error has occurred, please check your installation and try again Exception in thread "main" java.lang.ClassFormatError: Incompatible magic value ...
只改了头部一个字节,类加载器当场拒收——这就是文件格式验证在工作。生产上 ClassFormatError、VerifyError 一类报错,多半对应 jar 包损坏、被截断,或者不同构建产物的字节码混用。
准备阶段给静态字段分配内存并赋零值。注意 static int value = 123 在此之后仍是零——把一百二十三放进去是初始化阶段的事。唯一例外是 static final 修饰的编译期常量(ConstantValue 属性),准备阶段就直接落字面值。这个"零值时刻"不是冷知识:某个静态字段的可见性出问题时,先想想读者看到的是准备后的零值还是初始化后的真值,能省下大量排查时间。
解析阶段把常量池里的符号引用换成直接引用。符号引用是"欠条"(用全限定名描述目标),直接引用是能直接定位的指针或偏移。解析可以推迟到首次真正使用对应目标时进行(懒解析),这也是 debug 时看到某些异常从"第一次调用"而非"类加载时"抛出的原因。
初始化执行静态块与静态字段赋值,且严格保证:父类先初始化、多线程下只执行一次(JVM 内部加锁,这也解释了为什么静态内部类单例是线程安全的)。关键规则是触发时机——只有主动引用才触发初始化:
class HeavyInit { static final String LITERAL = "compile-time"; // 编译期常量 static final String COMPUTED = "run" + System.lineSeparator() + "time"; // 运行期才能确定 static { System.out.println("HeavyInit initialized"); } } public class PassiveRef { public static void main(String[] args) { System.out.println(HeavyInit.LITERAL); // 被动引用:不触发初始化 System.out.println(HeavyInit.COMPUTED); // 非常量字段:触发初始化 } }
$ java PassiveRef compile-time HeavyInit initialized run time
访问编译期常量没触发静态块——常量在编译期就被复制进了调用方自己的常量池,运行期根本不去查 HeavyInit。主动引用的完整清单是:new 实例化、读写非常量静态字段、调用静态方法、反射调用类、初始化子类时父类先行、主类随启动初始化。数组定义(new HeavyInit 数组)不触发,访问常量不触发,通过子类名访问父类静态字段只初始化父类。
⚠️ 常见坑:把 ClassNotFoundException 与 NoClassDefFoundError 混为一谈。前者是加载阶段找不到字节流(编译期存在、运行期 classpath 缺失,程序主动 catch 可恢复);后者是字节流曾找得到、但初始化或链接环节失败,第二次使用时类已处于"死亡"状态(典型如静态块抛异常后所有后续访问都报它)。前者查依赖是否在 classpath,后者查静态块是否吞过异常——处理路径完全不同。
💡 关键直觉:五阶段不是五扇依次打开的门,而是一条允许交叉的流水线——加载未完验证可先动工,解析可以懒执行,唯有"初始化"这道闸门有精确的触发规则。面试与线上排错的分歧点,九成集中在准备与初始化两站。
工序看完了,本节还留了一个尾巴:加载阶段"按名取流"时,谁来决定去哪取、能不能取?答案是类加载器体系与双亲委派——下一节拆解这套维持秩序的登记制度。