JVM垃圾回收算法全解析:可达性分析、标记清除与分代收集
发布时间:2026/10/23 7:15:41 锦皓数字建站

做Java开发的人尤其是写过几年代码被线上问题教育过之后基本都会回头补JVM内存结构的课。我自己印象最深的一次是一个服务在老年代占用还不到70%的状态下GC停顿却一次比一次长日志里Full GC后面跟着一串“promotion failed”。当时我对垃圾回收的概念还停留在“引用计数、标记清除”这六个字上排查起来就像手里拿着一张错的地图越找越偏。垃圾回收这事拆开其实就三问哪些对象值得回收回收采用什么策略暂停多久业务能接受。标题里那五个算法正好把这三问的答案串成一条线。这篇我会按“先判断对象死活再看回收策略然后拆分代收集的组合逻辑最后落到STW和收集器演变”这条主线来写既照顾初学者需要建立的画面感也尽量让有项目经验的人看完能把日志里的现象和算法对上号。1. 先判断“死没死”引用计数和可达性分析这对基本盘所有垃圾回收算法启动之前都得先回答同一个问题哪些对象还活着哪些对象可以当成垃圾处理。这个步骤叫“对象存活判定”。标题里的引用计数法和可达分析算法争的就是这件事。1.1 引用计数法思路朴素但有一个致命死角引用计数法的逻辑很直观给每个对象配一个计数器有一个新的引用指向它时计数器加1某个引用失效时计数器减1计数器归零就说明这个对象没人用了可以回收。它最大的优点是简单判定效率高而且在计数器归零的那一刻就能立刻回收对象不需要等到某个GC触发点。像Python这类语言早期就非常依赖引用计数来管理内存。但JVM最终没有全面采用它关键卡在“循环引用”这个问题上。我常说一个例子两个对象A和BA里有字段指向BB里也有字段指向A。它们互相引用但外部已经没有变量指向它们了。按引用计数器的逻辑算A的计数器是1B指向它B的计数器也是1A指向它永远归不了零这两块内存就成了回收不掉的死角。Python被迫在引用计数之外另搞了一个循环检测器来收拾这个残局而JVM在设计时直接绕开了这套机制。引用计数还有一个工程层面的麻烦每次赋值操作都要维护计数器赋值在程序里是最高频的操作之一JIT编译器虽然能做优化但计数器本身引入的额外指令和缓存竞争在高度优化的虚拟机里并不那么划算。更关键的是引用计数“归零即回收”的特点是分散的、局部的它很难配合分代收集去做整体规划和批量回收。1.2 可达性分析以GC Roots为锚点的整图扫描可达性分析的思路完全换了方向不去数“有多少引用指向这个对象”而是从一组被称为GC Roots的根节点出发沿着引用链在对象图中做遍历。凡是能从根节点直接或间接走到的对象都算“活的”走不到的对象就成了回收候选。GC Roots不是一个固定的集合常见来源包括虚拟机栈中栈帧的局部变量表里引用的对象方法区中类静态属性引用的对象方法区中常量引用的对象本地方法栈中JNI引用的对象活跃线程、被synchronized持有的锁对象等从根出发做一次图遍历好处立刻显现A和B即使互相引用只要外部没有根节点指向它们从GC Roots出发就扫不到它们照样被判定为可回收。循环引用问题被天然化解这也是可达性分析成为主流虚拟机选择的原因。值得多说一句的是可达性分析并不等于“一次标记就彻底判死刑”。HotSpot VM里对象被判定不可达之后还会经历两次标记过程第一次标记后如果对象覆盖了finalize方法且该方法未被调用过它会被放进一个F-Queue队列等待一个低优先级线程去调度执行finalize如果执行完finalize之后对象仍然不可达才会被真正回收。这套机制在JDK 9之后已经标记为废弃现实中很少有人依赖它兜底但面试被问到“对象如何自救”时指的就是这个两次标记的过程。1.3 为什么JVM最终没有用引用计数一个干脆的取舍很多初学者会困惑引用计数看起来又简单又高效为什么主流JVM统一倒向可达性分析循环引用确实是直接原因但更深一层的原因是工程形态。引用计数是“局部视角”它只能回答“这个对象有没有被引用”回答不了“这个对象整体上还不可达”。而JVM做分代收集、做并发标记时都需要在某个快照下对整张对象图做统一判定。可达性分析天然适合这种“确定一个边界扫描整个堆”的工作方式它把存活判定的复杂度集中在GC Roots的扫描和引用链的遍历上并且可以通过OopMap、安全点等机制与编译器和运行时深度配合。这才是工程上的根本取舍。2. 判定完事后三种回收策略各有各的账本对象死活定了接下来就看怎么回收。不管名称包装得多花哨回收动作本质上就三类清除、复制、压缩。标题里的标记清除、标记复制、标记压缩区别在于标记完存活对象之后各自对内存空间做了什么处理。2.1 标记-清除做法最直接代价是内存碎片标记-清除分两步走。第一步用可达性分析把所有存活对象标记出来第二步统一回收掉没有被标记的对象。这个策略的优势在于实现简单而且不需要移动对象用户线程在标记阶段和清除阶段都有机会介入这也是后续并发收集器的改良基础。但它的缺陷同样突出内存碎片。清除之后留下的空闲空间是零散的就像一块蛋糕被到处挖了几勺剩下的碎渣看着不少却很难再切出一整块来。后续要分配一个较大的对象时明明总的剩余空间足够却找不到一段连续区域可能被迫提前触发一次Full GC。碎片还会带来一系列连锁问题。比如某类老年代收集器在回收后如果没有做压缩老年代碎片越积越多最后很可能出现“promotion failed”——新生代对象想晋升进老年代却找不到足够大的连续空间。这也是为什么标记-清除策略在工程上很少单独存在要么搭配压缩要么配套其他防御机制来兜底。2.2 标记-复制用空间换时间对“朝生夕灭”的对象极度友好标记-复制的基本操作是把内存划分为两块等大的区域每次只使用其中一块。GC发生时把存活对象整体复制到另一块区域再把原来这一块一次性清理干净。它的直接好处是“无碎片”复制后存活对象是紧凑排列的新空间永远连续后续分配只需要移动堆顶指针效率极高。另一个隐藏优势是回收成本均匀不管原来的区域里有多少垃圾一次整块清理边界干净利落不会像标记-清除那样还得逐个处理碎片间隙。代价也很直观内存可用空间直接减半。而且如果对象存活率很高复制操作本身的开销很大存活对象越多复制量越大回收效率反而急剧下滑。所以复制算法天生适合“存活率低”的场景——绝大多数对象一次性死去只有极少数幸存者需要搬走这是新生代对象最典型的特征。2.3 标记-压缩治碎片但移动对象有成本标记-压缩是标记和压缩的组合拳。标记阶段和标记-清除完全一样区别在于清除前它会先把所有存活对象向内存的一端移动让它们紧密排列然后把边界以外的空间全部清理掉。从空间利用上看这是最理想的状态没有碎片可用内存率几乎无损。代价是移动对象本身要花时间而且堆里的引用要全部重新指向新地址这个过程如果用户线程还在运行指针随时可能踩空所以必须进入完整的Stop The World状态。对于老年代这种大容量、高存活率的区域标记-压缩的停顿时间通常比标记-清除更长但换来的是后续分配的稳定性和低碎片压力。2.4 三种策略不是单选题而是场景匹配题我整理了一张对比表方便一口气看清差异策略是否移动对象内存碎片适合场景主要代价标记-清除否严重老年代、停顿敏感并发场景碎片累积分配大对象困难标记-复制是跨区复制无新生代、存活率低空间浪费存活率高时开销大标记-压缩是区内移动无老年代、需要规整空间移动对象更新引用STW更长很多人刚接触时会纠结哪个算法“最好”。真实答案是不存在万能方案只看对象存活率和可容忍停顿。新生代存活率低复制算法性价比极高老年代存活率高且对象体积大复制成本太高要么用标记-清除忍受碎片要么用标记-压缩忍受更长的停顿。现代JVM的做法就是围绕这两条线的取舍把堆拆开分而治之。3. 分代收集现代JVM把算法组合起来打的组合拳如果你打开任意一个JVM的GC日志会看到新生代Young GC和老年代Full GC是完全不同的回收节奏。分代收集不是某一种算法的名字而是一套“按对象年龄划分区域、每个区域分配不同回收策略”的整体设计。3.1 为什么堆要分成新生代和老年代两个统计假说JVM设计者观察到两条非常稳定的规律绝大多数对象“朝生夕灭”比如循环体里创建的临时对象存活时间极短而那些熬过多次GC的对象反而越来越难被回收比如缓存、单例、长生命周期上下文对象。于是堆被拆成新生代和老年代。新生代的回收频率高但每次只处理一小片区域老年代的回收频率低但每次覆盖面大。这样做还有一层好处大部分Minor GC只扫新生代不需要动老年代停顿时间被压了下来。元空间在JDK8之后取代了永久代但它的回收逻辑类卸载、常量回收条件和堆完全不同属于另一个话题分代收集的主战场仍然是堆。3.2 新生代用复制算法Eden、S0、S1与8:1:1HotSpot VM把新生代再切成三块Eden区和两个Survivor区S0和S1默认比例是8:1:1。新对象通常优先分配到Eden。第一次Minor GC触发时Eden里存活下来的对象被复制到S0Eden被整块清空下一次GC时Eden和S0里的存活对象又被复制到S1然后Eden和S0一起清空。两个Survivor区来回切换角色空的那个做“接收区”用的那个做“来源区”。这套设计解决了一个大问题传统的对半复制会浪费一半内存而EdenS0S1的布局下真正空闲待命、暂时用不到的只有“当前不活跃的那个Survivor”也就是约10%的新生代空间远比对半分划算。对象每次Minor GC后年龄加1年龄超过阈值默认15或Survivor里同类年龄对象占比达到动态判定标准时就会晋升到老年代。我贴一段常见的GC日志来对照16.364: [GC (Allocation Failure) 16.364: [ParNew: 68544K-1843K(76800K), 0.0038682 secs] 71474K-4773K(253568K), 0.0039951 secs]ParNew是新生代收集器68544K压到1843K说明绝大多数对象一次回收后已经消失幸存下来的几千K对象被复制到Survivor区。这就是复制算法在新生代“低存活率快回收”的最佳体现。另外还有一个容易被忽略的参数-XX:PretenureSizeThreshold。超过阈值的大对象会直接进入老年代避免大对象在Eden和Survivor之间反复复制这个参数只对部分收集器生效用之前需要确认你的收集器是否支持。3.3 老年代用标记-清除或标记-压缩复制为什么玩不转老年代对象有两个特点存活率高单个体积通常也更大。如果用复制算法相当于每次Full GC都要把老年代里绝大多数“活口”整体搬到另一个区域这个复制量想想都心疼停顿时间也会被拉到无法接受的程度。所以在老年代常见的组合有两种Serial Old或Parallel Old采用标记-压缩CMS采用标记-清除的并发变体。标记-压缩保证老年代长期运行后仍然有连续空间可分配CMS则通过容忍碎片来换取更短的并发停顿代价是它最终还需要兜底机制来处理碎片问题。这里顺便多说一句让大对象直接进老年代对老年代回收反而更友好因为它省掉了在新生代反复复制的动作但是连续空间的压力会变大需要和压缩策略配合评估。3.4 分配担保复制算法失败时的兜底机制复制算法有一个前提条件接收区要装得下存活对象。万一Survivor空间不够怎么办JVM引入了分配担保机制。简单说做Minor GC之前虚拟机会先检查老年代是否有足够的连续空间来承接可能晋升的新生代对象。老版本里有个开关叫HandlePromotionFailureJDK 6u24之后演化为更直接的判断只要老年代连续空间大于新生代对象总大小或者大于历次晋升到老年代的对象的平均大小就允许进行Minor GC不满足条件就退化成一次Full GC。理解这个机制你就能看懂线上为什么会出现“Minor GC频发老年代连续空间不足导致Full GC”的现象这不是算法本身的问题而是分代之间协同失败时的保护性动作。4. 算法之外的工程真相STW、安全点与收集器演变算法只是一张图纸真正做成产品级的GC还得回答一个核心矛盾回收线程和业务线程共享同一块内存回收时业务线程到底停不停、什么时候停、怎么保证不出错。这一章的三个关键词是STW、安全点和收集器族谱。4.1 Stop The World为什么回收时必须停下来可达性分析要求GC Roots的对象引用关系在一个稳定的快照下保持不变否则标记可能“标了一半引用又改了”回收结果根本无法信任。所以现代JVM几乎都会在某个阶段暂停所有业务线程这个全局停顿就是Stop The World简称STW。移动对象的时候更是必须STW。复制和压缩会把对象从一处搬到另一处业务线程的栈上还存着旧地址的引用如果不停下来把这些引用同步成新地址业务线程一访问就是野指针。这也是为什么CMS这类并发收集器总要在“移动对象”这件事上做很多折中——它宁可用标记-清除容忍碎片也不轻易做压缩原因就在这。STW本身不是缺陷GC行业里各种优化本质上都是在“必须停的步骤”和“可以并发的步骤”之间重新切分时间片。你在任何收集器文档里看到的“并发”两个字背后都有一个工程事实并发环节一定是标记、清理这类不移动对象、引用关系相对容易同步的阶段。4.2 安全点和OopMap指令层面怎么配合暂停JVM不是在任何字节码位置都能随意暂停的。线程正在执行的指令如果停在对象引用状态不一致的位置GC根本没法安全扫描。所以HotSpot引入了“安全点”的概念所有线程在GC触发后必须运行到安全点才能被挂起。安全点通常选在“让程序长时间执行的指令”上典型位置包括方法调用之后、循环跳转处、异常跳转处。这些位置要么能代表一段执行的结束要么处于引用关系较稳定的状态。触发GC时JVM会设置一个中断标志各线程运行到安全点时主动轮询这个标志发现被置位就自己停下来进入等待。另一个配套机制是OopMap。JIT编译方法时会记录栈帧和寄存器里哪些位置存放着对象引用GC扫描时直接读取这些映射关系不需要把一个完整栈帧翻个底朝天。这就像消防员进楼前手里已经拿着房间分布图知道哪个房间里有人不用每扇门都踹开看一遍。实际工程里安全点、OopMap、安全区域这些机制和可达性分析深度绑定共同构成了HotSpot GC的扫描基础。4.3 从串行到CMS再到G1停顿与并发平衡的进化史把算法和工程机制放在一起看收集器的演进就很好懂了。最早的Serial收集器是单线程GC回收时全部业务线程都会停住算法上就是简单的新生代复制加老年代标记-压缩。Parallel收集器则把复制和压缩动作拆到多线程并行执行目标是最大化吞吐量STW时间并没有缩短太多但单位时间能干完的活变多了。CMS是第一个真正把“并发”带到老年代回收里的收集器。它把整个过程拆成初始标记、并发标记、重新标记、并发清除四段前两段和最后一段仍需要STW但并发标记和并发清除阶段可以和业务线程同时运行。代价是它本质是标记-清除的并发版老年代会累积碎片最终还得靠一次Full GC或Serial Old兜底压缩。CMS在JDK9被标记为过时后续版本逐步被移除核心原因就是它解决响应时间的同时把碎片和退化风险留给了系统。G1则换了一套思路它把整个堆划分为大小相等的Region不再要求新生代、老年代各自物理连续。年轻代回收时把Eden和Survivor里存活的对象复制到别的Region本质仍然是复制算法当老年代占用达到阈值后会触发并发标记然后挑选回收价值最高的若干Region做局部复制。这种“带优先级的局部回收”既保留了复制的无碎片优势又让停顿时间变得可预测。JDK9开始G1成为默认收集器。你去看G1的日志会发现新生代回收可以出现在整个堆的不同区域里但背后的算法逻辑仍然是这一套复制、标记、整理的组合拳。5. 学习GC算法时我最想分享的三个实操心得文章写到这里已经把这五套算法的来龙去脉都过了一遍。最后说点我自己的感受和实操建议希望对你有用。第一别为了看算法而看算法一定要搭配GC日志一起看。打开-XX:PrintGCDetails观察一次Young GC前后Eden和Survivor的数量变化再对比Full GC后老年代的占用情况你对复制、清除、压缩会形成肌肉记忆。我自己的体会是很多概念死记十遍不如亲眼看一条ParNew日志管用。第二如果你在准备面试回答“哪些对象可以被回收”时别只丢出“可达性分析”四个字。把引用计数为什么被淘汰说清楚再提一句GC Roots的主要来源最后落到“不可达不是立即死亡还有两次标记的缓冲过程”你的回答就有了完整的逻辑链。第三实际调优时先摸清自己业务的“对象生命周期特征”再谈选哪个收集器、调哪几个参数。如果你的对象大多是请求级的短命对象复制算法天生占优如果你的服务里大对象、缓存占了大头碎片和压缩就是主要矛盾。盲目跟风换G1或者随手调SurvivorRatio往往治标不治本。记住一句话算法解决的是“内存怎么管理”的问题而你的业务数据决定“哪种管理方式更划算”。以上就是我这些年和JVM垃圾回收算法打交道积累下来的东西。希望这篇能帮你把日志里的现象、参数背后的逻辑以及面试官追问的细节串联成一张完整的地图而不是再背一遍零散的定义。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。