深入探究 JVM 堆的垃圾回收机制(二)— 回收

发布于:2025-03-22 ⋅ 阅读:(10) ⋅ 点赞:(0)

GC Roots 枚举需要遍历整个应用程序的上下文,而在进行可达性分析或者垃圾回收时,如果我们还是进行全堆扫描及收集,那么会非常耗时。JVM 将堆分为新生代及老生代,它们的回收频率及算法不一样。

1 回收算法

在进行可达性分析时,我们会对对象进行标记:存活及待回收。然后再进行回收。

标记-清除

统一回收所有被标记为“待回收”的对象。

缺陷:1)执行效率不稳定,如果大部分对象需要回收,那清除工作将增加。2)内存空间碎片化,会产生大量不连续的内存碎片。

标记-复制

半区复制,将空间平分为2,每次只用其中1块,每次回收时将存活的对象复制到另一块,将当前块一次性清理掉。

缺陷:1)如果大部分对象是存活的,就会产生很大的内存复制开销。2)可用内存缩小到原来一半,内存利用率不高。

标记-整理

让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存。

缺陷:如果存活对象分布比较分散,那么移动对象将很耗时。

表 回收算法

“标记-清除”因为“碎片问题”在虚拟机中较少使用,“碎片问题”会带来垃圾回收频繁、大型数据无法存储等问题。

1.1 分代收集

弱分代假说:绝大多数对象都是朝生夕灭。

强分代假说:熬过越多次垃圾收集的对象就越难以消亡。

JVM 按照对象年龄(熬过垃圾收集过程的次数,默认15次)将堆划分为新生代与老生代。

新生代

依据弱分代假说,每次回收只关注如何保留少部分存活的对象。

算法:标记-复制

老生代

依据强分代假说,以较低频率回收这个区域。

算法:标记-整理

表 新生代与老生代

1.1.1 新生代 Appel 式垃圾回收

将新生代划分为三块空间:Eden、Servivor1、Servivor2,内存大小比例为8:1:1。

每次只使用Eden和一块Servivor,垃圾回收时,将它们当作还存活的对象一次性复制到另一块Servivor中,然后清理掉使用的Eden及Servivor块。

PS:回收时,如果Eden及Servivor存活下来的对象超过Servivor容量时,会将溢出的对象复制到老生代。

1.1.2 晋升到老生代

晋升到老生代有如下场景:

  1. 对象年龄达到阈值。
  2. 大对象(大小超过设定的阈值)直接分配至老生代。避免大对象在新生代频繁复制。
  3. 老生代空间担保失败,Minor GC完成后存活的对象大小超过一块Servivor的值。
  4. 动态年龄判定,如果年龄小等于X的对象总大小超过Servivor容量的50%,则所有年龄>=X的对象直接晋升。

1.1.3 Minor GC 与 Full GC

纬度

Minor GC

Full GC

作用区域

新生代

整个堆(新生代+老生代)+方法区

触发

频率高。

当Eden区空间不足时触发。

(并非每次Eden满都触发,若开启空间分配担保,则可能直接触发Full GC)。

频率低。

  1. 老生代空间不足。
  2. 方法区空间不足。
  3. 显式调用:System.gc()
  4. 空间分配担保失败。

算法

标记-复制

标记-整理/标记-清除

表 Minor GC 与Full GC的对比

1.2 跨代引用

新生代的对象可能被老生代引用。Minor GC时,进行可达性分析前还需要将引用了新生代对象的老生代对象加入到GC Roots中。

跨代引用假说:跨代引用相对于同代引用来说仅占少数。

1.2.1 卡表

为了能快速收集到引用了新生代的老生代对象,在新生代上建立一个全局的“记忆集”,来记录老生代中跨代引用的信息。

“卡表”是记忆集的一种实现,是一个字节类型的数组。数组中每个元素对应老生代中一块固定大小的内存区域。该区域称为卡页(默认512字节)。当卡页中有对象存在跨代引用时,卡表对应数组的元素值为1,否则为0。

1.2.2 卡表的“伪共享”

“伪共享”是一种性能问题。

操作系统中内存与缓存的取值最小单位是缓存行(64字节),多线程并发的场景下,不同线程对不同卡表项修改可能操作的是同一缓存行,从而引发频繁的缓存同步,降低性能。

优化方案:

  1. 在标记卡表前,如果该条目未被标记,才能进行标记。(减少不必要的写操作)
  2. 卡表稀疏化,调整卡表条目粒度。