资讯详情

JVM类加载与类初始化:从双亲委派到静态块执行时机解析

📅 2026/10/13 4:51:59 | 华诺云谱 👁 阅读
JVM类加载与类初始化:从双亲委派到静态块执行时机解析
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是0long是0Lboolean是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:TraceClassLoadingJDK 9以后推荐用统一的日志命令比如-Xlog:classloadinfo、-Xlog:classinitinfo。看到某一行日志出现Loaded但对应的静态块没有输出就说明它只是被加载直到出现初始化日志或者你的静态日志才说明真正进入了初始化阶段。另一个值得养成的习惯是把静态块当成类首次使用时的入口来对待而不是普通的工具方法。不要在静态块里做网络请求、不要启动线程、不要埋过重的缓存构建除非你明确知道自己在承担类初始化锁的等待成本。如果确实需要预加载尽量用懒加载或者独立初始化的方式把类和初始化锁的耦合降下来。最后再分享一句我自己的体会类加载与类初始化的问题大多数时候并不在高深的理论里而是在一个静态块的异常、一个循环依赖的字段、一个提前被加载的类里面。能把这两条时间线分清楚排查Java服务里的很多诡异问题就已经赢了一半。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑