关于Java与Golang的GC

Go语言精选

共 6495字,需浏览 13分钟

 · 2021-02-21

本文只做GC方面的一些概述,具体细节请单独看一些优秀blog。

同时文章内容为个人理解,欢迎评论指责,谢谢!

一、GC的普遍解决方案

一般来说GC分为两个部分,一部分是找到需要回收的对象,一部分是清除这些对象并执行一些额外操作,如碎片处理。因而本文从标记和清除两个方面来叙述

实际上所谓的清除方案如mark-sweep是使用的可达性分析的标记法,但本文将两者分离开讲。在清理部分只单论mark-sweep的清理思想,而不考虑标记细节,此处请注意⚠️

1、标记

总体来说,分为两大类方法,第一类方法为引用计数,也是最为基础简单的做法。第二类为可达性分析,即Golang包括Java所使用的标记方式

1.1、引用计数法

引用计数法很好理解,即在对象头部隐式增加一个计数器,通过计数器来计算引用次数。

如创建一个新对象并赋名,此时即为可引用状态,引用计数器++。

Student stu = new Student();

此时对象的引用为stu,计数器非0,则认定为有效对象。若此时将stu置空。

stu = null;

此时无法再引用到之前创建的对象,即对象存在与内存中,但引用stu已经被抹除,此时该对象计数器清零,可以被回收。

但仔细思考一下,引用计数法有一些难以解决的问题,如考虑以下场景:类A包含类B的成员变量,同时类B也包含类A,此时分别实例化两个类的对象,并相互引用。此时两个类内部的成员变量都包含对方类的引用,即使此时在主函数中将两个类引用置为null,两个实例在内存中依然会相互引用。计数器不清零,二者无法调用,也无法被清理。

(上图来自 知乎 海纳 GC算法之引用计数[1]

所以可能会想到,既然我们考虑GC的根本是可不可以到达,或可不可以通过直接或间接的引用去调用对象,那么我去构建一个引用有向图,做深度或者广度遍历不就知道哪些可以留,哪些没有用了嘛?

这就是可达性分析的原理~

1.2、可达性分析

简单来说,就是选取几个可靠的根节点,从根结点开始做遍历,遍历所有可以到达的对象,那么到不了的就视为无效对象,应被GC回收,这种想法就很好的避免了循环引用的问题,像是Java和Go的GC都采用的这种策略。但这种方式也会有一些缺点,和算法一样嘛,照顾了时间就要多花空间,没有完美的算法,只有合适的算法。

可达性分析的重点在于:如何选择合适的根节点

我们可以大概看看Java是如何选择的,JVM的GC通常会选择以下四类作为根节点

VM Stack中引用的对象方法区中类静态引用的对象方法区中常量引用的对象本地方法栈中的Native方法(JNI)

Java中Running Data Area包含几个区域,如总程序调用需要的栈区,即VM Stack,动态分配的堆区heap,方法区Method Area,还有PCR程序计数器和Native本地方法区。首先栈区为根节点的主要选择区,即在栈中的引用对象需要作为根节点处理。此外,类中的静态引用是作为类属性非对象属性存在,因而具有根节点特性。常量同理,这些都分配在堆区。还有本地方法栈的Native方法,Native方法本质是不在JVM的栈中调用的,而是动态连接在C栈(或其他)中进行处理。此外,Java在处理无法到达节点时,会留有一次生存机会,去判断是否执行finalize()方法,此处不细讲,毕竟不做Java,不是特别了解JVM。

而Go中的选择就更简单一些,即全局变量和G Stack中的引用指针,简单来说就是全局量和go程中的引用指针。因为Go中没有类的封装概念,因而Gc Root选择也相对简单一些

可达性分析的具体实现,如怎么去解引用等可深入了解一下,此处不再细解

1.3 二者对比

引用计数法很好的将标记工作平摊到日常的对象创建引用过程中,在对象引用时直接在头部计数器++即可,扫描阶段直接判断所有的计数器,把为0的清理掉即可。但问题也比较突出,就是循环引用终究无法解决,这样会造成很多无效引用挤占内存空间。但相对来说判定效率高,速度快,GC不会对性能产生较大影响。

可达性分析相对来说较为准确,能够很好的解决循环引用等问题。但可达性分析最大的问题在于,为了保持对象的一致性,即扫描阶段所有对象状态不可变更,此时需要STW(stop the world)。对于高并发的网络服务,STW无疑是致命的,因而之后也会提到,Go是如何优化这些缺点的。

2、清理

清理实际上是GC中最重要的部分,即如何充分利用空间和性能,同时如何避免产生大量的内存碎片,以及如何适应不同的业务需求,这些都是各种语言选择GC的重要标准,之后也会阐述,如为何Go不直接效仿Java成熟的GC机制,而有自己的GC路数。

以下演示图均来自 知乎 咱们从头到尾说一次 Java 垃圾回收[2]

2.1 mark-sweep

上文也提过,实际上mark-sweep使用的可达性分析,即根节点扫描的方式,但此处仅仅讲清理过程。

mark-sweep是最基础简单的清理办法,即标记需要回收的节点,然后清除节点即可。但这种做法明显有很多问题,即产生较多的内存碎片。因而这种算法大部分情况下需要改进使用!

2.2 copying

复制算法就要机智一些,把内存分割成两个相同的区域,每次只使用一半。当发生GC的时候,会把其中一块中的存活对象复制到另一块内存中,原先内存清空。这种方法相对来说不会产生大量的内存碎片,但问题在于变相减半了内存空间,这显然也是无法接受的。同时如果GC比较频繁的话,会涉及到大量的内存复制,降低性能。

2.3 mark-compact

和mark-sweep比较相似,但是其多了一步整理的过程,即将存活对象全部左移,再清理可回收对象。这种方法有效的避免了内存碎片的产生,同时也不会像复制算法变相减小内存空间,但需要频繁的内存移动操作,对性能也有一定的影响。

2.4 分代策略

可见,没有完美的算法,只有适合的算法。几种回收方式各有优劣,因而根据对象回收的实际情况选择对应的回收策略才是机智操作。JVM所采用的回收策略即分代策略,其将内存划分为几个代,每个代都有自己的特点,如新生代需要频繁的GC,大部分对象创建消亡极快,因而实际存活对象很少,使用复制算法可以不用额外处理回收对象,直接清空对应内存即可。而老年代存活几率大,且可能包含一些大对象(之后详细叙述),因而使用mark- compact就更加合适。

JVM的具体回收策略之后会再详细叙述,此处仅提出分代的思想。

2.5 三色标记法

三色标记法本质就是mark-sweep,但在可达性算法标记的时候,采用三色的标记策略,实现并发的回收。实际三色标记是一种标记的策略而非清理,但为了避免结构混乱,放在这里一起讲。

三色标记法将对象分为黑,灰和白三种(你喜欢红黄蓝也一个道理),黑色即为确认存活对象,灰色是当前分析对象,白色是不可达对象或未分析对象。简单来说把根节点标记为灰色push,之后pop出来再做可达性分析,把指向节点染灰并压栈,原节点染黑压黑栈,这样最终遍历完,清理白色节点。

但实际情况没有这么简单,Golang采用的就是三色标记法,原因和具体细节请继续往下看~

二、Java垃圾回收机制

1、Java分代策略

Java使用的是分代策略,分代策略的最大好处就是因地制宜,根据对象不同情况选择不同解决方案。

线程共享方法区和堆区,主要动态创建都在堆上,因而堆也是主要的回收空间。

JVM将堆区分为两个部分,新生代和老年代,新生代存新对象,老年代存老对象,但具体的抉择调度很复杂。

堆空间三分之一为新生代,初始创建的绝大部分对象都在新生代,而新生代又分为三个区域,分别是4/5的Eden区,和两个1/10的交替区,图中称from,to区,实际上可以理解为,两个区一模一样,有的文章会把他们叫做S0和S1,或者其他叫法,但实际上这两个区交替使用,没有差别。

2、Java GC简要过程

每次执行Minor GC,都会清理新生代区域,具体做法是:

每次创建对象都在Eden区中,如当前from区包含上次GC都剩余存活对象,则此次GC清理Eden区和from区,将两个区中所有的存活对象转移到to区中,并清空Eden区和from区。下次GC则将Eden和to的存活对象,转移到from。这样就很清晰了,实际上这是一种复制算法的改进策略,即不直接进行二分分块,而是用Eden做缓冲。当然这种做法的前提是,新生代98%的对象都是创建即销毁的,可以理解为挺不过一次GC,所以虽然每次存活对象最大区域才是新生代的1/10,但空间也是足够使用的。同时如果空间不够,会有其他策略进行处理,往老年代转移。

好处就是不会产生内存碎片,而且由于使用复制算法,存活对象比例较低,性能也较强,但需要注意,这些操作都是在STW的前提下的,即执行GC需要挂起所有线程去清理堆区。而Minor GC速度较快,STW时间极短,因而几乎无法察觉。但在高并发的网络服务面前,这就是一个致命的缺点,这也是Go不是用分代策略,使用三色标记法的根本原因之一。

Minor GC 清理新生代Major GC 清理老年代Full GC 全清

3、对象转移策略

而实际上,当from或to区不足以存储的时候,JVM就会将对象转移到老年代,此外当对象存活过15次GC后也会被送入老年代,一些大对象,如超长字符串数组也会被直接安排到老年代,而不会在新生代中频繁GC复制。

当执行一次Minor GC后,会根据之前每次Minor GC转移到老年代的对象空间进行判断,如老年代还剩1MB的空间,但之前每次Minor GC平均会将2MB的对象送入老年代,这时JVM就会出现担保风险,即它无法担保老年代剩余空间足够应付Minor GC转移过来的对象,所以此时会进行一次Major GC以释放老年代的空间。但可能这次只转移1KB的对象,但由于担保机制,JVM还是会执行Major GC。

三、Golang垃圾回收机制

1、标记-清除法(v1.3之前)

初代的golang垃圾回收机制非常简陋,即go runtime在一定条件下(主动或被动),暂停所有任务(STW),执行mark&sweep操作,执行完清理过程后再启动任务的执行。在需要回收较多废弃对象的时候,会出现较长时间的STW停顿。go设计初衷便是为了支持CSP模型下的并发任务处理,同时原生高程度的web支持也使其适用于应对轻量级web服务的搭建,因而v1.3之前的STW对于高并发网络服务是难以忍受的,因而只能通过控制内存分配数量,或手动管理来解决高并发场景,golang针对这一问题在后续不断的进行了改进。

2、标记-STW-清除法(v1.3)

实际使用STW的原因在于,需要在标记阶段去扫描所有对象,以分辨是否需要回收。如果此时不作限制,放任程序变更对象状态,便会导致错误的gc。而清除阶段由于不再需要扫描整个程序的对象状态,因而实际上在清除阶段是不需要STW的,所以在v1.3版本,go runtime分离了mark和sweep的操作,mark期间执行STW,结束后并发执行gc和其他业务逻辑。同时如果存在多核处理器,go会试图使用额外的核心处理gc,尽量不影响业务代码的运行。

3、三色标记法(v1.5之后)

此时,golang的gc是“非分代的、非移动的、并发的、三色的标记清除垃圾回收器”,这种gc方式简称三色标记法。

三色标记法大体过程如下:

1.创建白,灰,黑三个集合,并将所有对象放入白色集合2.从根节点遍历对象(非递归,类似广度优先),将遍历到的对象转移到灰色集合3.遍历灰色对象,将引用对象转移到灰色集合,原灰色对象转移到黑色集合,重复直至无灰色对象4.通过写屏障检测对象变化5.清理白色对象

三色标记法易于实现并发回收,即在程序运行的同时进行gc,不需要长时间暂停整个程序。这种mark操作可以渐进执行而不需要每次扫描整个内存,有效减少STW时间。

但此时也会有很多问题存在,当同时出现以下情况,就会出现错误的gc:

变更引用,导致白色对象被黑色对象引用破坏引用,导致白色对象与灰色对象的可达性关系被破坏

此时白色对象本质上是存活对象,但由于无法再被扫描到,所以会被误清除。因而我们需要一些额外的机制,在尽量少的STW的前提下,保证gc的准确性和可靠性。

其实主要的问题就在于对象引用状态的变更,只要破坏以上某一点,就可以保证回收的稳定性

3.1 Dijkstra插入屏障

在增加引用的时候,如果某个对象被引用,则需放入灰色集合,而不能放入白色集合。这样就保证无论如何,不会出现黑色引用白色的情况。

但插入屏障存在两个问题:

1.插入屏障在一次回收过程中可能会有残留对象存在,导致删除引用后,对象直到下一次gc才能被回收。2.在标记阶段,每次的引用插入操作都需要调用插入屏障,进而导致性能下降。因而go团队将插入屏障应用到了堆区的回收中,栈中使用原三色标记法。这就导致为了gc的准确性,需要在完成标记时启动STW对栈区再次扫描。

3.2 Yuasa删除屏障

在删除引用的时候,如果被删除的对象自身为白色或灰色,会被标记为灰色。这就保持删除引用时,始终保持一条灰色可达的通路,进而不会出现破坏引用,导致白色对象不可达的情况。但回收精度不高,对象即使被删除,引用指针依然可以存活一轮。GC开始时需要STW扫描堆栈来记录初始快照,从而保护初始状态下的存活对象。

3.3 混合写屏障(v1.8之后)

混合写屏障结合两者特点,通过以下方式实现并发稳定的gc:

1.将栈上的对象全部扫描并标记为黑色2.GC期间,任何在栈上创建的新对象,均为黑色。3.被删除的对象标记为灰色。4.被添加的对象标记为灰色。

由于要保证栈的运行效率,混合写屏障是针对于堆区使用的。即栈区不会触发写屏障,只有堆区触发,由于栈区初始标记的可达节点均为黑色节点,因而也不需要第二次STW下的扫描。本质上是融合了插入屏障和删除屏障的特点,解决了插入屏障需要二次扫描的问题。同时针对于堆区和栈区采用不同的策略,保证栈的运行效率不受损。

此时golang包含混合写屏障的并发三色标记法正式形成,“非分代的、非移动的、并发的、三色的标记清除垃圾回收器”能够避免STW,适应高并发场景,效率较高。

四、总结

综上,本文从大方向介绍了普遍的GC思想,并描述了Java与Golang采用的不同GC策略。

通过对比可以发现,两种语言采用的GC各有优劣。为了适应业务,做出很多独特的优化和改进,使语言在适用领域能够最大程度发挥作用。

Java发展时间长,GC机制较完善,效率高,碎片化小,兼顾了各种场景下的垃圾回收,充分利用堆栈空间。而Golang将更多的精力放在了并发解决上,也是其一贯的设计思想,相对Java来说机制不够完善,但针对并发的处理有独特的方式,因而或许更适合高并发的高GC的业务场景。

因此,Golang的三色标记法,是“非分代的、非移动的”(不同于Java),同时也是高度“并发的”回收机制。同时Golang团队也在不停的优化GC策略,包括一些异步的抢占式调度等,相信Golang会越来越好!

References

[1] 知乎 海纳 GC算法之引用计数: https://zhuanlan.zhihu.com/p/27939756
[2] 知乎 咱们从头到尾说一次 Java 垃圾回收: https://zhuanlan.zhihu.com/p/73628158



推荐阅读


福利

我为大家整理了一份从入门到进阶的Go学习资料礼包,包含学习建议:入门看什么,进阶看什么。关注公众号 「polarisxu」,回复 ebook 获取;还可以回复「进群」,和数万 Gopher 交流学习。

浏览 31
点赞
评论
收藏
分享

手机扫一扫分享

举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

举报