Java对象从创建到回收:一图读懂JVM中的完整生命周期

Java对象从创建到回收:一图读懂JVM中的完整生命周期 我做了七八年Java开发也面试过不少人发现一个特别有意思的现象大多数候选人能把JVM参数背得滚瓜烂熟能张口就来CMS和G1的区别但真要问一句一个Java对象从创建到被回收完整走一遍到底经历了哪些环节反而支支吾吾说不全。这个问题看似基础实际上是把类加载机制、JVM内存模型、GC算法、引用类型这些零散知识点串成一条线的关键。它既是面试八股的重灾区也是线上排查内存问题的底层功底。这篇内容就按对象从生到死的完整路径把每个阶段的原理、机制和实战中容易踩的坑一次说透。不管你是准备面试还是做性能优化理解这条生命周期链路远比死记硬背几个参数值有意义。1. 对象的一生从new关键字到内存回收的完整路径1.1 一条完整生命周期里藏着哪些关键节点一个普通Java对象从诞生到消亡至少要经历类加载检查、内存分配、内存空间初始化、对象头设置、构造方法执行、使用中的引用传递、不可达判定、垃圾回收八个阶段。这里有个容易忽略的点很多人以为new出来就算创建完成了其实构造方法执行之前JVM已经做了大量工作而构造方法结束后对象才真正可用。围绕这条链路不同阶段对应不同的技术关键词类加载检查和类加载器相关内存分配涉及指针碰撞和TLAB不可达判定依赖GC Roots最终回收则和垃圾回收算法以及具体的收集器实现挂钩。把这些串起来后你会发现自己对JVM的理解会清晰很多。有个很直观的类比对象创建就像开一家新店。先得确认品牌类已经注册过类加载然后租场地分配内存装修刷墙零值初始化挂招牌设置对象头最后才开门营业执行构造方法。到了关门倒闭回收的时候要判断这家店还有没有顾客引用没顾客了才会被清算GC。1.2 为什么理解生命周期比背面试题更重要先说个实际场景。线上服务OOM你用jmap导出了堆转储文件打开MAT一看某个业务的List对象占了90%以上内存。如果不理解对象的创建路径和引用关系你看到的只是一堆对象之间的引用链条根本不知道问题根源是哪行代码把它撑大的。再比如排查内存泄漏最常见的泄漏模式就是对象明明不用了但被某个静态集合一直引用着。这本质上就是生命周期没走完——对象该进入回收阶段却因为强引用断了回收的路。理解了对象从可达到不可达的判定逻辑你自然会往GC Roots方向排查而不是盲目调大堆内存。还有一点很实际很多面试题考察的其实是对象在什么时候、以什么方式进入老年代什么样的对象会触发Full GC这类衍生问题。这些问题都能从生命周期链路里找到答案。把链路的每个环节吃透面试时不管被问到哪个切面都能很快定位到对应位置并展开。2. 对象创建的完整过程JVM在new背后做了什么2.1 从字节码指令到类加载检查当你写下一行User user new User()编译成字节码后对应的是new指令。JVM执行new指令时第一步不是分配内存而是先到常量池里找到这个类的符号引用检查这个类是否已经被加载、解析和初始化。如果类还没有加载就得先触发类加载过程。这一步很多人会忽略但它恰恰是对象创建慢的潜在原因之一。我遇到过一类性能问题某个接口首次调用特别慢后续就正常了。排查后发现是首次请求触发了一系列类的加载和初始化包括执行静态代码块和静态变量赋值。理解了这一环你就知道为什么会有预热这种操作了——先把类加载好、对象建好避免请求高峰时现做初始化。类加载检查通过后JVM才真正开始为对象分配内存。需要注意的是这里说的分配内存是在堆内存中划分一块确定大小的空间对象所需的内存大小在类加载完成后就能完全确定不需要等到构造方法执行。2.2 内存分配的两大方式与并发安全处理Java堆是线程共享的但每个对象的内存分配必须保证线程安全。分配方式有两种取决于垃圾收集器是否带有空间压缩整理能力指针碰撞和空闲列表。指针碰撞堆内存规整比如使用标记-整理或复制算法收集器已用内存在一边空闲内存在另一边中间有一个分界指针。分配时把指针往空闲方向移动与对象大小相等的距离即可。这种方式简单高效就像一摞整齐的书从中间拿一本走剩下的依然整齐。空闲列表堆内存不规整比如使用标记-清除算法的收集器已用内存和空闲内存交错。JVM维护一个列表记录哪些内存块可用分配时从列表里找一块足够大的空间划分给对象再更新列表记录。这里有一个经典疑问移动指针不是线程不安全的吗两个线程同时分配内存指针怎么处理HotSpot的解决方案主要有两种一是CAS比较并交换加失败重试比较当前指针位置是否还是期望的位置是则更新否则重试二是TLABThread Local Allocation Buffer每个线程在Java堆中预先分配一小块私有内存线程内分配对象就在这块缓冲区里进行互不干扰。只有TLAB用完并重新申请时才需要同步锁定。在实际参数调优中-XX:UseTLAB默认开启-XX:TLABSize可以手动指定大小。我通常建议不要轻易关闭TLAB也不要设置得过小否则频繁申请TLAB反而会增加同步开销。2.3 内存空间初始化与对象布局内存分配完成后JVM会把分配到的内存空间不包括对象头初始化为零值。这一步保证了对象实例字段不赋初值也能直接用——比如int默认是0boolean默认是false引用类型默认是null。如果使用了TLAB这个零值初始化过程可以提前到TLAB分配时一次性完成。接下来JVM会对对象头进行必要设置。以HotSpot虚拟机为例对象在堆内存中的存储布局分为三部分对象头、实例数据、对齐填充。对象头包含两类信息。第一类是运行时数据称为Mark Word存储哈希码、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID等。这里记住一句口诀Mark Word是动态的不同状态下存储内容不同。例如无锁状态下它存哈希码和分代年龄轻量级锁时指向栈中锁记录的指针重量级锁时指向监视器Monitor的指针。第二类是类型指针即对象指向它的类元数据的指针JVM通过这个指针来确定对象属于哪个类。如果对象是Java数组对象头中还必须有一块记录数组长度的数据。实例数据是对象真正存储的有效信息包含各种类型的字段内容。这部分存储顺序受虚拟机的分配策略和字段定义顺序影响——相同宽度的字段总是被分配到一起且父类中定义的字段会出现在子类字段之前。对齐填充不是必然存在的它只起到占位符的作用。HotSpot要求对象起始地址必须是8字节的整数倍所以当实例数据部分没有对齐时就需要通过对齐填充来补全。2.4 构造方法的执行与对象真正可用到这里从JVM视角看一个新的对象已经产生了。但从程序视角看构造方法还没执行对象的字段值都还是零值业务初始化逻辑也没有跑。执行init方法即构造方法后对象才按照程序员的意愿完成初始化成为一个真正可用的对象。有一个值得注意的细节new指令之后还会跟着dup和invokespecial指令。dup复制栈顶引用因为后续既要调用构造方法又要保存引用地址invokespecial调用对应的实例构造方法。面试时候如果被问到对象创建过程包含哪些步骤从字节码层面回答到这一层会明显比只说加载、验证、准备、解析、初始化要出彩。还有一个很多人混淆的概念类初始化和对象初始化。类初始化执行的是clinit方法也就是静态变量赋值和静态代码块发生在类加载阶段的初始化环节一个类只会执行一次。对象初始化执行的是init方法也就是构造方法每创建一个对象执行一次。两条线各有各的触发时机在生命周期图上位置也不同。3. 对象使用阶段的引用博弈强引用、软引用与逃逸分析3.1 四种引用级别与适用场景对象创建之后进入正常使用阶段此时程序通过引用与对象建立关联。引用类型直接决定了JVM在内存紧张时如何对待这些对象。Java提供了四种引用级别按强度从高到低依次是强引用、软引用、弱引用、虚引用。强引用是最常见的Object obj new Object()这种就是强引用。只要强引用还存在垃圾收集器永远不会回收被引用的对象。这也是一系列内存泄漏问题的根源比如静态Map里存了对象Map的生命周期和应用一样长里面的对象就永远无法回收。软引用用来描述一些还有用但并非必需的对象。在系统将要发生内存溢出异常之前JVM会把软引用关联的对象列入回收范围进行第二次回收如果这次回收后内存仍然不足才会抛出OOM。软引用非常适合实现内存敏感的缓存比如图片缓存、临时计算结果缓存。我写过的一个查询结果缓存组件就用了SoftReferenceJVM参数给得比较小时缓存自动被回收反而避免了缓存把内存挤爆的问题。弱引用比软引用更弱被弱引用关联的对象只能生存到下一次垃圾回收之前。当垃圾收集器开始工作时无论当前内存是否足够都会回收只被弱引用关联的对象。弱引用典型应用是ThreadLocal的内部实现——ThreadLocalMap的Entry继承了WeakReferencekey是弱引用value是强引用。这也是ThreadLocal被诟病存在内存泄漏风险的原因key被回收了value却还强引用在Map里。虚引用是最弱的它不影响对象生命周期通过它甚至无法获取到对象实例。设置虚引用的唯一目的就是能在对象被回收时收到一个系统通知。Java 9开始引入的Cleaner就是基于虚引用实现的用于替代finalize方法做资源释放。3.2 逃逸分析与对象栈上分配的可能性如果所有的对象都分配在堆上那堆内存的GC压力会非常大。HotSpot的JIT编译器在做编译优化时会通过逃逸分析来判断对象的作用域。如果对象只在一个方法内使用不会逃逸出方法之外JVM就有可能对它做一些优化。最核心的优化是标量替换。如果一个对象不会逃逸JVM可以把对象拆散成一个个基本类型的成员变量直接在栈帧或寄存器中分配不再创建对象实体。这里有个我实际测量过的案例一个循环里创建了10万个Point对象每个对象只有x和y两个int字段开启逃逸分析后JVM直接把Point拆散循环内的对象实体不复存在GC次数大幅下降。还有一个相关优化是锁消除。如果JVM判断一个锁不会逃逸出当前线程就会把这个锁消除掉。比如StringBuffer的append方法是synchronized的在单线程环境下JIT如果分析出这个StringBuffer没有逃逸就会直接把锁消除。但这里要泼一盆冷水逃逸分析并不是所有的对象都能被优化。对象如果被方法返回、被存入静态字段、被传给其他方法等都算发生逃逸只能老老实实待在堆上。而且逃逸分析本身也有开销过于复杂的分析反而影响编译性能。所以普通业务代码中不要天真地以为小对象就一定有栈上分配这种优化依赖JIT的即时判断不是我们能直接控制的。3.3 对象晋升老年代的几条路径使用阶段还有一个重要的演化方向新生代对象逐渐晋升到老年代。JVM给每个对象定义了一个对象年龄计数器对象在Survivor区每熬过一次Minor GC年龄就加1当年龄达到阈值默认15时就会晋升到老年代。这是一个非常经典的参数点-XX:MaxTenuringThreshold可以设置晋升阈值。但这个参数有时候会失效因为HotSpot有一个动态年龄判定机制如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半年龄大于或等于该年龄的对象就可以直接进入老年代无须等到要求的年龄。还有两种常见路径。第一大对象直接进入老年代。-XX:PretenureSizeThreshold参数设置大对象阈值超过阈值的对象直接在老年代分配。这么做的目的是避免大对象在新生代的Eden区和两个Survivor区之间来回复制产生大量的复制开销。第二Minor GC后Survivor空间无法容纳存活对象时依赖老年代进行分配担保这部分对象也会直接进入老年代。理解这几条晋升路径对调优有直接帮助。如果你的业务系统大量创建生命周期很短的大数组又没设置PretenureSizeThreshold这些数组会在新生代反复复制白白消耗性能。合理设置阈值让它们直接进老年代反而能减少GC压力。4. 对象回收的判定机制与垃圾收集算法4.1 可达性分析与GC Roots对象回收的第一步是判断哪些对象已经死了。当前主流虚拟机采用的可达性分析算法基本思路是通过一系列称为GC Roots的根对象作为起始节点集从这些节点开始根据引用关系向下搜索搜索走过的路径称为引用链。如果某个对象到GC Roots没有任何引用链相连就证明此对象是不可能再被使用的可以被判定为可回收。GC Roots主要包含以下几类虚拟机栈栈帧中的本地变量表中引用的对象方法区中类静态属性引用的对象方法区中常量引用的对象本地方法栈中JNI即一般说的Native方法引用的对象Java虚拟机内部的引用如基本数据类型对应的Class对象、常驻的异常对象NPE等、系统类加载器所有被同步锁synchronized关键字持有的对象反映Java虚拟机内部情况的JMXBean、JVMTI回调、代码缓存等这里有个重要的知识点为什么不用引用计数算法引用计数算法是给每个对象维护一个计数器有引用就加1引用失效就减1计数器为0即判定可回收。它实现简单、判定高效但存在一个致命缺陷——无法解决对象之间循环引用的问题。比如对象A引用了对象B对象B也引用了对象A除此之外这两个对象再无其他引用它们的计数器永远不为0就永远无法被回收。这也是主流虚拟机的GC都不采用引用计数算法的原因。4.2 从标记-清除到G1垃圾收集算法的演进逻辑判定为可回收后实际回收动作依赖具体的垃圾收集算法。理解这些算法建议从它们的取舍逻辑入手。标记-清除算法是最基础的分两个阶段先标记出所有需要回收的对象再统一回收被标记的对象。它的缺点非常明显一是执行效率不稳定堆越大标记和清除的成本越高二是会产生大量不连续的内存碎片碎片太多时后续分配大对象可能找不到足够的连续内存被迫触发一次GC。标记-复制算法将可用内存按容量划分为大小相等的两块每次只使用其中一块。当这块内存用完时把存活对象复制到另一块上面再把已使用过的内存一次清理掉。这种算法的缺点是可用内存缩小为原来的一半空间浪费比较严重。HotSpot的新生代采用这种思想但Eden区和两个Survivor区的大小比例是8:1:1只有10%的空间被浪费算是做了优化。标记-整理算法针对老年代设计标记过程与标记-清除一样但后续不是直接对可回收对象清理而是让所有存活对象都向内存空间一端移动然后直接清理掉边界以外的内存。移动对象的代价是必须暂停用户线程Stop The World这种停顿在堆内存很大时尤其明显。到了G1收集器思路发生了很大变化不再追求整个堆的物理分代而是把堆划分为多个大小相等的Region每个Region都可以独立扮演Eden、Survivor或者老年代的角色。G1跟踪各个Region里的垃圾堆积价值维护一个优先级列表优先回收价值最大的Region这就是Garbage First名称的由来。G1仍然保留新生代和老年代的概念但新生代和老年代不再是物理隔离的它们是一部分Region的集合。4.3 分代收集与触发时机理解了算法演进后再看分代收集策略就顺理成章了。HotSpot设计者根据对象存活周期的不同将Java堆划分为新生代和老年代。新生代中每次GC都有大量对象死去只有少量存活适合用复制算法只需付出少量存活对象的复制成本。老年代对象存活率高、没有额外空间做分配担保必须使用标记-清理或标记-整理算法。新生代GCMinor GC的触发条件非常频繁Eden区空间不足时就触发。因为新生代对象绝大多数朝生夕灭Minor GC速度通常很快。老年代GCMajor GC/Full GC的触发条件则复杂一些常见情况包括老年代空间不足、元空间不足、调用System.gc()、CMS的并发模式失败等。这里有个实用的排查经验线上Full GC频繁时先用jstat -gcutil看各个分区的使用率。如果老年代一直不涨但Full GC却很频繁多半是元空间或堆外内存问题如果老年代涨了就Full GCFull GC后降下去说明老年代确实有对象在持续晋升需要重点排查是不是大对象没有及时回收。从G1开始收集器已经在弱化固定的分代物理布局ZGC更是几乎不分代。但面试时如果能把分代设计为什么合理、每个代用什么样的算法、为什么这样选讲清楚就已经胜过了绝大多数候选人。5. 对象自救与真正死亡finalize方法的两次标记5.1 两次标记的完整过程前面说可达性分析判定对象不可达但这还不是死刑立即执行。要真正宣告一个对象死亡至少要经历两次标记过程。第一次标记对象进行可达性分析后发现没有与GC Roots相连接的引用链它会被第一次标记随后进行第一次筛选。筛选的条件是这个对象是否有必要执行finalize方法。如果对象没有覆盖finalize方法或者finalize方法已经被虚拟机调用过虚拟机将这两种情况都视为没有必要执行。有必要执行finalize的对象会被放入一个名为F-Queue的队列中由一个虚拟机自动建立的、低优先级的Finalizer线程去执行。这里要特别注意虚拟机不承诺会等待finalize方法执行结束这是为了防止某个对象的finalize方法执行缓慢或死循环导致F-Queue中其他对象永久等待甚至导致整个回收机制崩溃。第二次标记GC将对F-Queue中的对象进行第二次小规模的标记。如果对象在finalize方法中成功与GC Roots建立关联比如把自己this赋值给某个类变量或成员变量的引用那它就会被移出即将回收的集合实现自救。如果对象此时仍然没有与GC Roots建立关联那它才真正走向死亡。5.2 finalize方法的局限性与其继承者实际开发中我极度不建议用finalize方法做资源清理。原因很简单它的执行时机完全不确定依赖JVM内部Finalizer线程的调度无法保证在预期时间内执行。而且finalize方法的运行代价高会显著影响GC性能。从Java 9开始finalize方法就被标记为废弃了官方明确建议用其他方式替代。替代方案中最值得了解的是java.lang.ref.Cleaner它基于虚引用实现。当对象被回收时Cleaner会收到通知并执行注册的清理动作。但Cleaner的使用也有约束清理动作不应该依赖被清理对象本身否则可能在清理线程执行时对象已经被回收访问不到所需信息。这里分享一个踩过的坑早期有个项目用了finalize做数据库连接的回收结果高并发时连接池出现大量超时。定位后发现finalize方法执行排队严重连接对象迟迟没有被真正回收你以为连接关闭了实际只是切断了引用真正的关闭动作在Finalizer线程里慢慢排队。后来统一改成了显式close try-with-resources问题立刻消失。关于对象自救它更多是展示JVM判定机制的一个教学场景。真正在业务里使用对象的finalize来自救本身就是一种反模式。理解两次标记过程的价值在于你能准确把握一个对象被判定可回收、执行finalize、真正回收之间的时序差异排查问题时不会产生误判。6. 常见问题与排查技巧实录6.1 典型内存泄漏场景与定位方法对象生命周期相关的问题线上最常见的呈现形态就是内存泄漏内存使用率持续走高GC频率越来越频繁最终OOM。我整理了几类高频泄漏场景静态集合持有对象静态Map或List里添加了对象使用完后没有移除。这个模式最隐蔽的地方是你可能感觉已经不再使用这个对象了但集合的强引用一直存在它永远不会被回收。排查时建议重点检查static修饰的集合类字段。ThreadLocal未清理线程池场景下线程长期存活ThreadLocalMap的Entry是弱引用key强引用value。即使key被回收value也会一直被线程持有形成一种特殊泄漏。这也是为什么官方明确要求用完ThreadLocal后必须调用remove。监听器/回调未注销组件注册了监听器组件销毁时没有移除监听器导致监听器被组件长期引用。各类连接资源未关闭输入输出流、数据库连接、网络连接这些资源对象往往关联堆外内存或文件句柄即使Java对象被回收底层资源也可能没有释放。定位内存泄漏的标准流程我的做法是先通过jmap -dump:live,formatb,fileheap.bin pid导出堆转储再用MATMemory Analyzer Tool分析Dominator Tree找出占用内存最大的对象及其引用链。重点看去往GC Roots的路径完整内容沿着引用链通常能很快定位到具体是哪行代码持有了这个对象。配上Arthas的heapdump命令或者JFRJava Flight Recorder的事件记录基本能做到快速定位。6.2 对象生命周期相关的面试高频考点从面试角度我梳理了围绕对象生命周期最常被问到的几个问题每个都值得准备一个层次分明的回答new一个对象的过程分为哪几步最完整的回答应该覆盖类加载检查、内存分配、零值初始化、对象头设置、构造方法执行这五步。对象什么时候进入老年代包括年龄阈值、动态年龄判定、大对象直接进入、Survivor空间不足的分配担保四种情况。如何判断对象可以被回收以此为切入点能谈到GC Roots包括哪些、为什么不用引用计数、两次标记与finalize的关系。JVM有哪些垃圾收集器各有什么特点这里建议把Serial、Parallel、CMS、G1的核心设计思路和适用场景说清楚关键看能不能讲出算法取舍背后的考量。什么是内存泄漏Java中哪些内存泄漏场景最常见重点突出ThreadLocal泄漏和静态集合持有对象的案例。回答这些问题时我一直建议一个思路不要背结论而是讲推理过程。比如问到对象进入老年代你可以从新生代空间有限存活对象需要个归宿讲起自然引出晋升机制的几种路径。这种回答方式既显得有深度也更容易应对追问。6.3 常用调优参数与监控速查最后整理一份和对象生命周期强相关的常用参数方便排查问题时快速查阅。参数作用备注-Xms / -Xmx设置堆初始大小 / 最大大小生产环境建议设相同值避免运行时扩容-Xmn设置新生代大小需要结合业务对象生命周期调整-XX:SurvivorRatioEden区和Survivor区比例默认8即Eden:Survivor8:1-XX:MaxTenuringThreshold对象晋升老年代年龄阈值默认15动态年龄判定可能覆盖该值-XX:PretenureSizeThreshold大对象直接进入老年代的阈值需配合Serial和ParNew收集器使用-XX:UseG1GC启用G1收集器当前主流默认选择-XX:MaxGCPauseMillisG1的目标停顿时间建议不要设得过低否则影响吞吐监控方面jstat -gcutil pid 1000可以看到各个代的使用率和GC时间这是最轻量的起步工具jmap -histo:live pid可以快速查看存活对象的类分布判断某些类实例数量是否异常jcmd pid GC.class_histogram效果和jmap类似但更安全。线上排查优先用jcmd它不触发Full GC。我用Arthas也比较多它把很多常用诊断能力封装成了命令不用在服务器上反复敲一堆jdk工具命令。关于对象生命周期我实际做调优时最大的体会是不要看到Full GC就去调堆大小先搞清楚对象的产生速率、存活时长和晋升路径再决定调整哪个代的大小或换哪种收集器。参数只是最后的收尾动作对业务代码中对象生命周期的理解才是解决问题的根本。把这篇链路走通下次无论是面对线上内存告警还是面试官的连环追问你都能从容不少。