当前位置:   article > 正文

JVM-垃圾回收机制_jvm垃圾回收机制

jvm垃圾回收机制

引言

1、什么是垃圾

什么是垃圾(Garbage)呢?
垃圾是指在 运行程序中没有任何指针指向的对象, 这个对象就是需要被回收的垃圾。

如果不及时对内存中的垃圾进行清理,那么这些垃圾对象所占的内存空间会一直保留到应用程序结束,被保留的空间无法被其他对象使用。甚至可能导致内存溢出

2、为什么需要GC

想要学习GC,首先需要理解为什么需要GC?

  • 对于高级语言来说,一个基本认知就是如果不进行垃圾回收,内存迟早都会被消耗完,因为不断地分配内存空间而不进行回收,就好像不断的生产生活垃圾而从来不打扫一样
  • 除了释放没用的对象,垃圾回收也可以清除内存里的记录碎片。碎片整理将所占用的堆内存移到堆的一端,以便JVM将整理出来的内存分配给新的对象
  • 随着应用程序所应付的业务越来越大、复杂,用户越来越多,没用GC就不能保证应用程序的正常进行。而经常造成STW的GC又跟不上实际的需求,所以才会不断地尝试对GC进行优化

3、Java中的垃圾回收

  • Java中自动内存管理无需开发人员手动参与内存的分配与回收,这样降低内存泄漏和内存溢出的风险
  • Java中自动内存管理机制,将程序员从繁重的内存管理中释放出来,可以更专心地专注于业务开发
  • Java堆是垃圾回收器的工作重点
  • 从次数上讲:
    • 频繁收集Young区
    • 较少收集Old区
    • 基本不动Perm区(或者元空间)

垃圾回收相关算法

垃圾判断算法

在堆里存放着几乎所有的Java对象实例,在GC(垃圾回收器)执行垃圾回收之前,首先需要区分出内存中哪些是存活对象(有用对象),哪些是死亡对象(垃圾对象)。只有被标记为已经死亡的对象,GC才会在执行垃圾回收时,释放掉其所占用的内存空间,因此这个过程我们可以称为垃圾标记阶段

那么在JVM中究竟是如何标记一个死亡对象呢?简单来说,当一个对象已经不再被任何的存活对象继续引用时就可以宣告为已经死亡

判断对象存活一般有两种方式:引用计数算法可达性分析算法

标记阶段:引用计数算法

引用计数算法(Reference Counting)比较简单,对每个对象保存一个整形的引用计数器属性,用于记录对象被引用的情况

优点实现简单,垃圾对象便于辨识;判定效率高,回收没有延迟性。
缺点

  • 需要单独的字段存储计数器,这样的做法增加了存储空间的开销
  • 每次赋值都需要更新计数器,伴随着加法和减法操作,这增加了时间开销
  • 引用计数器有个严重的问题,即无法处理循环引用的情况。这是一条致命缺陷,导致在Java的垃圾回收器中没有使用这类算法

循环引用现象

在这里插入图片描述

标记阶段:可达性分析算法

可达性分析特点:

  • 相较于引用计数算法而言,可达性分析算法不仅同样具备实现简单和执行高效等特点,更重要的是该算法可以有效地解决在引用计数法中循环引用的问题,防止内存泄漏的发生
  • 相较于引用计数算法,这里的可达性分析就是Java、C#选择的。这种类型的垃圾收集通常也叫做追踪性垃圾收集

基本思路:

  • 可达性分析是以跟对象集合(GC Roots)为起始点,按从上至下的方式搜索被跟对象集合所连接的目标对象是否可达
  • 使用可达性分析算法后,内存中存活的对象都会被根对象集合直接或间接连接着,搜索所过的路径称为引用链(Reference Chain)
  • 如果目标对象没有和任何引用链相连,则是不可达的,就以为对象已经死亡,可以标记为垃圾对象
  • 在可达性分析算法中,只有能被跟对象集合直接或间接连接的对象才是存活对象

在这里插入图片描述问题来了,哪些对象可被称为GC Roots对象呢?或者说Java中,GC Roots包含哪几类对象呢?

  • 虚拟机栈中引用的对象,比如:Java线程中,当前所有正在被调用的方法的引用类型参数、局部变量等
  • 本地方法栈中引用的对象
  • 方法区中类静态属性引用的对象
  • 方法区中常量引用的对象,比如:字符串常量池里的引用
  • 所有被同步锁synchronized持有的对象
  • Java虚拟机内部的引用。基本数据类型对应的Class对象,一些常驻的异常对象,系统类加载器。

注意:

  • 如果要使用可达性分析算法来判断内存是否可回收,那么分析工作必须在一个能保障一致性的快照中进行。这点不满足的话,分析结果的准确性就无法保证
  • 这点也是导致GC进行时必须“Stop The World”的一个重要原因。即使是号称几乎不会停顿的CMS垃圾回收器中,枚举根节点时也是必须要停顿的

垃圾清除算法

当成功区分出内存中存活对象和死亡对象后,GC接下来的任务就是执行垃圾回收、释放掉垃圾对象所占用的内存,以便有足够的可用空间为新对象分配内存。
目前在JVM中比较常见的三种垃圾回收算法是:

  • 标记 - 清楚算法(Mark - Sweep)
  • 复制算法(Copying)
  • 标记 - 压缩算法(Mark - Compact)

清除阶段:标记-清除算法

标记-清除(Mark - Sweep)算法是最基础的收集算法,它分为“标记”和“清除”两个阶段:

  • 标记:从GC Roots开始遍历,标记所有被引用的对象。一般是在对象头中记录是否是可达对象
  • 清除:对堆内存从头到尾遍历,如果发现某个对象的对象头中没有标记为可达对象,则将其回收

在这里插入图片描述

优点:

  • 不需要进行对象的移动,并且仅对不存活的对象进行处理,在存活对象比较多的情况下极为高效

缺点:

  • 标记和清除过程的效率都不算高
  • 这种方法需要使用一个空闲列表空闲列表记录哪些内存是没有被占用状态,空闲的来记录所有的空闲区域以及大小,对空闲列表的管理会增加分配对象时的工作量
  • 标记清除后会产生大量不连续的内存碎片

清除阶段:标记-整理算法

标记-整理分为“标记”和“整理”两个阶段:

  • 标记:和标记清除算法一样,从GC Roots开始标记所有被引用的对象
  • 整理将所有的存活对象压缩到内存的一端,按顺序排放之后清理外边界的空间(清理垃圾)

在这里插入图片描述
标记-整理算法的最终效果等同于标记-清除算法执行后,再进行一次内存碎片整理,因此也可以把它称为标记-清除-压缩算法

可以看到,标记的存活对象将会被整理,按照内存地址依次排序。如此一来,当我们需要给新对象分配内存时,JVM只需要持有一个内存的起始地址即可,这比维护一个空闲列表显然少了许多开销

优点:

  • 消除了标记-清除算法中,内存区域分散的缺点(内存碎片)

缺点:

  • 移动对象的同时,如果对象被其他对象引用,还需要调整引用的地址
  • 移动过程中,需要全程暂停用户应用程序。即STW

清除阶段:复制算法

核心思想:
将内存空间分为两块,每次只使用其中一块,在垃圾回收时将正在使用的内存中的存活对象复制到未被使用的内存块中之后清除正在使用的内存块中的对象,交换两个内存的角色,最后完成垃圾回收

在这里插入图片描述对于这种算法来说,如果存活的对象过多的话则要执行较多的复制操作,效率会变低,因此它适合存活率较低的情况。事实上在年轻代中就是使用的复制算法

优点:

  • 没有标记和清除的过程,实现简单,运行高效
  • 复制过去以后保证空间的连续性,不会出现“碎片”问题

缺点:

  • 需要两倍的内存空间,比较浪费
  • 如果存活对象较多,那么复制操作就比较多,效率相对会降低

对比三种清除算法

Mark-SweepNark-CompactCopying
速度中等最慢最快
空间开销少(但有内存碎片)少(没有内存碎片)需要额外的一半内存开销
移动对象

从效率来说,复制算法是当之无愧的老大,但是却浪费了太多内存

而为了尽量兼顾上面提到的三个指标,标记-整理算法相对来说更平滑一些,但是效率上不尽如人意,它比复制算法多了一个标记的阶段,比标记-清除算法多了一个整理的阶段

分代收集

通俗的理解java对象的这一辈子
我是一个普通的java对象,我出生在Eden区,在Eden区我还看到和我长的很像的小兄弟,我们在Eden区中玩了挺长时间。有一天Eden区中的人实在是太多了,我就被迫去了Survivor区的“From”区,自从去了Survivor区,我就开始漂了,有时候在Survivor的“From”区,有时候在Survivor的“To”区,居无定所。直到我18岁的时候,爸爸说我成人了,该去社会上闯闯了。于是我就去了年老代那边,年老代里,人很多,并且年龄都挺大的,我在这里也认识了很多人。在年老代里,我生活了20年(每次GC加一岁),然后被回收。

分代收集过程

  • 新生代:几乎所有新生成的对象首先都是放在年轻代的(对象过大会被分配到老年代)。新生代内存按照 8:1:1 的比例分为一个 Eden(伊甸园区) 和两个 Survivor(幸存区),幸存区一个称为“From”区、一个称为“To”区,名字是动态的(谁空谁是“to”)。当新对象生成,Eden存满了空间不足,则会发起一次 Minor GC。回收时先将 Eden 区存活对象复制到一个 From区,然后清空 Eden 区。再次触发Minor GC时(Eden园满了才会触发),则将 Eden 区和 From区的存活对象复制到另一个 To区,然后清空 Eden 和这个 From区,此时 From区是空的,然后将 From区和 To区交换(谁空谁是To区), 如此往复。当 To区不足以存放 Eden 和 From的存活对象时,就将存活对象直接存放到老年代。当对象在 Survivor 区躲过一次 GC 的话,其对象年龄便会加 1默认情况下,如果对象年龄达到 15 岁,就会移动到老年代中。若是老年代也满了就会触发一次 Full GC,也就是新生代、老年代都进行回收。新生代大小可以由-Xmn来控制,也可以用-XX:SurvivorRatio来控制 Eden 和 Survivor 的比例。(垃圾清除算法用的是复制算法
  • 老年代:在新生代中经历了 N 次垃圾回收后仍然存活的对象,就会被放到年老代中。因此,可以认为年老代中存放的都是一些生命周期较长的对象。内存比新生代也大很多(大概比例是 1:2),当老年代内存满时触发 Major GC 即 Full GC,Full GC 发生频率比较低,老年代对象存活时间比较长,存活率高。一般来说,大对象会被直接分配到老年代。所谓的大对象是指需要大量连续存储空间的对象,最常见的一种大对象就是大数组。当然分配的规则并不是百分之百固定的,这要取决于当前使用的是哪种垃圾收集器组合和 JVM 的相关参数。(垃圾清除算法用的是 标记清除或者标记整理

垃圾回收相关概念

System.gc()的理解

  • 在默认情况下,通过System.gc()或者RunTime.getRuntime().gc()的调用,会显式触发Full
    GC
    ,同时对老年代和新生代进行回收,尝试释放垃圾对象占用的内存
  • 然后System.gc()调用附带一个免责声明(不进行垃圾回收不怪我(#^.^#)),无法保证垃圾收集器一定调用
  • 一般情况下垃圾回收应该是自动进行的,无须手动触发,否则就过于麻烦。

因此我提问个问题,调用System.gc()方法后就一定会触发Full GC吗?答案是不一定,接下来我用代码来演示:

注意:Java提供了一个名为finalize()的方法,它的工作原理应该是这样的:一旦垃圾收集器准备好释放对象占用的存储空间,它首先调用finalize()。

因此我们利用finalize()这个方法来判断是否触发了垃圾回收

public class GCTest {
    public static void main(String[] args) {
        new SystemGCTest();
        System.gc();//提醒jvm的垃圾回收器执行gc,但是不确定是否马上执行gc
        //与Runtime.getRuntime().gc();的作用一样。
//        System.runFinalization();//强制调用 失去引用的对象的finalize()方法
    }

    @Override
    protected void finalize() throws Throwable {
        super.finalize();
        System.out.println("SystemGCTest 重写了finalize()");
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14

分析上述代码,new SystemGCTest()这个代码并没有任何引用指向它,说明它是一个垃圾对象,那么如果发生Full GC的话就肯定会回收这个垃圾对象,紧接着肯定会调用finalize方法,并打印"SystemGCTest 重写了finalize()"信息
测试结果:
在这里插入图片描述其实我测试了好多次,大多数情况下还是打印"SystemGCTest 重写了finalize()"这个信息的,表示Full GC的几率还是挺大的,但是在我不屈服的、不认输的努力下,这次测试是没有打印信息的,也即是说,这个垃圾对象没有被回收,并没有进行Full GC。得出结论,System.gc()无法保证Full GC一定执行

Stop The World

Stop The World,简称STW指的是垃圾回收事件发生过程中,会产生应用程序的停顿。停顿产生时整个应用程序线程都会被暂停,没有任何响应,有点像卡死的感觉,这个停顿称为“STW”。可达性分析算法中枚举GC Roots时会导致Java执行线程停顿。

GC时为什么会有全局停顿?
类比在聚会时打扫房间,聚会时很乱,又有新的垃圾产生,房间永远打扫不干净,只有让大家停止活动了,才能将房间打扫干净。当gc线程在处理垃圾的时候,其它java线程要停止才能彻底清除干净,否则会影响gc线程的处理效率增加gc线程负担,特别是在垃圾标记的时候。

内存溢出和内存泄漏

内存溢出

Javadoc中对OutOfMemoryError的解释是:没有空闲内存,并且垃圾收集器也无法提供更多内存

Java虚拟机的堆内存不够,原因有二:

  • Java虚拟机的堆内存设置不够。 比如:可能存在内存泄漏问题,也有可能是堆的大小设置不合理,比如我们处理比较可观的数据量,但我们没有显式指定JVM堆大小或者数值偏小。我们可以通过参数-Xms-Xmx来调整
  • 代码中创建了大量大对象,并且长时间不能被垃圾收集器收集(存在被引用)。对于老版本的JDK,因为永久代的内存大小是有限的,并且JVM对永久代的垃圾回收不积极,所以当我们不断的添加新类型的时候,永久代出现OutOfMemoryError也非常常见,尤其是在运行时存在大量动态类型生成的场合;类似intern字符串缓存占用太多空间,也会导致OOM问题,对应的异常信息,会标记出来与永久代有关:“java.lang.OutOfMemoryError: PermGen space”;随着元空间的引用,方法区内存就不再那么窘迫了,所以相应的OOM有所改观,出现OOM异常的信息则变成:“java.lang.OutOfMemoryError: Metaspace”; 直接内存不足也会导致OOM。

内存泄漏

严格来说,只有对象不会再被程序用到了,但是GC有不能回收他们的情况,才叫内存泄漏

内存泄漏举例:

  1. 一些提供close方法的资源未关闭导致内存泄漏,如:数据库连接、网络连接(socket)和io连接都必须手动close,否则是不能被回收的
  2. 匿名内部类创建静态实例造成的内存泄漏
  3. 单例模式,一般单例的对象都会设置为静态的,单例的生命周期和应用程序一样长,所以单例程序中,如果持有对外部对象的引用的话(这个外部对象只在某一小段时间使用,生命周期本该很短),那么这个外部对象是不能被回收的,则会导致内存泄漏的产生

强软弱虚引用

在java中,除了基本数据类型的变量外,其他所有的变量都是引用类型,指向堆上各种不同的对象。

在jvm中,除了我们常用的强引用我们平时无意之间用的大都是强引用)外,还有软引用弱引用虚引用,这四种引用类型的生命周期与jvm的垃圾回收过程息息相关。

所有引用类型,都是抽象类java.lang.ref.Reference的子类,这个类的主要方法为get()方法:

public T get() {
    return this.referent;
}
  • 1
  • 2
  • 3

除了虚引用(因为get永远返回null),如果对象还没有被销毁,都可以通过get方法获取原有对象。

强引用

最传统的引用的定义,是指在程序代码中最普遍存在的引用赋值,即类似“Object object = new Object()”这种引用关系。强引用(Strong references)就是直接new一个普通对象,表示一种比较强的引用关系,只要还有强引用对象指向一个对象,那么表示这个对象还活着(GC Roots可达),垃圾收集器宁可抛出OOM异常,也不会回收这个对象

软引用

软引用用于关联一些可有可无的对象例如缓存当系统内存充足时,这些对象不会被回收当系统内存不足,将要发生内存溢出之前,就会回收这些对象(即使这些对象GC Roots可达),如果回收完这些对象后内存还是不足,就会抛出OOM异常

// vm args: -Xmx36m -XX:+PrintGCDetails
public class SoftReferenceDemo {
    public static void main(String[] args) throws InterruptedException {

        SoftReference<User> softReference = new SoftReference<>(new User()); // 软引用

        System.out.println(softReference.get());

        System.gc(); 

        TimeUnit.SECONDS.sleep(3); // wait gc thread run

        System.out.println(softReference.get()); // User对象不会被回收

        byte[] bytes = new byte[1024 * 1024 * 10]; // 分配一个大对象使得堆空间不足,软引用对象会在OOM之前先被回收
        System.out.println(softReference.get());

    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19

在上面的例子中,第一次发生gc时,User对象不会被回收,第二次发生gc时由于堆空间不足,会先回收软引用的对象,回收完了还是空间不足,最后抛出OOM异常。

弱引用

被弱引用关联的对象只能生存到一下次垃圾回收之前。当垃圾收集器工作时,无论内存空间是否充足,都会回收掉被弱引用关联的对象。ThreadLocal中就使用了WeakReference来避免内存泄漏。

public class WeakReferenceDemo {

    public static void main(String[] args) throws InterruptedException {

        WeakReference<User> weakReference = new WeakReference<>(new User());

        System.out.println(weakReference.get());

        System.gc();

        TimeUnit.SECONDS.sleep(3); // wait gc thread run

        System.out.println(weakReference.get()); // null
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15

上面的例子只要发生gc,User对象就会被垃圾收集器回收。

虚引用

  • 虚引用必须和引用队列(ReferenceQueue)联合使用。当垃圾回收器准备回收一个虚引用对象时,在回收该对象后,就将这个虚引用加入到对联的引用队列中
  • 虚引用 - 形态虚设,如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收
  • 也无法通过虚引用来获取被引用的对象,当试图通过虚引用的get()方法取得对象时,总是null
  • 虚引用主要用来跟踪对象被垃圾回收的活动

垃圾回收器

接下来我们重点研究Jvm的垃圾收集器(serial收集器、parnew收集器、parallel scavenge收集器、serial old 收集器、parallel old收集器、cms收集器、g1收集器)。上面讲了那么多就是为它做铺垫的
正式进入前先看下图解HotSpot虚拟机所包含的收集器:
在这里插入图片描述
图中展示了7种作用于不同分代的收集器,如果两个收集器之间存在连线,则说明它们可以搭配使用。虚拟机所处的区域则表示它是属于新生代还是老年代收集器

  • 新生代收集器:Serial、ParNew、Parallel Scavenge
  • 老年代收集器:CMS、Serial Old、Parallel Old
  • 整堆收集器: G1

几个相关概念:

  • 并行收集:指多条垃圾收集线程并行工作,但此时用户线程仍处于等待状态
  • 并发收集:指用户线程与垃圾收集线程同时工作(不一定是并行的可能会交替执行)。用户程序在继续运行,而垃圾收集程序运行在另一个CPU上。
  • 吞吐量:即CPU用于运行用户代码的时间与CPU总消耗时间的比值(吞吐量 = 运行用户代码时间 / ( 运行用户代码时间 + 垃圾收集时间 ))。例如:虚拟机共运行100分钟,垃圾收集器花掉1分钟,那么吞吐量就是99%

一:Serial 收集器

特点
Serial 即串行的意思,也就是说它以串行的方式执行,单线程简单高效(限定单个CPU的环境来说),Serial收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程手机效率。收集器进行垃圾回收时,必须暂停其他所有的工作线程,直到它结束(Stop The World)。

Serial / Serial Old收集器运行示意图:
在这里插入图片描述

二:ParNew收集器

ParNew收集器其实就是Serial收集器的多线程版本。除了使用多线程外其余行为均和Serial收集器一模一样(参数控制、收集算法、Stop The World、对象分配规则、回收策略等)。

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

应用场景
ParNew收集器是许多运行在Server模式下的虚拟机中首选的新生代收集器,因为它是除了Serial收集器外,唯一一个能与CMS收集器配合工作的

ParNew/Serial Old组合收集器运行示意图如下:
在这里插入图片描述

三:Parallel Scavenge 收集器

与吞吐量关系密切,故也称为吞吐量优先收集器

特点
属于新生代收集器也是采用复制算法的收集器,又是并行的多线程收集器(与ParNew收集器类似)。该收集器的目标是达到一个可控制的吞吐量。还有一个值得关注的点是:GC自适应调节策略(与ParNew收集器最重要的一个区别)

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

  • XX:MaxGCPauseMillis 控制最大的垃圾收集停顿时间
  • XX:GCRatio 直接设置吞吐量的大小

四:Serial Old 收集器

Serial Old是Serial收集器的老年代版本

特点:同样是单线程收集器,采用标记-整理算法

Serial / Serial Old收集器工作过程图(Serial收集器图示相同):
在这里插入图片描述

五:Parallel Old 收集器

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

特点多线程,采用标记-整理算法

应用场景:注重高吞吐量以及CPU资源敏感的场合,都可以优先考虑Parallel Scavenge+Parallel Old 收集器。

Parallel Scavenge/Parallel Old收集器工作过程图:
在这里插入图片描述

六:CMS收集器

一种以获取最短回收停顿时间为目标的收集器。

特点:基于标记-清除算法实现。并发收集、低停顿。

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

CMS收集器的GC周期主要由7个阶段组成,其中有两个阶段会发生stop-the-world,其他阶段都是并发执行的。(亦有4个阶段、6个阶段等说法)

1: Initial Mark(初始化标记)

初始化标记阶段,是CMS GC的第一个阶段,也是标记阶段的开始。主要工作是标记GC Roota可直达的存活对象
主要标记过程:

  • 从GC Roots遍历可直达的老年代对象
  • 遍历被新生代存活对象所引用的老年代对象

程序执行情况:

  • 支持单线程或并行标记
  • 发生stop-the-world,暂停所有应用线程

2: Concurrent Mark(并发标记)

在该阶段,GC线程和应用线程将并发执行。也就是说,在第一个阶段(Initial Mark)被暂停的应用线程将恢复运行。

并发标记阶段的主要工作是,通过遍历第一个阶段(Initial Mark)标记出来的存活对象,继续递归遍历老年代,并标记可直接或间接到达的所有老年代存活对象。

由于在并发标记阶段,应用线程和GC线程是并发执行的,因此可能产生新的对象或对象关系发生变化,例如:

  • 新生代的对象晋升到老年代
  • 直接在老年代分配对象
  • 老年代对象的引用关系发生变更
  • 等等

对于这些对象,后面需要重新标记以防止被遗漏(漏标)。为了提高重新标记的效率,本阶段会把这些发生变化的对象所在的Card标识为Dirty,这样后续就只需要扫描这些Dirty Card的对象,从而避免扫描整个老年代

3: Concurrent Preclean(并发预清理)

该阶段将会重新扫描前一个阶段标记的Dirty对象,并标记被Dirty对象直接或间接引用的对象,然后清除Card标识

  1. 标记被Dirty对象直接或间接引用的对象
  2. 清除Dirty对象的Card标识

4: Concurrent Abortable Preclean(可中止的并发预清理)

本阶段尽可能承担更多的并发预处理工作,从而减轻在Final Remark阶段的stop-the-world

在该阶段,主要循环的做两件事:

  • 处理 From 和 To 区的对象,标记可达的老年代对象
  • 和上一个阶段一样,扫描处理Dirty Card中的对象

具体执行多久,取决于许多因素,满足其中一个条件将会中止运行:

  • 执行循环次数达到了阈值
  • 执行时间达到了阈值
  • 新生代Eden区的内存使用率达到了阈值

5: Final Remark(重新标记)

预清理阶段也是并发执行的,并不一定是所有存活对象都会被标记,因为在并发标记的过程中对象及其引用关系还在不断变化中。
因此,需要有一个stop-the-world的阶段来完成最后的标记工作,这就是重新标记阶段(CMS标记阶段的最后一个阶段)。主要目的是重新扫描之前并发处理阶段的所有残留更新对象

主要工作:

  • 遍历新生代对象,重新标记;(新生代会被分块,多线程扫描)
  • 根据GC Roots,重新标记
  • 遍历老年代的Dirty Card,重新标记。这里的Dirty Card,大部分已经在Preclean阶段被处理过了

6: Concurrent Sweep(并发清理)

并发清理阶段,主要工作是清理所有的死亡对象,回收被占用的空间

7: Concurrent Reset(并发重置)

并发重置阶段,将清理并恢复在CMS GC过程中的各种状态,重新初始化CMS相关数据结构,为下一个垃圾收集周期做好准备

问:CMS中的浮动垃圾理解?
书上说:并发清理阶段用户线程还在运行,这段时间就可能产生新的垃圾,新的垃圾在此次GC无法清除,只能等到下次清理。这些垃圾有个专业名词:浮动垃圾。
这个浮动垃圾如何理解?难道不是在本次GC重新标记remark的过程中被发现然后清理吗?为何还要等下次GC才能清理?
答:
重新标记(Remark) 的作用在于:之前在并发标记时,因为是 GC 和用户程序是并发执行的,可能导致一部分漏标的对象,因为用户程序的(并发)运行。Remark 的作用就是将这部分对象又标记为 可达对象。 至于 “浮动垃圾”,因为 CMS 在 并发标记 时是并发的,GC 线程和用户线程并发执行,这个过程当然可能会因为线程的交替执行而导致新产生的垃圾(即浮动垃圾)没有被标记到;而 重新标记 (Remark)主要关注的 前面并发标记 漏标的情况,所以是没有办法处理 “浮动垃圾” 的。

七:G1收集器

G1起源

G1从jdk7开始,jdk9被设为默认垃圾收集器;目标就是彻底替换掉CMS

CMS垃圾回收器缺点

  • 并发清理阶段存在浮动垃圾; – 并发执行导致
  • fgc算法是标记清除,会产生磁盘碎片 – 标记整理算法导致
  • 新生代配合ParNewGC使用,存在STW问题。 — 时间不可控,如果heap很大,可能GC时间很大,影响线上服务

G1内存分配策略

将内存分成一个个的Region,且不要求各部分是连续的G1在逻辑上还是划分Eden、Survivor、OLd,但是物理上他们不是连续的。
每个Region的大小在JVM启动时就确定,JVM通常生成2048个左右的heap区, 根据堆内存的总大小,区的size范围为1-32Mb,一般4M.

region类型:

  • 三种常见EdenSurvivorold generation(老年代)区
  • 巨无霸区保存比标准region区大50%及以上的对象,存储在一组连续的区中.转移会影响GC效率,标记阶段发现巨型对象不再存活时,会被直接回收
  • 未使用区未被使用的region

特别说明某个region的类型不是固定的,比如一次ygc过后,原来的Eden的分区就会变成空闲的可用分区,随后也可能被用作分配巨型对象

在这里插入图片描述

G1垃圾回收算法

  • 收集整体是使用“标记-整理”
  • Region之间基于“复制”算法。

G1的运行过程

在这里插入图片描述

G1的运行过程与CMS大体相似,分为以下四个步骤:

  • 初始标记(Initial Marking):仅仅只是标记一下GC Roots能直接关联到的对象,并且修改TAMS指针的值,让下一阶段用户线程并发运行时,能正确地在可用的Region中分配新对象。这个阶段需要停顿线程,但耗时很短,而且是借用进行Minor GC的时候同步完成的,所以G1收集器在这个阶段实际并没有额外的停顿。
  • 并发标记( Concurrent Marking):从GC Root开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,这阶段耗时较长,但可与用户程序并发执行。当对象图扫描完成以后,并发时有引用变动的对象会产生漏标问题,G1中会使用SATB来解决,后面会介绍。
  • 最终标记(Final Marking):对用户线程做一个短暂的暂停,用于处理并发标记阶段仍遗留下来的最后那少量的SATB记录(漏标对象)。
  • 筛选回收(Live Data Counting and Evacuation):负责更新Region的统计数据,对各个Region的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个Region构成回收集,然后把决定回收的那一部分Region的存活对象复制到空的Region中,再清理掉整个旧Region的全部空间。这里的操作涉及存活对象的移动,是必须暂停用户线程,由多个收集器线程并行完成的

TAMS指针:在并发标记过程中,如何进行新对象的内存分配呢?
G1有两个TAMS指针,把Region中的一部分空间划分出来用于并发回收过程中的新对象的分配。并发回收时新分配的对象地址都必须在这两个指针之上,G1收集器默认在这个地址上的对象是存活的,不纳入回收范围。

G1中重要数据结构

待回收集,CSet(Collection Set )

收集集合(CSet)代表每次GC暂停时回收的一系列目标分区。在任意一次收集暂停中,CSet所有分区都会被释放,内部存活的对象都会被转移到分配的空闲分区中。因此无论是年轻代收集,还是混合收集,工作的机制都是一致的。年轻代收集CSet只容纳年轻代分区,而混合收集会通过启发式算法,在老年代候选回收分区中,筛选出回收收益最高的分区添加到CSet中。

CSet根据两种不同的回收类型分为两种不同CSet。

  1. CSet of Young Collection
  2. CSet of Mix Collection

CSet of Young Collection 只专注回收 Young Region 跟 Survivor Region ,而CSet of Mix Collection 模式下的CSet 则会通过RSet计算Region中对象的活跃度,活跃度阈值-XX:G1MixedGCLiveThresholdPercent(默认85%),只有活跃度高于这个阈值的才会准入CSet,混合模式下CSet还可以通过XX:G1OldCSetRegionThresholdPercent(默认10%)设置,CSet跟整个堆的比例的数量上限。

卡表,Card Table

Java堆划分为相等大小的一个个区域,这个小的区域(一般size在128-512字节)被当做Card,而Card Table维护着所有的Card。Card Table的结构是一个字节数组,Card Table用单字节的信息映射着一个Card。当Card中存储了对象时,称为这个Card被脏化了(dirty card)。 对于一些热点Card会存放到Hot card cache。同Card Table一样,Hot card cache也是全局的结构。

已记忆集合,RSets

RememberedSets,存储着其他分区中的对象对本分区对象的引用,每个分区有且只有一个RSet。用于提高GC效率。
YGC时,GC root主要是两类:栈空间和老年代分区到新生代分区的引用关系。所以记录老年代分区对新生代分区的引用
Mixed GC时,由于仅回收部分老年代分区,老年代分区之间的引用关系也将被使用。所以记录老年代分区之间的引用
因此,我们仅需要记录两种引用关系:老年代分区引用新生代分区,老年代分区之间的引用。

常见问题

CSet是怎么维护的?怎么知道哪些是要回收的?
由G1MixedGCLiveThresholdPercent参数控制的,old代分区中的存活对象比,达到阀值时,说明该region可以被回收的对象比较多,这个old分区会被放入CSet,等待被GC。

cardTable和region是什么关系?对region的细分吗?有什么用?
可以认为region切分为一个一个固定大小card。而CardTable是一个全局的存储结构,其通过一个byte数组结构存储了对于每个Card的Entry,其不需要太大的存储空间。而RSet中的HashTable也就是一些其他Region(引用了RSet所在的Region)的card集合。

cardTable和RSets
RSets:哈希表来存储,key是region index,value是card数组
cardTable:记录多个cardPage信息,单个CardPage大小为512字节,卡表(Card Table)被实现为一个简单的字节数组,即卡表的每个标记项为1个字节。当对一个对象引用进行写操作时(对象引用改变),写屏障逻辑将会标记对象所在的卡页为dirty。如:
CARD_TABLE [this address >> 9] = 0;

GC后会将存活对象复制到可用分区(未分配的分区),所以不会产生空间碎片。

G1的GC类型

  • Ygc:仅处理年轻代region
  • MixedGc:包含所有年轻代以及部分老年代Region。
  • FullGc:全堆扫描,每个Region
youngGC

触发:分配一般对象(非巨型对象)时,当所有eden的region使用达到最大阀值并且无法申请足够内存时。
younggc会回收所有Eden以及Survivor区,并且将存活对象复制到Old区以及另一部分的Survivor区。因为YoungGC会进行根扫描,所以会stop the world。

YoungGC的回收过程如下:

  • 根扫描,跟CMS类似,Stop the world,扫描GC Roots对象。
  • 处理Dirty card(即保存了对象的小区域), 更新RSet(分区间对象的引用关系)。
  • 扫描RSet,扫描old区对象对于本young区的引用。
  • 拷贝扫描出的存活的对象到survivor2或old区
  • 处理引用队列,软引用,弱引用,虚引用
MixGC

触发:一次YoungGc之后,老年代占据堆内存的百占比超过InitiatingHeapOccupancyPercent(默认45%)时,超过这个值就会触发mixedGC。
混合回收都是基于复制算法进行的,把要回收的Region区存活的对象放入其他Region,然后这个Region全部清理掉,这样就会不断空出来新的Region;
有一个参数-XX:G1HeapWastePercent,默认值5%,即空出来的区域大于整个堆的5%,就会立即停止混合回收了。如正常默认回收次数是8次,但是可能到了4次,空闲Region大于整个堆的5%,就不会再进行后续回收了。
MixGc过程:

  • 标记GCroots,一般直接复用YoungGC中的结果
  • 根分区扫描(RootRegionScan)。这个阶段GC的线程可以和应用线程并发运行。其主要扫描初始标记以及之前YoungGC对象转移到的Survivor分区,并标记Survivor区中引用的对象。所以此阶段的Survivor分区也叫根分区(RootRegion)
  • 并发标记(ConcurrentMark)。会并发标记所有非完全空闲的分区的存活对象,也即使用了SATB算法,标记各个分区。
  • 最终标记(Remark)。主要处理SATB缓冲区,以及并发标记阶段未标记到的漏网之鱼(存活对象),会STW,可以参考上文的SATB处理。
  • 清除阶段(Clean UP)。整理堆分区,调整相应的RSet(比如如果其中记录的Card中的对象都被回收,则这个卡片的也会从RSet中移除),如果识别到了完全空的分区,则会清理这个分区的RSet。这个过程会STW。
  • 对存活对象进行转移(复制算法),转移到其他可用分区,所以当前的分区就变成了新的可用分区。复制转移主要是为了解决分区内的碎片问题。
FullGc

G1在对象复制/转移失败或者没法分配足够内存(比如巨型对象没有足够的连续分区分配)时,会触发FullGC。
开始版本FullGC使用的是stop the world的单线程的Serial Old模式。
JDK10以后,Full GC已经是并行运行,在很多场景下,其表现还略优于 Parallel GC 的并行 Full GC 实现。

三色标记法

【三色标记法】

完结

该篇文章,是我看了多个垃圾回收相关视频以及多篇优秀文章总结出来。(我不生产水,只是大自然的搬运工)
如果这篇文章对你有帮助的话,请点个赞吧!
如果文章有叙述不正确的地方,欢迎各位在评论区提出!!
谢谢,希望能够帮到你!!!

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/运维做开发/article/detail/939600
推荐阅读
相关标签
  

闽ICP备14008679号