垃圾回收算法有哪些?了解哪些垃圾回收器?

垃圾回收算法有哪些?

垃圾回收算法有四种,分别是标记清除法、标记整理法、复制算法、分代收集算法。

  • 标记清除算法:首先利用可达性去遍历内存,把存活对象和垃圾对象进行标记。标记结束后统一将所有标记的对象回收掉。这种垃圾回收算法效率较低,并且会产生大量不连续的空间碎片。
  • 复制清除算法:半区复制,用于新生代垃圾回收。将内存分为大小相同的两块,每次使用其中的一块。当这一块的内存使用完后,就将还存活的对象复制到另一块去,然后再把使用的空间一次清理掉。
    特点:实现简单,运行高效,但可用内存缩小为了原来的一半,浪费空间。
  • 标记整理算法:根据老年代的特点提出的一种标记算法,标记过程仍然与标记-清除算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉边界以外的内存。
  • 分类收集算法:根据各个年代的特点采用最适当的收集算法。

一般将堆分为新生代和老年代。新生代使用复制算法,老年代使用标记清除算法或者标记整理算法。

在新生代中,每次垃圾收集时都有大批对象死去,只有少量存活,使用复制算法比较合适,只需要付出少量存活对象的复制成本就可以完成收集。老年代对象存活率高,适合使用标记-清理或者标记-整理算法进行垃圾回收。

JVM 新生代垃圾回收如何避免全堆扫描?

JVM 在进行新生代垃圾回收时,通过以下方式避免全堆扫描:

  • 卡表(Card Table)机制:JVM 使用卡表记录老年代引用新生代对象的指针变化,从而在进行新生代回收时,只扫描那些老年代中确实有引用指向新生代的区域,避免了全堆扫描。
  • 写屏障(write Barier):当老年代中的对象引用新生代对象时,写屏障会拦载这种引用,并在卡表中标记相关信息。这样,垃圾回收器在扫描时只需要检查标记的区域,而不是遍历整个老年代。

什么是指针碰撞

在Java中,指针碰撞是一种垃圾收集算法中用于分配内存的一种方式。它通常用于实现停顿时间较短的垃圾收集器,如复制算法和标记整理算法。

指针碰撞的基本思想是将堆内存分为两个区域:一个是已分配的对象区域,另一个是未分配的空闲区域。通过一个指针来分隔这两个区域。当需要分配对象时,垃圾收集器将对象的大小与空闲区域的大小进行比较,如果空闲区域足够容纳对象,则将指针碰撞指针向前移动对象的大小,并返回指针碰撞指针的旧值作为对象的起始地址。如果空闲区域不足以容纳对象,则进行垃圾回收操作,释放一些内存后再进行分配。

指针碰撞的优点是分配内存的速度很快,只需简单地移动一个指针即可完成。而且由于已分配的对象区域和未分配的空闲区域是连续的,所以内存的利用率也比较高。

然而,指针碰撞算法的缺点是需要保证堆内存的连续性,即堆内存必须是一块连续的内存空间。这对于某些情况下的内存分配来说可能是一个限制,因为连续的内存空间可能会受到碎片化的影响,导致无法分配足够大的对象。因此,在实际应用中,指针碰撞算法通常与其他内存分配算法结合使用,以克服其局限性。

有哪些垃圾回收器?

以上是 HotSpot 虚拟机中的 7 个垃圾收集器,连线表示垃圾收集器可以配合使用。

并行收集: 指多条垃圾收集线程并行工作,但此时用户线程仍处于等待状态。

并发收集: 指用户线程与垃圾收集线程同时工作(不一定是并行的可能会交替执行)。用户程序在继续运行,而垃圾收集程序运行在另一个CPU上

吞吐量: 即CPU用于运行用户代码的时间与CPU总消耗时间的比值(吞吐量 = 运行用户代码时间 / ( 运行用户代码时间 + 垃圾收集时间 )),也就是。例如:虚拟机共运行100分钟,垃圾收集器花掉1分钟,那么吞吐量就是99%

串行Serial / Serial Old 收集器

特点:单线程、简单高效(与其他收集器的单线程相比),采用复制算法。对于限定单个CPU的环境来说,Serial收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率。收集器进行垃圾回收时,必须暂停其他所有的工作线程,直到它结束(Stop The World)参数:-XX:+UseSerialGC -XX:+UseSerialOldGC

安全点: 让其他线程都在这个点停下来,以免垃圾回收时移动对象地址,使得其他线程找不到被移动的对象因为是串行的,所以只有一个垃圾回收线程。且在该线程执行回收工作时,其他线程进入阻塞状态

Serial Old 是Serial收集器的老年代版本:采用标记整理算法

特点:

  • 单线程收集器

    • 收集效率高,不会产生对象引用变更

    • STW时间长

  • 使用场景:适合内存小几十兆以内,比较适合简单的服务或者单CPU服务,避免了线程交互的开销。

  • 优点:小堆内存且单核CPU执行效率高。

  • 缺点:堆内存大,多核CPU不适合,回收时长非常长。

ParNew 收集器

年轻代:-XX:+UserParNewGC 老年代搭配 CMS

ParNew收集器其实就是Serial收集器的多线程版本

  • 特点: 多线程、ParNew收集器默认开启的收集线程数与CPU的数量相同,在CPU非常多的环境中,可以使用-XX:ParallelGCThreads参数来限制垃圾收集的线程数。和Serial收集器一样存在Stop The World问题

CMS 收集器

老年代:-XX:+UserConcMarkSweepGC年轻代搭配ParNew

Concurrent Mark Sweep,一种以获取最短回收停顿时间为目标的老年代收集器

  • 特点: 基于标记清除算法实现。并发收集、低停顿,但是会产生内存碎片

运行过程分分为下列4步:

  1. 初始标记: 标记GCRoots直接关联的对象以及年轻代指向老年代的对象,会发生Stop the word。但是这个阶段的速度很快,因为没有向下追溯,即只标记一层。

例如:Math math = new Math();此时new Math()即为math的直接引用对象,再往下为间接引用不做记录,例如构造方法中引用了其他成员变量

  1. 并发标记: 接着从gc roots的直接引用对象开始遍历整条引用链并进行标记,此过程耗时较长,但无需停顿用户线程,可与垃圾收集线程一起并发运行。由于用户线程继续运行,因此可能会导致已经标记过的对象状态发生改变,这个阶段采用三色标记算法, 在对象头(Mark World)标识了一个颜色属性,不同的颜色代表不同阶段,扫描过程中给与对象一个颜色,记录扫描位置,防止cpu时间片切换不需要重新扫描。
  2. 重新标记: 为了修正并发标记期间因用户程序继续运行而导致标记产生变动的那一部分对象的标记记录。仍然存在Stop The World问题,这里会慢一些
  3. 并发清理: 标记结束之后开启用户线程,同时垃圾收集线程也开始对未标记的区域进行清除,此阶段若有新增对象则会被标记为黑色,不做任何处理

在整个过程中耗时最长的并发标记和并发清除过程中,收集器线程都可以与用户线程一起工作,不需要进行停顿。

  • **应用场景:**适用于注重服务的响应速度,希望系统停顿时间最短,给用户带来更好的体验等场景下。如web程序、b/s服务

  • 优点:

    • 并发收集;

    • STW时间相对短,低停顿;

  • 缺点:

    • 吞吐量低: 低停顿时间是以牺牲吞吐量为代价的,导致 CPU 利用率不够高。
    • 内存碎片问题:CMS本质上是实现了标记清除算法的收集器,这意味着会产生内存碎片,当碎片化非常严重的时候,这时候有大对象进入无法分配内存时会触发FullGC,特殊场景下会使用Serial收集器,导致停顿不可控。
    • 无法处理浮动垃圾,需要预留空间,可能出现 Concurrent Mode Failure。浮动垃圾是指并发清除阶段由于用户线程继续运行而产生的垃圾,这部分垃圾只能到下一次 GC 时才能进行回收。由于浮动垃圾的存在,因此需要预留出一部分内存,意味着 CMS 收集不能像其它收集器那样等待老年代快满的时候再回收。如果预留的内存不够存放浮动垃圾,就会出现 Concurrent Mode Failure,这时虚拟机将临时启用 Serial Old 来替代 CMS,会导致停顿时间更长

三色标记算法

三色标记

  • 黑色:代表了自己已经被扫描完毕,并且自己的引用对象也已经确定完毕。

  • 灰色:代表自己已经被扫描完毕了, 但是自己的引用还没标记完。

  • 白色:则代表还没有被扫描过。标记过程结束后,所有未被标记的对象都是不可达的,可以被回收。

三色标记算法的问题场景:当业务线程做了对象引用变更,会发生B对象不会被扫描,当成垃圾回收。

java 复制代码
public class Demo3 {
 
    public static void main(String[] args) {
        R r = new R();
        r.a = new A();
        B b = new B();
        // GCroot遍历R, R为黑色, R下面的a引用链还未扫完置灰灰色,R.b无引用, 切换时间分片
        r.a.b = b;
        // 业务线程发生了引用改变, 原本r.a.b的引用置为null
        r.a.b = null;
        // GC线程回来继续上次扫描,发现r.a.b无引用,则认为b对象无任何引用清除
        r.b = b;
        // GC 回收了b, 业务线程无法使用b
    }
}
 
class R {
    A a;
    B b;
}
 
class A {
    B b;
}
 
class B {
}

当GC线程标记A时,CPU时间片切换,业务线程进行了对象引用改变,这时候时间片回到了GC线程,继续扫描对象A, 发现A没有任何引用,则会将A赋值黑色扫描完毕,这样B则不会被扫描,会标记B是垃圾, 在清理阶段将B回收掉,错误的回收正常的对象,发生业务异常。

CMS基于这种错误标记的解决方案是采取写屏障 + 增量更新Incremental Update , 在业务线程发生对象变化时,重新将R标识为灰色,重新扫描一遍,Incremental Update 在特殊场景下还是会产生漏标。即当黑色对象被新增一个白色对象的引用的时候,记录下发生引用变更的黑色对象,并将它重新改变为灰色对象,重新标记。

java 复制代码
public class Demo3 {
 
    public static void main(String[] args) {
        // Incremental Update还会产生的问题
        R r = new R();
        A a = new A();
        A b = new A();
        r.a1 = a;
        // GC线程切换, r扫完a1, 但是没有扫完a2, 还是灰色
        r.a2 = b;
        // 业务线程发生引用切换, r置灰灰色(本身灰色)
        r.a1 = b;
        // GC线程继续扫完a2, R为黑色, b对象又漏了~
    }
}
 
class R {
    A a1;
    A a2;
}
 
class A {
}

当GC 1线程正在标记O, 已经标记完O的属性 O.1, 准备标记O.2时,业务线程把属性O,1 = B,这时候将O对象再次标记成灰色, GC 1线程切回,将O.2线程标记完成,这时候认为O已经全部标记完成,O标记为黑色, B对象产生了漏标, CMS针对Incremental Update产生的问题,只能在remark阶段,暂停所有线程,将这些发生过引用改变过的,重新扫描一遍。

吞吐量优先Parallel

  • 多线程

  • 堆内存较大,多核CPU

  • 单位时间内,STW(stop the world,停掉其他所有工作线程)时间最短

  • JDK1.8默认使用的垃圾回收器

Parallel Scavenge 收集器

新生代收集器,基于复制算法实现的收集器。特点是吞吐量优先,故也称为吞吐量优先收集器,能够并行收集的多线程收集器,允许多个垃圾回收线程同时运行,降低垃圾收集时间,提高吞吐量。 Parallel Scavenge 收集器关注点是吞吐量,高效率的利用 CPU 资源。 CMS 垃圾收集器关注点更多的是用户线程的停顿时间。

Parallel Scavenge 收集器提供了两个参数用于精确控制吞吐量,分别是控制最大垃圾收集停顿时间的

-XX:MaxGCPauseMillis 参数以及直接设置吞吐量大小的-XX:GCTimeRatio 参数。

  • -XX:MaxGCPauseMillis 参数的值是一个大于0的毫秒数,收集器将尽量保证内存回收花费的时间不超过用户设定值。

  • -XX:GCTimeRatio 参数的值大于0小于100,即垃圾收集时间占总时间的比率,相当于吞吐量的倒数。

该收集器的目标是达到一个可控制的吞吐量。还有一个值得关注的点是:GC自适应调节策略(与ParNew收集器最重要的一个区别)

GC自适应调节策略: Parallel Scavenge收集器可设置-XX:+UseAdptiveSizePolicy参数。当开关打开时不需要手动指定新生代的大小(-Xmn)、Eden与Survivor区的比例(-XX:SurvivorRation)、晋升老年代的对象年龄(-XX:PretenureSizeThreshold)等,虚拟机会根据系统的运行状况收集性能监控信息,动态设置这些参数以提供最优的停顿时间和最高的吞吐量,这种调节方式称为GC的自适应调节策略。

  • 使用场景:适用于内存在几个G之间,适用于后台计算服务或者不需要太多交互的服务,保证吞吐量的服务。

  • 优点:可控吞吐量、保证吞吐量,并行收集。

  • 缺点:回收期间STW,随着堆内存增大,回收暂停时间增大。

Parallel Old 收集器

是Parallel Scavenge收集器的老年代版本

特点:多线程,采用标记整理算法(老年代没有幸存区)

  • 响应时间优先

  • 多线程

  • 堆内存较大,多核CPU

  • 尽可能让单次STW时间变短(尽量不影响其他线程运行)

G1垃圾回收期

详情可以看这篇文章:G1收集器

CMS 垃圾回收流程是怎样的?

  1. 初始标记(initial mark):在这个阶段,CMS 会进行一个快速的初始标记,标记所有根对象(如栈中的引用)直接可达的对象。此过程是 STW的,但时间较短
  2. 并发标记(Concurrent marking):初始标记后,CMS 进入并发标记阶段,在此阶段,垃圾收集器与应用线程并发运行,从上一步标记的根直接可达对象开始进行tracing,递归扫描所有可达对象。此阶段可能会持续较长时间。
  3. 并发预清理(Concurrent precleaning):这个阶段也是和应用线程并发,就是想帮重新标记阶段先做点工作,扫描一下卡表脏的区域和新晋升到老年代的对象等,因为重新标记是 STW 的,所以先分担一点。
  4. 可中断的预清理阶段(AbortablePreclean):这个和上一个阶段基本上一致,就是为了分担重新标记标记的工作,但是它可以被中断
  5. 重新标记(remark):这个阶段是 STW 的,因为并发阶段引用关系会发生变化,所以要重新遍历一遍新生代对象、Gc Roots、卡表等,来修正标记
  6. 并发清理(Concurrent sweeping):CMS 进行并发清除阶段,标记为不可达的对象会被清除。此过程与应用线程并发运行,旨在减少停顿时间。
  7. 并发重置(Concurrent reset):这个阶段和应用线程并发,重置 cms内部状态。

cms 的瓶颈就在于重新标记阶段,需要较长花费时间来进行重新扫描。

G1垃圾回收流程是怎样的?

G1 从大局上看分为两大阶段,分别是并发标记和对象拷贝。

并发标记:并发标记是基于 SATB 的,可以分为四大阶段:

  1. 初始标记(initial marking),这个阶段是 STW 的,扫描根集合,标记根直接可达的对象即可。在G1中标记对象是利用外部的bitmap来记录,而不是对象头。
  2. 并发阶段(concurent marking),这个阶段和应用线程并发,从上一步标记的根直接可达对象开始进行tracing,递归扫描所有可达对象。SATB 也会在这个阶段记录着变更的引用。
  3. 最终标记(final marking),这个阶段是 STW 的,处理 SATB 中的引用。
  4. 清理阶段(clenaup),这个阶段是 STW 的,根据标记的 bitmap 统计每个 region 存活对象的多少,如果有完全没存活的 region 则整体回收.

对象拷贝阶段(evacuation):这个阶段是 STW 的。根据标记结果选择合适的 reigon 组成收集集合(collection set 即 CSet),然后将 CSet 存活对象拷贝到新 region 中

G1 的瓶颈在于对象拷贝阶段,需要花较多的时间来转移对象。

什么是三色标记算法

三色标记法是一种用于垃圾回收算法中的对象标记方法,特别用于标记清除型垃圾回收器。这种方法通过使用三种颜色(白色、灰色和黑色)来跟踪对象的可达性和垃圾回收状态,以避免对象的重复回收和丢失。

三色标记的基本概念:

  • 白色:表示对象尚未被检查。白色对象可能是垃圾,直到证明它们是可达的。
  • 灰色:表示对象被检查过,并且其本身是可达的,但其引用的对象还未全部检查。
  • 黑色:表示对象和它所有引用的对象都已检查且是可达的。

三色标记步骤:

  1. 初始化:所有对象开始时都是白色。
  2. 标记开始:从GC Roots开始,根对象标记为灰色。
  3. 扫描灰色对象:
    1. 将灰色对象引用的所有白色对象标记为灰色。
    2. 然后将该灰色对象标记为黑色。
  4. 重复步骤3直到没有更多的灰色对象。
  5. 清除:未标记为黑色的对象为白色,即垃圾,可被回收。

G1 收集器的最大特点

  • G1 最大的特点是引入分区的思路,弱化了分代的概念。

  • 并行与并发:G1 能充分利用 CPU、多核环境下的硬件优势,使用多个 CPU(CPU 或者 CPU 核心)来缩短 Stop-The-World 停顿时间。部分其他收集器原本需要停顿 Java 线程执行的 GC 动作,G1 收集器仍然可以通过并发的方式让 Java 程序继续执行。

  • 空间整合:与 CMS 的"标记-清除"算法不同,G1 从整体来看是基于"标记-整理"算法实现的收集器,不会产生空间碎片;从局部上来看是基于"标记-复制"算法实现的。

  • 可预测的停顿:G1垃圾回收器设定了用户可控的停顿时间目标,开发者可以通过设置参数来指定允许的最大垃圾回收停顿时间。G1会根据这个目标来动态调整回收策略,尽可能地减少长时间的垃圾回收停顿。

G1如何完成可预测的停顿?

G1根据历史数据来预测本次回收需要的堆分区数量,也就是选择回收哪些内存空间。最简单的方法就是使用算术的平均值建立一个线性关系来进行预测。比如:过去10次一共收集了10GB的内存,花费了1s。那么在200ms的时间下,最多可以收集2GB的内存空间。而G1的预测逻辑是基于衰减平均值和衰减标准差来确定的。

什么是 PLAB?

PLAB(Promotion Local Alocation Buffer)是Java 垃圾回收器中的一种优化机制,主要用于G1垃圾收集器,目的是提高对象晋升(Promotion)到老年代时的效率,在垃圾回收过程中,新生代中的某些对象由于生命周用较长,会被置升到老年代。

为了减少线程竞争和是升晋升效率,G1为每个 GC线程分配一个局部缓中区,称为PLA8,每个线程可以在其本地PL8 中直接进行对象晋升操作,而不需事竞争全局老年代的内存空间,减少了锁竞争,提高了多线程垃圾回收的效率。

在多线程并行执行 YGC 时,可能有很多对象需要晋升到老年代,此时老年代的指针就"热"起来了,于是搞了个 PLAB。每个线程先从老年代 freelist(空闲内存链表)申请一块空间(PLAB),然后单个线程在这一块空间中就可以通过指针加法 (bump the pointer) 来分配内存,这样对 freeist 竟争也少了,分配空间也快了。

和TLAB的思想类似

CMS 和 G1 的区别

  • CMS 中,堆被分为 PermGen,YoungGen,OldGen ;而 YoungGen 又分了两个 survivo 区域。在 G1 中,堆被平均分成几个区域 (region) ,在每个区域中,虽然也保留了新老代的概念,但是收集器是以整个区域为单位收集的。

  • G1 在回收内存后,会立即同时做合并空闲内存的工作;而 CMS ,则默认是在 STW(stop the world)的时候做。

  • G1 会在 Young GC 中使用;而 CMS 只能在 Old 区使用

CMS 垃圾回收器和 G1垃圾回收器在记忆集的维护上有什么不同?

CMS 垃圾回收器:

  • CMS 使用卡表(Card Tabe,记忆集的一种实现)来记录老年代中引用新生代的对象。卡表的维护较为简单,老年代对象指向新生代对象时,会触发写屏障并标记相应的卡片。
  • CMS 的卡表是通过写屏障维护的,当老年代对象引用新生代对象时,CMS 会在卡表中将对应区域标记为"脏卡",以便在 GC 时扫描这些区域。

G1 垃圾回收器:

  • G1的记忆集(Remembered set),其粒度可以细化到堆的各个区域(Region)。记忆集用于跟踪一个 Region 中的对象引用了其他 Region 的对象。
  • G1 采用多层次的记忆集维护机制,将老年代对新生代的引用、其他Region之间的引用关系都记录在记忆集中。每个 Regqion 都有自己的记忆集,维护成本相对较高,但有助于 G1 进行精准的增量式回收。
  • 精确度:G1的记忆集在某些情况下会比CMS 的卡表更加精细和准确,可以根据需要选择扫描的具体区域,而 CMS 的卡表往往只能标记大范围的区域。

G1如何解决漏标问题吗?为什么G1采用SATB而不用incremental update?

**SATB算法:**是一种基于快照的算法,它可以避免在垃圾回收时出现对象漏标或者重复标记的问题,从而提高垃圾回收的准确性和效率,在垃圾回收开始时,对堆中的对象引用进行快照,然后在并发标记阶段中记录下所有被修改过对象引用,保存到satb_mark_queue中,最后在重新标记阶段重新扫描这些对象,标记所有被修改的对象,保证了准确性和效率。

因为采用incremental update把黑色重新标记为灰色后,之前扫描过的还要再扫描一遍,效率太低。G1有RSet与SATB相配合。Card Table里记录了RSet,RSet里记录了其他对象指向自己的引用,这样就不需要再扫描其他区域,只要扫描RSet就可以了。

也就是说 灰色-->白色 引用消失时,如果没有 黑色-->白色,引用会被push到堆栈,下次扫描时拿到这个引用,由于有RSet的存在,不需要扫描整个堆去查找指向白色的引用,效率比较高。SATB配合RSet浑然天成。

CMS 和 G1垃圾收集器如何维持并发的正确性?

CMS 和 G1 分别通过增量更新(lincremental update)和 SATB (snapshot at-the-begining)来打破这两个充分必要条件,维持了GC 线程与应用线程并发的正确性。

并发执行可能出现对象漏标,而漏标的两个充分必要条件是:

  1. 将新对象插入已扫描完毕的对象中,即插入黑色对象到白色对象的引用,
  2. 删除了灰色对象到白色对象的引用。

CMS 用了增量更新,打破了第一个条件,通过写屏障将插入的白色对象标记成灰色,即加入到标记栈中,在remark 阶段再扫描,防止漏标情况。

G1 用了SATB,打破了第二个条件,会通过写屏障把旧的引用关系记下来,之后再把旧引用关系扫描一遍。

G1一定不会产生内存碎片吗?

堆内存的动态变化、分配模式以及回收行为等因素影响下,仍然可能出现一些碎片问题。当某些Region中存在多个不连续的小块空闲内存,无法完全满足某些大对象的内存需求时,仍然可以称之为碎片问题。

  1. 分配模式不规律: 如果应用程序的内存分配模式不规律,频繁地分配和释放不同大小的对象,可能会导致一些小的空闲内存碎片在堆中产生。
  2. 大对象分配: G1回收器的区域被划分为不同大小的Region,当一个大对象无法在单个Region中分配时,G1可能会在多个Region中分配这个大对象,这可能导致跨多个Region的碎片。
  3. 并发情况下的内存变化: G1回收器会在后台进行并发的垃圾回收,如果在回收过程中发生了内存变化,如某个区域中的对象被回收,留下一些零散的空闲空间,也有可能会导致内存碎片。
  4. 频繁的Full GC: 尽管G1垃圾回收器的设计可以减少Full GC(全局垃圾回收)的频率,但如果频繁发生Full GC,可能会导致内存布局的重组,产生一些碎片。