对象在JVM中的生命周期:从new到GC的完整链路
对象在JVM中的生命周期:从new到GC的完整链路
适读人群:Java中高级开发工程师 | 阅读时长:约16分钟 | 适用JDK版本:JDK 8 / 11 / 17 / 21
开篇故事
2021年,我负责一个电商订单系统的性能优化。系统的Young GC非常频繁,平均每5秒就触发一次,每次停顿约80ms。算下来每分钟GC时间将近1秒,占整体运行时间的1.7%。用户感知上就是偶尔的"卡一下"。
我查看GC日志,发现每次GC回收的对象量非常大,Eden区几乎是满了才触发GC,回收后存活下来晋升到Survivor的对象却很少——说明大部分对象的生命周期都很短。这本来是个好现象,但问题在于GC太频繁了。
继续深挖,用jmap -histo看了一眼堆内存的对象分布,发现了大量的byte[]、char[]和字符串对象。追查代码,发现是一个日志打印逻辑:在每个订单处理的关键路径上,用JSON.toJSONString(order)把整个订单对象序列化成JSON字符串写日志,而订单对象本身有几十个字段,序列化过程中会产生大量临时字符串。每秒处理的订单量在2000+,这些临时对象把Eden区填得飞快。
解决方案很简单:把日志级别从INFO改成DEBUG,让这段序列化只在调试时才执行;同时改用结构化日志,只打印关键字段。改完之后,Young GC频率从每5秒一次降到每45秒一次,停顿时间从80ms降到60ms,系统整体吞吐量提升了约8%。
这件事让我意识到:真正理解对象的创建和销毁过程,才能做出有针对性的优化。
一、问题根因分析
很多开发者对new关键字习以为常,却很少想过这条语句背后到底发生了什么。从JVM角度来看,对象创建到销毁的完整链路涉及:内存分配、对象初始化、引用追踪、GC标记、内存回收等多个步骤,每一步都有可以优化的空间。
在我的调优经历中,对象生命周期相关的问题主要表现为以下几种:
问题一:对象创建过于频繁,Eden区迅速被填满,Young GC频繁触发,CPU被GC占用比例过高。
问题二:短命对象意外晋升老年代,导致老年代空间快速增长,最终触发Full GC。最常见的原因是Survivor区太小,存放不下存活对象,只能直接晋升。
问题三:对象引用链复杂,GC无法回收,即内存泄漏。GC只能回收不可达对象,如果某个对象被不恰当地长期持有引用,就永远不会被回收。
问题四:对象头信息理解不足,不清楚锁状态和GC年龄是怎么存储的,导致锁优化和GC年龄相关的参数调整没有理论依据。
二、原理深度解析
2.1 对象的创建过程
new Object()这行代码在JVM层面触发了一系列操作:
第一步:类检查。JVM检查new指令的参数是否能在常量池中定位到一个类的符号引用,并检查这个符号引用代表的类是否已被加载、解析和初始化。如果没有,则先执行类加载。
第二步:内存分配。类加载检查通过后,JVM为新对象分配内存。分配方式取决于堆是否规整:
- 指针碰撞(Bump the Pointer):堆内存规整时,只需将已用和未用内存的分界指针向未用区域移动对象大小的距离。Serial、ParNew等带压缩整理的GC使用这种方式。
- 空闲列表(Free List):堆内存不规整时,JVM维护一个记录可用内存块的列表,找到足够大的空间分配。CMS使用这种方式。
TLAB(Thread Local Allocation Buffer) 是解决内存分配线程安全问题的重要机制。每个线程在Eden区预先申请一小块私有内存(默认占Eden的1%),对象优先在TLAB中分配,不需要任何同步操作。TLAB用完之后,再通过CAS申请新的TLAB。这是JVM中对象分配速度快的关键原因之一——大多数情况下,分配内存只是一个指针自增操作。
# 查看TLAB相关统计
jstat -gccapacity <pid>
# 启用TLAB详细日志(JDK 8)
-XX:+PrintTLAB
# JDK 11+用统一日志
-Xlog:gc+tlab=debug第三步:对象头初始化。内存分配完成后,JVM将分配到的内存空间(不包括对象头)都初始化为零值。然后设置对象头信息,包括:
- Mark Word:存储对象的运行时数据(哈希码、GC年龄、锁状态等)
- 类型指针:指向对象所属类的元数据
第四步:执行init方法。最后执行<init>方法,按照程序员定义的方式初始化对象,这才是new指令的语义完成。
2.2 对象头的详细结构
理解对象头是理解锁升级和GC年龄的基础。在64位JVM中(开启指针压缩),对象头的结构如下:
64位Mark Word在不同状态下的布局:
无锁状态:
[identity_hashcode(31位)][unused(1位)][age(4位)][biased_lock(1位)=0][lock(2位)=01]
偏向锁:
[thread_id(54位)][epoch(2位)][age(4位)][biased_lock(1位)=1][lock(2位)=01]
轻量级锁:
[ptr_to_lock_record(62位)][lock(2位)=00]
重量级锁:
[ptr_to_heavyweight_monitor(62位)][lock(2位)=10]
GC标记:
[empty(62位)][lock(2位)=11]GC年龄存在Mark Word的第4位(4个bit,最大值15),这就是-XX:MaxTenuringThreshold最大只能设置到15的原因。
2.3 对象在堆中的移动轨迹
2.4 对象的可达性分析
GC通过可达性分析(Reachability Analysis)判断对象是否可以回收。从"GC Roots"出发,沿着引用链向下搜索,不能到达的对象被标记为可回收。
GC Roots包括:
- 虚拟机栈中引用的对象(方法中的局部变量)
- 方法区中类静态属性引用的对象
- 方法区中常量引用的对象
- 本地方法栈中JNI引用的对象
- JVM内部引用(如基本类型对应的Class对象、系统类加载器等)
- 同步锁(synchronized)持有的对象
- JVM内部的JMXBean、JVMTI中注册的回调、本地代码缓存等
2.5 finalize()方法与对象的两次标记
一个对象被判定可回收到真正被回收,有两次标记过程:
第一次标记:对象在可达性分析中发现没有引用链可达,被标记一次,并进行筛选——判断是否有必要执行finalize()方法。如果对象没有覆盖finalize(),或者finalize()已经被虚拟机调用过,则直接进入回收队列。
第二次标记:如果对象覆盖了finalize()且从未被调用过,对象会被放到F-Queue队列,由Finalizer线程(低优先级)执行finalize()。如果在finalize()中对象与引用链上的任何对象重新建立了关联("拯救自己"),则移出回收队列;否则第二次标记后被回收。
// 不推荐的做法:在finalize中自救
public class Resurrection {
public static Resurrection instance = null;
@Override
protected void finalize() throws Throwable {
super.finalize();
// 重新建立引用链,阻止回收
Resurrection.instance = this;
System.out.println("finalize被执行,对象自救成功");
}
}这种做法极其不推荐:finalize()只会被执行一次,第二次不会再触发。而且Finalizer线程优先级低,可能导致大量对象堆积在F-Queue中,造成OOM。Java 9之后finalize()已被标记为deprecated,建议用try-with-resources和Cleaner替代。
2.6 引用类型与GC行为
Java有四种引用类型,对GC行为的影响完全不同:
| 引用类型 | 对象回收时机 | 典型用途 |
|---|---|---|
| 强引用(Strong) | 永远不回收,OOM也不回收 | 普通对象引用 |
| 软引用(Soft) | 内存不足时回收 | 缓存(如图片缓存) |
| 弱引用(Weak) | 下次GC时回收 | WeakHashMap、ThreadLocal |
| 虚引用(Phantom) | 随时回收,不能通过虚引用获取对象 | 跟踪对象回收、直接内存管理 |
// 软引用示例:缓存大对象
SoftReference<byte[]> softRef = new SoftReference<>(new byte[1024 * 1024]);
byte[] data = softRef.get(); // 可能为null(内存不足时已回收)
// 弱引用示例
WeakReference<Object> weakRef = new WeakReference<>(new Object());
// GC之后,weakRef.get()返回null
// 配合引用队列使用
ReferenceQueue<Object> queue = new ReferenceQueue<>();
WeakReference<Object> weakRefWithQueue = new WeakReference<>(new Object(), queue);
// 对象被回收后,weakRefWithQueue会被加入queue
Reference<?> ref = queue.poll(); // 可以监控对象回收三、诊断工具与命令
3.1 对象分配速率监控
# 用jstat监控对象分配速率
# 观察E(Eden)列的变化速率
jstat -gc <pid> 1000
# 查看当前堆中对象分布
jmap -histo <pid> | head -30
# 输出格式:序号 实例数 字节数 类名
# 查看存活对象(只统计GC后存活的)
jmap -histo:live <pid> | head -303.2 追踪对象分配
# 使用JVM诊断命令追踪分配
# 启动时开启对象分配追踪(有性能开销)
-XX:+PrintHeapAtGC
# 使用async-profiler追踪分配热点
./profiler.sh -e alloc -d 30 -f alloc.html <pid>
# 使用JFR(Java Flight Recorder)
jcmd <pid> JFR.start duration=60s filename=/tmp/alloc.jfr settings=profile
# 然后用JMC(Java Mission Control)分析3.3 GC行为分析
# 分析GC日志中的对象晋升情况
# GC日志中的关键信息(JDK 8格式)
# [GC (Allocation Failure) [PSYoungGen: 1048576K->8192K(1048576K)]
# 1048576K->16384K(2097152K), 0.0123456 secs]
# PSYoungGen后的数字:GC前大小->GC后大小(区域总大小)
# 计算晋升量 = 堆GC后大小 - 年轻代GC后大小
# 16384K - 8192K = 8192K 晋升到老年代
# 用GCViewer或GCEasy.io分析GC日志(可视化)3.4 查看对象年龄分布
# 在JDK 8中,使用以下参数打印年龄分布(不推荐生产使用)
-XX:+PrintTenuringDistribution
# 输出示例:
# Desired survivor size 52428800 bytes, new threshold 6 (max 15)
# - age 1: 8388608 bytes, 8388608 total
# - age 2: 4194304 bytes, 12582912 total
# - age 3: 2097152 bytes, 14680064 total
# 说明不同年龄段的对象数量,可以根据此调整MaxTenuringThreshold四、完整调优方案
4.1 减少对象分配的优化技巧
对象池(Object Pool):对于创建成本高的对象,使用对象池复用。
// Apache Commons Pool2示例
GenericObjectPoolConfig<Connection> config = new GenericObjectPoolConfig<>();
config.setMaxTotal(50); // 最大对象数
config.setMaxIdle(20); // 最大空闲数
config.setMinIdle(5); // 最小空闲数
config.setMaxWaitMillis(3000); // 等待超时
GenericObjectPool<Connection> pool = new GenericObjectPool<>(factory, config);避免自动装箱产生临时对象:
// 差:每次循环都创建Integer对象
Long sum = 0L;
for (long i = 0; i < 1000000; i++) {
sum += i; // sum被自动拆箱,加法结果再自动装箱
}
// 好:全程使用基本类型
long sum = 0L;
for (long i = 0; i < 1000000; i++) {
sum += i;
}StringBuilder替代String拼接:
// 差:每次+都创建新的String对象
String result = "";
for (String s : list) {
result += s;
}
// 好:复用StringBuilder
StringBuilder sb = new StringBuilder(list.size() * 10);
for (String s : list) {
sb.append(s);
}
String result = sb.toString();4.2 控制对象晋升的JVM参数
# 增大Survivor区,减少过早晋升
-XX:SurvivorRatio=6 # Eden:S0:S1 = 6:1:1(默认8:1:1)
# 注意:增大Survivor会减小Eden
# 调整晋升阈值
-XX:MaxTenuringThreshold=10 # 默认15,可以适当降低让短命对象早点被回收
# 也可以升高让对象在年轻代多呆几次
# 打印年龄分布(调优时使用,生产谨慎)
-XX:+PrintTenuringDistribution
# 禁止大对象直接进老年代(调试用)
-XX:PretenureSizeThreshold=0 # 默认0(不限制),可以设置如2m4.3 逃逸分析与栈上分配
JIT编译器通过逃逸分析(Escape Analysis)判断对象是否逃逸出方法范围。如果不逃逸,可以在栈上分配,方法结束时自动回收,不产生GC压力。
// 不逃逸的对象,可以栈上分配
public void process() {
Point p = new Point(1, 2); // p不逃逸出process方法
int sum = p.x + p.y;
// p在方法结束时自动销毁,无需GC
}
// 逃逸的对象,必须堆分配
public Point createPoint() {
return new Point(1, 2); // 返回出去,逃逸了
}# 启用/禁用逃逸分析(JDK 8默认开启)
-XX:+DoEscapeAnalysis # 开启(默认)
-XX:-DoEscapeAnalysis # 关闭(用于对比测试)
# 查看JIT编译后的逃逸分析结果
-XX:+PrintEscapeAnalysis # JDK 8五、踩坑实录
坑一:以为对象都分配在堆上,忽略了栈上分配
有一次做压测对比,关闭了逃逸分析,GC频率明显上升了,Young GC从每30秒一次变成了每5秒一次。才意识到系统中有大量的局部临时对象本来是栈上分配的,关闭逃逸分析后全部跑到Eden区了。
教训:调优时不能随意关闭JVM的优化选项,要理解每个选项的作用。
坑二:WeakHashMap不是万能的缓存
有个需求是做方法级别的缓存,key是请求参数对象。用WeakHashMap存储,以为key被GC后缓存自动清理,不会内存泄漏。
结果发现缓存的value是一个持有大量数据的对象,WeakHashMap的key(参数对象)是弱引用,但value是强引用。如果其他地方没有持有key的强引用,key确实会被GC回收,entry也会从WeakHashMap中删除,value随之可以被回收。
但问题在于:请求参数对象在处理过程中被线程池缓存了一份,导致key一直有强引用,WeakHashMap的清理机制完全没有触发,缓存越积越多,最终OOM。
教训:WeakHashMap只有在key没有强引用时才会自动清理,一定要确保key真的没有强引用持有。
坑三:误解了finalize的执行时机
有个遗留代码,在finalize()中关闭数据库连接,以为对象被GC时连接会自动归还连接池。
生产上跑了一段时间后,出现连接池耗尽的报错。排查发现:Finalizer线程优先级低,大量对象堆积在F-Queue中等待finalize执行,连接迟迟没有归还,新的请求申请不到连接,就报了连接池耗尽。
教训:千万不要在finalize里做资源回收。使用try-with-resources实现AutoCloseable接口,或者显式调用close()方法,才是正确的资源管理方式。
坑四:对象分配速率监控误判
有次监控显示Young GC的回收量很大,我以为是对象分配速率过高,开始排查哪里创建了大量对象。折腾半天,发现其实是Survivor区太小,每次Young GC后大量存活对象因为Survivor放不下而晋升到老年代,导致Young GC后老年代增长很快,监控数字被误读了。
教训:分析GC日志时,要同时关注年轻代GC前后的大小变化,以及堆总体的变化,计算出实际的晋升量,才能准确判断问题。
六、总结
对象从new到被GC回收,走过了:TLAB分配 → 对象头初始化 → 构造函数执行 → 引用追踪 → 可达性分析标记 → 必要时执行finalize → 最终回收这一完整链路。
对我日常调优最有价值的几个认知:
一是TLAB让对象分配变成了一个极快的指针自增操作,但TLAB空间有限,超过TLAB的对象需要在Eden区进行CAS分配,有竞争开销。对象越小,TLAB越高效。
二是对象头Mark Word的设计极其精妙,只用64位就存储了GC年龄、锁状态、哈希码等多种运行时信息,理解它才能理解锁升级机制。
三是引用类型的选择影响GC行为。业务缓存用软引用,防止OOM;临时关联关系用弱引用,GC后自动清理;直接内存管理用虚引用,监控回收时机。
四是逃逸分析让不逃逸的对象可以在栈上分配,彻底避免GC压力。写代码时尽量减少对象逃逸,让JIT更好地做优化。
