「Java 进阶之路」系列 Day33
写在前面
垃圾回收算法这部分很多人背得出"标记清除、标记整理、复制算法"这三个名字,但问一句"复制算法要白白浪费一半内存,这么不划算的算法为什么现在的JVM还在用",就说不清楚了。这篇把三种基础算法的原理和真正的适用场景讲清楚,为下一篇讲分代收集打好基础。
一、是什么:三种基础回收算法长什么样
标记清除(Mark-Sweep)
分两步走:先从GC Roots出发,把所有能被引用到的存活对象标记出来;再把内存里没有被标记的对象直接清除掉。这是最直观的思路,但清除之后,存活对象和被清空的位置犬牙交错地分布在内存里,会产生大量不连续的内存碎片。
标记整理(Mark-Compact)
前半步和标记清除一样,先标记存活对象;不同的是后半步------把所有存活对象整体向内存的一端移动挤压,让它们紧凑地排列在一起,再直接清理掉边界之外的剩余空间。这样处理完之后,剩下的空闲内存是连续的一整块,解决了标记清除的碎片问题,代价是"移动对象"这个操作本身有开销(不仅要搬运数据,还要同步更新所有指向这些对象的引用地址)。
复制算法(Copying)
把可用内存划分成两块相同大小的区域,每次只使用其中一块。垃圾回收发生时,把当前正在使用这一块里的存活对象,整体复制到另一块空闲区域,然后把原来那一整块内存直接清空------因为是整块清空,不需要一个个甄别哪里该清哪里不该清,效率很高,而且天然没有碎片问题(复制过去的对象是紧凑排列的)。代价很直接:能实际使用的内存永远只有总容量的一半,另一半时刻空闲着等待被交换进来。
二、为什么三种算法会共存:适用场景完全不同
三者不存在绝对的"谁比谁更好",各自的取舍决定了它们分别适合什么场景:
| 算法 | 是否有碎片 | 内存利用率 | 存活对象多时的开销 |
|---|---|---|---|
| 标记清除 | 有 | 高 | 较低(不用移动对象,只标记和清除) |
| 标记整理 | 无 | 高 | 较高(存活对象越多,要移动、更新引用的开销越大) |
| 复制算法 | 无 | 低(永远浪费一半) | 极高(存活对象越多,要复制的数据越多) |
这张表最关键的信息藏在最后一列 :标记整理和复制算法的开销都随着"存活对象的数量"增加而增加,只是标记整理是"移动"、复制算法是"复制";反过来说,如果一个内存区域里的对象大部分都很快死掉、只有极少数存活下来,复制算法的复制成本就低到可以忽略------这恰好是JVM新生代的真实特点:新创建的对象绝大多数都是"朝生夕死"的临时对象,一次GC下来能存活的可能只有个位数百分比。既然要复制的对象本来就没几个,"浪费一半空间"这个代价换来的是"效率极高、没有碎片"这两个明显的好处,非常划算。
反过来,老年代的对象大多是经历过多次GC依然存活下来的"老兵",存活率很高,如果在老年代用复制算法,每次GC要复制的对象数量会非常庞大,效率反而很差;而且老年代空间本身通常比新生代大得多,"浪费一半"这个代价在老年代场景下完全无法接受。所以老年代更适合用标记整理(或者标记清除)这类不需要为"永久闲置一半空间"买单的算法。
标记清除作为最基础、最直接的思路,虽然有碎片问题,但胜在实现简单、不需要移动对象,在内存空间比较充裕、暂时不那么在意碎片的场景下(比如某些以标记清除为主要策略的老年代收集器)依然有它的价值,是理解后面两种改进算法的基础。
三、怎么用:一句话记住三者的选型逻辑
- 对象大多"朝生夕死"、存活率低 → 用复制算法,牺牲一半空间换来极高的回收效率和零碎片
- 对象大多长期存活、存活率高 → 用标记整理,虽然移动对象有开销,但换来了没有碎片、内存利用率高
- 想要最简单直接、能接受碎片 → 用标记清除,实现简单、不用移动对象
这三条选型逻辑不是孤立存在的------它们正是现代JVM"分代收集"思想的地基:新生代对象存活率低,天然适合复制算法;老年代对象存活率高,天然适合标记整理(或标记清除)。下一篇会具体讲这套分代收集机制,以及CMS、G1、ZGC这些实际的垃圾回收器分别是怎么组合运用这些基础算法的。
四、面试追问
Q1:标记清除算法最大的缺点是什么?
会产生大量不连续的内存碎片。因为清除操作只是把没有被标记的对象所在的内存位置标记为可用,并不会移动、整理剩下的存活对象,导致空闲内存和存活对象犬牙交错地分布,如果后续需要分配一个较大的连续内存空间,即使空闲内存总量足够,也可能因为找不到足够大的连续空间而提前触发一次垃圾回收。
Q2:标记整理算法是怎么解决标记清除的碎片问题的?
标记出存活对象之后,不是直接原地清除死亡对象,而是把所有存活对象整体向内存的一端移动挤压,让它们紧凑排列,再统一清理边界之外的剩余空间。这样处理完之后剩下的空闲内存是连续的一整块,不存在碎片问题,代价是移动对象本身有开销,需要同步更新所有指向这些被移动对象的引用地址。
Q3:复制算法为什么要浪费一半的内存空间,这样设计的好处是什么?
因为复制算法每次只使用一半的内存区域,垃圾回收时把存活对象整体复制到另一半空闲区域,再把原来使用的那一半直接整体清空。好处是清空操作不需要逐一甄别该清理哪些位置,效率很高,而且复制过去的存活对象天然紧凑排列,完全没有碎片问题。代价是能实际使用的内存永远只有总容量的一半。
Q4:为什么新生代适合用复制算法,老年代不适合?
新生代里的对象绝大多数都是"朝生夕死"的临时对象,一次垃圾回收下来能存活的对象比例很低,需要被复制的对象数量本来就很少,复制的开销可以忽略不计,"浪费一半空间"换来的高效率和零碎片非常划算。老年代的对象大多是经历过多次垃圾回收依然存活的对象,存活率很高,如果用复制算法,每次都要复制大量对象,效率反而很差,而且老年代空间通常更大,浪费一半空间的代价也更难接受。
Q5:三种垃圾回收算法在效率、内存利用率、碎片问题上分别有什么取舍?
标记清除实现简单、不需要移动对象,效率相对较高,但会产生内存碎片;标记整理没有碎片问题、内存利用率高,但移动对象和更新引用有额外开销,存活对象越多开销越大;复制算法效率高、没有碎片,但要牺牲一半的内存空间不能使用,且存活对象越多需要复制的数据量也越大。三者没有绝对的优劣,要结合具体场景(比如对象的存活率高低)来选择。
下一篇预告
Day34 讲分代收集思想以及CMS、G1、ZGC这几种常见垃圾回收器该怎么选,把这篇讲的三种基础算法真正落地到实际的JVM调优场景里。