一文了解GO垃圾回收与内存管理

GC 常识

GC,即垃圾回收,是一种自动内存管理的机制。当程序向操作系统申请的内存不再需要时,垃圾回收主动将其回收,并供其他代码进行内存申请时复用,或者将其归还给操作系统。这种对内存级别资源的自动回收过程称为垃圾回收,负责垃圾回收的程序组件称为垃圾回收器。

垃圾回收的好处体现在两方面。一方面,程序员不需要对内存进行手动地申请和释放操作,GC 会在程序运行时自动释放残留的内存。另一方面,GC 对程序员几乎透明,只有在程序需要特殊优化时,通过提供可调控的 API,对 GC 的运行时机、运行开销进行把控时才得以现身。

通常垃圾回收分为两个半独立的组件:

  1. 赋值器
    指代用户态的代码。对垃圾回收器而言,用户态的代码仅仅只是修改对象之间的引用关系,即在对象图上进行操作。
  2. 回收器
    负责执行垃圾回收的代码。

根对象

根对象又被称为根集合,是垃圾回收器在标记过程时最先检查的对象,包括:

  • 全局变量:程序在编译器就能确定的那些存在于程序整个生命周期的变量。
  • 执行栈:每个 goroutine 都包含自己的执行栈,这些执行栈上包含栈上的变量以及指向分配的堆内存区块的指针。
  • 寄存器:寄存器的值可能表示一个指针,参与计算的这些指针可能指向某些赋值器分配的堆内存区块。

常见的垃圾回收实现方式

所有 GC 算法的存在形式都可以归结为追踪和引用计数两种形式的混合运用。

  1. 追踪式 GC

从跟独享触发,根据对象之间的引用信息,一步步推进直到扫描完毕整个堆对象,并确定需要保留的对象,从而回收所有可回收的对象。Go、Java、V8 对 JavaScript 的实现都是追踪式 GC。

  1. 引用计数式 GC

每个对象自身包含一个被引用的计数器,当计数器归零时自动得到回收。整个方法的缺陷较多,在追求高性能的时候通常不被应用。Python、Objective-C 都是引用计数式 GC。

比较常见的 GC 实现方式包括:

  • 追踪式

    • 标记清扫:从根对象触发,将确定存活的对象进行标记,并清扫可以回收的对象。
      实现起来相对简单,但是容易造成内存碎片,在大量不连续小分块内存中找到合适内存分块的代价会更高,且小分块内存难以被使用。此问题可以基于BiBOPBig Bag Of Pages)的思想,将内存块划分为多种规格,再统一管理。或者使用Mark-Compact 算法通过在完成标记后移动非垃圾数据使他们紧凑在一起,空出大块内存,问题是又会带来多次内存扫描、移动的开销。
    • 标记整理:为了解决内存碎片问题而提出,在标记过程中,将对象尽可能整理到一块连续的内存上。
      将内存分为 from、to 两块,从 from 开始标记,将标记过的非垃圾数据拷贝到 to 中,等到标记完成后,交换二者的角色,那么之前的 from 则可以全部被回收。
    • 增量式:将标记与清扫过程分批执行,每次执行很小的一部分,从而增量推进垃圾回收,达到近似实时、几乎无停顿的效果。
    • 增量式整理:在增量式的基础上,增加对对象的整理过程。
    • 分代式:将对象根据存活时间的长短进行分类。存活时间小于某个阈值的为年轻代,大于某个阈值的为老年代,永远不会参与回收的对象为永久代。并根据分代假设(一个对象存活时间不长则倾向于被回收,一个对象已经存活很长时间则倾向于存活更长时间)对对象进行回收。年轻代的回收会比较频繁,老年代则会间隔较长一段时间。
      主要基于弱分代假说:大部分对象都在年轻时死亡。因此可以将数据分为新生代和老年代,减少老年代数据的执行 gc 频率,将明显提升效率。两分代还能使用不同的 gc 策略。
  • 引用计数:

    • 根据对象自身引用计数来回收,当引用计数归零时立即回收。
      执行对象时递增计数,当计数为 0 时代表无用可回收。垃圾识别的任务分摊到每一次操作上,但是高频率更新计数的开销大,且可能会出现循环引用

三色标记法

三色标记法的关键在于理解三色抽象波面推进。三色抽象只是一种描述追踪式回收器的方法,在实践中并没有实际意义,它的作用在于推导标记清扫法的正确性。

从垃圾回收器的角度看,三色抽象规定了三种不同类型的对象,并用三种不同的颜色相称:

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

这三种不变性所定义的回收过程其实是一个波面不断前进的过程,这个波面同时也是黑色对象和白色对象的边界,灰色对象就是这个波面。

当垃圾回收开始时,只有白色对象。随着标记过程的进行,灰色对象开始出现,这时波面开始扩大,当一个对象的所有子节点都扫描完毕,会被着色为黑色。整个堆遍历完成时,只剩下黑色对象和白色对象,黑色对象为可达对象,存活;白色对象为不可达对象,需要被回收。

image

STW

通常意义上指代从 Stop the World 这一动作到 Start the World 这一动作发生时的这一段时间间隔。它是在垃圾回收过程中为了保证实现的正确性、防止无止境内存增长等问题而不可避免的需要停止赋值器进一步操作对象图的过程。

在这个过程中,整个用户代码被停止或者放缓执行,STW 越长,对用户代码造成的影响就越大。

当要考虑用户程序 STW 时延时,可以用增量 gc,但是可能会导致一些错误:

  1. 多标-浮动垃圾问题
  2. 黑色指向了灰色,但是此时黑色断开对灰色的引用,灰色本该被回收的,但是已经被标记为灰色了,会被当做存活对象继续遍历下去,因此本轮 gc 不会回收这部分内存
  3. 漏标-悬挂指针问题
  4. 标记的黑色在下次时段引用了个白色,那么白色会被误判为垃圾回收

这里不得不提一下三色标记法很明确地展示了 gc 的过程,如果可以避免黑色对象直接引用白色对象(被称为强三色不变式),或者说通过灰色可以抵达白色对象(弱三色不变式),就不会出现这种问题。

屏障技术

因此,上述的问题可以通过引入屏障技术来保证数据一致性

A memory barrier is a type of barrier instruction that causes a central processing unit (CPU) or compiler to enforce an ordering constraint on memory operations issued before and after the barrier instruction. This typically means that operations issued prior to the barrier are guaranteed to be performed before operations issued after the barrier.

内存屏障是一种屏障指令,它可以使 CPU 或编译器对在该屏障指令之前和之后发出的内存操作强制执行排序约束,在内存屏障前执行的操作一定会先于在内存屏障后执行的操作。

强弱三色不变式就是通过写屏障来实现的,写屏障有个记录集,在写过程中插入指令,目的是将数据对象的修改通知到 gc(记录集存储方式和内容需要考虑),对于插入写屏障而言,可以将黑色指针指向的白色着色为灰色,或者将黑色着色为灰色。而弱三色不变式要删除灰色对象对执行白色对象的引用时,可以将白色对象着色为灰色(因为白色对象 B 可能会被引用其他黑色对象引用,先着色为灰色,避免此次 gc 清扫)

为什么不是读屏障?

因为对于一个不需要对象拷贝的垃圾回收器来说, Read barrier(读屏障)代价是很高的,对于这类垃圾回收器来说是不需要保存读操作的版本指针问题。相对来说 Write barrier(写屏障)代码更小,因为堆中的写操作远远小于堆中的读操作

复制式 gc 会移动数据来减少内存碎片化,读数据时可能会出现用户程序错读内存地址(或读陈旧对象)的问题。读屏障在检测到对象已经存在副本时会转而读取新对象

GC in GO

go 的 gc 是基于精确类型的,并且被设计成可以与普通的线程并发运行,同时允许多个 gc 线程并行运行。它是一个使用写屏障的无分代(对象没有代际之分)、不整理(回收过程中不对对象进行移动与整理)、并发(与用户程序并发执行)的三色标记清扫算法。

  • 对象整理的优势是解决了内存碎片化问题以及“允许”使用顺序内存分配器。而基于 tcmalloc 的内存分配算法基本不会有内存碎片产生。
  • 分代假设没有带来直接的优势,且 go 的垃圾回收器与用户程序并发执行,使得 STW 的时间与对象的代际、对象的 size 没有关系

go 支持自动垃圾回收

  • 采用的是Mark-Sweep 标记清扫回收算法
  • 支持主体并发增量式回收
  • 采用插入-删除两种写屏障结合的混合写屏障

怎么区分哪些是垃圾

  • Mark-Sweep
  • Mark-Compact
  • 复制回收
  • 分代回收
  • 引用计数

当要考虑 STW(stop the world,停止用户程序进行 gc)时

  • 增量式垃圾回收

考虑多核场景

  • 并行垃圾回收

  • 并发垃圾回收

    • 考虑用户程序与 gc 程序之间竞争问题
  • 主体并发式垃圾回收

  • 主体并发增量式回收

GC 执行流程

当前版本的 Go 以 STW 为界限,可以将 GC 划分为五个阶段:

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

各个阶段的触发函数如图所示:

具体流程:

  • GC 在准备阶段会为每个 P 创建一个 mark worker 协程,即 p.gcBgMarkWorker(现在好像变成了一个池),后台 makr worker 很快就进入休眠,等待到标记阶段进入执行
1
2
3
// Pool of GC parked background workers. Entries are type
// *gcBgMarkWorkerNode.
gcBgMarkWorkerPool lfstack
  • 当第一次进入 STW 的时候,用于标记 gc 阶段的全局变量gcphrase=_GCMark,全局变量writeBarrier.enabled=true用于标记是否开启写屏障,全局变量gcBlackenEnabled=1用于标记是否允许进行 gc mark 工作
  • 当 STW 结束后,所有 p 知道写屏障已经开启,然后后台的 mark worker 协程开始调度开始 gc mark 工作
  • 当没有标记工作的时候,进入第二个 STW,将gcphrase设置为_GCMarkTermination,确认标记工作已经完成,并将gcBlackenEnabled标记为0
  • 接下来进入_GCOff阶段,将gcphrase标记为_GCOff,关闭写屏障,设置writeBarrier.enabled=false,Start The World,进入清扫流程。在 GCMarkTermination 阶段分配的新对象会被直接标记为黑色,而到 GCOFF 阶段,新分配的对象会被标记为白色。bgsweep开始调度。到达清扫阶段,bgsweep 会被加到 runq 中,得到调度执行时会执行清扫任务。完成清扫开始前需要确保上次清扫任务已完成。

GC 如何找到需要标记的对象,如何标记

  • 标记工作要从扫描 bss 段、数据段、以及协程栈上的这些 root 结点开始,追踪到堆上的节点。
  • Go 语言在编译阶段会生成 bss 段、数据段等对应的元数据,存储在可执行文件中。通过各模块对应的 moduledata 可以获得 gcdatamask、gcbssmask 等信息,它们会被用于判断特定 root 节点是否为指针
  • 协程栈也有对应的元数据,存储在 stackmao 中。扫描协程栈时,通过对应元数据,可以知道栈上的局部变量、参数、返回值等对象中,哪些是存活的指针。还要进一步判断是否指向堆内存,是的话就要加入 GC 工作队列中,进行进一步的扫描。
  • 确定了 root 节点是否为指针,还要再进一步判断这些指针是否指向堆内存,如果指向堆内存就得把它们加入到 GC 工作队列中进行进一步的扫描。

  • 堆上的对象也有元数据
  • mheap 中每个 arena 都有个HeapArena 记录 arena 的元数据信息,其中有一个bitmap,可以标记 arena 中连续四个指针大小的内存(称为 word),每个 word 对应的两个 bit 中,低位的 bit 用于标识这个是否为指针(1 为指针,0 位非指针),高位 bit 用于标识是否需要继续扫描,为 1 时代表扫描完当前 word 并不能完成当前数据对象的扫描(意味着这个数据对象比较大,占了多个 word)
  • bitmap 信息在分配内存的时候设置,会使用到对应元数据的 gcdata 的信息,HeapArena 中还有个spans字段,是一个*mspan 类型的数组,用于记录当前 arena 中每一页对到哪一个 span
  • 基于 HeapArena 记录的元数据信息,我们只要知道一个对象的地址,就可以根据 bitmap 信息扫描它内部是否含有指针,也可以根据对象地址计算出它在哪一页,然后通过 spans 信息查到该对象存在哪一个 span 中。

  • 每一个 span 都对应了两个位图标记 bitmap,记录在 mspan 中。
  • mspan.allocBits中每一位标记一个对象存储单元是否已分配alloced。
  • mspan.gcmarkBits中的每一位用于标记一个对象是否存活
  • 有了这些元数据和位图标记就能知道哪一个对象是需要被标记为灰色的,将其 gcmarkBits 标记为 1,并将其加入到工作队列中
  • 使用 bitmap 来记录回收内存的位置:大幅优化垃圾回收器自身消耗的内存

工作队列与工作缓冲区

  • 全局变量 work 中记录了全局工作缓存

  • 而每个 p 都有个本地工作队列 gcWork(即 gcw),gcw 中有两个 workbuf

  • 添加任务时总是向 workbuf1 中添加,当 workbuf1 满了就交换两个 workbuf。如果交换后依然是满的,就将 workbuf1 的工作都 flush 到全局工作缓存中

  • markwork 在执行工作时,会处理对本地工作队列和全局工作队列的工作量的均衡问题

    • 如果全局工作缓存为空,就把当前 p 的工作分一些到全局工作缓存中。具体做法是:如果 wbuf2 不为空,就把它整个 flush 到全局工作缓存中;如果为空,而 wbuf1 中元素个数大于 4,就把 wbuf1 中一半的工作放到全局工作缓存中。
    • 如果在执行 mark 工作时发现本地工作队列为空,也会从全局工作缓存中获取任务放到本地队列中
  • 每个 p 都有一个写屏障缓冲区 wbBuf,写屏障触发时,会将相关指针写入 wbBuf 中。当写屏障缓冲区满了,或 mark worker 获取不到任务时,会将写屏障缓冲区的任务都 flush 到本地工作队列的 workbuf 中

  • 通过区分本地工作队列与全局工作缓存,并为每个 P 设置写屏障缓冲区,缓解了执行并发标记工作时操作工作队列的竞争问题。

GC 对 CPU 的使用率

主要是处理 mark worker 调度的问题

gc 默认的 cpu 目标使用率为 25%

在 gc 初始化阶段,会更具有 ginmaxprocs*目标使用率来计算要启用的 mark worker 数量

对于计算结果不为整数的情况,需要对结果进行 rounding(和原目标误差大于 0.3 计算多了)

引入工作模式

  • Dedicated 模式

执行标记任务直到被抢占

  • Fractional 模式

除了被抢占,还会在达到 Fractional 部分目标(fractionUtilizationGoal)的条件会主动让出

fractionUtilizationGoal由所有 p 共同负责

全局变量 gcController 中会记录可以启动多少个 Dedicated 模式的 worker(dedicatedMarkWorkersNeeded),还会记录fractionalUtilizationGoal

在调度器执行 findRunnabledGcWorker 想要恢复后台 mark worker 时需要设置工作模式,看看是否达到 dedicatedMarkWorkersNeeded 启动 dedicated 模式 worker 的上限,如果没有直接设置为 dedicated 模式,如果达到了,就看看是否需要 fractional 模式的 worker 辅助工作

p会记录自己执行 fractional woker 的时间gcFractionalMarkTime)和当前 worker 开始工作的时间gcMarkWorkerStartTime),gcController记录本轮 gc 工作开始的时间markStartTime

当前 p 执行 fractional 模式的 worker 时,每完成一定量的工作后都会去检查是否达到了 fractional 的部分条件,即(fractional 模式累计时间/本轮标记总时间)是否超过 fractionalUtilizationGoal,如果达到了就主动让出当前 worker

有效控制 cpu 的使用率

GC 缓解内存分配压力

实现了GC Assist 机制,其实是一种借贷偿还机制

如果协程在请求分配内存时,此时 mark 工作没有结束,那么该协程就要分摊一部分的 mark 工作。申请的内存越大,要偿还的债务就越多

当前 g 的 gcAssistBytes 小于 0,则代表负债,否则表示有结余

需要在申请内存前辅助 gc 完成一些 mark 工作来偿还债务

当后台 mark worker 完成了一定量的 mark 工作,就会在 gcController 这里存一部分的信用(bgScanCredit),欠债的协程可以在这 steal 一部分尽量大的信用来抵消债务,如果就剩就留着当结余,用于抵消下次债务

gc 的标记阶段,每次内存分配都会检查是否需要辅助标记,gc 的清扫阶段每次内存分配就可能会触发执行辅助清扫,如直接从 mheap 分配一个大对象内存时,为了维护堆内存分配量和清扫页面数量的线性关系,可能需要先清扫,再分配。如从本地缓存中分配一个 span 时,如果遇到了没分配的 span,需要先清扫再分配

避免并发垃圾回收中因为过大的内存分配压力导致 gc 来不及回收的情况

混合写屏障

  • GC 使用写屏障来追踪指针的赋值操作,Go 使用的是一种组合了删除写屏障和插入写屏障的混合写屏障。删除写屏障负责对地址被覆盖掉的对象进行着色,插入写屏障负责对新地址指向的对象进行着色。在 goroutine 的栈是灰色的时候,才有必要执行插入写屏障。
  • 按照这种设计思想,混合写屏障的伪代码如下:
1
2
3
4
5
WritePointer(slot, ptr): 
shade(* slot)
if current stack if grey:
shade(ptr)
*slot = ptr
  • slot 的原指针是 old,如果要把 ptr 写入 slot,那么对原指针可达路径的删除会触发删除写屏障,新指针到 slot 可达路径的增加会触发插入写屏障。Go 语言整合了插入与删除写屏障,把它称为混合写屏障。
  • 在引入混合写屏障之前,只有插入写屏障,但是这需要对所有堆、栈的写操作都开启写屏障,代价太大。为了改善这个问题,改为忽略协程栈上的写屏障,只在标记结束阶段重新扫描那些被激活的栈帧。但 Go 语言通常会有大量活跃的协程,这就导致第二次 STW 时,重新扫描协程栈的时间太长。
  • 如果在当前栈忽略写屏障的前提下,能够保证写入栈上的数据对象不会被 hiding,就不用在第二次 STW 时重新扫描这些栈帧了,而删除写屏障恰好可以保障这一点。例如这里,当前 G 的栈帧中 A 已经完成扫描 ,然后 G 执行,把 old 写入栈上的本地变量 A。栈上并没有插入写屏障,old 不会被标记,之后把新指针 ptr 写入 slot。如果没有引入删除写屏障,抵达 old 的唯一路径被切断,old 就不能被 GC 发现了。所以需要在删除 old 的可达路径时,通过删除写屏障把它标记为灰色。

  • 如果 slot 已经标记为黑色,栈上的 C 还未被扫描,几天下来 G 执行,把 ptr 写入 slot。而当前 G 切断 C 到 ptr 的可达路径时,并没有删除写屏障,不会标记 ptr。为了避免将白色对象写入堆上的黑色对象,就要靠插入写屏障在写入 slot 是标记新指针。至于判断当前栈是否为灰色,是因为如果当前是已经完成扫描的黑色栈,那么它指向的对象一定已经被标记了,插入写屏障这里就没必要再标记一次了。

  • 所以,使用混合写屏障的意义在于,既可以忽略当前栈帧的写屏障,也不用在第二次 STW 时,重新扫描所有活跃 G 的栈帧。

GC 的触发方式

  • 主动触发:

    • 通过调用 runtime.GC 来触发 GC,此调用阻塞式地等待当前 GC 运行完毕。
  • 被动触发,分为两种方式:

    • 在 runtime 包初始化时,会以 forcegchelper 为执行入口开启一个协程,只不过它被创建后会很快休眠。监控线程在检测到距离上次 GC 已经超过指定时间时,就会把 forcegchelper 协程添加到全局 runq 中。等它得到调度执行时,就会开启新一轮的 GC 了。
    • 分配内存时,有些情况下需要检查是否需要触发 GC,每次 GC 都会在标记结束后设置下一次触发 GC 的堆内存分配量。分配大对象或从 mcentral 获取空闲内存时,会判断是否达到了这里设置的 gc_trigger,以决定是否需要触发 GC。

内存结构

堆内存管理结构

https://vwn3qg1pgo.feishu.cn/docs/doccnK4IZu1h1pXcMhiTH0nzUnb

mheap 管理着地址空间的一大段连续的内存

以 8K 为一页,多个页(8K)组成一个 span,多个 span 组成一个 arena,每个 span 只存储一种大小的元素

span 的数据结构是mspan,span 存储的大小规格覆盖了小于等于 32K 的 66 种大小,对应编号是 1-66,记录在mspanspanclass中,大于 32K 的记录编号为 0,直接在 mheap 中分配,所以一共 67 种

除此之外,还会按存储元素是否含有指针来分类,也存在 spancalss 中,所以分两种(scan 和 no-scan),一共 134 种

类型元数据的_typeptrdata就是用于区分需要放在 scan 还是 no-scan 中,ptrdata=0 的会放在 no-scan 中,即使是指针类型也不会被 gc 扫描,ptrdata 不为 0 的会分配到 scan 中,会被 gc 扫描。

  • 减少了 gc 耗时

go 实现了全局和本地 span 缓存,防止每次进行堆空间分配的时候都要去申请一次

mheap.central 记录了 span 的全局缓存,每一个 mcentral 代表了一种 spanclass。并且将有空闲空间 empty,没空闲空间 nonempty 分别管理

每个 P 都有一个 mcache 用于本地缓存,一样用 spanclass 区分,共 134 个 mspan 链表

总结:

Go 的内存分配器在分配对象时,根据对象的大小,分成三类:小对象(小于等于 16B)、一般对象(大于 16B,小于等于 32KB)、大对象(大于 32KB)。

大体上的分配流程:

  1. 32KB 的对象,直接从 mheap 上分配;
  2. <=16B 的对象使用 mcache 的 tiny 分配器分配;
  3. (16B,32KB] 的对象,首先计算对象的规格大小,然后使用 mcache 中相应规格大小的 mspan 分配;
  4. 如果 mcache 没有相应规格大小的 mspan,则向 mcentral 申请
  5. 如果 mcentral 没有相应规格大小的 mspan,则向 mheap 申请
  6. 如果 mheap 中也没有合适大小的 mspan,则向操作系统申请

runtime 将堆地址空间定义为一个一个 arena,在 amd64 的 Linux 环境下,每个 arena 占 64MB,起始地址也对齐了 64MB。每个 arena 包含了 8192 个 page(8K)。

内存分配采用了与 Tcmalloc 类似的内存分配器,按照一组预置的大小规格把内存页划分为块,然后将不同规格内存块放入空闲链表中。一共 67 种(go1.16)最小 8 字节,最大 32K,即 span

mheap 中有一个全局管理中心,是一个长度为 132 的数组,元素是 mcentral 结构加上 padding,一个 mcentral 对应一种 mspan

mallocgc

辅助 gc

申请一字节内存空间需要做多少扫描工作,或者完成一次扫描后能分配多大空间,都需要 gc 实时处理

每次辅助 gc 都要扫描 64K,g 每次执行辅助 gc,多出来的部分都会作为信用存储到当前 g 中,后续再执行时只要额度用不完就不用辅助 gc。还有另一种可以避免辅助 gc 的方式:后台的 mark worker 会在全局 gcController(bgScanCredit)处积累信用,如果能够从这里窃取到足够的信用来抵消负债的信用 gcAssistBytes,那么也不会执行辅助 gc

空间分配

根据分配空间大小与是否为 no-scan 类型来选择不同的分配策略。

如果超过 32K,会直接根据需要 page 数来直接分配一个 span。小于 16B 时为了不浪费,tiny allocator 将几个小块的内存分配请求合并,能提高内存使用率。tiny cache 的分配在 mcache 中,维护了一个 tiny cache16B,和一个 offset,如果经过内存对齐后不满足,则从 mspan 中拿多一个 16B 的过来,分配完成后,如果空闲空间比之前那个 tiny cache 大,则替换掉他。

位图标记

通过一个特定的堆内存地址如何找到对应的 heapArena 和 mspan

heapArena 的地址存到了一个二维数组里,寻址时要根据 arena 编号计算出一个 arenaIdx,是个 uint。分为两部分作为两个维度的索引

收尾工作

mspan 分配给堆和栈,不过 span 的状态不同:分配给堆是 mSpanInUse,分配给栈是 mSpanManual。调度器初始化的时候会初始化两个用于栈分配的全局缓存对象:一个是stackpool,是一个 span 链表的数组,面向 32KB 以下的栈分配,分配的内存必须是 2 的幂,Linux 环境下划分为 2K、4K、8K、16K 4 个空闲链表。另一个是stackLarge,分配大于等于 32KB 的栈,也是一个 span 链表的数组,长度为 25,规格从 8K 开始,后面都是前一个的 2 倍。8 与 16K 一直是空的,是为了使用 mspan 包含页面数的(以 2 为底)对数作为数组的下标

每个 P 也有用于栈分配的本地缓存,与 stackpool 差不多。

总结

什么时候会触发 golang GC 呢

  1. 执行 runtime.gc()
  2. 监控线程 runtime.sysmon 定时调用;
  3. 申请内存时 runtime.mallocgc 会根据堆大小判断是否调用;

Go 语言 GC 实现原理及源码分析