GO学习--GC

GC的认识

  1. 什么是GC,有什么作用?
    答: GC即垃圾回收,是一种自动内存管理的机制。 当程序向操作系统申请的内存不再需要时,垃圾回收自动将其回收并供其他代码进行内存申请时复用,或者将其归还给操作系统。
    通常垃圾回收的执行过程被划分为两个半独立的组件:
  • 赋值器Mutator:指代用户态的代码。修改对象之间的引用关系,即在对象图上进行操作;
  • 回收器Collector:执行垃圾回收代码。
  1. 根对象是什么?
    答:在垃圾回收中术语又叫做根集合,是垃圾回收器在标记过程中最先检查的对象。包括:

    • 全局变量:程序在编译期间就确定的存在于程序整个声明周期的对象;
    • 执行栈:每个goroutine都包含自己的执行栈,这些执行栈上包含栈上的变量即其指向分配的堆内存块的指针。
    • 寄存器:寄存器的值可能表示为一个指针,可能指向某些赋值器分配的堆内存区块。
  2. 常见的GC实现方式有哪些?
    答: 所有的GC算法其存在的形式可以归结为追踪和引用计数两种形式的混合运用。

    • 追踪式GC,从根对象出发,根据对象之间的引用信息,一步步推进直到扫描完毕整个堆并确定要保留的对象。Go Java V8对JS的实现均为追踪式GC
    • 引用计数GC,每个对象自身包含一个被引用的计数器,当计数器归零时自动得到回收。缺陷较高,在追求高性能时不适用。Python,Object-C等均为引用基数。

目前常见的GC实现方式:

  • 标记清扫: 从根对象出发,将确定存活的进行标记,并清扫可回收的对象;
  • 标记整理:为了解决内存碎片而提出,在标记过程中将对象尽可能整理到一块连续的内存上;
  • 增量式:将标记和清扫的过程分批执行,每次执行很小的部分,从而增量的推进垃圾回收,达到近实时无停顿的目的;
  • 增量整理:在增量式基础上增加整理过程;
  • 分代式:将对象根据存活时间长短进行分类。存活时间小于某个值为年轻代,大于某个值为老年代,永远不参与回收的为永久代。根据分代假设进行回收(一个对象存活时间不长更倾向于被回收)
  • 引用计数:根据对象的自身引用计数来回收,归零时立即回收

Go的GC目前使用的是无分代、不整理、并发的三色标记清扫算法。 原因在于:

  • Go运行时的分配算法基于tcmalloc,基本上不存在碎片问题。并且顺序内存分配器在多线程场景下并不适用。因此整理不会带来实质性的性能提升
  • Go编译器会通过逃逸分析将大部分新生对象存储在栈上(栈直接被回收),只有那些需要长期存在的对象才会被分配到需要垃圾回收的堆上。分代假设并没有带来直接优势。并且Go的垃圾回收器和用户代码并发执行,使得STW的时间和对象的代际、size没有关系。Go团队更关注如何更好的并发执行而不是减少停顿时间这一单一目标。
  1. 三色标记法是什么?
    答: 理解三色标记法的关键是理解对象的三色抽象和波面推进。
    • 白色对象(可能死亡),未被回收器访问到的对象。在开始阶段所有对象均为白色,当回收结束后白色对象均不可达
    • 灰色对象(波面):已经被回收器访问的对象,但回收器需要对其中的一个或多个指针进行扫描,因为他们还可能指向白色对象
    • 黑色对象(确定存活):已经被回收器访问的对象且所有字段都已扫描,任何一个指针都不指向白色对象

垃圾回收结束后只有白色和黑色对象。

  1. STW是什么意思?
    答: STOP the World,指在垃圾回收过程中为了保证实现的正确性、防止无止境的内存增长,而不可避免的需要停止赋值器进一步操作对象图的一段过程。
    Go1.14之后STW的时间不会超过半个毫秒。

  2. 如何观察GC?
    答:

    • GODEBUG=gctrace=1
    • go tool trace (调用trace API 可视化展示)
    • debug.ReadGCStats
    • runtime.ReadMemStats
  3. 有了GC,为什么还会发生内存泄露?
    答: 在包含GC的语言中,我们常说的内存泄露是指:预期能很快被释放的内存由于附着在了长期存活的内存上,或生命期意外地被延长,导致预计能够立即回收的内存长时间得不到回收。
    1)预期很快能释放的内存被根对象引用,而没有迅速释放 (比如将某个变量附着在全局变量上,且忽略将其释放)

    var cache = map[interface{}]interface{}
    func keepalloc() {
    for i := 0; i < 1000; i++ {

    m := make([]byte, 1<<10)
    cache[i] = m
    

    }
    }

2) goroutine泄露 (如一个程序不断的产生新的goroutine,且不结束已创建的goroutine并复用这部分内存)

func keepalloc2(){
  for i := 0; i < 10000; i++ {
    go func() {
      select {}
    }()
  }
}
//或者channel泄露导致
var ch = make(chan struct{})
func keepalloc2(){
  for i := 0; i < 10000; i++ {
    go func(){ ch <- struct{}{}}() //没有接收方,goroutine一直阻塞
  }
}
  1. 并发标记清除法的难点是什么?
    答: 难点在于用户代码在回收过程中会并发的更新对象图,从而造成渎职器和回收器对对象图的机构产生不同的认知。
    回收器不会重新扫描黑色对象,在扫描完之后用户代码对该黑色对象修改,导致有些对象可能错误地被回收。 因此难点在于如何保证标记和清除过程的正确性。

  2. 什么是写屏障、混合写屏障,如何实现?
    答: 垃圾回收器的正确性体现在不应出现对象的丢失,也不应错误的回收还不需要回收的对象。当下面两个条件同时满足时会破坏正确性:

  • 条件1,当赋值器修改对象图,导致某一黑色对象引用白色对象;
  • 条件2,从灰色对象出发,到达白色对象的、未经访问过的路径被赋值器破坏。
    只要能够避免其中任何一条件,正确性都能得到满足。因此我们可以将三色不变性所定义的波面根据这两个条件进行削弱:
  • 当满足原有的三色不变性定义的情况成为强三色不变形
  • 当赋值器另黑色对象引用白色对象时,称为弱三色不变形
    当赋值器进一步破坏灰色对象到达白色对象的路径时,即打破弱三色不变形,也就破坏了正确性。

因此可以引入赋值器的颜色:

  • 黑色赋值器:已经由回收器扫描过,不会再次对其进行扫描
  • 灰色赋值器:尚未被回收器扫描过,或尽管已经扫描过,但仍需重新扫描

为了确保强弱三色不变性的并发指针更新操作,需要通过赋值器屏障技术来保证指针的读写操作一致。

因此Go中的写屏障、混合写屏障,其实是指赋值器的写屏障,用来保证赋值器在进行指针操作时不会破坏弱三色不变性。

Dijkstra插入屏障: 避免条件1, 需要标记终止阶段STW时进行重新扫描
Yuasa删除屏障:避免条件2,依然会产生丢失的对象,需要在标记前对整个对象图进行快照

Go在1.8时为了简化GC流程,同时减少标记终止阶段的重新扫描成本,将两种方法进行了混合,形成混合写屏障: 对正在被覆盖的对象进行着色,且如果当前栈未扫描完成,则同样对指针进行着色。

因为着色成本是双倍的,而且编译器需要插入的代码也成倍增加,随之带来的结果就是编译后的二进制文件大小也进一步增加。 Go 1.10 前后,Go 团队随后实现了批量写屏障机制。其基本想法是将需要着色的指针统一写入一个缓存,每当缓存满时统一对缓存中的所有 ptr 指针进行着色。

GC的实现细节

  1. 当前版本的Go1.14以STW为界限,将GC划分为5个阶段
  • GCMark, 标记准备,为并发标记做准备工作,启动写屏障。 STW
  • GCMark, 扫描标记阶段,与赋值器并行执行,写屏障开启。 并发
  • GCMarkTermination, 标记终止阶段,保证一个周期内标记任务完成,停止写屏障。 STW
  • GCoff, 内存清扫阶段,将需要回收的内存归还到堆中,写屏障关闭, 并发
  • GCoff,内存归还阶段,将过多的内存归还给操作系统,写屏障关闭,并发
  1. 触发GC的时机是什么?
    答: 存在两种形式:
  • 主动触发,调用runtime.GC来触发,阻塞式等待当前GC运行完毕;
  • 被动触发
    • 使用系统监控:超过两分钟没有任何GC时,强制触发
    • 使用步调Pacing算法,控制内存增长的比例
  1. 如果内存分配速度超过了标记清除速度怎么办?
    答:目前go实现中,当GC触发后会首先进入并发标记阶段,该阶段会设置一个标志,当mallocgc调用时进行检查。当存在新的内存分配时,会暂停分配内存过快的那些goroutine,将其转去执行一些辅助标记Mark Assist的工作,从而达到放缓继续分配、辅助GC标记工作的目的。

编译器会分析用户代码,并在需要分配内存的位置,将申请内存的操作翻译为 mallocgc 调用。

GC的优化问题

  1. GC关注的指标有哪些?
    答: CPU利用率,GC停顿时间(STW,Mark Assist两部分可能造成),GC停顿频率,GC可扩展性(当堆内存变大时,垃圾回收器性能如何?)

  2. GC如何调优?
    答: GO的GC被设计为极致简洁,与Java GC数十个可控参数相比,严格意义上Go可供用户调整的只有GOGC环境变量。

GC调优:减少用户代码对GC产生的压力,即减少用户代码分配的内存数量,最小化Go的GC对CPU的使用率

所谓 GC 调优的核心思想也就是充分的围绕两点来展开:优化内存的申请速度,尽可能的少申请内存,复用已申请的内存。或者简单来说,不外乎这三个关键字:控制、减少、复用
1) 合理化内存分配速度、提高赋值器的CPU利用率 (goroutine 池的使用)
2) 降低并复用已经申请的内存(使用 sync.Pool 复用需要的 buf, concat 函数可以预先分配一定长度的缓存而后再通过 append 的方式将字符串存储到缓存中)
3) 调整GOGC (治标不治本,更妥当的做法仍然是,定位问题的所在,并从代码层面上进行优化)

GC 调优过程中 go tool pprof 和 go tool trace 可以帮助我们快速定位 GC 导致瓶颈的具体位置

过早优化是万恶之源。

  1. Go的垃圾回收器有哪些相关的API?
  • runtime.GC: 手动触发 GC
  • runtime.ReadMemStats: 读取内存相关的统计信息,其中包含部分 GC 相关的统计信息
  • debug.FreeOSMemory: 手动将内存归还给操作系统
  • debug.ReadGCStats: 读取关于 GC 的相关统计信息
  • debug.SetGCPercent: 设置 GOGC 调步变量
  • debug.SetMaxHeap: 设置 Go 程序堆的上限值

GC的历史及演进

  1. GO历史各版本在GC方面的改进
  • Go1 串行三色标记清扫
  • Go1.3 并行清扫,标记过程需要STW,停顿时间约几百ms
  • Go1.5 并发标记清扫,停顿时间在100ms以内
  • Go1.6 使用bitmap记录回收内存的位置,大幅优化垃圾回收器自身消耗的内存,停顿时间在10ms以内
  • Go1.7 停顿时间在2ms以内
  • Go1.8 混合写屏障,停顿时间在半个ms以内
  • Go1.9 彻底移除了栈的重新扫描过程
  • Go1.12 整合和两个阶段的Mark Termination,但引入了一个严重的GC bug,至今未修复
  • Go1.13 着手解决向操作系统归还内存,提出新的Scavenger
  • Go1.14 替代了仅存活一个版本的Scavenger,全新的页分配器,优化分配内存过程中的速率和现有的扩展性问题,引入异步抢占,解决密集循环导致的STW时间过长的问题
  1. Go GC在演化过程中还有哪些设计? 为什么没有被采用?
    答:
  • 并发栈重扫:允许灰色赋值器存在的垃圾回收器需要引入重扫过程来保证正确性,除了混合写屏障消除该过程外,还可以通过并发重扫提高性能。没有得以实现: 实现过程相比引入混合写屏障复杂,而且引入混合写屏障可以消除重扫过程,简化垃圾
  • ROC: Request Oriented Collector, 面向请求的回收器,其实也是分代GC的一种重新叙述。它提出一个请求假设:与一个完整请求、休眠goroutine所关联的对象更容易死亡。 但是在实现上由于垃圾回收器必须确保是否有goroutine私有指针被写入公共对象,因此写屏障要一直打开。 昂贵的写屏障以及其带来的缓存未命中是未被采用的原因
  • 传统分代GC:不适用于Go的运行栈机制。年轻代对象其实在栈上就已经死亡,扫描本就该回收的执行栈并没有带来性能提升。
  1. 目前提供GC的语言有哪些?GC和NOGC的优缺点?
    答:目前提供GC的有go,python,java,javascript,objective-c,Swift,没有提供但是支持自行实现GC的有C,C++,也有一些语言在编译器就可以依靠插入清理代码的方式实现精确的清理,如Rust

NOGC:

  • 在仍然有指向内存区域的指针存在情况下释放该内存,会产生悬挂指针。
  • 多重释放同一块内存区域,可能导致不可知的内存损坏。
  • 没有额外的扫描性能开销
  • 精确的手动内存管理,极致的利用机器性能。
  1. GO对比Java,V8中的Javascript GC性能
    答: java和javascript都基于分代假设,每次回收只回收其中一个区域。
  • V8的GC: 将内存分为新生代和老生代。1)新生代的对象通过副垃圾回收器进行回收,采用复制的方式实现垃圾回收,将堆内存一分为2,只有一个处于使用中From,另一个闲置To。分配对象先在From分配,当开始垃圾回收时检查From中存活对象,复制到To空间中,非存活空间被释放。完成复制后From、To角色互换。2)老生代由主垃圾回收器负责,实现标记清扫过程,并且清扫完成后进行整理碎片。
  • Java: G1将堆分为年轻代、老年代、永久代。包括4种操作,从上往下依次执行。1)只对年轻代进行收集整理;2)只对老年代进行收集整理;3)混合年轻代和老年代进行收集整理;4)完成GC对整个堆进行收集整理。在回收过程中G1会对停顿时间进行预测,竭尽所能调整GC策略达到用户代码配置的-XX:MaxGCPauseMillis对停顿时间的要求。
  1. 目前Go GC存在的问题?
    答:
  • Mark Assit停顿时间过长
  • Sweep停顿时间过长
  • 由于GC算法不正确性导致GC被迫重新运行:(能够在 1334 次构建中发生一次)
  • 创建大量Goroutine后导致GC消耗更多的CPU: 通常发生于峰值流量后,大量 goroutine 由于任务等待被休眠,从而运行时不断创建新的 goroutine,旧的 goroutine 由于休眠未被销毁且得不到复用,导致 GC 需要扫描的执行栈越来越多,进而完成 GC 所需的时间越来越长。一个解决办法是使用 goroutine 池来限制创建的 goroutine 数量。

垃圾回收器的设计权衡了很多方面的因素,同时还受语言自身设计的影响,因为语言的设计也直接影响了程序员编写代码的形式,也就自然影响了产生垃圾的方式。但总的来说,他们三者对垃圾回收的实现都需要 STW,并均已达到了用户代码几乎无法感知到的状态(据 Go GC 作者 Austin 宣称 STW 小于 100 微秒)。