
做到第10期了。从第一期开始咱们这个系列就不是来背八股的而是拿面试题反过来拷打面试官——把他问到的每一处细节掰开揉碎看看他到底是在背书还是真懂。今天这道题我每次面试必问也是最能分辨“会用Java”和“真的懂Java”的一道。题目就一句话请完整描述一个Java对象从new出来到被垃圾回收器回收的完整一生。别小看这句话它从类加载一路问到内存分配、GC Roots、三色标记、收集器选型、线上日志排查把JVM三大件——内存模型、垃圾回收、调优实践全部串成了一条线。适合正在准备中高级Java岗位面试的朋友也适合带团队的老手用来压场子。哪怕你完全不面试把这个对象的整个生命链路理一遍排查线上OOM和Full GC问题时也会顺手很多。今天这期我把我自己的回答框架、面试官最常追问的三个坑、还有一份实战GC日志的逐行拆解全部交出来。1. 今天的靶场为什么偏偏挑“对象的一生”当考题1.1 这个系列到底在拷打什么先说明白这个系列的底层逻辑。市面上的面经答案都长得差不多“双亲委派、线程池七大参数、B树优点”这些背出来已经打动不了任何人。真正的高手面试看的不是你记住了多少名词而是你在一个名词下面能往下挖几层。挖到第三层背书的人基本就现形了。所以Day10选对象生命周期就是因为这道题的深度没有上限。第一层能答“堆里分配、标记清除回收”算及格。第二层能答TLAB、指针碰撞、空闲列表算有实操经验。第三层能答出逃逸分析标量替换说明你看过底层优化。到了第四层能扯到三色标记、SATB和写屏障面试官基本就被你摁住了这已经不是面经上会写的东西是你真正读源码、跟过线上问题才能讲出来的内容。我见过太多简历上写“熟悉JVM调优”的候选人一问他CMS和G1的根本区别是分代模型还是回收策略都说不清。这期内容就是用来帮你在这一类人里脱颖而出的。1.2 为什么对象生命周期是考点之王一个对象从出生到消亡横跨了JVM的运行时数据区、对象创建链路、可达性分析、垃圾收集器选型四大块。这四大块恰好就是JVM面试的所有必考范畴而且它们之间是有因果关系的对象创建时怎么分配内存决定了GC时怎么找垃圾GC时怎么标记决定了用哪个收集器收集器怎么选又直接影响到你线上怎么配参数。更关键的是这个链路能直接映射到线上问题。比如最典型的内存泄漏老年代一直在涨Full GC越来越频繁但回收量接近零。如果你脑子里没有一个完整的对象生命周期地图你连排查方向都定不了。反之你只要把这条链路在脑子里过一遍基本就能判断出该去看哪一代、该查哪个集合、该用什么命令看哪项指标。今天这一期就是一个完整的对象成长日记。2. 第一问new出来的对象从字节码到堆内存到底走了几步2.1 类加载与实例化之间那条隐形线先说最容易混的一个考点类初始化和对象创建不是一回事。很多候选人一上来就背类加载的加载、验证、准备、解析、初始化五阶段但题目问的是new对象你答半天类加载流程其实是偏了。实际上new一个对象的字节码很短一共就四步// Java源码 User user new User();对应的字节码大致是这样new User // 1. 在堆上分配内存并压入对象引用 dup // 2. 复制栈顶引用给构造方法用 invokespecial User.init()V // 3. 调用构造器完成实例字段初始化 astore_1 // 4. 把引用存到局部变量表这里值得展开的是第一步背后的东西。JVM规范里定义了类加载的“主动引用”时机遇到new、getstatic、putstatic、invokestatic这四条字节码指令时如果类还没初始化就要先触发初始化。也就是说new指令执行前类要先被加载、验证、准备、解析、初始化走一遍加载器由双亲委派模型决定。但这里有一个反直觉的细节如果这个类已经在当前类加载器里被初始化过再次new并不会重新触发初始化。只有首次访问才会。所以new Object一个已经活跃的类字节码层面就是纯粹分配内存加构造初始化没有额外的类加载开销。面试官最爱在这一段追问那静态变量的赋值发生在哪个阶段准备阶段会为静态变量分配内存并设置零值真正的用户赋值发生在初始化阶段也就是clinit方法里。答到这里这一part才算真正过关。2.2 内存分配指针碰撞与空闲列表之争类初始化完成new指令开始真正在Java堆里划分一块内存出来。这块内存怎么分两种方式取决于堆内存是否规整。堆是规整的一边是已用对象一边是空闲区中间放一个分界指针。分配内存就是把这个指针往空闲方向挪动同样大小的距离这叫指针碰撞。堆不规整已用对象和空闲对象交错在一起JVM就只能维护一个空闲列表分配时在列表里找一块足够大的空间划分给新对象这叫空闲列表。关键不在于能背上这两个名词而是能说清楚谁用哪种方式。拥有压缩整理能力的收集器比如Serial、ParNew回收后堆内存是规整的自然用指针碰撞而CMS这种基于标记清除的收集器垃圾回收后会有大量碎片只能用空闲列表。G1则因为把堆拆成了一个个Region每个Region内部管理方式又不完全一样。说完这个还要提TLAB这是线程并发环境下对象分配的核心机制。JVM给每个线程在Eden区里划了一小块私有空间线程内分配对象不需要加锁。TLAB默认开启开启参数是-XX:UseTLAB。空间用完会怎么样两个选择如果剩余空间小且当前对象也放不下JVM会直接把这个对象在主Eden区用加锁方式分配如果剩余空间还足够大但只是装不下当前对象则会重新申请一个新的TLAB。这个细节能让面试官看到你真得操作过因为好多人根本不知道TLAB的存在以为所有堆对象分配都要竞争同一把锁。2.3 逃逸分析不是所有对象都注定活在堆里在HotSpot里对象并不一定百分之百分配在堆上这是一个能让面试官眼前一亮的知识点。它由逃逸分析出发如果JVM能证明一个对象不会逃逸出方法也就是这个对象只在当前线程、当前方法内部使用那它就有机会被优化掉。怎么优化栈上分配是一种说法但HotSpot真正落地的主要是标量替换。看这段代码public void printSum() { Point p new Point(1, 2); System.out.println(p.x p.y); }如果JVM做逃逸分析发现p没有逃出printSum方法它可以把Point这个对象拆开直接用两个局部变量来表示x和y对象头都省了。这样一个Point对象原本至少占用16字节12字节对象头4字节数据标量替换后就没有任何堆分配了。开启参数是-XX:DoEscapeAnalysisJDK 6u23之后默认开启。配套还有-XX:EliminateAllocations和-XX:EliminateLocks后者就是锁消除对应那些没有锁竞争却加了锁的代码。这里有个坑要提醒面试时别直接说“对象分配在栈上”。HotSpot确实有栈上分配的实现思路但你去看源码落地手段更多是标量替换。你说“栈上分配”会被较真的面试官追问一句“那对象头放哪”很容易就绕进去了。正确说法是逃逸分析后对象可能被拆解为多个标量字段分配本身被消除不存在实际的对象实体了。这个口径严谨得很还能显得你读过源码。3. 第二问GC怎么判断一个对象该死还是该活3.1 GC Roots到底有哪些对象分配完之后随着程序运行GC开始介入。判断对象是否被引用主流用的是可达性分析而不是引用计数原因所有面经都写了循环引用的问题。但引用计数不是本段重点重点在于GC Roots的枚举范围。很多人能背出“GC Roots包括虚拟机栈中引用的对象、方法区静态变量引用的对象、常量引用的对象、JNI引用的对象”但到实战级别还不够。我给一份更完整的清单虚拟机栈局部变量表中引用的对象包括被synchronized锁持有的当前对象方法区中静态属性引用的对象也就是static字段指向的对象方法区中常量引用的对象比如字符串常量池里的引用本地方法栈中JNI引用的对象即native方法引用的全局对象活跃线程本身Thread对象JVM内部的一些根系统类加载器、运行时常量池里的符号引用、异常表引用的对象、JIT编译产物中的栈上对象注意静态变量本身不全是Root。如果一个静态变量赋了值但该对象没有任何活跃线程引用它仍然可能被回收吗不对这里要小心静态变量持有引用它就是Root该对象以及它引用的整棵对象树都不会被回收。静态变量如果是null那它原来指向的对象就可能被回收。这看起来简单但经常有人把“静态变量”和“静态变量指向的对象”搞混。这一段的面试技巧是不要一上来就背五个Root而是先反问一句“您指的是哪一代GC的Roots?”然后说“年轻代GC和Full GC的Roots枚举范围其实不一样年轻代还要额外把老年代对新生代的引用算进来这就引入了卡表”。这一反问直接把对话档次拉高背书的人答不了。3.2 finalize方法实际上是个坑可达性分析之后如果一个对象从所有Root出发都不可达它就会被回收吗严格说还要看finalize方法。历史上有一种“自救”玩法在finalize里让对象重新把自己赋值给某个静态变量使对象重新变得可达那这个对象就不会被回收。我见过有人在生产代码里真的这么干上一次SQL连接释放失败就指望finalize兜底结果对象在Finalizer线程里排队内存越堆越高。这个故事本身就能说明问题finalize只能在对象第一次被标记为不可达时执行而且只执行一次执行时机不受控制执行过程中还可能抛出异常导致对象不能按时回收。JDK9就把它标记为废弃了。负责任的回答应该是finalize从来不是一个可靠的资源回收手段生产环境绝不应该依赖它整个机制未来都会被移除。同时要补一句比finalize更靠谱的是CleanerJDK9之后替代它的机制比如PhantomReference配合ReferenceQueue基于虚引用实现的资源清理。能聊到虚引用证明你不是只背了本章节。3.3 三色标记与漏标并发回收的命门这是今天最硬的一块也是真正能把面试官“拷打”住的地方。现代收集器做并发标记时不是一次性把所有对象扫完而是结合可达性分析的过程把对象分成三种颜色白色还没被访问到的对象结束后白色对象会被判定为垃圾。 灰色自身已经被访问到但它引用的其他对象还没有全部扫描完。 黑色自身和它引用的对象都已经被扫描完。大多数情况下标记线程和业务线程同时跑引用关系随时可能变化这就可能出现一个经典问题——漏标。漏标发生的条件可以严格写成两个第一一个黑色对象持有了一个白色对象的引用。 第二所有原本能到达这个白色对象的灰色对象引用都被删掉了。这种情况下这个白色对象在并发标记结束后会被误判为垃圾其实它还活着。这属于必须STW来解决的一致性破坏问题比单纯的多标严重得多。不同收集器采用了不同策略。CMS用的是增量更新它盯的是“黑色对象引用白色对象”这个动作发生时就把黑色对象打回灰色重新扫描一遍。G1用的是SATB它盯的是“删除灰色对象到白色对象引用”的动作在引用被删除前把这个引用记录下来标记结束后重新扫描这些快照里记录的对象来保证原本存活的对象不被漏掉。这玩意儿的代价是写屏障所有引用赋值操作都得额外付出开销。这是并发收集器“低延迟”表象下面的真实成本。面试时如果把漏标的两个条件讲清楚再补一句“CMS和G1分别解决了哪个环节代价是什么”这一问面试官基本没法再追问下去了因为他可能自己都说不清增量更新和SATB的差异。4. 第三问年轻代老年代是怎么合力干活的4.1 年轻代为什么用复制算法而不是标记整理对象被分配进Eden之后在程序中短暂存活然后迎来第一次GC。年轻代GC使用的算法是复制算法核心思想很朴素把Eden和一个Survivor里还活着的对象一次性复制到另一个空闲Survivor然后整块清空原来的区域。为什么不用标记整理因为年轻代的典型特征是存活率低。绝大多数对象朝生夕灭能被复制走的对象数量很少复制成本极低。而标记整理虽然也能清理空间但需要对整个区域做大量对象移动成本跟存活对象数量正相关。年轻代用复制算法开销正比于存活对象体积正好切中最优场景。默认比例是Eden占8一个Survivor占1另一个Survivor同样是1。也就是说每次年轻代回收真正可用的内存是Eden加上一个Survivor另一个Survivor纯粹用来承接存活对象。这比例设计得很有意思10份内存实际可用9份留1份作为复制算法的“逃生空间”。然后面试官通常会接着问什么条件会晋升到老年代标准答案有三个。一是年龄阈值默认15次GC后晋升二是动态年龄判定Survivor里同龄对象大小总和超过Survivor空间的一半那些年龄以上的对象就会直接晋升三是大对象直接进老年代-XX:PretenureSizeThreshold参数可以设定门槛。不过最后这个参数只对Serial和ParNew这类收集器有效G1里有自己的大对象规则别乱套。4.2 老年代回收CMS为什么退场G1怎么接管年轻代回收很快但老年代里的对象大多数是长期存活的回收难度和停顿时间瞬间上来了。老年代收集器的演变史就是一部低延迟斗争史。先看CMS。它的设计目标就是降低停顿核心分为四个阶段初始标记、并发标记、重新标记、并发清除。初始标记和重新标记需要STW但它把最耗时的遍历标记和清除阶段做成了和业务线程并发的。这听起来很美代价却是三个第一并发阶段CPU资源被抢占吞吐量下降第二并发清理阶段产生的新垃圾只能留到下次GC这叫浮动垃圾第三标记清除算法天然产生内存碎片碎片多了之后空间连续分配困难可能直接触发Serial Old做标记整理停顿剧烈上升。CMS在JDK9之后就不再是默认收集器JDK14直接被移除因为它在碎片化上实在无解。接棒的是G1。G1把堆切成一张张大小相等的Region默认约2048个每个Region可以独立扮演Eden、Survivor或者老年代的角色。物理上不再分代逻辑上仍然有年轻代老年代的概念。G1每次回收时会按照“垃圾占比最高的Region优先”来排序这就是Garbage First名字的由来。它的STW停顿通过-XX:MaxGCPauseMillis来约束但不是保证值而是软目标G1可以通过动态调整年轻代大小去尽量贴近它。G1还有一个关键内存结构叫RSet记录的是哪些Region里的对象引用了当前Region的对象。有了它G1在标记老年代Region时就不需要全堆扫描但代价是RSet本身占内存每个Region还要维护写屏障这是G1的隐藏开销。能讲出RSet的取舍在面试里又是一个加分项。4.3 面试官只要追问这三个问题就能暴露是否真懂这一part单独写成你反打面试官的工具箱。你在上面把G1讲完之后可以顺着抛回去三个问题第一个G1的-XX:MaxGCPauseMillis真的能保证停顿吗正确答案是它只是个软目标JVM会动态调整年轻代大小去尝试满足但如果系统业务模型本身就不支持极端情况下依然会出现远超目标的停顿。第二个G1处理“Humongous大对象”为什么特别吃力因为大对象会占用连续多个RegionG1没法复制这种对象到别的Region只能依赖并发标记后直接回收。而且大对象扫描成本高频繁分配大对象会显著增加Full GC概率。第三个ZGC为什么敢说停顿不随堆变大因为ZGC用了染色指针把GC信息直接编码进对象引用里读屏障配合remap阶段把标记和重定位拆到了并发过程中。能答到这一步的人基本都是真正在发行版里盯过ZGC的。这三个问题抛出去面试官如果没有实际配置过G1只能含糊带过这就是“拷打面试官”这个系列的乐趣所在。5. 实战拷打现场拆一篇GC日志5.1 一篇真实的Parallel GC日志逐行拆背完理论我们落到日志上看看真实世界里一个对象是怎么被回收的。下面是一段典型的Parallel Scavenge收集器GC日志网上和本地跑服务经常能看到长得一模一样的[GC (Allocation Failure) [PSYoungGen: 61440K-7895K(61440K)] 89000K-21800K(202240K), 0.0123456 secs]拆解一下GC这次是年轻代垃圾回收也就是Minor GC。注意不是Full GC。(Allocation Failure)触发原因是Eden区分配新对象时内存不够了。PSYoungGen: 61440K-7895K(61440K)年轻代回收前是61440K也就是60MB回收后剩下7895K。括号里的61440K是年轻代当前总容量。回收率非常高这也是年轻代的典型特征。89000K-21800K(202240K)这是整个堆的情况。回收前总堆占用89MB回收后21.8MB总容量约200MB。可以看出来年轻代回收后整体堆压力明显下降属于正常现象。0.0123456 secs暂停时间约12毫秒这个级别的停顿对大多数业务完全无感。再看一条Full GC日志[Full GC (Ergonomics) [PSYoungGen: 61440K-0K(61440K)] [ParOldGen: 12600K-20300K(61440K)] 89000K-20300K(125440K), [Metaspace: 3456K-3456K(1056768K)], 0.4567890 secs]拆解重点Full GC (Ergonomics)触发原因不是代码调用了System.gc()而是JVM自适应调整策略主动发起的Full GC一般意味着老年代空间压力已经很大。PSYoungGen: 61440K-0K(61440K)年轻代被清空存活对象都晋升或复制走了。ParOldGen: 12600K-20300K(61440K)老年代从12.6MB涨到20.3MB总容量60MB。注意这里满GC之后老年代占用量居然涨了不是回收了而是年轻代晋升上来的对象比回收掉的还多。最后一个0.4567890 secsFull GC停顿了约457毫秒。如果线上一次Full GC停顿到这个级别对交易类系统已经是明显卡顿。我个人的习惯是看日志先看两个趋势Full GC频率有没有变化Full GC前后老年代占用差是不是在收窄。前者变化往往和流量峰值、定时任务有关后者收窄往往意味着有对象在不断晋升且没有被回收。5.2 用一个线上案例把日志和对象的生命周期串起来讲讲我踩过的一个真实坑虽然不涉及具体业务但思路完全通用。某天服务报警Full GC从一天几次变成一小时几十次每次停顿300到500毫秒。用jstat先看GC情况jstat -gcutil pid 1000输出里老年代占用率一直在85%以上GC后几乎不下降。接着用jmap -histo:live pid看存活对象分布排名靠前的清一色是byte[]和Object[]。再配合jmap -dump导出一份堆dump用MAT打开很快就定位到一个静态Map业务代码在批量处理请求时把每个会话对象都塞进了这个Map但没有任何淘汰机制所谓“缓存”实际上是个无限增长的内存泄漏。用今天的知识复盘一下这些对象都是通过静态变量这个大Root直接可达的所以它们永远不是垃圾标记阶段根本不会标它们老年代只能越占越满。Full GC为什么频繁老年代空间不够新晋升的对象无处安放JVM就不断尝试Full GC去腾空间但腾不出来。这个案例最好的作用是证明对象生命周期不是纸上谈兵。你在面试里讲这个案例比背十遍“什么是内存泄漏”都有说服力。排查思路本身就对应着今天讲的整条链路分配找到泄漏点GC日志确认代际压力GC Roots判定不可回收最后回扣到业务设计缺陷。6. 常见问题与避坑清单六个一答就错的经典论断6.1 六句面试官天天听见的错误说法我在面试和帮人模拟面试时发现下面这六句话出现频率极高而且每一句都错得很典型。抄下来对照一下自己会不会中招。错误说法正确认知坑在哪里Java对象一定分配在堆上经过逃逸分析后对象可能被标量替换不再有实际对象实体忽略了JIT编译期的优化动作GC Roots就是所有静态变量和常量静态变量只是Root的一类活跃线程、JNI引用、异常表等都是RootRoot集合远超想象动态变化finalize可以可靠释放资源JDK9已废弃执行时机不可控只能执行一次旧面经还在传实战里是大坑System.gc()会立即Full GC它只是建议JVM执行GCGC线程可能忽略把“建议”当成“命令”G1一定比CMS快G1更可控但RSet和写屏障有额外开销吞吐量场景G1未必能打过Parallel老年代回收必须要STWZGC已经把大部分标记和重定位并发化了新时代收集器打破了旧认知这六句话你要是平时就搞得清清楚楚今天这篇文章的目的就达到了。但我要多嘴一句这些错误认知不是背了就能纠正的最好自己在本地写个测试工程用不同收集器跑一轮GC日志亲眼看一遍区别印象会深得多。6.2 从Day10往回看这个系列真正在练什么做了十期“拷打面试官”不管是前面聊线程安全、索引选择还是今天聊对象生命周期你会发现主线从来没有变过拿到一个面试题先拆考点再把每个考点往下挖到原理层最后回到一个真实的线上场景验证一遍。过程有点像拆一个复杂的钟表表面上是问问答题实际是逼自己把手伸进机芯里看每根齿轮是怎么咬合的。这一期如果只记住一件事我希望你把“对象的一生”当成一张地图存进脑子里从类加载到字节码new从TLAB分配到逃逸分析从GC Roots标记到三色标记并发从年轻代复制到老年代回收再到真刀真枪看GC日志、定位线上内存问题。这张地图就是你JVM知识体系的骨架以后遇到任何内存相关的问题你都能顺着这条链路迅速定位。最后再分享一个小技巧是我这些年面试总结出来的。被问到JVM相关问题时不要急着回答先反问一句“您线上用的是什么收集器留没留GC日志”。这一句能扭转局势它让你从“被考的人”变成“聊技术的人”而且对方会立刻意识到你不是纯背书的。哪怕你回答最后有几个点没那么全这种带着现场感讨论问题的姿态也远比完美背诵更有说服力。Day10到这里剩下的路就该你自己去跑一遍了。