golang gc
https://zhuanlan.zhihu.com/p/297177002
https://liangyaopei.github.io/2021/01/02/golang-gc-intro/
https://www.zhihu.com/column/c_1131869478647238656
https://mp.weixin.qq.com/s/AaHk-yg8D4atbO-zVAvhKQ
Golang
等较新的语言采取的都是自动垃圾回收方式,程序员只需要负责申请内存,垃圾回收器会周期性释放结束生命周期的变量所占用的内存空间。
垃圾回收器主要包括三个目标:
- 无内存泄漏:垃圾回收器最基本的目标就是减少防止程序员未及时释放导致的内存泄漏,垃圾回收器会识别并清理内存中的垃圾
- 自动回收无用内存:垃圾回收器作为独立的子任务,不需要程序员显式调用即可自动清理内存垃圾
- 内存整理:如果只是简单回收无用内存,那么堆上的内存空间会存在较多碎片而无法满足分配较大对象的需求,因此垃圾回收器需要重整内存空间,提高内存利用率
垃圾回收的常见方法
根据判断对象是否存活的方法,可以简单将GC
算法分为“引用计数式”垃圾回收和“追踪回收式”垃圾回收。前者根据每个对象的引用计数器是否为0
来判断该对象是否为未引用的垃圾对象,后者先判断哪些对象存活,然后将其余的所有对象作为垃圾进行回收。追踪回收本身包括标记-清除Mark-Sweep
、标记-复制Mark-Copy
和标记-整理Mark-Compact
三种回收算法。
1. 引用计数
引用计数Reference counting
会为每个对象维护一个计数器,当该对象被其他对象引用时加一,引用失效时减一,当引用次数归零后即可回收对象。使用这类GC
方法的语言包括python
、php
、objective-C
和C++
标准库中的std::shared_ptr
等。
引用计数法优点包括:
- 原理和实现都比较简单
- 回收的即时性:当对象的引用计数为
0
时立即回收,不像其他GC
机制需要等待特定时机再回收,提高了内存的利用率 - 不需要暂停应用即可完成回收
缺点包括:
- 无法解决循环引用的回收问题:当
ObjA
引用了ObjB
,ObjB
也引用ObjA
时,这两个对象的引用次数使用大于0
,从而占用的内存无法被回收 - 时间和空间成本较高:一方面是因为每个对象需要额外的空间存储引用计数器变量,另一方面是在栈上的赋值时修改引用次数时间成本较高(原本只需要修改寄存器中的值,现在计数器需要不断更新因此不是只读的,需要额外的原子操作来保证线程安全)
- 引用计数是一种摊销算法,会将内存的回收分摊到整个程序的运行过程,但是当销毁一个很大的树形结构时无法保证响应时间
2. 追踪基础:可达性分析算法
尽管前面提到的三种追踪式垃圾回收算法实现起来各不相同,但是第一步都是通过可达性分析算法标记Mark
对象是否“可达”。一般可到达的对象主要包括两类:
GC Root
对象:包括全局对象、栈上的对象(函数参数与内部变量)- 与
GC Root
对象通过引用链Reference Chain
相连的对象
对于“不可达”的对象,我们可以认为该对象为垃圾对象并回收对应的内存空间。
同引用计数法相比,追踪式算法具有如下优点:
- 解决了循环引用对象的回收问题
- 占用空间更少
缺点包括:
- 同引用计数相比无法立刻识别出垃圾对象,需要依赖
GC
线程 - 算法在标记时必须暂停整个程序,即
Stop The World, STW
,否则其他线程的代码会修改对象状态从而回收不该回收的对象
3. 标记-清除算法
标记-清除Mark-Sweep
算法是最基础的追踪式算法,分为“标记”和“清除”两个步骤:
- 标记:记录需要回收的垃圾对象
- 清除:在标记完成后回收垃圾对象的内存空间
优点包括:
- 算法吞吐量较高,即运行用户代码时间 / (运行用户代码时间 + 运行垃圾收集时间)较高。与引用计数器相比,不需要记录每次引用次数变更,只需要在清除垃圾时遍历一次
- 空间利用率高:同标记-复制相比不需要额外空间复制对象,也不需要像引用计数算法为每个对象设置引用计数器
缺点包括:
- 清除后会产生大量的内存碎片空间,导致程序在运行时可能没法为较大的对象分配内存空间,导致提前进行下一次垃圾回收
4. 标记-复制算法
标记-复制Mark-Copy
算法将内存分成大小相同的两块,当某一块的内存使用完了之后就将使用中的对象挨个复制到另一块内存中,最后将当前内存恢复未使用的状态。
优点包括:
- 标记-清除法需要在清除阶段对大量垃圾对象进行扫描,标记-复制则只需要从
GC Root
对象出发,将“可到达”的对象复制到另一块内存后直接清理当前这块的内存,因此提升了垃圾回收的效率(不care垃圾内存,但是复制的时间成本不高?) - 解决了内存碎片化的问题,防止分配较大连续空间时的提前
GC
问题
缺点包括:
- 同标记-清除法相比,在“可达”对象占比较高的情况下有复制对象的开销(所以耗时并不是个稳定的优点
- 内存利用率较低,相当于可利用的内存仅有一半
5. 标记-整理算法
标记-整理Mark-Compact
算法综合了标记-清除法和标记-复制法的优势,既不会产生内存碎片化的问题,也不会有一半内存空间浪费的问题。该方法首先标记出所有“可达”的对象,然后将存活的对象移动到内存空间的一端,最后清理掉端边界以外的内存。
优点包括:
- 避免了内存碎片化的问题
- 在对象存活率较高的情况下,标记-整理算法由于不需要复制对象效率更高(移动不是复制?),因此更加适合老年代算法
缺点包括:
- 整理过程较为复杂,需要多次遍历内存导致
STW
时间比标记-清除算法更长
6. 三色标记法
前面提到的“标记”类算法都有一个共同的瑕疵,即在进行垃圾回收的时候会暂停整个程序(STW
问题)。三色标记法是对“标记”阶段的改进,在不暂停程序的情况下即可完成对象的可达性分析。GC
线程将所有对象分为三类:
- 白色:未搜索的对象,在回收周期开始时所有对象都是白色,在回收周期结束时所有的白色都是垃圾对象
- 灰色:正在搜索的对象,但是对象身上还有一个或多个引用没有扫描
- 黑色:已搜索完的对象,所有的引用已经被扫描完
具体的实现如下:
- 初始时所有对象都是白色对象
- 从
GC Root
对象出发,扫描所有可达对象并标记为灰色,放入待处理队列 - 从队列取出一个灰色对象并标记为黑色,将其引用对象标记为灰色放入队列
- 重复上一步骤,直到灰色对象队列为空
- 此时所有剩下的白色对象就是垃圾对象
优点:
- 不需要暂停整个程序进行垃圾回收
缺点:
- 如果程序垃圾对象的产生速度大于垃圾对象的回收速度时,可能导致程序中的垃圾对象越来越多而无法及时收集
- 线程切换和上下文转换的消耗会使得垃圾回收的总体成本上升,从而降低系统吞吐量
读写屏障技术
1. 三色标记法的并发性问题
假设三色标记法和用户程序并发执行,那么下列两个条件同时满足就可能出现错误回收非垃圾对象的问题:
- 条件1:某一黑色对象引用白色对象
- 条件2:对于某个白色对象,所有和它存在可达关系的灰色对象丢失了访问它的可达路径
一种最简单解决三色标记并发问题的方法是停止所有的赋值器线程,保证标记过程不受干扰,即垃圾回收器中常提到的STW, stop the world
方法。另外一种思路就是使用赋值器屏障技术使得赋值器在进行指针写操作时同步垃圾回收器,保证不破坏弱三色不变性
3. 读写屏障技术
屏障技术:给代码操作内存的顺序添加一些限制,即在内存屏障前执行的动作必须先于在你内存屏障后执行的动作。
使用屏障技术可以使得用户程序和三色标记过程并发执行,我们只需要达成下列任意一种三色不变性:
- 强三色不变性:黑色对象永远不会指向白色对象
- 弱三色不变性:黑色对象指向的白色对象至少包含一条由灰色对象经过白色对象的可达路径
GC
中使用的内存读写屏障技术指的是编译器会在编译期间生成一段代码,该代码在运行期间用户读取、创建或更新对象指针时会拦截内存读写操作,相当于一个hook
调用,根据hook
时机不同可分为不同的屏障技术。由于读屏障Read barrier
技术需要在读操作中插入代码片段从而影响用户程序性能,所以一般使用写屏障技术来保证三色标记的稳健性。
我们讲内存屏障技术解决了三色标记法的STW
缺点,并不是指消除了所有的赋值器挂起问题。需要分清楚STW
方法是全局性的赋值器挂起而内存屏障技术是局部的赋值器挂起。
4. Dijkstra插入写屏障
我们先看来下下面的伪代码:
writePointer(slot, ptr):
shade(ptr)
*slot = ptr
总结:
如果是纯粹的插入写屏障是满足强三色不变式的(永远不会出现黑色对象指向白色对象);但是由于栈上对象无写屏障(不 hook),那么导致黑色的栈可能指向白色的堆对象,所以必须假定赋值器(mutator)是灰色赋值器,扫描结束之后,必须 STW 重新扫描栈才能确保不丢对象;STW 重新扫描栈再 goroutine 量大且活跃的场景,延迟不可控,经验值平均 10-100ms;golang 1.5 之后实现的就是这种类型的插入写屏障。
5. Yuasa删除写屏障
Yuasa-style 屏障
我们先看来下下面的伪代码:
writePointer(slot, ptr)
shade(*slot)
*slot = ptr
总结:
删除写屏障也叫基于快照的写屏障方案,必须在起始时,STW 扫描整个栈(注意了,是所有的 goroutine 栈),保证所有堆上在用的对象都处于灰色保护下,保证的是弱三色不变式;由于起始快照的原因,起始也是执行 STW,删除写屏障不适用于栈特别大的场景,栈越大,STW 扫描时间越长,对于现代服务器上的程序来说,栈地址空间都很大,所以删除写屏障都不适用,一般适用于很小的栈内存,比如嵌入式,物联网的一些程序;并且删除写屏障会导致扫描进度(波面)的后退,所以扫描精度不如插入写屏障;
混合写屏障
golang 1.5 之后已经实现了插入写屏障,但是由于栈对象赋值无法 hook 的原因,导致扫描完之后还有一次 STW 重新扫描栈的整机停顿,混合写屏障就是解决这个问题的。
我们先看下论文中的伪代码:
writePointer(slot, ptr):
shade(*slot)
if current stack is grey:
shade(ptr)
*slot = ptr
我们先看golang 实际实现的伪代码:
writePointer(slot, ptr):
shade(*slot)
shade(ptr)
*slot = ptr
总结:
混合写屏障继承了插入写屏障的优点,起始无需 STW 打快照,直接并发扫描垃圾即可;混合写屏障继承了删除写屏障的优点,赋值器是黑色赋值器,扫描过一次就不需要扫描了,这样就消除了插入写屏障时期最后 STW 的重新扫描栈;混合写屏障扫描精度继承了删除写屏障,比插入写屏障更低,随着带来的是 GC 过程全程无 STW;混合写屏障扫描栈虽然没有 STW,但是扫描某一个具体的栈的时候,还是要停止这个 goroutine 赋值器的工作的哈(针对一个 goroutine 栈来说,是暂停扫的,要么全灰,要么全黑哈,原子状态切换);