jvm 垃圾回收(常见算法介绍)

 2022-09-12
原文地址:https://blog.csdn.net/qq_42315935/article/details/124804529

1. 什么是垃圾回收?

程序的运行必然申请内存资源,如果无效的对象不清理一直占用资源,那么肯定会导致内存溢出,所以内存资源的管理就很重要了

2. 垃圾回收的常见算法

2.1、 引用计数法

2.1.1 原理

假设有一个对象a,任何对对象A的引用,引用计数器都会加1,当引用失败时,对象A的引用计数器就-1,如果对象计数器的值为0,表示对象没有引用可以被回收了。

2.1.2、优缺点

优点:
  • 实时性比较高,无需等到内存不够时才回收,运行时根据对象计数器的值为0时就可直接回收
  • 应用在垃圾回收时不需要stw(stop the world)。如果申请内存空间不够,则立刻报错outofmemory 错误。
  • 区域性,更新对象计数器时,只是影响到该对象,不会扫描全部对象
缺点:
  • 每次对象被引用时都需要更新计数器。有一点时间开销
  • 浪费cpu资源,技术内存够用,仍然在运行进行计数器的统计
  • 无法解决循环依赖(重点)
什么是循环引用?
    class TestA {
        public TestB b; 
        
    }
    class TestB { 
        public TestA a; 
        
    }
    public class Main { 
        public static void main(String[] args){
            A a = new A(); 
            B b = new B(); 
            a.b=b; b.a=a; 
            a = null;
            b = null;
        } 
    }
    虽然 a 和 b 都为null,但是它们直接存在互相引用,所以永远不会被回收。

2.2、 标记清除算法

标记清除算法是将垃圾回收分为两个阶段,分别是标记和清除

  • 标记:从根节点开始标记引用的对象。

  • 清除:未被标记引用的对象就是垃圾对象,可以被清理。

    202209122041445751.png

优点:
  • 解决了引用计数法中循环引用的问题,没有从root节点引用的对象都会被回收。
缺点:
  • 效率问题,标记和清除都需要遍历所有对象,并且GC时,需要stw停止所有程序,对于交互性高的应用体验来说是很差的。
  • 空间问题,会产生大量不连续的内存碎片,清理出来的内存不连贯

2.3、标记压缩(整理)算法

2.3.1、原理

根据老年代的特点提出的一种标记算法,标记过程与标记清除算法一致,在清理阶段则不是简单的清理,而是将存货的对象向一端压缩,然后清理边界以外的垃圾,解决碎片化的问题。

202209122041455332.png

2.3.1、优缺点

优缺点同标记清除算法一致,解决了内存碎片化的问题,但是多了一步,对象移动内存位置的步骤,其效率也有一定影响。

2.4、标记-复制算法

为了解决效率问题,标记复制算法出现了。复制算法的核心就是将内存一分为二,每次只用其中的一块,在垃圾回收时,将还存活的对象复制到另一个空间中,然后将内存空间清空,交换两个内存的角色,完成垃圾回收。在Young Region 中就使用到了标记复制算法(Suvivor TO 和 Suvivor From)

202209122041463203.png

2.4.1、JVM中的年轻代内存空间

  1. 在GC开始的时候,对象只会存在于Eden区和名为“From”的Survivor区,Survivor 区“To”是空的。
  2. 紧接着进行GC,Eden区中所有存活的对象都会被复制到“To”,而在“From”区中,仍 存活的对象会根据他们的年龄值来决定去向。年龄达到一定值(年龄阈值,可以通过-XX:MaxTenuringThreshold来设置)的对象会被移动到年老代中,没有达到阈值的对 象会被复制到“To”区域。
  3. 经过这次GC后,Eden区和From区已经被清空。这个时候,“From”和“To”会交换他 们的角色,也就是新的“To”就是上次GC前的“From”,新的“From”就是上次GC前 的“To”。不管怎样,都会保证名为To的Survivor区域是空的。
  4. GC会一直重复这样的过程,直到“To”区被填满,“To”区被填满之后,会将所有对象 移动到年老代中。

2.4.2、优缺点

优点:
  • 在垃圾对象多的情况下,效率较高 清理后,内存无碎片
缺点:
  • 在垃圾对象少的情况下,不适用,如:老年代内存 分配的2块内存空间,在同一个时刻,只能使用一半,内存使用率较低

注意:标记算法为什么需要停止程序?

    标记和清除都需要遍历所有对象,在运行中会一直产生对象,所以在遍历中可能产生新的对象,标记可能失败,也可能错误清除对象,清除了正在使用对象。

2.5、分代(收集)算法
当前虚拟机的垃圾回收算法都才用分代收集算法,这种算法没有什么新思想,只是根据对象的存活周期将内存分为几块。一般将java的内存分为新生代和老年代,所以我们可以根据内存区域的特点选择不同的垃圾回收算法。
新生代中每次收集都会有大量对象死去,所以我们可以使用标记-复制算法,只需要付出少量对象的复制成本就可以完成垃圾回收。而老年代的对象存活几率是最高的,而且没有额外的空间对它进行分配,所以我们选择标记清除算法标记整理算法

3. 垃圾收集器

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-3wv3MhSj-1652694235241)(AD15D4A8022F4DD5A88B7F78E0DA04D0)]

如果说收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。

虽然我们对各个收集器进行比较,但并非要挑选出一个最好的收集器。因为直到现在为止还没有最好的垃圾收集器出现,更加没有万能的垃圾收集器, 我们能做的就是根据具体应用场景选择适合自己的垃圾收集器 。试想一下:如果有一种四海之内、任何场景下都适用的完美收集器存在,那么我们的 HotSpot 虚拟机就不会实现那么多不同的垃圾收集器了。

3.1、串行(serial)垃圾收集器

Serial (串行)是历史最悠久的垃圾收集器了,它是一个单线程收集器,垃圾回收时,只有一个线程在工作,且java应用中的所有工作线程都要暂停,等待垃圾回收的完成。这种现象为 STW(Stop The World) ,直到它收集结束,才会重新唤醒程序其他工作线程。

新生代采用标记-复制算法,老年代采用标记-整理算法。

202209122041471864.png

虚拟机的设计者们当然知道 Stop The World 带来的不良用户体验,所以在后续的垃圾收集器设计中停顿时间在不断缩短(仍然还有停顿,寻找最优秀的垃圾收集器的过程仍然在继续)。

但是 Serial 收集器有没有优于其他垃圾收集器的地方呢?当然有,它 简单而高效(与其他收集器的单线程相比) 。Serial 收集器由于没有线程交互的开销,自然可以获得很高的单线程收集效率。Serial 收集器对于运行在 Client 模式下的虚拟机来说是个不错的选择。javaweb应用不会使用串行垃圾收集器。

3.2、并行垃圾收集器

并行和并发概念补充:

  • 并行(Parallel) :指多条垃圾收集线程并行工作,但此时用户线程仍然处于等待状态。
  • 并发(Concurrent) :指用户线程与垃圾收集线程同时执行(但不一定是并行,可能会交替执行),用户程序在继续运行,而垃圾收集器运行在另一个 CPU 上。

3.2.1、ParNew垃圾收集器

ParNew 收集器其实就是 Serial 收集器的多线程版本,除了使用多线程进行垃圾收集外,其余行为( 控制参数、收集算法、回收策略 等等)和 Serial 收集器完全一样,它是工作在新生代上的,老年代用的还是串行收集器。

新生代采用标记-复制算法,老年代采用标记-整理算法。

202209122041479665.png

3.2.2、 ParallelGC Scavenge 收集器

ParallelGC 收集器的工作机制和 ParNew 收集器一样,只是在此基础之上,新增了两个和 系统吞吐量相关的参数,使得其使用起来更加的灵活和高效。

    -XX:+UseParallelGC
        年轻代使用ParallelGC垃圾回收器,老年代使用串行回收器。
    -XX:+UseParallelOldGC
        年轻代使用ParallelGC垃圾回收器,老年代使用ParallelOldGC垃圾回收器。
    -XX:MaxGCPauseMillis
        设置最大的垃圾收集时的停顿时间,单位为毫秒 需要注意的时,ParallelGC为了达到设置的停顿时间,可能会调整堆大小或其他 的参数,如果堆的大小设置的较小,就会导致GC工作变得很频繁,反而可能会 影响到性能。 该参数使用需谨慎。
    -XX:GCTimeRatio 
        设置垃圾回收时间占程序运行时间的百分比,公式为1/(1+n)。 它的值为0~100之间的数字,默认值为99,也就是垃圾回收时间不能超过1% 
    -XX:UseAdaptiveSizePolicy  
    自适应GC模式,垃圾回收器将自动调整年轻代、老年代等参数,达到吞吐量、 堆大小、停顿时间之间的平衡。 一般用于,手动调整参数比较困难的场景,让收集器自动进行调整。

Parallel Scavenge 收集器关注点是吞吐量(高效率的利用 CPU)。CMS 等垃圾收集器的关注点更多的是用户线程的停顿时间(提高用户体验)。所谓吞吐量就是 CPU 中用于运行用户代码的时间与 CPU 总消耗时间的比值。 Parallel Scavenge 收集器提供了很多参数供用户找到最合适的停顿时间或最大吞吐量,如果对于收集器运作不太了解,手工优化存在困难的时候,使用 Parallel Scavenge 收集器配合自适应调节策略,把内存管理优化交给虚拟机去完成也是一个不错的选择。

新生代采用标记-复制算法,老年代采用标记-整理算法。

202209122041487606.png

这是 JDK1.8 默认收集器

使用 java -XX:+PrintCommandLineFlags -version 命令查看

    -XX:InitialHeapSize=262921408 -XX:MaxHeapSize=4206742528 -XX:+PrintCommandLineFlags -XX:+UseCompressedClassPointers -XX:+UseCompressedOops -XX:+UseParallelGC
    java version "1.8.0_211"
    Java(TM) SE Runtime Environment (build 1.8.0_211-b12)
    Java HotSpot(TM) 64-Bit Server VM (build 25.211-b12, mixed mode)

JDK1.8 默认使用的是 Parallel Scavenge + Parallel Old,如果指定了-XX:+UseParallelGC 参数,则默认指定了-XX:+UseParallelOldGC,可以使用-XX:-UseParallelOldGC 来禁用该功能

3.2.3.Serial Old 收集器

Serial 收集器的老年代版本 ,它同样是一个单线程收集器。它主要有两大用途:一种用途是在 JDK1.5 以及以前的版本中与 Parallel Scavenge 收集器搭配使用,另一种用途是作为 CMS 收集器的后备方案。

3.2.4 Parallel Old 收集器

Parallel Scavenge 收集器的老年代版本 。使用多线程和“标记-整理”算法。在注重吞吐量以及 CPU 资源的场合,都可以优先考虑 Parallel Scavenge 收集器和 Parallel Old 收集器。

3.3 CMS垃圾收集器

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。它非常符合在注重用户体验的应用上使用。

CMS(Concurrent Mark Sweep)收集器是 HotSpot 虚拟机第一款真正意义上的并发收集器,它第一次实现了让垃圾收集线程与用户线程(基本上)同时工作。

从名字中的 Mark Sweep 这两个词可以看出,CMS 收集器是一种 “标记-清除”算法 实现的,它的运作过程相比于前面几种垃圾收集器来说更加复杂一些。整个过程分为四个步骤:

  • 初始标记: 暂停所有的其他线程,并记录下直接与 root 相连的对象,速度很快 ;
  • 并发标记: 同时开启 GC 和用户线程,用一个闭包结构去记录可达对象。但在这个阶段结束,这个闭包结构并不能保证包含当前所有的可达对象。因为用户线程可能会不断的更新引用域,所以 GC 线程无法保证可达性分析的实时性。所以这个算法里会跟踪记录这些发生引用更新的地方。
  • 重新标记: 重新标记阶段就是为了修正并发标记期间因为用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段的时间稍长,远远比并发标记阶段时间短
  • 并发清除: 开启用户线程,同时 GC 线程开始对未标记的区域做清扫。

CMS垃圾回收器的详细执行过程如下:

202209122041495437.png

  • 初始化标记(CMS-initial-mark) ,标记root,会导致stw;
  • 并发标记(CMS-concurrent-mark),与用户线程同时运行;
  • 预清理(CMS-concurrent-preclean),与用户线程同时运行;
  • 重新标记(CMS-remark) ,会导致stw;
  • 并发清除(CMS-concurrent-sweep),与用户线程同时运行;
  • 调整堆大小,设置CMS在清理之后进行内存压缩,目的是清理内存中的碎片;
  • 并发重置状态等待下次CMS的触发(CMS-concurrent-reset),与用户线程同时运行;
    从它的名字就可以看出它是一款优秀的垃圾收集器,

主要优点: 并发收集、低停顿 。但是它有下面三个明显的缺点:

  • 对 CPU 资源敏感;
    在并发阶段 , 它虽然不会导致用户线程停顿, 但却会因为占用了一部分线程 (或者说处理器的计算能力) 而导致应用程序变慢 , 降低总吞吐量 。 CMS默认启动的回收线程数是 (处理器核心数量+3) /4, 也就是说, 如果处理器核心数在四个或 以上, 并发回收时垃圾收集线程只占用不超过25%的处理器运算资源, 并且会随着处理器核心数量的增加而下降 。 但是当处理器核心数量不足四个时, CMS对用户程序的影响就可能变得很大 。 如果应用本来的处理器负载就很高 , 还要分出一半的运算能力去执行收集器线程, 就可能导致用户程序的执行速度忽然大幅降低 。 为了缓解这种情况 , 虚拟机提供了一种称为“增量式并发收集器”(Incremental Concurrent Mark Sweep/i-CMS) 的CMS收集器变种, 所 做的事情和以前单核处理器年代PC机操作系统靠抢占式多任务来模拟多核并行多任务的思想一样, 是在并发标记、 清理的时候让收集器线程 、 用户线程交替运行, 尽量减少垃圾收集线程的独占资源的时间, 这样整个垃圾收集的 过程会更长, 但对用户程序的影响就会显得较少一些, 直观感受是速度变慢的时间更多了, 但速度下降幅度就没 有那么明显 。 实践证明增量式的CMS收集器效果很一般, 从JDK 7开始, i-CMS模式已经被声明为“deprecated”, 即已过时不再提倡用户使用, 到JDK 9发布后i-CMS模式被完全废弃。
    ————————————————
    版权声明:本文为CSDN博主「檀越剑指大厂」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
    原文链接:https://blog.csdn.net/qyj19920704/article/details/123934266
  • 无法处理浮动垃圾;
  • 它使用的回收算法-“标记-清除”算法会导致收集结束时会有大量空间碎片产生。

3.4 G1收集器

G1 (Garbage-First) 是一款面向服务器的垃圾收集器,主要针对配备多颗处理器及大容量内存的机器. 以极高概率满足 GC 停顿时间要求的同时,还具备高吞吐量性能特征,它是jdk1.7正式使用的全新垃圾收集器,oracle官方计划在jdk1.9中将G1变成默认的垃圾收集器,以替代CMS

    G1的设计原则就是简化JVM性能调优,开发人员只需要简单的三步即可完成调优: 1. 第一步,开启G1垃圾收集器 2. 第二步,设置堆的最大内存 3. 第三步,设置最大的停顿时间 G1中提供了三种模式垃圾回收模式,Young GC、Mixed GC 和 Full GC,在不同的条件 下被触发。

3.4.1 原理

G1 垃圾收集器相对比其他收集器而言,最大的区别在于它取消了新生代、老年代的物理划分,取而代之的是将堆划分为若干个区域(Region),这些区域中包含了有逻辑上的新生代和老年代区域。
这样做的好处就是不用再对单个的空间对每个代进行设置了,不用担心每个代的内存是否足够。

202209122041504048.png

202209122041513869.png

在G1划分的区域中,年轻代的垃圾收集依然采用暂停所有应用线程的方式,将存活对象拷贝到老年代或者Suvivor空间中,G1收集器通过将对象从一个区域复制到另一个对象区域,完成了清理工作。
这就意味着,在正常的处理过程中,G1完成了堆的压缩(至少是部分堆的压缩),这样处理了CMS中内存碎片化的问题。

在G1中,有一种特殊的区域,叫Humongous区域。

  • 如果一个对象占用的空间超过了分区容量50%以上,G1收集器就认为这是一个巨型 对象。
  • 这些巨型对象,默认直接会被分配在老年代,但是如果它是一个短期存在的巨型对 象,就会对垃圾收集器造成负面影响。
  • 为了解决这个问题,G1划分了一个Humongous区,它用来专门存放巨型对象。如果 一个H区装不下一个巨型对象,那么G1会寻找连续的H分区来存储。为了能找到连续 的H区,有时候不得不启动Full GC。

3.4.2、Young GC Young

Young GC主要是对Eden区进行GC,它在Eden空间耗尽时会被触发。
  • Eden空间的数据移动到Survivor空间中,如果Survivor空间不够,Eden空间的部分 数据会直接晋升到年老代空间。
  • Survivor区的数据移动到新的Survivor区中,也有部分数据晋升到老年代空间中。
  • 最终Eden空间的数据为空,GC停止工作,应用线程继续执行。

2022091220415273910.png

2022091220415435211.png

3.4.2.1、Remembered Set(已记忆集合)

在GC年轻代的对象时,我们如何找到年轻代中对象的根对象呢? 根对象可能是在年轻代中,也可以在老年代中,那么老年代中的所有对象都是根么? 如果全量扫描老年代,那么这样扫描下来会耗费大量的时间。 于是,G1引进了RSet的概念。它的全称是Remembered Set,其作用是跟踪指向某个堆 内的对象引用。

2022091220415541812.png

每个Region初始化时,会初始化一个RSet,该集合用来记录并跟踪其它Region指向该 Region中对象的引用,每个Region默认按照512Kb划分成多个Card,所以RSet需要记录 的东西应该是 xx Region的 xx Card。

3.4.3、Mixed GC

当越来越多的对象晋升到老年代old region时,为了避免堆内存被耗尽,虚拟机会触发一 个混合的垃圾收集器,即Mixed GC,该算法并不是一个Old GC,除了回收整个Young Region,还会回收一部分的Old Region,这里需要注意:是一部分老年代,而不是全部 老年代,可以选择哪些old region进行收集,从而可以对垃圾回收的耗时时间进行控制。 也要注意的是Mixed GC 并不是 Full GC。 MixedGC什么时候触发? 由参数 -XX:InitiatingHeapOccupancyPercent=n 决定。默 认:45%,该参数的意思是:当老年代大小占整个堆大小百分比达到该阀值时触发。 它的GC步骤分2步: 1. 全局并发标记(global concurrent marking) 2. 拷贝存活对象(evacuation)

3.4.3.1、全局并发标记

  • 全局并发标记,执行过程分为五个步骤: 初始标记(initial mark,STW) 标记从根节点直接可达的对象,这个阶段会执行一次年轻代GC,会产生全局停 顿。
  • 根区域扫描(root region scan) G1 GC 在初始标记的存活区扫描对老年代的引用,并标记被引用的对象。 该阶段与应用程序(非 STW)同时运行,并且只有完成该阶段后,才能开始下 一次 STW 年轻代垃圾回收。
  • 并发标记(Concurrent Marking) G1 GC 在整个堆中查找可访问的(存活的)对象。该阶段与应用程序同时运行, 可以被 STW 年轻代垃圾回收中断。
  • 重新标记(Remark,STW) 该阶段是 STW 回收,因为程序在运行,针对上一次的标记进行修正。
  • 清除垃圾 (clearup,STW)
    清点和重置标记状态,该阶段会STW,这个阶段并不会实际去做垃圾的收集,等待evacuation阶段来回收。
3.4.3.2 拷贝存活对象

Evacuation阶段是全暂停的。该阶段把一部分Region里的活对象拷贝到另一部分Region

3.4.4、G1收集器相关参数

  • -XX:+UseG1GC 使用 G1 垃圾收集器
  • -XX:MaxGCPauseMillis 设置期望达到的最大GC停顿时间指标(JVM会尽力实现,但不保证达到),默认 值是 200 毫秒。
  • -XX:G1HeapRegionSize=n 设置的 G1 区域的大小。值是 2 的幂,范围是 1 MB 到 32 MB 之间。目标是根 据最小的 Java 堆大小划分出约 2048 个区域。 默认是堆内存的1/2000。
  • -XX:ParallelGCThreads=n 设置 STW 工作线程数的值。将 n 的值设置为逻辑处理器的数量。n 的值与逻辑 处理器的数量相同,最多为 8。
  • -XX:ConcGCThreads=n
    设置并行标记的线程数。将 n 设置为并行垃圾回收线程数 (ParallelGCThreads) 的 1/4 左右。
  • -XX:InitiatingHeapOccupancyPercent=n 设置触发标记周期的 Java 堆占用率阈值。默认占用率是整个 Java 堆的 45%。

3.4.5、对于G1垃圾收集器优化建议

  • 年轻代大小
    避免使用 -Xmn 选项或 -XX:NewRatio 等其他相关选项显式设置年轻代大小。 固定年轻代的大小会覆盖暂停时间目标。
  • 暂停时间目标不要太过严苛 G1 GC 的吞吐量目标是 90% 的应用程序时间和 10%的垃圾回收时间。 评估 G1 GC 的吞吐量时,暂停时间目标不要太严苛。目标太过严苛表示您愿意 承受更多的垃圾回收开销,而这会直接影响到吞吐量。