关于Java与Golang的GC
本文只做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
推荐阅读