【JVM原理详解】05-类加载的生命周期
类加载的生命周期在上一篇模块中我们完成了对JVM内存结构的系统性梳理。从本篇开始我们进入「类加载机制」模块。一个Java类从源代码到最终在JVM中被执行中间经历了一系列精密的阶段——这就是类的生命周期。理解类加载的完整过程是掌握类加载器、双亲委派模型、热部署等高级主题的基础。本篇将逐一拆解类加载的7个阶段讲清每个阶段做什么以及为什么这样做并重点分析初始化的触发条件。类加载的生命周期概览一个类从被加载到虚拟机内存中开始到卸载出内存为止它的整个生命周期包括加载Loading→ 验证Verification→ 准备Preparation→ 解析Resolution→ 初始化Initialization→ 使用Using→ 卸载Unloading。其中加载、验证、准备、解析四个阶段合称为链接Linking。七个阶段的先后顺序如下图所示┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ ┌────────┐ ┌──────┐ ┌──────┐ │ 加载 │───▶│ 验证 │───▶│ 准备 │───▶│ 解析 │───▶│ 初始化 │───▶│ 使用 │───▶│ 卸载 │ └──────┘ └──────┘ └──────┘ └──────┘ └────────┘ └──────┘ └──────┘ Loading Verification Preparation Resolution Initialization Using Unloading │◀──────────────── 链接 Linking ────────────────▶│需要注意虽然上图展示的是线性顺序但实际过程中这些阶段并非严格串行。例如解析阶段在某些情况下可以在初始化之后才开始支持Java的运行时绑定/晚期绑定。不过加载、验证、准备、初始化的开始顺序是确定的。加载阶段加载阶段是类加载过程的第一步它的核心任务是通过类的全限定名获取定义此类的二进制字节流然后将这个字节流转化为方法区的运行时数据结构并在Java堆中生成一个代表这个类的java.lang.Class对象作为方法区这个类的各种数据的访问入口。简单来说加载阶段做三件事查找字节流通过类的全限定名获取定义此类的二进制字节流。字节流的来源非常灵活——可以是本地文件系统中的.class文件可以是网络下载的jar包可以是运行时动态生成的如动态代理甚至可以从加密文件中解密得到。转化为方法区数据结构将字节流所代表的静态存储结构转化为方法区的运行时数据结构。这包括将常量池、字段表、方法表等数据存入方法区。生成Class对象在Java堆中生成一个java.lang.Class对象作为方法区中该类数据的访问入口。// 加载阶段对开发者是透明的但可以通过Class对象感知结果Class?clazzClass.forName(com.example.MyClass);// 此时MyClass已完成加载阶段方法区中已有其数据结构// clazz就是堆中那个作为访问入口的Class对象加载阶段既可以使用JVM内置的引导类加载器完成也可以由用户自定义的类加载器完成。关于类加载器的具体内容我们将在下一篇详细展开。验证阶段验证是链接阶段的第一步目的是确保Class文件的字节流中包含的信息符合当前虚拟机的要求并且不会危害虚拟机自身的安全。如果验证失败会抛出VerifyError异常。Java语言本身是类型安全的语言但Class文件并不一定由Java源码编译而来——它可能来自其他语言甚至可能是被人为篡改的。因此JVM必须在加载后对字节流进行严格校验。验证阶段大致分为四个子阶段文件格式验证这一阶段验证字节流是否符合Class文件格式的规范。检查项包括是否以魔数0xCAFEBABE开头主次版本号是否在当前JVM处理范围内常量池中的常量是否有不被支持的类型指向常量池的索引值是否指向了不存在的常量通过文件格式验证后字节流才会被存入方法区后续三个验证阶段都是基于方法区的存储结构进行的。元数据验证对类的元数据进行语义分析确保其符合Java语言规范。例如类是否有父类除java.lang.Object外所有类都应有父类父类是否继承了不允许被继承的类如final类非抽象类是否实现了所有需要实现的接口方法字节码验证最复杂的验证阶段通过数据流和控制流分析确保程序语义是合法的、符合逻辑的。例如保证操作数栈的数据类型与指令代码序列能配合工作保证方法体中的类型转换是安全的保证跳转指令不会跳转到方法体之外符号引用验证发生在解析阶段之前确保符号引用可以正确解析为直接引用。例如符号引用中通过全限定名引用的类是否存在引用的字段和方法是否在目标类中存在且可访问准备阶段准备阶段是为类的静态变量分配内存并将其初始化为默认值的阶段。这些变量所使用的内存都将在方法区中进行分配。这里有一个非常容易混淆的考点准备阶段分配的是静态变量的内存不包括实例变量而且赋的是默认零值不是代码中赋的初始值。publicclassPreparationExample{// 准备阶段value被赋值为0int的默认零值// 注意不是123publicstaticintvalue123;// 准备阶段被final修饰的常量在准备阶段就会被赋值为123// 因为ConstantValue属性在编译期就已确定publicstaticfinalintCONSTANT123;// 实例变量不在此阶段分配随对象一起在堆上分配publicintinstanceVar456;}上面代码中value在准备阶段被赋值为0而真正的123是在初始化阶段通过执行类构造器clinit方法才赋值的。但如果变量被final修饰如CONSTANT编译时Javac会为其生成ConstantValue属性在准备阶段虚拟机就会直接赋予声明值。常见类型的默认零值表数据类型默认零值int0long0Lfloat0.0fdouble0.0dbooleanfalsereferencenull解析阶段解析阶段是将常量池内的符号引用替换为直接引用的过程。符号引用Symbolic Reference以一组符号来描述所引用的目标与虚拟机内存布局无关。符号引用的字面量形式明确定义在Class文件格式中。直接引用Direct Reference可以直接指向目标的指针、相对偏移量或间接定位到目标的句柄。直接引用与虚拟机的内存布局相关。解析主要针对以下几类符号引用类或接口的解析判断是否为数组、是否需要加载父类和接口等字段解析先在本类查找再递归查找接口和父类方法解析类方法还是接口方法的查找规则不同接口方法解析接口方法的解析流程publicclassResolutionExample{// 在解析阶段对Object类的符号引用会被替换为直接引用// 编译后常量池中有一项CONSTANT_Class_info指向java/lang/Object// 解析后变为指向方法区中Object类数据的直接指针privateObjectobjnewObject();// 对String.length()方法的调用也会被解析// 从符号引用方法名描述符变为直接引用方法表中的偏移量publicintgetLength(Stringstr){returnstr.length();}}并非所有的符号引用都会在类加载时就完成解析。对于支持晚期绑定的语言如Java的多态部分符号引用的解析会在第一次使用该符号时才进行甚至每次使用时都动态解析。初始化阶段初始化是类加载的最后一步也是真正开始执行类中定义的Java程序代码或者说字节码的阶段。在准备阶段变量已经赋过一次默认零值而在初始化阶段则通过执行类构造器clinit方法来为静态变量赋予实际初始值。clinit方法的特点clinit方法由编译器自动收集类中所有静态变量的赋值动作和**静态代码块static {}**合并产生收集顺序由源文件中出现的顺序决定。publicclassInitOrderExample{static{value10;// 可以赋值编译通过// System.out.println(value); // 编译报错非法向前引用System.out.println(静态代码块执行);}staticintvalue20;// 会被收集到clinit中// clinit方法等价于按顺序执行// 1. value 10来自static块// 2. System.out.println(静态代码块执行)// 3. value 20来自静态变量赋值}clinit方法的几个关键特性线程安全JVM会保证clinit方法在多线程环境下被正确加锁和同步。这意味着如果多个线程同时去初始化一个类只会有一个线程执行clinit其他线程阻塞等待。利用这个特性可以实现线程安全的单例模式。父类优先JVM保证子类的clinit执行前父类的clinit已经执行完毕。非必需如果一个类没有静态变量赋值或静态代码块编译器可以不生成clinit方法。接口特殊性接口中不能有静态代码块但可以有静态变量赋值。接口的clinit执行不要求父接口先执行只有在真正使用父接口中的变量时才会初始化父接口。// 利用clinit线程安全特性实现的单例模式publicclassSingleton{privateSingleton(){}// 静态内部类不会在Singleton加载时就初始化// 只有在getInstance()被调用时Holder类才会被加载和初始化// 此时JVM保证clinit的线程安全性privatestaticclassHolder{privatestaticfinalSingletonINSTANCEnewSingleton();}publicstaticSingletongetInstance(){returnHolder.INSTANCE;}}初始化的触发条件6种主动引用JVM规范严格规定了类的初始化时机只有以下6种情况会触发类的初始化称为主动引用创建类的实例new、反射、反序列化、clone访问类的静态变量非final常量或为其赋值调用类的静态方法反射调用Class.forName()初始化子类时父类会被先初始化JVM启动时包含main方法的类会被初始化// 主动引用示例publicclassActiveReferenceDemo{publicstaticvoidmain(String[]args)throwsException{// 1. new实例化// new MyClass(); // 触发初始化// 2. 访问静态变量非final// int v MyClass.value; // 触发初始化// 3. 调用静态方法// MyClass.doSomething(); // 触发初始化// 4. 反射调用// Class.forName(com.example.MyClass); // 触发初始化// 5. 初始化子类时先初始化父类// new ChildClass(); // 先触发ParentClass初始化再触发ChildClass初始化// 6. main方法所在类// ActiveReferenceDemo本身会被初始化}}被动引用不触发初始化的场景与主动引用相对某些场景看起来会触发类的初始化但实际上不会这些称为被动引用。理解被动引用有助于避免对类加载时机的误判。场景一通过子类引用父类的静态字段classParent{static{System.out.println(Parent类初始化);}staticintvalue100;}classChildextendsParent{static{System.out.println(Child类初始化);}}publicclassPassiveReferenceDemo1{publicstaticvoidmain(String[]args){// 只会输出Parent类初始化不会输出Child类初始化// 因为value是父类定义的只触发父类初始化System.out.println(Child.value);}}场景二通过数组定义引用类publicclassPassiveReferenceDemo2{publicstaticvoidmain(String[]args){// 不会触发Parent类的初始化// JVM会生成一个数组类型 [Lcom.example.Parent; 的类Parent[]parentsnewParent[10];}}场景三引用编译期常量classConstants{static{System.out.println(Constants类初始化);}// 编译期常量在调用类的常量池中就存了值publicstaticfinalintMAX65535;}publicclassPassiveReferenceDemo3{publicstaticvoidmain(String[]args){// 不会输出Constants类初始化// 因为MAX是编译期常量在编译阶段就被存入PassiveReferenceDemo3的常量池中// 运行时直接从常量池取值不需要加载Constants类System.out.println(Constants.MAX);}}需要特别注意JDK 8 与 JDK 11/17 在接口中默认方法的初始化行为上有细微差异。JDK 8 开始接口可以有default方法如果一个类实现了接口但未重写default方法在初始化该类时接口的初始化行为需要参照规范的具体条款。使用与卸载使用阶段类完成初始化后就可以被程序正常使用了——创建实例、调用方法、访问字段等。卸载阶段当一个类不再被使用JVM可以在合适的时候将其卸载。卸载需要满足三个条件该类所有的实例都已被回收加载该类的类加载器已经被回收该类对应的Class对象没有在任何地方被引用在JDK 8中类信息存储在永久代PermGen类的卸载与永久代的垃圾回收相关。JDK 8之后永久代被移除类信息存储在元空间Metaspace中元空间使用本地内存类卸载机制也相应调整。在实际生产中类的卸载主要发生在热部署、动态语言支持等场景大部分类在应用运行期间不会被卸载。实践要点clinit线程安全的双刃剑虽然clinit保证线程安全但如果在静态代码块中执行耗时操作会导致其他线程长时间阻塞。避免在静态初始化中做重计算或I/O操作。常量的本质只有编译期可确定的常量基本类型和String字面量才会在准备阶段就赋值。通过方法返回的常量如static final int X someMethod()不是编译期常量在准备阶段仍是零值初始化阶段才赋值。类初始化死锁多线程环境下如果两个线程分别等待对方所在类的初始化完成会形成死锁。虽然clinit有锁但锁是按类粒度的交叉等待时可能产生死锁。观察类加载过程使用-verbose:class参数可以观察类的加载顺序和加载来源是排查类加载问题的第一步。java-verbose:class-jaryour-app.jar-Xverify:none的风险在早期JVM中可以关闭字节码验证以加速启动但JDK 13以后该选项已被废弃。生产环境绝不应关闭验证这会失去对恶意字节流的防护。小结类的生命周期包括加载→验证→准备→解析→初始化→使用→卸载七个阶段其中前四个合称链接。加载阶段通过类加载器获取字节流并在方法区建立数据结构验证阶段确保字节流安全合法准备阶段为静态变量分配内存并赋零值解析阶段将符号引用转为直接引用初始化阶段执行clinit赋予静态变量实际值。初始化只在6种主动引用场景下触发而通过子类引用父类静态字段、数组定义、引用编译期常量这三种被动引用不会触发初始化。clinit方法由静态变量赋值和静态代码块组成具有线程安全性和父类优先性。下一篇我们将深入类加载器本身剖析JVM中各类加载器的职责划分和著名的双亲委派模型。