JVM 垃圾回收算法深度解析:从基础原理到面试实战
JVM 垃圾回收算法个人声明本文为个人学习过程中的零散笔记经 AI 辅助梳理成文内容仅供参考和学习不保证完全准确。如有错误欢迎指正。一、为什么学习垃圾回收算法先想清楚我们学 GC 算法到底是为了解决什么问题手动内存管理的痛点C/C 需要malloc/free手动管理内存容易出现内存泄漏、野指针、重复释放等问题GC 是 Java 生产力的核心JVM 自动回收不再使用的对象让开发者专注业务逻辑但代价是 STWStop-The-World和额外的 CPU 开销高级工程师的必备素养生产环境 OOM、频繁 Full GC 导致接口超时你能精准定位根因吗不同业务场景低延迟/高吞吐/大内存如何选择合适的 GC 收集器调优参数-Xms/-Xmx/-XX:NewRatio/-XX:SurvivorRatio背后的原理是什么一句话定位GC 算法是垃圾收集器的理论基础不理解算法原理调优就是瞎调参数。二、GC 算法全貌从根上理解分类先建立一个完整的分类体系垃圾回收算法 ├── 1. 对象存活判定怎么判断对象是不是垃圾 │ ├── 引用计数法Reference Counting │ └── 可达性分析算法Reachability Analysis← HotSpot 采用 │ ├── 2. 垃圾清除算法垃圾确定了怎么收 │ ├── 标记-清除算法Mark-Sweep │ ├── 标记-复制算法Copying │ └── 标记-整理算法Mark-Compact │ ├── 3. 内存分配算法回收后新对象怎么放 │ ├── 指针碰撞Bump-the-Pointer │ └── 空闲列表Free List │ ├── 4. 分代收集理论为什么要分新生代老年代 │ ├── 弱分代假说 │ ├── 强分代假说 │ └── 跨代引用假说 │ └── 5. 标记-整理的具体实现Mark-Compact 的 3 种方式 ├── 双指针算法Two-Finger ├── Lisp2 算法标准滑动压缩 └── 单次整理算法Single-Pass三、对象存活判定怎么知道对象死了在谈回收算法之前必须先回答JVM 怎么知道哪些对象该回收3.1 引用计数法Reference Counting原理每个对象维护一个引用计数器被引用时 1引用失效时 -1计数器为 0 的对象就是死对象。优点缺点实现简单判定效率高无法解决循环引用问题A 引用 BB 引用 A两者都可达但计数器都不为 0不需要从根集扫描引用计数的增减有额外开销面试坑很多人以为 Java 用的是引用计数法错HotSpot 用的是可达性分析算法。Python 用引用计数 分代回收。3.2 可达性分析算法Reachability Analysis原理以一系列被称为“GC Roots”的根对象作为起点从这些节点开始向下搜索走过的路径称为引用链Reference Chain。一个对象到 GC Roots 没有任何引用链相连就证明它是不可达的可以被回收。GC Roots 包括哪些对象面试高频类别说明虚拟机栈栈帧中的局部变量表中引用的对象方法参数、局部变量、临时变量等方法区中类静态属性引用的对象如static Object obj new Object()方法区中常量引用的对象字符串常量池中的引用本地方法栈中 JNINative 方法引用的对象Native 代码持有的 Java 对象引用被 synchronized 持有的对象锁对象JVM 内部引用基本数据类型对应的 Class 对象、常驻异常对象、系统类加载器等记忆技巧栈虚拟机栈本地方法栈 方法区静态常量 锁 内部引用。核心是外部持有的、不可能被回收的引用。3.3 四种引用类型扩展JDK 1.2 之后Java 对引用的概念做了扩充对象的可达性与引用类型相关引用类型说明回收时机用途强引用Strong普通的Object obj new Object()从不回收普通对象软引用SoftReference还有用但非必须内存溢出前回收缓存弱引用WeakReference非必须比软引用更弱下次 GC 时回收ThreadLocal 中的 Key虚引用PhantomReference最弱的引用完全不影响对象生命周期对象被回收时收到通知堆外内存回收管理NIO易错点可达性分析判定为不可达的对象不是立即回收。要真正回收需要经历两次标记过程第一次标记不可达 → 执行finalize()方法如果重写了且未执行过→ 第二次标记如果在 finalize 中重新建立引用链则自救成功。但finalize()已被官方标记为废弃JDK 9 起Deprecated(since9)。四、三大基础垃圾回收算法4.1 标记-清除算法Mark-Sweep出处最早也是最基础的 GC 算法由 John McCarthy 于 1960 年提出。执行过程分为两个阶段——标记清除标记阶段从 GC Roots 出发遍历所有可达对象打上存活标记清除阶段遍历整个堆回收所有没有标记的对象即死亡对象标记前[A][B][C][D][E][F][G] 活 死 活 活 死 死 活 标记后[A✓][B✗][C✓][D✓][E✗][F✗][G✓] 清除后[A][ ][C][D][ ][G]优点实现简单不需要移动对象存活对象多时效率高只需要扫一遍死对象缺点❌内存碎片严重回收后产生大量不连续的空闲内存大对象可能找不到连续空间提前触发 Full GC❌执行效率不稳定堆中对象越多标记和清除的开销越大时间复杂度 O(n)❌ 两次全堆遍历标记一次 清除一次适用场景老年代存活对象多、死亡对象少的区域4.2 标记-复制算法Copying / Semi-Space出处由 Marvin Minsky 于 1963 年提出为了解决 Mark-Sweep 的碎片问题。执行过程将可用内存按容量划分为大小相等的两块From / To也叫 Semispace每次只使用其中一块From 区发生 GC 时将 From 区中存活的对象全部复制到 To 区按顺序整齐排列清空 From 区交换 From 和 To 的角色下次从新的 From 区开始From 区使用中 [A][B][C][D][E] 活 死 活 死 活 To 区空闲 [ ][ ][ ][ ][ ] 复制后 From 区清空 [ ][ ][ ][ ][ ] To 区存活对象 [A][C][E][ ][ ]优点✅没有内存碎片复制后对象在 To 区连续排列新对象分配用指针碰撞即可✅只遍历一次标记和复制合二为一不需要专门的清除阶段✅存活对象少时效率极高只需要复制存活对象死亡对象直接被覆盖缺点❌内存利用率低可用内存只有原来的一半空间浪费严重❌ 存活对象多时复制开销大如果存活对象很多复制成本很高❌ 需要频繁移动对象更新所有引用地址适用场景新生代对象朝生夕死存活率极低复制成本小4.3 标记-整理算法Mark-Compact出处由 Donald E. Knuth 于 1969 年提出结合了前两种算法的优点。执行过程前两个阶段和 Mark-Sweep 一样但第三阶段不是直接清理而是把所有存活对象向一端移动然后清理掉边界以外的内存。标记阶段同 Mark-Sweep标记所有存活对象整理阶段将所有存活对象移动到内存的一端按顺序排列清除阶段直接回收边界以外的所有内存标记前[A][B][C][D][E][F][G] 活 死 活 活 死 死 活 标记后[A✓][B✗][C✓][D✓][E✗][F✗][G✓] 整理后[A][C][D][G][ ] ← 存活 → | ← 空闲 →优点✅没有内存碎片存活对象连续排列新对象分配简单✅ 内存利用率 100%不像复制算法浪费一半空间缺点❌需要移动对象存活对象多时移动和更新引用的开销大❌ 暂停时间长STW 时间长因为必须暂停所有用户线程才能移动对象❌ 实现复杂度最高要遍历三次堆标记 计算新地址 移动/更新引用适用场景老年代存活对象多、内存不能浪费的场景4.4 三种算法对比总结面试高频维度标记-清除复制算法标记-整理速度中等两次遍历最快一次复制最慢三次遍历 移动对象内存碎片✗ 严重碎片✗ 无碎片✓ 无碎片内存利用率高低只有一半/90%高移动对象不需要需要需要实现复杂度简单中等复杂适用区域老年代新生代老年代代表收集器CMSSerial / Parallel ScavengeSerial Old / Parallel Old一句话总结各自的定位复制算法 速度换空间新生代对象死得快标记-清除 折中方案牺牲碎片化换速度老年代早期方案标记-整理 空间换时间牺牲停顿换完整内存老年代现代方案五、内存分配算法回收后新对象怎么放内存分配方式取决于堆内存是否规整而堆内存是否规整又取决于采用的 GC 算法。5.1 指针碰撞Bump-the-Pointer前提堆内存是规整的所有存活对象在一边空闲内存在另一边中间一个指针作为分界点原理分配内存时只需要把指针向空闲方向挪动一段与对象大小相等的距离。[存活对象][空闲空间] ↑ 指针 → 向右移动对象大小即可时间复杂度O(1)极快配合的 GC 算法复制算法、标记-整理算法内存规整的算法5.2 空闲列表Free List前提堆内存是不规整的存活对象和空闲区域交错原理JVM 维护一个空闲内存块的列表记录哪些内存块是空闲的、大小是多少。分配时从列表中找一块足够大的空间划分给对象。三种分配策略策略英文做法优点缺点首次适应First-Fit从头开始找第一个足够大的空闲块就用速度快倾向于留下高地址的大块低地址碎片多容易产生外部碎片最佳适应Best-Fit找所有空闲块中大小最接近的不浪费大空间空间利用率高容易产生很小的外部碎片需要遍历所有块最差适应Worst-Fit找最大的空闲块来分配不容易产生小碎片大空闲块被消耗快大对象可能分配失败配合的 GC 算法标记-清除算法内存不规整有碎片六、分代收集理论6.1 三个分代假说假说内容实践指导弱分代假说Weak Generational Hypothesis绝大多数对象都是朝生夕灭的新生代用复制算法存活率低复制成本小强分代假说Strong Generational Hypothesis熬过越多次 GC 的对象就越难以消亡老年代用标记-清除或标记-整理存活率高不用复制跨代引用假说Intergenerational Reference Hypothesis跨代引用相对于同代引用来说仅占极少数不需要为了少量跨代引用扫描整个老年代6.2 分代收集的基本思想基于前两个假说JVM 将堆分为新生代和老年代堆内存 ├── 新生代Young Generation约 1/3 堆空间 │ ├── Eden 区8/10新对象优先分配在这里 │ ├── Survivor From1/10GC 后存活对象的暂存区 │ └── Survivor To1/10GC 目标区与 From 轮流互换 │ └── 老年代Old Generation约 2/3 堆空间 └── 长期存活的对象、大对象直接进入GC 类型Minor GC / Young GC只回收新生代发生频繁速度快Major GC / Old GC只回收老年代这个术语有歧义很多人混用Full GC回收整个堆新生代 老年代 方法区速度慢STW 时间长应尽量避免6.3 跨代引用问题与卡表Card Table问题如果新生代的对象被老年代引用了那 Minor GC 的时候怎么知道这些对象是存活的难道要扫描整个老年代吗答案不需要。基于跨代引用假说跨代引用很少所以用卡表Card Table来优化。卡表原理把老年代分成一个个 512 字节的卡页Card Page卡表是一个字节数组每个元素对应一个卡页当老年代某卡页中的对象引用了新生代对象时将该卡标记为脏卡Dirty CardMinor GC 时只需要扫描脏卡对应的老年代区域不需要扫描整个老年代老年代分成若干卡页每页 512 字节 [卡页0][卡页1][卡页2][卡页3]...[卡页N] 卡表字节数组 [0][1][0][0]...[0] ↑ 脏卡卡页1中有对象引用了新生代对象结果Minor GC 时只需要扫描脏卡极大减少了扫描范围。6.4 对象晋升规则补充知识对象从新生代进入老年代的几种情况年龄阈值对象每熬过一次 Minor GC年龄 1默认达到15 岁-XX:MaxTenuringThreshold晋升老年代动态年龄判定如果 Survivor 区中相同年龄的所有对象大小总和大于 Survivor 空间的一半年龄大于等于该年龄的对象直接进入老年代大对象直接进入老年代需要大量连续内存的对象如大数组、长字符串直接分配在老年代避免在 Eden 和 Survivor 之间来回复制-XX:PretenureSizeThreshold分配担保失败Minor GC 后 Survivor 放不下通过分配担保机制直接进入老年代七、标记-整理算法的三种实现方式7.1 按移动策略分类三种风格这是按对象怎么移动的维度分类策略英文说明特点任意顺序Arbitrary Order对象随意移动不保持原顺序实现简单但彻底打乱对象布局可能影响缓存局部性线性顺序Linear Order / Two-Finger也叫双指针法两端指针向中间靠拢实现简单只能用于所有对象大小相同的场景几乎不用滑动顺序Sliding Order / Lisp2存活对象向一端滑动保持原有相对顺序最常用保持对象局部性缓存友好7.2 三种经典实现算法按具体实现方式有三个代表性算法① 双指针算法Two-Finger Algorithm原理从内存两端各设一个指针free指针从前往后找空闲位置live指针从后往前找存活对象找到后把后面的存活对象搬到前面的空闲位置两指针相遇时停止空闲→ ←存活 [A][ ][B][C][ ][D] ↓ [A][D][B][C][ ][ ] ↑ 分界线优点实现简单一次遍历完成缺点只能用于所有对象大小相同的场景实际 JVM 中几乎不用对象顺序被打乱② Lisp2 算法标准滑动压缩算法出处由 Guy L. Steele 等人在 Lisp 语言中提出是最经典的标记-整理实现。执行过程需要三次遍历堆内存第一次标记从 GC Roots 出发标记所有存活对象第二次计算转发地址从前往后遍历为每个存活对象计算它在压缩后应该在的位置forwarding pointer存在对象头中第三次移动 更新引用再次遍历把所有存活对象移动到计算好的位置并更新所有指向这些对象的引用原内存 [A][ ][B][C][ ][D] ✓ ✓ ✓ ✓ 计算目标位置A→位置0, B→位置1, C→位置2, D→位置3 移动后 [A][B][C][D][ ]优点✅ 保持对象的相对顺序滑动压缩缓存局部性好✅ 适用于各种大小的对象缺点❌ 需要三次遍历堆速度慢STW 时间长❌ 实现复杂代表HotSpot 的 Serial Old、Parallel Old 收集器使用类似 Lisp2 的滑动压缩③ 单次遍历整理算法Single-Pass / One-Pass Compact原理将标记、计算地址、移动合并在更少的遍历次数中完成通常用额外的数据结构记录目标位置。常见的有Immix 算法基于块Block的标记-整理只整理碎片严重的块减少移动开销GenMS 中的快速路径G1 的 Region 设计部分借鉴了单次整理的思想优点遍历次数少STW 时间更短缺点实现极其复杂可能引入额外空间开销总结三种整理方法是按移动策略分的任意顺序 / 线性顺序 / 滑动顺序三种具体实践算法是按实现方式分的双指针线性顺序的实现/ Lisp2滑动顺序的实现/ 单次整理更优化的实现这两个分类维度有重叠不要混为一谈。面试时说清Lisp2 是滑动顺序的经典实现即可。八、面试题精选Java 高级工程师方向按基础 → 进阶 → 场景三个难度等级排列每道题包含考察点和答题思路。基础题中级工程师必会Q1Java 中判断对象是否存活有哪两种方法HotSpot 用的是哪种为什么考察点对象存活判定的基本概念参考答案两种方法是引用计数法和可达性分析算法。HotSpot 用的是可达性分析算法原因是引用计数法无法解决循环引用问题。举个例子对象 A 引用对象 B对象 B 也引用对象 A除此之外它们没有任何外部引用按引用计数法两者计数器都为 1永远不会被回收造成内存泄漏。可达性分析从 GC Roots 出发沿引用链搜索不可达的对象都会被回收不存在循环引用问题。Q2GC Roots 有哪些考察点可达性分析的基础知识记忆准确性参考答案GC Roots 是一组必须存活的根对象主要包括虚拟机栈中局部变量表引用的对象方法参数、局部变量本地方法栈中 JNI 引用的对象方法区中类静态属性引用的对象方法区中常量引用的对象如字符串常量池被synchronized 锁持有的对象JVM 内部引用基本类型的 Class 对象、常驻异常对象等答题技巧按栈 方法区 锁 内部四个维度回答不容易漏。Q3三大垃圾回收算法分别是什么各有什么优缺点考察点GC 基础算法的理解与对比参考答案标记-清除先标记存活对象再回收未标记对象优点实现简单不需要移动对象缺点内存碎片严重效率随对象数增加而下降复制算法内存分两块每次只用一块GC 时把存活对象复制到另一块优点无碎片分配简单指针碰撞存活对象少时极快缺点内存利用率低浪费一半存活对象多时复制开销大标记-整理标记后把存活对象移到一端回收边界外内存优点无碎片内存利用率高缺点需要移动对象STW 时间长实现最复杂答题加分项说出各自的适用场景——复制算法用于新生代标记-清除/整理用于老年代。Q4什么是分代收集理论为什么要分代考察点分代收集的设计思想参考答案分代收集基于两个经验假说弱分代假说绝大多数对象朝生夕灭强分代假说熬过越多次 GC 的对象越难消亡根据这两个假说把堆分成新生代和老年代新生代对象存活率低 → 用复制算法只复制少量存活对象效率高老年代对象存活率高 → 用标记-清除或标记-整理不需要复制节省空间如果不分代每次 GC 都要扫描整个堆效率极低。分代后新生代 GC 频繁但速度快老年代 GC 少但彻底。进阶题高级工程师核心Q5跨代引用怎么处理什么是卡表Card Table考察点分代收集的技术细节跨代引用优化参考答案跨代引用指老年代对象引用了新生代对象。Minor GC 时如果为了找全新生代的存活对象而扫描整个老年代代价太大。基于跨代引用假说跨代引用占比极少引入了卡表Card Table把老年代分成一个个 512 字节的卡页卡表是一个字节数组每个元素对应一个卡页当卡页中某个对象的引用发生变化可能指向了新生代对象时将该卡标记为脏卡Minor GC 时只需要扫描脏卡对应的老年代区域而不是整个老年代这个操作通过写屏障Write Barrier实现每次给引用赋值时写屏障检查是否是老年代对象引用了新生代对象如果是就标记脏卡。Q6对象进入老年代的方式有哪些考察点分代收集的对象生命周期管理参考答案年龄达到阈值每熬过一次 Minor GC 年龄 1默认 15 岁-XX:MaxTenuringThreshold晋升老年代动态年龄判定Survivor 中相同年龄的对象总大小超过 Survivor 空间的一半年龄 ≥ 该年龄的对象直接晋升大对象直接进入需要大量连续内存的对象如大数组直接分配在老年代-XX:PretenureSizeThreshold避免在新生代来回复制分配担保失败Minor GC 后 Survivor 空间不足存活对象通过分配担保直接进入老年代Q7指针碰撞和空闲列表分别在什么情况下使用考察点内存分配方式与 GC 算法的关系参考答案指针碰撞Bump-the-Pointer前提是内存规整存活对象在一边空闲在另一边分配时只需移动指针O(1) 时间极快配合复制算法、标记-整理算法使用空闲列表Free List内存不规整时使用有碎片维护空闲块列表分配时找合适的块有三种策略首次适应、最佳适应、最差适应配合标记-清除算法使用面试加分提到TLABThread Local Allocation Buffer——每个线程在 Eden 区有一块私有分配缓冲区多线程下分配不需要加锁进一步提升分配速度。Q8标记-整理有哪些实现方式Lisp2 算法的执行过程是怎样的考察点标记-整理算法的实现细节深度题参考答案按移动策略分三种任意顺序对象随意移动不保持原顺序实现简单但破坏局部性线性顺序双指针两端指针向中间靠拢只适用于对象大小相同的场景滑动顺序存活对象向一端滑动保持原相对顺序最常用Lisp2 算法滑动顺序的经典实现执行过程分三步标记阶段从 GC Roots 标记所有存活对象计算转发地址阶段遍历堆为每个存活对象计算压缩后的目标地址存在对象头的 forwarding pointer 中移动与更新引用阶段再次遍历把对象搬到目标位置并更新所有指向这些对象的引用优点是保持对象相对顺序、缓存友好缺点是需要三次遍历STW 时间长。Serial Old 和 Parallel Old 采用类似的滑动压缩。场景题高级工程师 / 架构师方向Q9为什么新生代用复制算法而不是标记-清除老年代为什么不用复制算法考察点算法选型的设计思考参考答案新生代用复制算法的原因新生代对象朝生夕灭存活率极低通常只有 1%~10%复制存活对象的成本非常小复制算法没有内存碎片分配对象时用指针碰撞速度极快空间浪费问题通过Eden 2 个 Survivor8:1:1的设计缓解实际利用率达 90%老年代做分配担保Survivor 不够用时对象直接进老年代老年代不用复制算法的原因老年代对象存活率高如果用复制算法每次要复制大量对象开销极大老年代没有额外空间做另一半10G 老年代需要 10G 空闲浪费太大所以老年代用标记-清除CMS或标记-整理Serial Old / Parallel Old更合适Q10Minor GC、Major GC、Full GC 有什么区别什么情况会触发 Full GC考察点GC 类型与触发条件调优相关参考答案Minor GC / Young GC只回收新生代频率高、速度快、STW 短Major GC / Old GC只回收老年代这个术语有歧义不同收集器定义不同一般少用Full GC回收整个堆新生代 老年代 方法区速度慢、STW 长应尽量避免Full GC 的触发条件老年代空间不足大对象直接进入、对象晋升等导致元空间方法区空间不足调用System.gc()不一定立即执行只是建议CMS GC 失败Concurrent Mode Failure / Promotion Failed晋升担保失败Minor GC 前检查老年代最大可用连续空间 历次晋升平均大小面试加分说出 CMS 的 Promotion Failure 和 Concurrent Mode Failure 的区别。Q11什么是 TLAB为什么需要 TLAB考察点内存分配优化细节参考答案TLABThread Local Allocation Buffer线程本地分配缓冲区是每个线程在Eden 区私有的一块内存。为什么需要对象创建在堆上而堆是线程共享的。如果没有 TLAB每次分配内存都需要加锁CAS保证线程安全在高并发下分配效率低。有了 TLAB 后每个线程先在自己的 TLAB 中分配对象不需要加锁速度极快TLAB 用完了再去 Eden 区申请新的 TLAB这个过程需要加锁大对象直接分配在老年代不走 TLAB参数-XX:UseTLAB默认开启-XX:TLABWasteTargetPercentTLAB 浪费比例Q12说一下你对垃圾回收算法演进的理解为什么会有 G1、ZGC 这些新收集器考察点GC 技术发展史对趋势的理解参考答案GC 算法的演进是围绕减少停顿时间这个目标展开的串行时代Serial / Serial Old单线程 GC简单但 STW 长适合小内存客户端并行时代Parallel Scavenge / Parallel Old多线程并行 GC关注吞吐量适合后台计算任务并发标记时代CMSGC 标记阶段和用户线程并发执行关注低延迟但有浮动垃圾、内存碎片问题Region 化时代G1把堆分成多个 Region可预测停顿时间兼顾吞吐量和延迟极低延迟时代ZGC / Shenandoah染色指针、读屏障技术把 STW 控制在微秒级和堆大小无关底层算法层面基础还是三大算法标记-清除 / 复制 / 标记-整理但越来越多的工作放到并发阶段执行减少 STWRegion 化设计把整堆打散每次只回收部分 Region控制停顿时间ZGC 的染色指针技术把标记信息存在指针中移动对象也可以并发进行这道题考察的是知识体系的完整性和技术视野回答时体现出从吞吐量优先 → 延迟优先 → 可预测延迟 → 亚毫秒级延迟的演进脉络。九、学习建议第一层记熟三大算法的优缺点和适用场景——这是基础中的基础面试必问第二层理解分代收集的设计思想——为什么要分代、怎么分、各代用什么算法第三层深入细节——卡表、TLAB、分配担保、Lisp2 执行过程第四层结合收集器学——算法是理论收集器是实践。学完算法后去学 Serial、Parallel、CMS、G1、ZGC 这些具体收集器理解它们分别用了什么算法组合第五层实战调优jstat、jmap、jhat、MAT、GC 日志分析结合生产环境问题加深理解