一个更头疼的问题,怎么判定某个事物或程序中的空间是垃圾?

  • 现实中:

    1、首先,这个是一个不好掌控的问题,大爷终究是大爷,他可以说路边的杂草是垃圾,他也可以不去管理这些杂草。2、但是,社会共识会告诉我们每一个人,什么是垃圾,什么不是垃圾,什么可以直接收集到垃圾站(烟头、纸屑),什么事物最好不要(掉落的身份证)。

1、首先,这个是一个不好掌控的问题,大爷终究是大爷,他可以说路边的杂草是垃圾,他也可以不去管理这些杂草。2、但是,社会共识会告诉我们每一个人,什么是垃圾,什么不是垃圾,什么可以直接收集到垃圾站(烟头、纸屑),什么事物最好不要(掉落的身份证)。

  • 程序中:

    1、程序本身并不知道,什么是垃圾,什么不是。2、我们需要告诉程序,什么是垃圾。3、标记对象是否是垃圾的方法大致分为两类:引用计数式:在对象上标记一个数字,记录有多少变量在使用它, Python,PHP等语言的GC实现就是这个方式。(引用计数式)。

    追踪式:从根对象出发,根据对象之间的引用信息,一步步推进直到扫描完毕整个堆并确定需要保留的对象,从而回收所有可回收的对象。 Golang、 Java的实现等均为追踪式 GC。

1、程序本身并不知道,什么是垃圾,什么不是。2、我们需要告诉程序,什么是垃圾。3、标记对象是否是垃圾的方法大致分为两类:引用计数式:在对象上标记一个数字,记录有多少变量在使用它, Python,PHP等语言的GC实现就是这个方式。(引用计数式)。

追踪式:从根对象出发,根据对象之间的引用信息,一步步推进直到扫描完毕整个堆并确定需要保留的对象,从而回收所有可回收的对象。 Golang、 Java的实现等均为追踪式 GC。

优点: 1、引用计数法可以在对象不活跃时(引用计数为0)立刻回收其内存。因此可以保证堆上时时刻刻都没有垃圾对象的存在(先不考虑循环引用导致无法回收的情况)。 2、引用计数法的最大暂停时间短。由于没有了独立的GC过程,而且不需要遍历整个堆来标记和清除对象,取而代之的是在对象引用计数为0时立即回收对象,这相当于将GC过程“分摊”到了每个对象上,不会有最大暂停时间特别长的情况发生。

劣势:

优点:

缺点:

首先,Go 的 GC 目前使用的是无分代(对象没有代际之分)、不整理(回收过程中不对对象进行移动与整理)、并发(与用户代码并发执行)的三色标记清扫算法。可见,go在追踪式的GC模式中,引入了并发和 三色标记清扫算法(V1.5),在go的不断调优下,已经是做到了准实时(1ms以内)的gc过程。

STW

我们知道,在追踪式的GC过程中,我们需要进行两步操作,分别是标记和 清除,为了避免程序本身运行给GC标记和清除带来不一致性,导致误删,为了保证一致性,golang会停止除了GC模块程序之外的程序运行,这个过程被称为 STW。

在这个过程中整个用户代码被停止或者放缓执行, STW越长,对用户代码造成的影响(例如延迟)就越大,早期 Go 对垃圾回收器的实现中 STW的停顿时间甚至是达到s级,对时间敏感的实时通信等应用程序会造成巨大的影响。举例:

package mainimport ( &quotruntime" &quottime")func main { go func { for { } } time.Sleep(time.Millisecond) runtime.GC println(&quotOK")}

在这行代码中,程序步骤大概如下:

结果:不会有打印结果(v1.14以前)。v1.14之后可以打印。

原因就在于GC中,会试图去等待其他goroutine所有的用户代码停止,但显然,示例中的代码因为for {}无法停止,导致始终无法进入 STW 阶段,造成程序卡死。

可见,如果实际业务中在,当某个goroutine的代码一直得不到停止,就会导致程序一直停留在STW阶段而无法执行GC,造成程序卡死或其他问题。

STW优化的版本迭代

既然gc stw的持续时间,直接影响到程序中因为gc所带来的负面影响,那我们需要想办法去缩短stw所持续的时间,所以,go在各个版本中,便对gc的过程做了一定的优化。

go V1.1

在这个过程中,gc过程是串行的,stw的时间 = mark时间+ sweep时间

go V1.3版本的优化就是Mark和Sweep分离. Mark STW, Sweep并发。 也就是大致变成了一下流程:

可以看到,这个时候的stw只存在于mark阶段,且sweep清扫阶段变成了并发执行。

go V1.5 再次将mark也变成了并发的。

go1.8 整合插入屏障和删除屏障为混合写屏障,将STW的停顿时间真正进入到毫秒级。

go 1.14:替引入了异步抢占,解决了由于密集循环导致的 STW 时间过长的问题。也就是上文示例所示的因为GC等待用户代码停止时间过长的问题。

mark(标记)是如何实现的?

三色标记法

从垃圾回收器的视角来看,三色标记法是一种抽象,它规定了三种不同类型的对象,并用不同的颜色相称:

  • 白色对象(可能死亡):未被回收器访问到的对象。在回收开始阶段,所有对象均为白色,当回收结束后,白色对象均不可达。
  • 灰色对象(波面):已被回收器访问到的对象,但回收器需要对其中的一个或多个指针进行扫描,因为他们可能还指向白色对象。
  • 黑色对象(确定存活):已被回收器访问到的对象,其中所有字段都已被扫描,黑色对象中任何一个指针都不可能直接指向白色对象。

回收器(Collector):负责执行垃圾回收的代码。对应的还有赋值器。

赋值器

赋值器(Mutator):这一名称本质上是在指代用户态的代码。因为对垃圾回收器而言,用户态的代码仅仅只是在修改对象之间的引用关系,也就是在对象图(对象之间引用关系的一个有向图)上进行操作。

追踪式GC方法提到一个很重要的内容,那就是根对象。那什么是根对象呢?

根对象在垃圾回收的术语中又叫做根集合,它是垃圾回收器在标记过程时最先检查的对象,包括:

也就是说,三色标记法是从根对象出发,不断地把白色对象(可能死亡)一步步标记为灰色(确认存活,还需要扫描)、黑色对象(存活)的过程。

可以简单理解为(不是真正的go gc的逻辑):

动图示例(图中的颜色不是黑灰白,而是使用蓝黄白相对应):

可以看到,以上的mark过程即2-9的过程,同样处于一个stw的过程中,也就是说,Golang GC真正运行的时候,用户程序是不能够运行的。

为什么需要这样呢,因为GC mark的时候,如果不关闭用户程序的操作,那我们就可能出现以下情况导致错误标记和清除:

其中C是根节点,在gc开始的准备阶段被标记为灰色

时序 回收器 赋值器
1 A是C的子节点,A着色为灰色
2 C的所有子节点遍历完毕,C被着色为黑色
3 C.ref3 = C.ref2.ref1 C关联B
4 A断开于B的关联
5 遍历灰色节点A的所有子节点,因为此时 A.ref1为 nil,所以没有子节点
6 A被着色为黑色
7 回收器:由于所有子节点均已标记,回收器也不会重新扫描已经被标记为黑色的对象,此时 A 被着色为黑色, scan(A)什么也不会发生,进而 B 在此次回收过程中永远不会被标记为黑色,进而错误地被回收。

结果:虽然根节点有指向B的关系,但是B被错误的回收了。

为什么会出现这个情况?很明显,根本原因是当回收器在执行标记的时候, 赋值器也在不断的更改对象之间的关系。所以在mark标记过程中,我们依旧要STW这个过程。

有办法解决这个问题吗?也就是我在mark标记的过程中,赋值器同样可以执行,也就是用户程序可以运行。

写屏障

写屏障是一个在并发垃圾回收器中才会出现的概念,垃圾回收器的正确性体现在: 不应出现对象的丢失,也不应错误的回收还不需要回收的对象。大佬们已经证明,当以下两个条件同时满足时会破坏垃圾回收器的正确性:

  • 条件 1: 赋值器修改对象图,导致某一黑色对象引用白色对象;
  • 条件 2: 从灰色对象出发,到达白色对象的、未经访问过的路径被赋值器破坏。

只要能够避免其中任何一个条件,则不会出现对象丢失的情况,因为:

  • 如果条件 1 被避免,则所有白色对象均被灰色对象引用,没有白色对象会被遗漏;
  • 如果条件 2 被避免,即便白色对象的指针被写入到黑色对象中,但从灰色对象出发,总存在一条没有访问过的路径,从而找到到达白色对象的路径,白色对象最终不会被遗漏。

也就是说,在上面举例中,C黑色节点不能直接指向一个白色节点&quotB",或者灰色节点A不能删除白色节点&quotB"的引用,就不会导致错误回收的问题。

很显然,在go想要把mark过程从stw中分离出来,与业务程序并行,就要解决整个问题,写屏障是go团队最开始的方法。

写屏障是针对赋值器改变对象间引用关系改变时的一种同步机制,有两种非常经典的写屏障:Dijkstra 插入屏障和 Yuasa 删除屏障。

Dijkstra 插入屏障

插入屏障旨在破坏正确性的条件一,也就是黑色对象建立于白色对象的链接。

// 灰色赋值器 Dijkstra 插入屏障func DijkstraWritePointer(slot *unsafe.Pointer, ptr unsafe.Pointer) {` shade(ptr) *slot = ptr}

因为黑色对象不会再被扫描标记,那如果一旦有未扫描的对象被关联到一个黑色对象上,且整个白色对象没有其他关联,就会导致白色对象被标记清除。

所以Dijkstra 插入屏障在建立关系之前,把指针本身着色成灰色,放入待扫描的灰色节点池中,我们知道,mark会扫描标记所有灰色池,所有 灰色最终都会变成黑色而不会被清除。

显然,这可以解决并发mark和用户程序赋值器的不一致性问题,但是它的缺点就是可能会导致应该被删除的对象,在mark过程中因为存在赋值操作,而在本次gc过程中未被回收。

网图说明:

可以看见,在C于B建立关系ref3的时候,A并未扫描到B,但是B已经变成灰色,进而最终被标记成黑色。

Yuasa 删除屏障

插入屏障针对的是条件1,那么删除屏障就是针对的条件2:从灰色对象出发,到达白色对象的、未经访问过的路径被赋值器破坏。。

1. `// 黑色赋值器 Yuasa 屏障` 2. `func YuasaWritePointer(slot *unsafe.Pointer, ptr unsafe.Pointer) {` 3. ` shade(*slot)` 4. ` *slot = ptr` 5. `}`

通过代码可以看到,当赋值器需要删除节点的关联时,会将父节点的颜色shade(*slot)着色成灰色,也就是需要重新扫描。

网图说明:

很明显,删除屏障的缺点就是会带来重复扫描的问题,因为一旦存在删除关系的操作,就需要重新扫描。

但是有缺点没关系,相对优秀就可以。在三色标记法+写屏障的保证下,我们就可以让mark的大部分过程从stw中解放出来,并且可以对mark进行并发操作。

这时候,我们理解的gc流程就可以优化成:

也就是说,stw的过程仅包含了2-4这几个步骤,那stw的时间相对的减少很多,并且mark、sweep的并发操作,可以让整个流程都缩短很多。

写屏障优化

很显然,golang的开发者们知道自己选择的写屏障的优缺点,所以也在版本的更迭中不断的去优化,使其能做到更好。

如上版本迭代所示,golang团队的人员在V1.5进入mark的并发版本(普通写屏障)之后,V1.8就优化了写屏障变成 混合写屏障,于其他的GC优化一起,把Golang GC真正的带入了毫秒级时代。

Golang GC的流程

当然,这只是我们所梳理出来的GC过程,真正的Golang GC流程应该是有出入的,比如mark结束之后应该会有写屏障关闭的阶段,而这个阶段应该也会有一个stw。

当前版本的 Go 以 STW 为界限,可以将 GC 划分为五个阶段(来自:https://www.bookstack.cn/read/qcrao-Go-Questions/GC-GC.md )

阶段 说明 赋值器状态
SweepTermination 清扫终止阶段,为下一个阶段的并发标记做准备工作,启动写屏障 STW
Mark 扫描标记阶段,与赋值器并发执行,写屏障开启 并发
MarkTermination 标记终止阶段,保证一个周期内标记任务完成,停止写屏障 STW
GCoff 内存清扫阶段,将需要回收的内存归还到堆中,写屏障关闭 并发

第一个阶段 gc开始 (stw)

第二阶段 marking(这个阶段,用户程序跟标记携程是并行的)

第三阶段 处理marking过程中修改的指针 (stw)

第四阶段 sweep 清除白色的对象 到这一阶段,所有内存要么是黑色的要么是白色的,清楚所有白色的即可

有没有GC的优劣

首先,我们可以先了解一下GC在那些语言里面有涉及。

有GC模块的语言:

  • Golang
  • Python
  • PHP
  • Java
  • ...

没有GC的语言:

  • C
  • C++

看到这样的集合分类,大致就明白,没有GC的优势:

劣势应该就是需要手动管理内存,开发周期和开发所需要的技术知识储备会要求高一些。

相对的,有GC的优势就是 开发人员可以专心完成业务代码,而不用在内存管理这块花太多心思。同样的,GC所带来的开销肯定会让程序相对没那么快。各有优劣,根据实际情况选择就好。

JAVA的GC

我倒是没怎么使用过Java,因为学习GC简单了解了一下。

java的GC也是基于追踪式方式的,它本身实现的Java GC完成了分代GC的具体实现。

分代GC

简单说,分代GC的目的是减少需要频繁扫描的节点数量,希望每一次标注的扫描都是有意义的。

他的背景是:大多数的对象是不会持久活跃的,而真正持久活跃的对象不用每一次都去参与正常的GC。

他的实现:所以使用新生代、 老年代进行对象进行区分,然后根据不同的频率对不同类型的对象进行扫描回收。

年轻代

几乎所有新生成的对象首先都是放在年轻代的。当一个变量在新生代经历一次GC之后,他的年龄+1。

年老代

在年轻代中经历了N次垃圾回收后仍然存活的对象,就会被放到年老代中。因此,可以认为年老代中存放的都是一些生命周期较长的对象。

分代GC回收

很明显,当对象经过分代后,我们可以用不同的GC,以不同的频率去清理对应代的对象。新生代的就需要频繁一些,而老年代的就可以间隔长一些,不用每次都去扫描,这样可以减少GC过程中需要扫描的对象的额数量。

当然,不管是Java还是Golang的GC,都是需要经过STW这个过程的, 不过经过不断的迭代更新,都已经已达到了用户代码几乎无法感知到的状态。

GOlang 和 Java对GC的调优 Java

可以通过各种参数调优,所以Java的GC好像是必须要熟悉的。

Golang

Go 的 GC 被设计为极致简洁,与较为成熟的 Java GC 的数十个可控参数相比,严格意义上来讲,Go 可供用户调整的参数只有 GOGC 环境变量,他简单来说就是一个阈值,数值越大,GC执行的频率越低。

当然GC调优的核心还是:

看网上的有人说,go的GC优化或许就会基于当前的GC引入分代GC的内容,因为当前GC虽然效率上去了,但是却是用CPU的开销来撑起来的,所以还有优化的空间。