技术演进中的开发沉思-313 JVM:类型与对象的完整生命周期
上个篇幅我们拆解了 Class 文件的静态结构,就像看懂了一份产品的设计图纸;而本章要聊的类型与对象的生命周期,就是这份图纸从 “进厂加工”(装载)到 “投入使用”(使用)再到 “报废回收”(卸载 / GC)的完整过程。二十余年的开发生涯里,我踩过最多的坑,往往不是代码逻辑错误,而是对生命周期的理解偏差 —— 比如误以为访问静态常量会触发类初始化,导致启动时加载了大量无用类;比如依赖finalize()做资源释放,结果因执行时机不确定导致文件句柄耗尽;比如忽略类卸载条件,导致元空间堆积大量无用类元数据最终溢出。读懂类型与对象的生命周期,就是读懂 Java 代码 “活” 的过程:你能知道类何时被加载、何时被初始化,对象何时被创建、何时被回收,从而精准掌控 JVM 的内存与性能,而非对着 “莫名其妙” 的 OOM 或性能问题束手无策。

一、类型的六段旅程
一个 Java 类(类型)从被 JVM 感知到最终被清理,要走过装载→连接(验证→准备→解析)→初始化→使用→卸载 六个阶段 —— 这是类的完整生命周期,也是 JVM 处理 Class 文件的核心流程。我习惯把这个过程比作 “工厂加工产品”:装载是 “采购原料(Class 文件)”,连接是 “检验、备料、预处理”,初始化是 “组装产品”,使用是 “投入市场”,卸载是 “产品报废回收”。
1. 装载
装载阶段的核心是 “找到 Class 文件,读取其二进制数据”—— 但这里的 “找到” 绝非仅从本地磁盘读取,这是我早年的认知误区:直到做 Java Applet 开发时,才发现类可以从网络下载;直到用动态代理生成$Proxy类时,才知道类可以完全在内存中动态生成,无需物理文件。JVM 的类装载器(我们第二章聊过的双亲委派模型)会按全限定名查找类的二进制流,来源可以是文件、网络、数据库,甚至是其他程序生成的字节数组 —— 只要字节流符合 Class 文件规范,就能被装载。我曾用自定义类加载器从数据库读取加密的 Class 文件,解密后再装载,这正是利用了 “装载阶段仅读取二进制数据” 的特性,实现了代码的简单加密。
2. 连接
连接是装载后的核心环节,分为验证、准备、解析三步,每一步都为后续执行筑牢基础:
- 验证:对应 JVM 的 Class 文件检验器,是 “质检环节”。它会校验 Class 文件的魔数、版本号、字节码合法性,确保文件未被篡改、不会危害 JVM 安全 —— 我曾故意修改 Class 文件的字节码指令,结果验证阶段直接抛出
VerifyError,连准备阶段都没进入。这也是 Java 沙箱机制的第一道防线,避免恶意字节码执行。 - 准备:为类变量(static 变量)分配内存并设置默认初始值,这是最容易踩坑的阶段。注意:这里只分配静态变量的内存(存储在方法区),且只赋默认值(int→0、boolean→false、引用类型→null),不会执行静态代码块或显式赋值。我早年写过
static int a = System.currentTimeMillis();,以为准备阶段 a 会被赋值为当前时间,结果调试发现 a 是 0—— 直到理解准备阶段的规则才明白:显式赋值要等到初始化阶段才执行,准备阶段只负责 “分配内存 + 赋默认值”。 - 解析:将常量池中的符号引用转换为直接引用(衔接第五章的常量池知识点)。比如把
CONSTANT_Methodref_info(符号引用:类名 + 方法名 + 描述符)转化为方法在内存中的实际地址。这里有个关键特性:解析不一定在初始化前完成 —— 静态方法、私有方法等 “非虚方法” 会立即解析,而重写的虚方法会延迟到实际调用时解析(动态绑定),这也是 Java 多态的底层支撑。
3. 初始化
初始化是类生命周期中最关键的阶段之一:执行类变量的显式赋值代码,以及静态代码块(static{}),且严格遵循 “父类优先” 原则 —— 子类初始化前,必须先初始化父类(接口除外,接口父类不会因子类初始化而初始化)。我曾调试过一个静态代码块执行顺序的 Bug:子类静态代码块依赖父类的静态变量,但父类静态变量还未初始化,就是因为忽略了 “父类优先初始化” 的规则。
初始化不是自动触发的,必须满足特定条件(本章核心重点),JVM 严格规定了五种 “主动使用” 场景才会触发初始化:
new实例化类(如new User());- 访问类的静态变量 / 静态方法(注意:编译期常量除外,下文详解);
- 反射调用类(如
Class.forName("com.test.User")); - 初始化子类(子类初始化必然触发父类初始化);
- 执行包含
main()方法的类(程序入口类必初始化)。
这里有个经典的坑:访问final static编译期常量不会触发初始化。比如public final static int MAX = 100;,访问User.MAX时,MAX 的值已在编译期存入常量池,JVM 直接读取常量池的值,不会触发 User 类的初始化;但如果是public static int MAX = 100;(非 final),或public final static int MAX = new Random().nextInt();(运行期常量),访问 MAX 就会触发初始化。我早年做服务启动优化时,就是把大量静态常量改为final static编译期常量,减少了启动时初始化的类数量,让启动速度提升了 20%。
4. 使用与卸载
初始化完成后,类就进入 “使用阶段”:创建实例、调用静态方法、访问静态变量,直到不再被使用。而类的 “卸载” 是极其苛刻的 —— 必须满足三个条件:
- 类的所有实例都已被 GC 回收;
- 加载该类的类装载器已被回收;
- 没有任何代码引用该类的
Class对象(如User.class)。
这里有个关键认知:系统类加载器(Application ClassLoader)加载的类几乎不会卸载 —— 比如java.lang.String、org.springframework.core.io.Resource,这些类会伴随 JVM 整个生命周期;只有自定义类加载器加载的类才可能被卸载。我早年做 Tomcat 热部署时,核心原理就是利用这一点:每个 Web 应用对应一个自定义类加载器,热部署时卸载旧加载器(满足卸载条件),再用新加载器加载新类,实现 “不重启服务更新代码”。
二、对象的生命周期
如果说类的生命周期是 “生产线” 的生命周期,那对象的生命周期就是 “产品” 的生命周期:实例化→使用→垃圾收集→终结(finalize ()),核心围绕堆内存展开。
1. 实例化
实例化是通过new、反射、克隆、反序列化等方式创建对象,JVM 会在堆中分配内存,初始化实例变量(默认值→显式赋值→构造方法赋值),并在 Java 栈中创建引用指向堆中的对象(衔接第四章的运行时数据区交互)。实例化与类初始化的区别是本章核心难点:
- 类初始化:类层面,静态,只执行一次,处理静态变量和静态代码块;
- 对象实例化:实例层面,非静态,每次
new都执行,处理实例变量和构造方法。
我曾见过新手写的错误代码:把 “每次实例化都要执行的逻辑” 放在静态代码块里,结果只有第一次实例化时执行,后续实例化都不生效 —— 这就是混淆了类初始化与对象实例化的典型问题。
2. 使用与垃圾收集
对象创建后进入使用阶段,直到变为 “不可达”(没有任何引用指向),就会被 GC 标记为可回收对象。这里要注意:GC 只回收堆中的对象内存,不会回收类元数据(类元数据在方法区 / 元空间,需类卸载才会回收)。
3. 终结(finalize ())
finalize()是 Object 类的方法,理论上是对象被回收前的 “最后执行机会”,但这也是 JVM 中最容易踩坑的机制(本章核心难点):
- 执行时机不确定:GC 时不一定执行
finalize(),可能对象已被回收,finalize()还没运行; - 只执行一次:即使在
finalize()中 “复活” 对象(重新引用),第二次 GC 时也不会再执行该对象的finalize(); - 性能损耗大:增加 GC 的复杂度,延长回收周期。
我早年曾用finalize()释放文件句柄,结果线上服务频繁报 “文件句柄耗尽”—— 排查发现finalize()执行延迟,大量文件句柄未及时释放。后来改用try-finally(或 try-with-resources)手动释放资源,问题彻底解决。如今我始终强调:永远不要依赖 finalize () 做资源释放,它的设计初衷是处理 “本地方法分配的内存”(JNI),而非业务资源。
三、重点难点拆解
1. 精准判断初始化触发条件(核心重点)
避免误触发初始化是性能优化的关键,我总结了几个易混淆场景的判断规则:
- ✅ 触发初始化:
new User()、User.age(静态变量)、User.sayHello()(静态方法)、Class.forName("com.test.User"); - ❌ 不触发初始化:访问
User.MAX(final static 编译期常量)、User.class(仅获取 Class 对象)、子类访问父类的 final static 常量、加载类但不主动使用(如类装载器装载后未做任何操作)。
2. 分清类型初始化与对象实例化(核心难点)
| 维度 | 类型初始化 | 对象实例化 |
|---|---|---|
| 触发时机 | 类被主动使用时 | 每次 new / 反射 / 反序列化时 |
| 操作对象 | 静态变量、静态代码块 | 实例变量、构造方法 |
| 执行次数 | 仅一次(类加载器加载后) | 每次实例化都执行 |
| 内存区域 | 方法区 / 元空间 | 堆内存 |
3. finalize () 的使用原则
- 禁用:业务代码中绝不使用,资源释放用
try-finally/try-with-resources; - 了解即可:知道其存在,但不要依赖 ——JDK 9 已将
finalize()标记为 Deprecated,后续可能移除。
4. 类卸载的实用场景
类卸载的核心价值是 “热部署”(如 Tomcat、Spring Boot DevTools),实现思路是:
- 自定义类加载器加载业务类;
- 热更新时,确保旧类满足卸载条件(无实例、无引用、类加载器可回收);
- 用新类加载器加载新的 Class 文件,实现代码更新。
最后小结:
二十余年的开发经历让我明白:对生命周期的理解深度,决定了 JVM 优化的上限。比如:
- 避免不必要的类初始化,能减少服务启动时间和内存占用;
- 理解对象实例化的内存分配逻辑,能优化高并发下的堆内存性能;
- 掌握类卸载条件,能实现优雅的热部署;
- 避开
finalize()的坑,能避免线上资源泄漏故障。
Class 文件是静态的 “图纸”,生命周期是动态的 “流程”,两者结合,才构成了 Java 代码从 “编写” 到 “执行” 的完整闭环。下一章,我们将聚焦 JVM 的 “执行引擎”—— 聊聊加载后的字节码,是如何被解释、编译为机器指令,最终在 CPU 上执行的;以及即时编译(JIT)如何让 Java 从 “解释执行” 的慢速度,逼近 C++ 等编译型语言的性能。
更多推荐




所有评论(0)