☰
JVM类加载与类初始化:从双亲委派到静态块执行时机解析
2026/10/13 4:51:55 网站建设 项目流程

"Java虚拟机的类加载与类初始化,是我在面试应届和两三年经验的开发时几乎必问的一块。背过的人不少,但大部分人能把七个阶段一口气说出来,却说不清楚一个问题:为什么有的代码明明碰了某个类,那个类的静态块却没有执行。答案恰恰藏在"类加载"和"类初始化"这两条不同的时间线里。这篇文章不是把《Java虚拟机规范》翻译一遍,而是围绕类加载与类初始化把最容易混淆的边界拆开——什么情况下类被加载,什么情况下类才被初始化,加载、连接、初始化各自做了什么,双亲委派为什么存在、又在什么场景下被打破。读完之后,你可以直接拿文中的实验代码验证,也能把这些规律用到实际的故障排查里。

1. 类加载与类初始化:先把两条时间线分开

1.1 七个阶段中,只有初始化在执行"你的代码"

JVM规范里,一个类从被JVM接纳到可以被卸载,要经过加载、验证、准备、解析、初始化、使用、卸载七个阶段。其中验证、准备、解析三个合起来叫"链接",也就是Linking。加载和链接更多是"构造"和"检查"的过程,真正会去执行Java字节码里那些静态赋值语句、静态代码块的,只有初始化这个阶段。这一点是很多误解的源头:你以为"类加载做了初始化的事",其实加载只是把字节流搬运进来、建好Class对象,并没有执行你的任何逻辑。

所以判断一个问题归属哪个阶段,有个最简单的办法:看有没有执行你写的代码。new对象、读静态字段、调静态方法,这些动作背后都隐含着类初始化;而如果你只是用ClassLoader.loadClass去加载类,不触发初始化,它就像把一份资料归档到文件柜里,什么动作都不会发生。很多初次接触JVM的人会惊讶于"原来初始化不是加载的一部分",其实两条线是分开走的,理解这一点是后面所有内容的前提。

1.2 加载时机比你想的更自由,初始化时机却很严格

规范对"什么时候必须初始化一个类"给了明确清单,但对"什么时候加载"其实留了很大空间。主流JVM完全可以提前加载一些类,比如启动阶段把核心库先拉起来,也可以在类真正需要链接之前再读取字节流。换句话说,你观察到"这个类怎么这么早就被加载了"并不一定代表它在主动使用,很有可能只是JVM预加载,或者解析另一个类时顺手把它带上。

但初始化不一样。JVM规范的措辞是"在首次主动使用时"初始化。它有一套非常严格的触发条件,这套条件直接决定你的静态块、静态变量赋值在什么时候发生。理解了这个差异,再去分析"某个静态块为什么没执行"这类问题,思路就会清晰很多。后面我会把触发条件逐个拆开讲,这里先记住:加载负责"把类立起来",初始化负责"把类准备好投入正式使用",两者之间有明确的时差。

2. 从字节流到可执行类:加载与连接阶段的真实分工

2.1 加载阶段:字节流未必来自.class文件

加载阶段要做三件事:按全限定名获取定义此类的二进制字节流;把字节流按照JVM要求的结构转换到方法区中;在堆内存里生成一个Class对象作为后续访问的入口。很多人以为"获取字节流"就是从磁盘上读class文件,其实这个动作可以由任何方式完成:JAR、ZIP、网络、数据库、第三方库动态生成,甚至运行时直接拼接字节码。

这里有个特例容易忽略:数组类本身不通过类加载器加载,而是由JVM在运行时根据维度直接创建。但数组的元素类型如果是引用类型,JVM会先用类加载器去加载这个元素类型。也就是说,你写一行ParentDemo[] arr = new ParentDemo[3],JVM可能会去加载ParentDemo类,但不会触发ParentDemo类的初始化。这就解释了为什么很多人在"定义数组"这个场景下没看到静态块执行。加载完成后,Class对象已经存在,类在方法区也有了对应的运行时数据结构,但此刻它还是一个"只挂了号、没开始接诊"的状态。

2.2 验证:JVM凭什么相信这段字节码

验证是链接的第一个环节,也是不少同学最容易跳过的部分。它的核心目的只有一个:保证传进来的字节流不会损害JVM自身的运行安全。毕竟字节码不一定都来自可信编译器,也可能来自手写、篡改或者异常渠道。

验证大概分四个层次。文件格式验证,检查魔数、版本号是否合法;元数据验证,检查类之间继承关系是否合理、方法签名是否有冲突;字节码验证,通过数据流和控制流分析判断指令不会导致栈溢出、类型错乱;符号引用验证,看看常量池里的符号引用能不能解析到正确的类、字段与方法。文件格式验证会在加载阶段同步进行,其他验证可以后移。这套检查听起来繁琐,但在实际运行中大量代码早就被验证过,性能影响通常可以接受,而且它拦住了很多会在运行时引发内存破坏的非法字节码。

2.3 准备:静态变量先"补齐默认值"

准备阶段会让静态字段进入可用状态,但它不是执行赋值代码,而是先把内存分配好、把值设成零值。int是0,long是0L,boolean是false,引用类型是null。所以在准备阶段结束、初始化阶段还没开始时,一个静态int字段看起来有值,但它是零值,不是你写的初始值。

有一个例外值得单独记住:static final的编译期常量,如果编译时就能确定值,会直接在准备阶段写入ConstantValue属性里的那个值。比如static final int CONST = 42,准备阶段直接就存42,不会再等待初始化去赋值。但static final Object OBJ = new Object()不会提前,因为new Object()需要执行字节码,只能在初始化阶段完成。准备阶段是所有类共享的那块静态存储空间的一次集中规划,理解它对排查"字段为什么是默认值"非常有用。

2.4 解析:从"符号引用"到"直接引用"

解析阶段是把方法区常量池里的符号引用替换为直接引用的过程。符号引用是一组字符串和标识,比如"java/lang/String"或某个字段的名字;直接引用则能定位到具体的内存地址或者说可以完成实际访问的引用。程序在编译期不知道引用的东西到底在哪个地址,所以运行时需要解析。

但要注意,JVM规范并没有强制"解析必须在初始化之前全部完成"。主流实现里,很多符号引用会留到第一次真正使用时才解析,这就是常说的"延迟解析"。这样设计是为了配合动态绑定——多态方法的实际调用目标要等运行期才能最终确定。所以从加载、验证、准备到解析,这个顺序是大多数人脑中的理想顺序,实际执行中验证和准备可能早于解析,解析也可能被延迟穿插在使用阶段。理解这一点,看各种"为什么这个类现在才加载"的问题就不会一刀切下结论。

3. 类初始化什么时候被触发:六种主动引用与反直觉案例

3.1 六种主动引用场景

类初始化的触发时机,是JVM规范明确列举的"首次主动使用"。用new创建实例,或者读写某个类的静态字段(编译期常量除外)、调用静态方法,是最常见的一类;用反射API对类做反射调用,也是一类;初始化某个类时,发现父类还没有初始化,父类会先被初始化;程序启动时指定了主类,主类会被初始化;在动态语言支持层面,解析到的方法句柄所指向的类需要初始化;如果一个接口声明了默认方法,这个接口的实现类初始化时,接口也通常会被初始化。

这六条是硬规则。只要你的代码触发了其中任意一条,类就必须初始化;反过来,如果你没有触发,即使类已经被加载、已经被链接,静态块和静态字段的赋值也一定不会执行。很多时候我们以为"代码里写了new就一定触发初始化",但new之外还有大量边界情况,比如继承、反射、数组、常量内联,全都有各自的触发逻辑。

3.2 只看代码会误判的反例

下面三种"碰了类但没触发初始化"的情况最容易踩坑。第一种,通过子类去读取父类中定义的静态字段,只会初始化父类,不会初始化子类。编译器在常量池里解析字段时就直接定位到了父类的字段,子类不会因此被"主动使用"。第二种,定义数组。ParentDemo[] arr = new ParentDemo[2]会让JVM去加载ParentDemo,但不会初始化ParentDemo。第三种,引用编译期常量。如果静态字段是final而且值是编译期能确定的常量,调用方在编译时就直接内联了这个值,根本不需要在运行时访问那个类。

实际上,我在很多代码评审里见过隐性触发:某个类初始化时,它的静态块里new了别的类;那个类的初始化又去读第三个类的静态字段。层层触发之下,日志里会出现一串连续的初始化输出,排查的人需要从入口理到尾。这种链条在工程里尤其隐蔽,因为触发源不在业务调用点,而在一个看似无关的静态初始化副作用里。

3.3 反直觉的一组输出

为了让你对"加载不初始化"有直观感受,这里放一个极简实验。定义父类和子类,分别加静态块,然后在不同场景下引用它们,观察输出。

public class ParentDemo { static { System.out.println("ParentDemo initialized"); } public static final int CONST = 42; public static int value = 10; } public class ChildDemo extends ParentDemo { static { System.out.println("ChildDemo initialized"); } public static void main(String[] args) throws Exception { System.out.println("--- 通过子类引用父类静态字段 ---"); System.out.println(ChildDemo.value); System.out.println("--- 定义数组 ---"); ParentDemo[] arr = new ParentDemo[3]; System.out.println("--- 读取编译期常量 ---"); System.out.println(ParentDemo.CONST); System.out.println("--- Class.forName ---"); Class.forName("ChildDemo"); System.out.println("--- loadClass ---"); ClassLoader.getSystemClassLoader().loadClass("ChildDemo"); } }

输出会是"ParentDemo initialized"出现一次、"ChildDemo initialized"出现一次。第一次引用value只初始化父类;定义数组和读常量都没有任何初始化输出;Class.forName触发子类初始化,并且由于父类还没初始化,JVM会把父类也初始化;最后的loadClass又什么都不输出。静态块总共输出两行,但ChildDemo.value那一行只出现在ParentDemo initialized之后——这就是主动引用和被动引用的直观差异。

4. 静态初始化顺序:继承、循环依赖与初始化锁

4.1 单个类的静态内容执行顺序

一个类内部,静态变量声明、静态代码块按代码顺序从上到下执行。也就是说,如果你在静态块里访问前面声明的静态变量,能看到它的赋值;如果访问后面声明的静态变量,看到的可能还是默认值。这不是Bug,而是规则。把静态变量的初始化和静态块当成一个顺序执行的"类初始化方法",你就能推断出这类行为。

这里也顺便提一下实例初始化的区别。实例变量的声明赋值和执行块在构造器代码之前按顺序执行,然后才进入构造器。所以"类初始化"和"对象初始化"是两条完全独立的线:一个发生在类被首次主动使用的时候,一个发生在每次new的时候。很多问题描述里把它们混在一起,定位时就容易跑偏。我在评审代码时经常提醒:静态块里别依赖实例逻辑,两个生命周期都不一样,更别说线程了。

4.2 继承关系下,父类总是跑在前面

当多个类有继承关系时,JVM会先初始化父类,再初始化子类。因此,在子类的静态块里访问父类的静态字段,那一定是安全的,因为父类此刻已经进入了初始化完成状态。反过来,你在父类的静态块里想访问子类的静态字段,虽然语法上可能做不到,但即便做到也是不安全的,因为子类可能还没初始化。

同样的规律也适用于静态块里创建子类实例的场景。比如初始化ParentDemo时new了一个ChildDemo,这时ChildDemo会立即触发初始化,而它的初始化又会要求ParentDemo先完成,于是JVM会停下ParentDemo正在进行的初始化,先把ChildDemo初始化完,再回到ParentDemo剩下的内容。这种嵌套会带来非常绕的执行顺序,排查时最好在静态块里加上日志,按时间线核对。

4.3 循环依赖:正在初始化中的类会"漏出"中间状态

比继承更隐蔽的是两个类之间的循环依赖。看这个例子:

class CircleA { static CircleB b = new CircleB(); static int a = 1; } class CircleB { static int b = CircleA.a + 1; }

当CircleA被初始化时,它会先执行CircleB b = new CircleB(),于是CircleB被触发初始化。CircleB初始化时去读CircleA.a,可是此刻CircleA的静态字段a还没有执行到赋值那一步,正处于默认值0的状态。JVM不会因为CircleA正在初始化而把CircleB卡死,它直接返回当前这个"半初始化"状态给CircleB,最终CircleB.b的值为1,而不是2。

我建议不要在真实的业务代码里写这种互相触发的初始化,但理解这个机制能帮你解释很多诡异的线上日志:某个类静态字段的值"看起来好像被改过",其实只是被正在初始化中的另一个类提前读到了默认值。JVM只保证每个类的初始化发生一次,并不保证初始化过程中对外暴露的中间状态是完整的。这种"中间状态外泄"在真实项目里一旦出现,往往很难一眼看穿。

4.4 初始化锁与多线程

类初始化是线程安全的,这是JVM层面的保证。多个线程同时第一次使用同一个类时,只有一个线程会真正执行静态代码,其余线程会等待它完成。这个机制靠的是每个类或接口都有一把初始化锁,进入初始化的线程持有锁,初始化完了再唤醒等待者。

因此,把重要的业务初始化、数据库连接建立、缓存预热之类的工作放在静态块里,要小心两类问题:一是静态块耗时过长,会拖慢所有线程的首次访问;二是如果静态块里发生了循环等待,那么等待同一个类初始化的所有线程都会被卡住。实际排错时,看到大量线程阻塞在某个类的初始化锁上,先去看那个静态块是不是做的太重了。很多时候,把重量级逻辑从静态块里挪出去,线程停滞的问题立刻缓解。

5. 双亲委派:类加载器协作机制的价值与边界

5.1 三层类加载器

说完了类内部的时序,再回过头看"类是怎么被加载进来的"。JDK 9之后,类加载器通常分成三层:启动类加载器负责加载核心模块,它由虚拟机自身实现,没有父加载器;平台类加载器负责加载平台相关模块;应用类加载器负责加载用户classpath中的类。日常写的类,默认都由应用类加载器加载。

三层结构看起来简单,背后的协作规则才是关键。当一个类加载请求出现时,当前加载器不会先尝试自己加载,而是把请求交给父加载器。父加载器也继续向上委托,直到启动类加载器。只有父加载器明确表示自己加载不了时,才由子加载器自己尝试。这就是双亲委派模型。它本质上是一种"向上请求、向下兜底"的协作机制。

5.2 向上委托的意义

双亲委派至少解决两个核心问题。一是避免重复加载:同一个全限定名,在整条委派链路上只会有一个加载器最终加载它,避免同一个类出现多份、类型互相不认的混乱。二是保护核心类库:用户不能通过在classpath里塞一个同名类来替换java.lang里的核心类型,因为加载请求会一直委托到启动类加载器,启动类加载器发现能加载就轮不到应用类加载器。

在我实际排查的经验里,最常见的问题是"类重复定义"和"类转换异常"。原因往往就是某个框架自己定义了一个类加载器,却没有遵守双亲委派,导致同一个类被加载了两遍,不同加载器加载出的Class对象互相不兼容。理解双亲委派,基本可以快速定位这类问题的方向。

自定义类加载器时,有个很容易犯的细节错误:直接在loadClass方法里写自己的加载逻辑。按双亲委派的语义,你应该重写findClass方法,因为ClassLoader的loadClass已经实现了向上委托逻辑,留给子类扩展的正是"父加载器找不到时,我自己去哪找"的findClass。写反了,就把双亲委派绕开了,后续踩坑的概率会高很多。

5.3 不得不打破的几种场景

双亲委派不是金科玉律,现实里有很多必须打破它的场景。最典型的是SPI机制:启动类加载器加载的框架代码,需要加载由用户classpath提供的实现类,此时向上委托就找不到这些类了,于是要走线程上下文类加载器这条反向路径,把加载请求交给应用类加载器。另一个典型是Web应用之间的隔离:容器需要让不同应用加载各自版本的类库,还要支持应用热部署,这就要为每个应用准备独立的类加载器,应用之间的类互相隔离,也不再简单遵从向上委托。

所以面试里如果问"何时打破双亲委派",答案不是"为了炫技去打破",而是当类的来源已经超出父加载器的视野、且需要隔离和独立生命周期时,双亲委派才会显得不够用。实际项目里,99%的自定义类加载器都不需要真正打破双亲委派,老老实实按findClass扩展就够了。这个比例不是随便说的,我见过太多自定义加载器,真正的需求其实就是"换个目录读class文件"。

6. 可以直接跑的实验代码与踩坑总结

6.1 完整实验

把前面几个分散的例子放到一起,跑一个完整验证。除了第3章里的触发实验,还可以加一个反射和loadClass的对比:

public class InitCheck { public static void main(String[] args) throws Exception { System.out.println("--- 1. 通过子类引用父类静态字段 ---"); System.out.println(ChildDemo.value); System.out.println("--- 2. 定义数组 ---"); ParentDemo[] arr = new ParentDemo[3]; System.out.println("--- 3. 读取编译期常量 ---"); System.out.println(ParentDemo.CONST); System.out.println("--- 4. Class.forName ---"); Class.forName("ChildDemo"); System.out.println("--- 5. ClassLoader.loadClass ---"); ClassLoader.getSystemClassLoader().loadClass("ChildDemo"); } }

输出结果会清晰呈现:哪些操作只加载不初始化,哪些操作连加载都会提前发生。你可以在静态块里打上日志,再配合JVM参数观察加载日志,就能验证"加载和初始化不是同一件事"。这是我这几年分享给很多同学的一课。建议你也动手把代码跑一遍,然后试着改一改字段顺序、加一层继承,再看输出变化,这会让你对触发机制产生肌肉记忆。

6.2 初始化失败后的连锁反应

类初始化一旦在静态块里抛出异常,JVM会把这个首次失败记录下来,然后抛出ExceptionInInitializerError指向出错的类。更麻烦的是,之后任何线程再次主动使用这个类,都不会重试初始化,而是直接抛NoClassDefFoundError。所以线上如果看到NoClassDefFoundError,但类文件明明还在,第一反应应该是去查它是不是曾经初始化失败。

我曾经排过一个案例,某个类的静态块里做资源预加载,资源暂时不可用导致初始化抛异常,随后服务里所有用到这个类的线程全部报NoClassDefFoundError。修复方式不是检查CLASSPATH,而是先把静态块改成安全的延迟加载,再清理类加载器或者重启进程。这类问题最坑的地方在于:第一次的ExceptionInInitializerError往往会被前面的业务日志淹没,排查时一定要按时间线把同类加载器首次触发的时间点找出来。

6.3 排错时我常开的两个开关

排查类加载和初始化问题,我习惯先打开类加载跟踪参数。JDK 9之前是-verbose:class和-XX:+TraceClassLoading,JDK 9以后推荐用统一的日志命令,比如-Xlog:class+load=info、-Xlog:class+init=info。看到某一行日志出现"Loaded"但对应的静态块没有输出,就说明它只是被加载;直到出现初始化日志或者你的静态日志,才说明真正进入了初始化阶段。

另一个值得养成的习惯是,把静态块当成"类首次使用时的入口"来对待,而不是普通的工具方法。不要在静态块里做网络请求、不要启动线程、不要埋过重的缓存构建,除非你明确知道自己在承担类初始化锁的等待成本。如果确实需要预加载,尽量用懒加载或者独立初始化的方式,把类和初始化锁的耦合降下来。

最后再分享一句我自己的体会:类加载与类初始化的问题,大多数时候并不在高深的理论里,而是在一个静态块的异常、一个循环依赖的字段、一个提前被加载的类里面。能把这两条时间线分清楚,排查Java服务里的很多诡异问题就已经赢了一半。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询