Java垃圾收集算法详解,JVM垃圾收集机制

垃圾收集算法

GC算法网上已经有一大堆了,相信很多人已经看了至少2~3遍,在这里简单的过一遍。

1. GC算法有哪些?各有什么优缺点?

1.1 标记-清除 算法

算法分为两个阶段,先标记,后清除。先将不可回收对象进行标记,剩下的那些需要回收的统一清除。

算法简单,缺点是会产生内存碎片。

1.2 复制算法

将内存分为两块,每次使用其中的一块,当内存不足时,将存活的对象复制到另一块内存中去,剩下的直接清除。

回收后内存时连续的,缺点是会浪费一些内存,因为每次只使用其中的一块。

1.3 标记-整理 算法

类似标记清除算法,只是在回收的时候将存活的对象移动到一端,剩下的清除掉。

充分使用了内存,也避免了内存碎片。因为要移动对象,效率较低。

1.4 分代算法

严格上讲,它并不是一个算法,只是按照对象存活的特性将内存分成不同的区域,在不同的区域采用不同的算法,而这些算法是上面三种的组合。

JVM中,堆内存被分为新生代和老年代。

新生代中,又细划分为,Eden、Survivor1、Survivor2,这个区域一般是复制算法

老年代,对象在新生代中经过多次复制后仍然存活(默认是对象年龄达到15),会被复制到老年代,或者大对象等直接在老年代分配,而老年代根据垃圾收集器的不同,会采用不同的算法,如标记清除,标记整理等

算法名称优点缺点
标记清除实现简单回收后内存会变得不连续,即内存碎片,新创建的大对象可能会不够分配
复制算法GC后的内存空间是连续的一部分的内存浪费
标记整理GC后内存是连续的,也没有浪费内存效率低
分代算法将内存划分成不同的区域,采用不同的收集算法进行回收-

2. 如何判断对象已死亡?(如何标记哪些对象需要被回收)

回收算法就是为了回收不再存活的对象,那么如何判断对象死亡呢?

2.1 引用计数法

每个对象中都有一个计数器,每当对象被引用一次就加1,失去引用就减1,这样当计数器为0时,代表着可回收。这种方法足够简单高效,但是解决不了循环引用问题,例如:

    public class A {
        public B b;
    }

    public class B {
        public A a;
    }

   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对象,b对象中引用a对象,导致a、b的计数器都不为0,本该被回收的对象无法回收,该方法基本不被虚拟机使用。

2.2 可达性分析

通过一些列的称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链,当一个对象到GC Roots没有任何引用链相连时(就是从GC Roots 到这个对象是不可达),则证明此对象是不可用的,它们会被判定为可回收对象,当然要真正宣告一个对象死亡,至少要经历两次标记过程。

可以作为GC Roots中的对象:

1.JAVA虚拟机栈中引用的对象(线程私有的空间,说明线程正在执行中,栈帧中引用的对象多少都是存活的)

2.本地方法栈(Native方法)中引用的对象(与虚拟机栈类似)

3.方法区中类静态变量和常量引用的对象

3. 垃圾收集器CMS,G1,ZGC

CMS

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。这是因为CMS收集器工作时,GC工作线程与用户线程可以并发执行,以此来达到降低收集停顿时间的目的.

CMS收集器仅作用于老年代的收集,是基于标记-清除算法的,过程分为4个步骤:

  • 初始标记

  • 并发标记

  • 重新标记

  • 并发清除

初始标记、重新标记这两个步骤仍然需要停顿(Stop-the-world)。

初始标记仅仅只是标记一下GC Roots能直接关联到的对象,速度很快。

并发标记阶段就是进行GC Roots 跟踪的过程。

而重新标记阶段则是为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始阶段稍长一些,但远比并发标记的时间短。

优点:并发收集、低停顿

缺点:对CPU资源非常敏感,无法处理浮动垃圾,标记-清除算法会产生内存碎片。

CMS收集器之所以能够做到并发,根本原因在于采用基于“标记-清除”的算法并对算法过程进行了细粒度的分解。而标记-清除算法将产生大量的内存碎片这对新生代来说是难以接受的(新生代占用的比例小,内存更加宝贵),因此新生代的收集器并未提供CMS版本。

JVM在暂停的时候,需要选准一个时机。由于JVM系统运行期间的复杂性,不可能做到随时暂停,因此引入了安全点的概念

安全点(Safepoint)

安全点,即程序执行时并非在所有地方都能停顿下来开始GC,只有在到达安全点时才能暂停。Safepoint的选定既不能太少以至于让GC等待时间太长,也不能过于频繁以致于过分增大运行时的负荷。

安全点的初始目的并不是让其他线程停下,而是找到一个稳定的执行状态。在这个执行状态下,Java虚拟机的堆栈不会发生变化。这么一来,垃圾回收器便能够“安全”地执行可达性分析。只要不离开这个安全点,Java虚拟机便能够在垃圾回收的同时,继续运行这段本地代码。程序运行时并非在所有地方都能停顿下来开始GC,只有在到达安全点时才能暂停。安全点的选定基本上是以程序“是否具有让程序长时间执行的特征”为标准进行选定的。“长时间执行”的最明显特征就是指令序列复用,例如方法调用、循环跳转、异常跳转等,所以具有这些功能的指令才会产生Safepoint。对于安全点,另一个需要考虑的问题就是如何在GC发生时让所有线程(这里不包括执行JNI调用的线程)都“跑”到最近的安全点上再停顿下来。

两种解决方案:

抢先式中断(Preemptive Suspension)

抢先式中断不需要线程的执行代码主动去配合,在GC发生时,首先把所有线程全部中断,如果发现有线程中断的地方不在安全点上,就恢复线程,让它“跑”到安全点上。现在几乎没有虚拟机采用这种方式来暂停线程从而响应GC事件。

主动式中断(Voluntary Suspension)

主动式中断的思想是当GC需要中断线程的时候,不直接对线程操作,仅仅简单地设置一个标志,各个线程执行时主动去轮询这个标志,发现中断标志为真时就自己中断挂起。轮询标志的地方和安全点是重合的,另外再加上创建对象需要分配内存的地方。

G1

它虽然还是保留的有新生代和老年代的概念,但是新生代和老年代之前再也不是区域上的隔离了。它将整个 Java 堆划分为多个大小相等的独立区域,叫做 Region 。而新生代和老年代就是由一个个 Region 动态组成的区域,它们可以是不连续的区间。每一个 Region 都可以根据需要,扮演新生代的 Eden 空间,Survivor 空间,或者老年代空间。除此之外它还有一类特殊的区域叫做 Humongous,专门用来存储大对象。G1的堆内存被划分为多个 Region , Region 的总个数在 2048 个左右,默认是 2048 。对于一个 Region 来说,是逻辑连续的一段空间,其大小的取值范围是 1MB 到 32MB 之间。

G1 工作过程:

  • 初始标记:仅仅只是标记一下GC Roots能直接关联到的对象,并且修改TAMS(Next Top at Mark Start)的值,让下一阶段用户程序并发运行时,能在正确可用的Region中创建新对象,这阶段需要停顿线程,但耗时很短。

  • 并发标记:是从GC Roots开始堆中对象进行可达性分析,找出存活的对象,这阶段耗时较长,但可与用户程序并发执行

  • 最终标记:是为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分标记记录,虚拟机将这段时间对象变化记录在线程Remembered Set Logs里面,最终标记阶段需要把Remembered Set Logs的数据合并到Remembered Set中,这阶段需要停顿线程,但是可并行执行

  • 筛选回收:首先对各个Region的回收价值和成本进行排序,根据用户所期望的GC停顿时间来制定回收计划。这个阶段也可以做到与用户程序一起并发执行,但是因为只回收一部分Region,时间是用户可控制的,而且停顿用户线程将大幅提高收集效率

4. 什么是三色标记?

三色标记将对象的颜色分为了黑、灰、白,三种颜色。

黑色:表示对象已经被垃圾收集器访问过, 且这个对象的所有引用都已经扫描过。 黑色的对象代表已经扫描过, 它是安全存活的, 如果有其他对象引用指向了黑色对象, 无须重新扫描一遍。 黑色对象不可能直接(不经过灰色对象) 指向某个白色对象。

灰色:表示对象已经被垃圾收集器访问过, 但这个对象上至少存在一个引用还没有被扫描过

白色:表示对象尚未被垃圾收集器访问过。 显然在可达性分析刚刚开始的阶段, 所有的对象都是白色的, 若在分析结束的阶段, 仍然是白色的对象, 即代表不可达。

5. CMS和G1都是有一个并发标记的过程,并发标记要解决什么问题?带来了什么问题?怎么解决这些问题呢?浮动垃圾如何产生的?

解决的问题:消减标记过程中的停顿时间。那就是让垃圾回收器和用户线程同时运行,并发工作。也就是我们说的并发标记的阶段。

带来的问题:在该阶段,并发标记的同时用户线程也在执行,也就是说用户线程也在修改对象的引用关系,那么可能会带来如下问题:

一种是把原本消亡的对象错误的标记为存活,这不是好事,但是其实是可以容忍的,只不过产生了一点逃过本次回收的浮动垃圾而已(如果面试官问,浮动垃圾如何产生的,在那一阶段产生的,就可以用这个问题回答),下次再清理就可以。本应该回收 但是 没有回收到的内存,被称为“浮动垃圾”。浮动垃圾并不会影响应用程序的正确性,只是需要等到下一轮垃圾回收中才被清除

一种是把原本存活的对象错误的标记为已消亡,这就是非常严重的后果了,一个程序还需要使用的对象被回收了,那程序肯定会因此发生错误

相比于第一个问题,显然第二个要严重的多,那么如何解决这个问题呢?

Wilson于1994年在理论上证明了,当且仅当以下两个条件同时满足时,会产生“对象消失”的问题,即原本应该是黑色的对象被误标为白色:

赋值器插入了一条或多条从黑色对象到白色对象的新引用;

赋值器删除了全部从灰色对象到该白色对象的直接或间接引用。

由于两个条件之间是当且仅当的关系。所以,我们要解决并发标记时对象消失的问题,只需要破坏两个条件中的任意一个就行。
于是产生了两种解决方案:增量更新(Incremental Update)和原始快照(Snapshot At The Beginning,SATB)

在HotSpot虚拟机中,CMS是基于增量更新来做并发标记的,G1则采用的是原始快照的方式。

增量更新,解决的是第一个问题:

在三色标记中,当黑色标记的对象引用白色标记的对象时,就将这个新的引用记录下来,等并发扫描结束之后,再以这些记录过引用关系中的黑色对象为根,重新向下扫描一次。这可以简化理解为,黑色对象一旦新插入了指向白色对象的引用之后,它就变回灰色对象了,这个过程其实就是重新标记。

原始快照,解决的是第二个问题:

当灰色对象要删除指向白色对象的引用关系时,就将这个要删除的引用记录下来,在并发扫描结束之后,再将这些记录过的引用关系中的灰色对象为根,重新扫描一次。
这个可以简化理解为:无论引用关系删除与否,都会按照刚刚开始扫描那一刻的对象图快照来进行搜索。

zgc

zgc 是一个以实现低停顿(停顿时间小于10ms)为目标的垃圾收集器,JDK11 开始支持。

与G1 一样,ZGC也采用基于Region的堆内存布局

很多低延迟高可用Java服务的系统可用性经常受GC停顿的困扰。GC停顿指垃圾回收期间STW(Stop The World),当STW时,所有应用线程停止活动,等待GC停顿结束。

以G1 为例:

  • 初始标记:需要STW,停顿时间很短,可以忽略

  • 并发标记:不需要STW

  • 最终标记:需要STW,重新标记并发阶段产生变化的对象,这类变动不会太多,停顿时间也可以忽略

  • 筛选回收:需要STW,该阶段分为两步,清理阶段:清点出有存活对象的分区和没有存活对象的分区,该阶段不会清理垃圾对象,也不会执行存活对象的复制,停顿时间可以忽略。复制阶段:需要分配新内存和复制对象的成员变量,其中内存分配通常耗时非常短,但对象成员变量的复制耗时有可能较长,这是因为复制耗时与存活对象数量与对象复杂度成正比。对象越复杂,复制耗时越长。

G1停顿时间的瓶颈主要是标记-复制中的转移阶段STW,即把对象转移到新的内存中去。为什么转移阶段不能和并发标记阶段一样并发执行呢?主要是G1未能解决转移过程中准确定位对象地址的问题。

那么zgc 如何解决这个问题的呢?

与G1 一样 ,ZGC也采用标记-复制算法,不过ZGC对该算法做了重大改进:ZGC在标记、转移和重定位阶段几乎都是并发的,这是ZGC实现停顿时间小于10ms目标的最关键原因

ZGC只有三个STW阶段:初始标记,再标记,初始转移,初始标记和初始转移分别都只需要扫描所有GC Roots,其处理时间和GC Roots的数量成正比,一般情况耗时非常短;再标记阶段STW时间很短,最多1ms,超过1ms则再次进入并发标记阶段。即,ZGC几乎所有暂停都只依赖于GC Roots集合大小,停顿时间不会随着堆的大小或者活跃对象的大小而增加。与ZGC对比,G1的转移阶段完全STW的,且停顿时间随存活对象的大小增加而增加。

ZGC关键技术(如何实现并发转移的)

1.读屏障(Load Barrier):并发转移中“并发”意味着GC线程在转移对象的过程中,应用线程也在不停地访问对象。假设对象发生转移,但对象地址未及时更新,那么应用线程可能访问到旧地址,从而造成错误。而在ZGC中,应用线程访问对象将触发“读屏障”,如果发现对象被移动了,那么“读屏障”会把读出来的指针更新到对象的新地址上,这样应用线程始终访问的都是对象的新地址。

2.着色指针(也可以叫染色指针)(Colored Pointer)

GC标记方案通常有如下几种:

  • 把标记直接记录在对象头上(如Serial收集器);
  • 把标记记录在与对象相互独立的数据结构上(如G1、Shenandoah使用了一种相当于堆内存的1/64大小的,称为BitMap的结构来记录标记信息);
  • 直接把标记信息记在引用对象的指针上(如ZGC)

着色指针/染色指针是一种直接将少量额外的信息存储在指针上的技术。目前在Linux下64位的操作系统中高18位是不能用来寻址的,但是剩余的46为却可以支持64T的空间,到目前为止我们几乎还用不到这么多内存。于是ZGC将46位中的高4位取出,用来存储4个标志位,剩余的42位可以支持4TB(2的42次幂)的内存,也直接导致ZGC可以管理的内存不超过4TB

ZGC将对象存活信息存储在42~46位中,这与传统的垃圾回收并将对象存活信息放在对象头中完全不同。

ZGC的优点

  • 低停顿,高吞吐量,ZGC收集过程中额外耗费的内存小。
  • 低停顿,几乎所有过程都是并发的,只有短暂的STW。
  • 内存小,ZGC没有写屏障,卡表之类的。
  • 吞吐量方面,在ZGC的‘弱项’吞吐量方面,因为和用户线程并发,还是有影响的。但是!低延迟为首要目标的ZGC已经达到了以高吞吐量为目标Parallel Scavenge的99%,直接超越了G1。
  • G1通过写屏障维护记忆集,才能处理跨代指针,得以实现增量回收。记忆集占用大量内存,写屏障对正常程序造成额外负担。
  • 在多核处理器的某种架构下,ZGC优先在线程当前所处的处理器的本地内存上分配对象,以保证内存高效访问。
  • 并发停顿方面:ZGC只有短暂的STW,大部分的过程都是和应用线程并发执行,比如最耗时的并发标记和并发移动过程。
  • ZGC中没有引入分代,也就没有新生代和老年代的概念,只有一块一块的内存区域page,以page单位进行对象的分配和回收。
  • 并发的标记-整理算法。没有内存碎片。

ZGC的缺点

  • 承受的对象分配速率不会太高,因为浮动垃圾。
  • ZGC的停顿时间是在10ms以下,但是ZGC的执行时间还是远远大于这个时间的。假如ZGC全过程需要执行10分钟,在这个期间由于对象分配速率很高,将创建大量的新对象,这些对象很难进入当次GC,所以只能在下次GC的时候进行回收,这些只能等到下次GC才能回收的对象就是浮动垃圾。
  • 造成回收到的内存空间小于期间并发产生的浮动垃圾所占的空间。
  • ZGC没有分代概念,每次都需要进行全堆扫描,导致一些“朝生夕死”的对象没能及时的被回收。所以就不存在Young GC、Old GC,所有的GC行为都是Full GC。
  • ZGC目前只在Linux/x64上可用

参考:

弄明白CMS和G1,就靠这一篇了

面试官问我G1回收器怎么知道你是什么时候的垃圾?

你说你熟悉jvm?那你讲一下并发的可达性分析

新一代垃圾回收器ZGC的探索与实践

理解并应用JVM垃圾收集器-ZGC

OpenJDK zgc wiki

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

云哲-吉吉2021

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值