GC 垃圾回收
- 虽然有垃圾收集的成本,但是却没有内存管理的负担。Go 语言同时兼顾了开发和运行效率,垃圾收集器是实现这一目的的重要组成部分。
- Java 中有2种 GC 实现,分别是 ZGC 与 CMS (Concurrent Mark Sweep collector)。Golang 1.8版本开始使用的是 (Mark + Sweep + Hybrid write barrier) ,因此与 CMS 更加相似。关于 ZGC 与 CMS 的区别,基于 ZGC 是如何实现的,暂时还没有过多了解,可以参考一些更专业的文档。
- 1.3以前的版本使用标记-清理的方式,整个过程都需要STW。
- 1.3版本分离了标记和清理的操作,标记过程STW,清理过程并发执行。
- 1.5版本在标记过程中使用三色标记法。回收过程主要有四个阶段,其中,标记和清理都并发执行的,但标记阶段的前后需要STW一定时间来做GC的准备工作和栈的re-scan。同时引入了写入屏障(write-barrier)
- 1.8版本在引入混合屏障(Hybrid write barrier)rescan来降低mark termination的时间
- 在4次大的演进过程中,主体思想并未改变,即:标记+清除。这其中改变背后均是为了解决2个问题:
- 最大化降低 STW 的时间。stop the world, 指整个进程中应用程序逻辑停止,CPU在执行 GC 的操作,这是会影响到应用程序的响应延迟及吞吐量。
- 主要通过优化 Mark 的实现来提升其执行效率,其次优化 Sweep 的执行效率。
- Mark and Sweep (标记、清除)是什么?
- 标记:标记出不可达对象,认定为程序不再需要,可以进行内存回收,即:垃圾回收。
- 清除:在标记出哪些内存是可以回收后,这一阶段就专注与进行清理回收操作。因为要尽可能减少 STW 的时间,现在的版本已经把 sweep 阶段设计为并行执行了,即与应用程序逻辑同时执行,不再需要 STW,以尽可能减少其对应用程序的影响。
- Mark + Sweep 垃圾回收机制与PHP中的通过引用计数实现的垃圾回收是2种不同的机制。引用计数需要在每一个新创建的变量附上一个引用计数变量,而标记清除法是一种定时清理机制,其把内存的申请与内存的回收进行里分离。
- 垃圾收集器的大致步骤 v1.5 / v1.8 :
- 把全部根对象标记为:灰色。
- 根对象的定义是什么?什么对象算根对象?待确认!!!!!
- 然后从灰色对象集合中取出一个对象,将其标记为黑色,将该对象引用到的对象标记为灰色。
- 如何定义引用?什么样的关系算引用?在下文会提到,是遍历内存图,因此与其变量作用域并没有直接关系。应该是内存指针的指向。
- 重复上面的步骤2,直到灰色集合为空为止。
- 结束后,剩下没有被标记的白色对象即为 根对象不可达,可以进行回收。
- 把全部根对象标记为:灰色。
- 三色标记法,三种颜色的区分:
- 黑色:该对象已经被标记过,且该对象下直接引用对象也全部都被标记过了。(程序所需要的对象,不需要回收)
- 灰色:该对象已经被标记过,但该对象下直接引用对象并没有全部标记完。(GC 需要继续迭代标记,这也是一种中间状态)
- 白色:该对象没有被标记过。因为新创建的对象默认是白色,所以不可达对象也是有颜色的,即为默认的白色。(程序不在需要的对象,称之为垃圾,需要被回收)
由于 Go1.5版本 GC 调整相对较大,因此针对 Go1.5 版本中 GC 的设计思路进行一定的概括与总结。
-
如果只使用单纯的三色标记法,由于为了减少STW的时间,标记与应用程序是并行执行的,因此会存在如下的问题:
- 多标-浮动垃圾问题:部分本应该回收,但是没有回收到的内存,被称之为“浮动垃圾”。
- 漏标-悬挂指针问题:部分本不应该回收,但是没有被标记为黑色,导致被当作垃圾进行清除。这个直接影响到了应用程序的正确性,是不可接收的。这也是 Go 需要在GC 时解决的问题。
-
为了解决上面实现中存在的悬挂指针问题,就需要引入屏障技术来保障数据的一致性。
- 实现思路就是通过新增一些限制条件来实现标记算法的正确性。规则如下:
- 强三色不变性:黑色对象不会指向白色对象,只会指向灰色对象或者黑色对象。
- 弱三色不变性:即使黑色对象指向白色对象,那么从灰色对象出发,总存在一条可以找到该白色对象的路径。
- 内存屏障 (memory barrier),是一种屏障指令,它能使CPU或编译器对在该屏障指令之前和之后发出的内存操作强制执行排序约束,在内存屏障前执行的操作一定会先与内存屏障后执行的操作。根据其定义,其与写屏障是完全不用的实现方式,具体是如何使用的呢?还需要进一步确认!!!
- 写屏障(write barrier),实现过程是通过在
writePointer(slot, ptr)
方法调用中加入shade
操作来实现:如果该对象是白色的话,shade(ptr)
会将对象标记成灰色,这样可以保证强三色不变性,它会保证 ptr 指针指向的对象在赋值给*slot
前不是白色。- 优点:实现非常简单,并且也能保证强三色不变性。
- 缺点:《Proposal: Eliminate STW stack re-scanning》中指出其确定与改进建议。因为栈上的对象在垃圾收集中也会被认为是根对象,所以要么为栈上的对象增加写屏障,但是这会大幅度增加写入指针的额外开销;要么当发生栈上的写操作时,将其标记为恒灰(permagrey)
- 实现思路就是通过新增一些限制条件来实现标记算法的正确性。规则如下:
-
在重新扫描的过程中(re-scan)必须保证对象的引用不会改变,因此会进行暂停程序(STW),将所有栈对象标记为灰色并重新扫描,这通常会消耗10-100ms的时间。
writePointer(slot, ptr): shade(ptr) *slot = ptr
Go1.8 中使用 Hybrid write barrier,其是结合了 Yuasa write barrier 和 Dijkstra write barrier ,其中原始提议文档为:Proposal: Eliminate STW stack re-scanning。这样做不仅简化 GC 的流程,同时减少标记终止阶段的重扫成本。混合写屏障的基本思想是:
the write barrier shades the object whose reference is being overwritten, and, if the current goroutine's stack has not yet been scanned, also shades the reference being installed.
writePointer(slot, ptr):
shade(*slot)
if current stack is grey:
shade(ptr)
*slot = ptr
同时,在GC的过程中所有新分配的对象都会立刻变为“黑色”.
Go 垃圾收集器的行为分位两大阶段Mark(标记)阶段和Sweep(清理)阶段。Mark 阶段又分为三个步骤,其中两个阶段会有 STW(Stop The World);另一个阶段也会有延迟,从而导致应用程序延迟并降低吞吐量,这三个步骤是:
- Mark Setup 阶段 - STW
- Marking 阶段 - 并发执行
- Mark 终止阶段 - STW
- 垃圾收集开始时,必须执行的一个动作是打开写屏障(write barrier)。写屏障的目的是允许垃圾收集器在垃圾收集期间维护堆上的数据完整性,因为垃圾收集器和应用程序将并发执行。
- 为了打开写屏障,必须停止每个 goroutine。此动作通常非常快,平均在 10-30us 之内完成。
- 为了暂停所有的goroutine,唯一的方法是让垃圾收集器观察并等待每个 goroutine 进行函数调用。等待函数调用是为了保证 goroutine 停止时处于安全点。如果其中一个goroutine (比如,其在 P4 上执行) 不进行函数调用而其他 goroutine 执行函数调用,这种情况下会发生什么呢?这个 goroutine 停止之前,垃圾收集器无法启动。更糟糕的是,当垃圾收集器等待 P4 时,其他 P 也无法提供服务。所以 goroutines 在合理的时间范围内进行函数调用对于 GC 来首是至关重要的。
- 一旦写屏障打开,垃圾收集器就开始标记阶段。垃圾收集器所做的第一件事是占用 25% 的CPU。垃圾收集器使用 goroutines 进行垃圾收集工作,这意味着对对于一个4线程的Go程序,一个 P 将专门用于垃圾收集工作。
- 标记阶段需要标记在堆内存中仍然在使用中的值。
- 首先检测所有当前 goroutines 的堆栈,以找到堆内存的根指针。
- 然后,收集器必须从那些根指针遍历内存图,标记正在使用的内存,其余未标记的则认为是可以回收的内存。(标记的算法就是三色标记法)
- 为了解决程序中存在大量分配内存的goroutine,导致回收速度小于分配速度,最终达到堆内存的极限,则会触发操作系统强制杀死该进程,因此引入了 Mark Assit (协助标记)角色。Mark Assist 有助于更快地完成垃圾收集。
- 如果垃圾收集器确定需要减慢内存分配,原本运行应用程序的 Goroutines 会协助标记工作。应用程序 Goroutines 成为 Mark Assist 中的时间长度与它申请的堆内存成正比。
- 选择 Assist 的方法很巧妙,就是谁在快速分配内存,就把他的 CPU 时间分配一部分用来执行标记操作。这样做一方面减缓了内存的分配,同时又提高了内存的标记。这是非常好的思路,值得我们在设计其他技术方案是参考。
- 垃圾收集器的一个设计目的是减少对 Mark Assist 的需求。如果任何本次垃圾回收最终需要大量的Mark Assist 才能完成工作,则垃圾收集器会提前开始下一个垃圾收集周期。这样可以减少下一次垃圾收集所需的Mark Assist。
- 执行动作:
- 关闭写屏障
- 执行各项清理任务
- 计算下一个垃圾回收周期的目标。
- 一直处于循环中的 goroutine 也可能导致 STW 延长(类似 Mark Setup的情况)
- 在这个阶段也需要停止所有的 goroutine,这一动作平均在 60-90us 之间完成。这个阶段可以在没有 STW 的情况下完成,但是使用 STW 的代码更简单。
- 一旦收集完成,应用程序Goroutines 就可以再次使用所有的 P,应用程序将恢复到油门全开的状态。
- 清理阶段用于回收标记阶段中标记出来的可回收的内存。
- 当应用程序goroutine 尝试在堆内存中分配新内存时,会触发该操作。
- 清理导致的延迟和吞吐量降低被分散到每次内存分配时。
- 运行时中有GC Percentage的配置选项,默认情况下为100
- 此值表示在下一次垃圾收集必须启动之前可以分配多少新内存的比率。
- 将GC百分比设置为100意味着,基于在垃圾收集完成后标记为活动的堆内存量,下次垃圾收集前,堆内存使用可以增加100%。
- GC trace
- 在运行任何Go应用程序时,可以通过使用环境变量GODEBUG和gctrace = 1选项生成GC trace。每次发生垃圾收集时,运行时都会将GC trace信息写入stderr。
- 通过GC trace 可以看到类似这样的信息。“通过此GC trace可以看出,在标记工作开始之前,使用中的堆内存量为7MB。标记工作完成后,使用中的堆内存量达到11MB。这意味着在收集过程中有4MB新分配内存。标记工作完成后活动堆内存量为6MB。这意味着在下一次垃圾收集启动前,应用程序可以将堆内存增加到12MB。”
- 可以通过添加gcpacertrace = 1从GC trace中获取更多详细信息。这会导致垃圾收集器打印有关并发起搏器内部状态的信息。
- 减少堆内存的压力是最好的优化方式。 压力可以定义为应用程序在给定时间内分配堆内存的速度。 当堆内存压力减小时,垃圾收集器造成的影响会减少。减少GC延迟的方法是从应用程序中识别并去掉不必要的内存分配。
- 以下做法可以帮助垃圾收集器:
- 尽可能保持最小的堆。
- 最佳的一致的起博频率。
- 保持在每次收集的目标之内。
- 最小化每次垃圾收集的STW和Mark Assist的持续时间
- 所有这些都有助于减少垃圾回收造成延迟,也将提高应用程序的性能和吞吐量。 垃圾收集的频率与此无关
- 关于Golang GC的一些误解--真的比Java算法更领先吗?https://mp.weixin.qq.com/s/eDd212DhjIRGpytBkgfzAg
- 两万字长文带你深入Go语言GC源码 https://zhuanlan.zhihu.com/p/359582221
- Garbage Collection In Go : Part I - Semantics https://www.ardanlabs.com/blog/2018/12/garbage-collection-in-go-part1-semantics.html
- Visualizing Garbage Collection Algorithms https://spin.atomicobject.com/2014/09/03/visualizing-garbage-collection-algorithms