
写Java写了快十年我最常被新同事问倒的问题居然不是那些看起来很唬人的分布式事务、高并发设计而是这么一句听起来很像面试八股的话“Java的类生命周期有哪几个阶段”表面看这是个背题就能答的送分题但只要你往深处多问一句——加载和初始化是一回事吗static final 常量到底会不会触发类初始化为什么 Tomcat 重新部署一次就能加载全新代码——绝大多数人就卡住了。我也确实因为不理解这条链路踩过大坑线上Metaspace OOM查到最后发现是自定义类加载器反复加载同一个类那一刻我才真正意识到类生命周期的 7 大阶段不是用来应付面试的它是一张理解 JVM 运行时行为的地图。这篇文章我打算把 7 大阶段——加载、验证、准备、解析、初始化、使用、卸载——逐个拆开讲清楚同时带上源码逻辑、实际案例和线排淤排查经验。适合三类人看准备 Java 面试的开发者、正在排查类加载相关问题的工程师以及那些写了好几年代码但始终没把 JVM 地基打牢的同学。全文不会只停留在概念背诵我尽量把每一步的原理和“为什么”都交代清楚。1. 为什么要把类生命周期当成“地图”而不是“八股文”先说一个我真实遇到过的现象。有个朋友的项目里有人把配置从常量改成了动态读取配置中心的值部署之后怎么都不生效。代码看起来完全没问题改的字段也是个普通的static String但运行时的取值始终是旧值。排查了半天最后发现这个字段的修饰符是static finaljavac 在编译期就会把引用它的地方直接替换成常量本身运行时代码里根本没有去读取这个字段的指令类初始化也压根不会重新执行。如果他不理解“编译期常量替换”和“类初始化时机”这两件事这个问题在他眼里就是玄学。这其实就是一个非常典型的例子类生命周期不是一个孤立的 JVM 知识点它决定了你的代码什么时候被执行、什么时候不被执行、对象和类在内存里各占什么位置、以及一个类到底能不能被回收。无论是线上偶现的“改了代码没生效”、启动时NoClassDefFoundError、还是Metaspace不断上涨最终 OOM归到根上全是类生命周期的问题。《Java虚拟机规范》里对类生命周期的定义一般归纳为下图这些阶段阶段主要工作内存位置典型误区加载读取字节码生成 Class 对象Java 堆中的 Class 对象、方法区中的类元信息以为“加载了”就等于“初始化了”验证校验字节码格式与语义安全无忽略字节码可能被篡改的风险准备为静态变量分配内存并设零值方法区 / 元空间以为这里会赋代码里的初值解析将符号引用替换为直接引用方法区 / 运行时常量池解析必须严格按顺序发生初始化执行静态变量赋值和静态代码块方法区 / 元空间以为 new 对象就一定初始化类使用对象创建、调用、GC 管理Java 堆把“类”与“实例”混为一谈卸载类元数据从元空间移除元空间以为卸载条件只看“没有对象”这张表你可以存着后面每一节都会反复用到它。但要记住加载、验证、准备、解析、初始化这五个阶段是“按顺序开始”而不是“按顺序完成”——解析阶段的开始时间在规范里其实是允许晚于初始化的这是为了给 Java 的动态绑定留出空间后面我会专门讲。2. 加载、验证、准备、解析JVM 动手前的“入职流程”2.1 加载把字节码变成内存里的“类模板”加载阶段做的事简单说就是根据类的全限定名获取二进制字节流然后把字节流中的静态存储结构转换成方法区HotSpot 里 JDK 8 以后叫元空间里的运行时数据结构再在 Java 堆中生成一个java.lang.Class对象作为访问方法区数据的入口。这里有个容易忽略的点字节流不一定是 .class 文件。虽然最常见的来源是 classpath 下的 jar 包、war 包但它也可以是网络传输的字节流、动态代理生成的字节码、JSP 编译后的 class甚至是数据库里存的一段二进制。我们写框架的人经常在这上面做文章比如 Spring 的ReflectiveASM、MyBatis 的 Mapper 代理、各种字节码增强工具本质上都是在“加载”这一步偷梁换柱。执行加载动作的是类加载器。HotSpot 里默认有三层启动类加载器Bootstrap ClassLoader、平台类加载器JDK 9 之前是扩展类加载器、应用类加载器Application ClassLoader。它们之间遵循双亲委派模型就是“先让父加载器尝试加载父加载器加载不了才轮到子加载器”。这样做最大的好处是保证核心类库的类不会被用户自定义的同名类替换掉。我见过不少人把“类加载器”和“类加载”混在一起说其实这里对概念的颗粒度要求很高。类加载器解决的是“由谁去找字节码”而加载阶段本身是 JVM 实现层面的动作。面试时如果能区分这两个概念比单纯背双亲委派加分得多。2.2 验证JVM 的自保机制比你想的更严格验证阶段是很多博客一句话带过的部分但它恰恰是 JVM 安全模型的第一道防线。为什么要有验证因为你要运行的不一定是你自己编译的 class。一个字节码文件可以被手工修改、可以被反编译后篡改如果不做任何校验就让 JVM 去执行恶意代码完全可以构造非法指令把 JVM 搞崩甚至绕过权限控制。验证阶段包含四个动作文件格式验证、元数据验证、字节码验证、符号引用验证。文件格式验证是最容易撞到的经典的CAFEBABE就是我们常说的 class 文件魔数放在文件头四个字节。如果你的 class 文件被截断、被篡改第一道验证就会抛ClassFormatError。元数据验证检查类的语义比如父类是否合法、是否继承了 final 类。字节码验证是整个验证阶段的重点它会通过数据流分析判断方法体的字节码指令是否安全。符号引用验证发生在解析阶段附近确保引用的类、字段、方法都存在且可见。这里有个实战细节字节码验证非常耗时尤其大应用启动时量级能占到启动时间的一两成。所以很多生产环境会加-Xverify:none跳过验证换取更快的启动速度。我个人的建议是如果你确定自己的字节码来源完全可信可以在测试环境用一用提升开发迭代速度但线上除非特别追求启动时间否则别全量关毕竟你无法保证所有 jar 包都不会出现字节码问题。还有一个很常见的异常跟这个阶段相关UnsupportedClassVersionError。它本质是类文件版本号和当前 JVM 支持版本不匹配发生在验证阶段。很多人一看到这个报错就懵其实只要知道这是“类的版本比 JVM 老/新”的信号解决方案无非是换 JDK 版本或重新编译。2.3 准备静态变量的“零值”真相准备阶段是最容易被“看起来很简单”这句话坑住的阶段。它的核心任务是为类中的静态变量分配内存并设置初始零值。注意是“零值”不是代码里写的那个初值。直接看例子public class UserService { private static int count 5; private static final String NAME demo; }在准备阶段count会被设为 0而不是 5。为什么因为编译后的putstatic指令是在初始化阶段才会执行的准备阶段只负责把内存清好把变量占住位置。等到初始化阶段执行到count 5这条赋值逻辑时5 才真正写进去。但NAME这种static final常量是特例。javac 会在常量属性ConstantValue里记录它的值准备阶段直接就把demo放进去了。原因也很朴素final 常量不可能再被修改没必要留到初始化阶段再赋值。这也是为什么面试里经常问“static final常量会不会触发类初始化”——它连初始化阶段都不用等自然也就不会因为访问它而触发类的初始化。这个细节后面还会展开。还有一个容易混淆的点是“零值”到底是什么。引用类型的零值是 nullint 是 0boolean 是 falsefloat 是 0.0f。很多人在写单例或者工具类时静态字段没有显式初始化就拿来用结果拿到 null 或者 0就是没搞明白准备阶段和初始化阶段的界限。说实话准备阶段本身不长但它牵扯出的“初值到底是谁赋的”这个问题对理解 Java 的类加载顺序非常有帮助。2.4 解析符号引用到直接引用的“翻译官”解析阶段做的事一句话概括把运行时常量池里的符号引用替换为直接引用。符号引用是什么你可以把它理解成一组“字符串形式的坐标”由全限定名、字段名或方法名、描述符组成。比如java/lang/String.length()I它描述的是“String 类里那个返回 int 的 length 方法”但它还不是内存里的真实地址。直接引用则不同它是指向目标的指针、偏移量或句柄拿到它 JVM 才能执行具体的字段访问和方法调用。打个比方符号引用相当于你只知道某人的“姓名公司工号”你要联系他还得先找到他的联系方式直接引用就是你跟他已经在同一个群里点开头像就能发消息。解析就是把这个“找人”的过程做掉。这里我要回应一下热搜词里那句“java是静态链接的”。说实话这是个很常见的认知误区。Java 的 class 文件里大量使用的是符号引用真正的绑定动作是运行期由 JVM 完成的。一个类可以被不同的类加载器加载多次每加载一次符号引用所解析出的直接引用都可能不同。正是这种“运行时绑定”的灵活性才让框架的插拔、热部署、字节码增强这些玩法有了基础。所以严格说Java 不是纯静态链接它在解析阶段保留了大量动态空间。还有一个细节必须强调解析阶段并不一定发生在初始化之前。比如调用接口方法、调用invokedynamic指令时解析会被延迟到第一次执行相关指令时才进行。这就是为什么《Java虚拟机规范》说 5 个阶段是“按顺序开始”而不是“按顺序完成”。解析这个阶段尤其特殊它允许插队这也是类生命周期里最容易和面试官聊出深度的地方。3. 初始化阶段你以为的开始往往是陷阱初始化阶段是很多人最熟悉、也最容易翻车的阶段。它负责执行类构造器clinit方法也就是收集所有的静态变量赋值语句和静态代码块按代码顺序合成一段逻辑。注意这个clinit方法是 JVM 自动生成的正常代码里你是看不到的。3.1 主动引用与被动引用什么时候才会触发初始化《Java虚拟机规范》里明确列出了 6 种触发类初始化的主动引用场景遇到new、getstatic、putstatic、invokestatic这四条字节码指令时如果类还没初始化就触发初始化。使用java.lang.reflect包的方法对类进行反射调用时。初始化子类时父类还没初始化先触发父类初始化。JVM 启动时先初始化包含main方法的主类。使用 JDK 7 的动态语言支持时MethodHandle解析结果指向的类如果没初始化就触发。当一个接口定义了默认方法并且这个接口的实现类被初始化时接口自身也要初始化JDK 8 新增。与之相对的有三个非常经典的被动引用场景是面试题里的“钉子户”通过子类引用父类的静态字段只会触发父类的初始化不会触发子类的初始化。比如Child.getName()如果getName是父类的静态方法子类不会初始化。通过数组定义来引用类不会触发这个类的初始化。User[] users new User[10]只是创建了一个数组对象数组类由 JVM 直接生成跟User类本身无关。访问static final常量不会触发初始化。因为常量在准备阶段就已经被写进常量池了运行期用的直接把值替换进去根本不需要类初始化。最好用表格把两组场景放在一起对比类别场景是否触发初始化主动引用new一个对象触发主动引用调用类的静态方法触发主动引用读取/写入类的静态字段非 final 常量触发主动引用反射调用Class.forName(X)触发不带initializefalse时被动引用访问static final常量不触发被动引用通过子类引用父类静态字段只触发父类被动引用new Xxx[10]数组定义不触发被动引用ClassLoader.loadClass(X)默认调用不触发默认不连接不初始化我特别想强调最后一行的区别。很多人背了“Class.forName和ClassLoader.loadClass的区别”这道题就只知道“一个有初始化一个没初始化”但不知道为什么。你看Class.forName的源码签名是forName(String name, boolean initialize, ClassLoader loader)initialize默认是 true它打开的是完整链路而ClassLoader.loadClass默认resolve参数是 false意思是“只加载不链接”链接都没做自然不会有初始化阶段。老 JDBC 代码里为什么都要写Class.forName(com.mysql.jdbc.Driver)就是要触发驱动类的初始化让静态块里把 Driver 实例注册进DriverManager。3.2 类初始化顺序与线程安全一个不可忽视的死锁现场初始化阶段的执行顺序很简单静态变量赋值语句在前静态代码块按源码顺序执行它们的整体就是clinit。但顺序简单并发问题不简单。JVM 有个机制保证同一个类在多线程环境下只会被初始化一次当某个线程开始初始化一个类时其他线程如果也来初始化这个类会阻塞等待。这相当于给clinit加了一把锁。但这个锁也带来了一个隐蔽的问题——如果两个类在静态代码块里互相依赖初始化就可能死锁。举个抽象例子class A { static { System.out.println(A init start); new B(); System.out.println(A init end); } } class B { static { System.out.println(B init start); new A(); System.out.println(B init end); } }如果线程 1 先初始化 A线程 2 同时初始化 BA 的静态块里要 new BB 的静态块里又要 new A两边都拿着锁等对方释放。这种死锁一旦在线上出现排查起来非常隐蔽因为没有任何异常堆栈提示你“类初始化死锁”只有线程 dump 里能看到大量CLINIT状态的线程。我的建议是不要在静态块里做重型依赖初始化尤其不要写成“你初始化我、我初始化你”这种循环结构。静态块适合做轻量级的常量准备复杂对象交给 Spring 容器或懒加载。这个坑我在代码评审里看到过太多次很多时候不是写代码的人不懂而是压根没意识到静态块会参与并发的锁竞争。4. 使用与卸载类的“后半生”比前半生更难管理4.1 使用阶段对象创建与类生命周期解耦加载、验证、准备、解析、初始化都完成后类就真正“就绪”了进入使用阶段。这阶段最常见的问题是很多人把“创建对象”和“类初始化”混在一起。其实 new 一个对象JVM 内部走的是另一条指令流程先做类加载检查如果没有初始化就先初始化然后在堆上分配内存、把实例字段设为零值、设置对象头Mark Word、类型指针、最后执行构造方法init。这个过程和类的clinit是两回事。clinit是类的构造器init是对象的构造器。一个类可以 new 出无数个对象出来但clinit只执行一次。理解这层关系之后你再去看 Spring 的 Bean 生命周期、JPA 的延迟加载都会清晰很多。使用阶段还涉及GC对实例的管理。不过这里要分清对象和类的关系对象在堆上类是元数据在元空间。一个类即使一个对象都没有了类元数据也不一定马上卸载。什么时候卸载条件很苛刻见下一节。4.2 卸载类的生命周期终点《Java虚拟机规范》对类卸载的定义很简单一个类被卸载需要有同时满足以下三个条件该类的所有实例都已被回收堆中不存在任何该类的实例。加载该类的 ClassLoader 已经被回收。该类对应的Class对象没有任何引用无法再通过反射等方式访问。这里最核心的是第二条。由启动类加载器加载的类比如java.lang.String永远不会被卸载因为启动类加载器本身不可能被回收。自定义 ClassLoader 加载的类能不能卸载取决于这个 ClassLoader 能不能被回收。而 ClassLoader 被回收又要求它不被任何对象引用。现实中很多框架会持有 ClassLoader 的引用比如线程上下文类加载器、Tomcat 的 WebappClassLoader 等只要有一处强引用存在相关的类就永远“卸载不掉”。4.3 热部署、Metaspace 泄漏后半生的真实战场理解了“类卸载靠 ClassLoader 回收”热部署的原理就一目了然了。Tomcat 每次重新部署一个 Web 应用不是“把老类原地更新”而是重新创建一个 WebappClassLoader用全新的 ClassLoader 去加载新版本的类。新旧两个 ClassLoader 都会加载同样的com.example.UserController但它们是不同的类彼此之间不能互相赋值也不会互相冲突。这个机制同样解释了为什么频繁重新部署会导致 Metaspace 上涨。每次创建新 ClassLoader、加载一批新类如果旧的 ClassLoader 没有彻底被回收那它加载的所有类元数据就一直堆在 Metaspace 里。日积月累就是 Metaspace OOM。我之前处理过一个线上案例某个接口每调用一次都会通过反射配合Proxy动态生成一个新的代理类而动态代理默认使用的 ClassLoader 又没有缓存等于每次请求都往 Metaspace 里写一堆类定义。最终结果就是服务运行几天后 Metaspace 飙满GC 日志里全是 Full GC 尝试回收元空间。要定位这类问题就是沿着生命周期这张地图一路查先看类是否大量重复加载再看是哪个 ClassLoader 在反复创建最后定位到代码里的动态生成点。5. 用 JVM 参数和工具亲眼看见类的一生5.1 打开加载与卸载日志理论说了一堆最好还是亲自看看类是怎么加载的。HotSpot 提供了一组简单直接的参数。JDK 8 及以前用java -XX:TraceClassLoading -XX:TraceClassUnloading -jar YourApp.jarJDK 9 以后建议用统一日志系统格式稍微有点变化java -Xlog:classloadinfo -Xlog:classunloadinfo -jar YourApp.jar运行后你会看到类似这样的输出[0.118s][info][class,load] java.lang.Object source: jrt:/java.base [0.123s][info][class,load] java.io.Serializable source: jrt:/java.base [0.887s][info][class,load] com.example.UserService source: file:/app/classes/结合前面的理论看这个日志你会直观感受到JVM 启动进程时先加载核心类等业务代码里第一次访问UserService时才轮到它被加载。如果你在日志里看到同一个类被反复加载就要警惕是否有多个 ClassLoader 在重复加载或者某个框架在频繁创建新的 ClassLoader。clown 提醒一下TraceClassLoading的日志量非常大生产环境不要长期开可以先在小流量实例上开一段时间收集到信息后立刻关掉。5.2 一个 Metaspace OOM 的完整排查过程前面提到的 Metaspace OOM 案例我把排查链路完整写出来很多人问我是怎么定位的其实思路就是对照类生命周期一步步排查一点都不玄。第一步看 GC 日志。如果发现 Metaspace 使用率持续增长且 Full GC 无法回收基本可以确定是类加载导致的元空间泄漏而不是常规对象泄漏。第二步打开类加载日志。用上面的-Xlog:classloadinfo配合-Xlog:classunloadinfo观察有没有大量重复加载的业务类。很多时候你会看到一个后缀类似$Proxy1、$FastClassByCGLIB的类名疯狂刷屏。第三步拿到 thread dump 或 heap dump查这些动态类的 ClassLoader 是谁。常见的源头有这几类反射 Proxy动态代理每次创建代理类时没有复用 ClassLoader。CGLIB、ASM 等字节码工具在运行时生成了大量新类。热部署框架反复创建新的 ClassLoader老的没有被释放。自研框架里用defineClass动态定义类用完就丢。第四步定位代码后修复方式一般是把 ClassLoader 缓存下来复用或者把动态生成类的逻辑改成静态预生成。修复完再看 Metaspace 曲线平稳就意味着问题解决了。说实话这个排查过程不需要你会什么特别高深的工具核心就是对类生命周期里“加载”和“卸载”的理解。你知道“类是可以被卸载的”你才会去查“为什么这里卸载不掉”你知道“类的加载和 ClassLoader 强绑定”你才会把目光从单个类转移到 ClassLoader 上。6. 面试高频题应答把“7大阶段”变成你的谈资既然标题里写了“Java类生命周期”就得聊聊面试。说实话面试官问这个问题不是为了听你背出 7 个名词而是看你有没有把“类从字节码到对象再到回收”的全过程讲顺。我整理了几个高频问题附上一点应答思路你可以对着镜子练。第一个问题“Java 类生命周期分哪几个阶段”不要只报名字。更好的答法是先说 7 个阶段然后主动强调两个关键点——一是“加载、验证、准备、解析、初始化是按顺序开始解析可以在初始化之后”二是“使用和卸载阶段容易被忽略但它们对应实际问题热部署、Metaspace 泄漏”。这两句话一出来面试官基本就知道你是真懂还是背答案。第二个问题“Class.forName 和 ClassLoader.loadClass 有什么区别”核心差异就一个Class.forName默认会连接并初始化类ClassLoader.loadClass默认只加载不连接。补充一个加分项提到 JDBC 时代为什么要用Class.forName因为驱动类的静态块负责注册 Driver。如果你还能说出forName有initialize参数可以控制那就更好了。第三个问题“访问 static final 常量会触发类初始化吗”不会。原因有两层编译期常量替换 准备阶段就已经通过ConstantValue属性把值写好了。如果能补充“但如果不是编译期可确定的常量比如System.currentTimeMillis()行为就不同了”说明你对细节有把握。第四个问题“多线程下类初始化会执行几次JVM 怎么保证”只会执行一次。JVM 在类初始化阶段会加锁一个线程初始化时其他线程阻塞等待。回答时能主动提到“有死锁可能”以及“静态块里不要做复杂依赖初始化”的实践教训这题就不只是八股而是你的实战经验了。第五个问题“一个类什么时候会被卸载”给出三个条件实例全回收、ClassLoader 可回收、Class 对象无引用。然后主动延伸到“启动类加载器加载的类不会被卸载”和“Tomcat 热部署就是基于这个原理”整道题就从死记硬背变成了工程理解。我始终觉得面试题背是背不完的但类生命周期这种“元知识”你只要把它想成地图很多题会自己串起来。面试官问左问右本质都是让你在这张地图上定位。7. 写代码时多想想“这个类此刻处于哪一阶段”理解生命周期之后我的编程习惯确实变了一些。写公共工具类或者基础设施代码时我会刻意避开在静态块里初始化外部依赖因为我知道静态块参与类初始化的锁竞争一旦被多线程触发可能成为隐形的性能瓶颈。排查问题时也多了个视角。比如一个接口偶尔第一次调用很慢我现在会下意识去看是不是类加载触发的一个服务频繁 Full GC我会先查 Metaspace 而不是只盯着堆。类生命周期这张地图本质上给了我们一套“先看哪、再看哪”的排查顺序。最后我给你留一个练手的小作业找一个你手头正在跑的服务加上-Xlog:classloadinfo跑几分钟看看日志里哪些类先加载、哪些类后加载有没有同一个类被反复加载。这个动作的成本很低但对“类生命周期”的体感帮助极大。等你能对着日志讲清楚一个类从加载到卸载的完整过程这 7 个阶段就算真正长在你脑子里了。