目录
一. 基础问题
go内存会分成堆区(Heap)和栈区(Stack)两区域, 程序在运行期间可以主动从堆区申请内存空间,这些内存:
- 由内存分配器进行分配,由垃圾收集器负责回收。
- 栈区中存储着函数的参数以及局部变量,它们会随着函数的创建而创建,函数的返回而销毁,栈区的内存由编译器自动进行分配和释放
- Go使用垃圾回收收集不再使用的span,把span释放交给mheap,mheap对span进行span的合并,把合并后的span加入scav树中,等待再分配内存时,由mheap进行内存再分配。因此,Go堆是Go垃圾收集器管理的主要区域
什么是STW
- 了解垃圾收集前,先了解一下STW
- 垃圾收集器在回收垃圾时,必须在一个能保障一致性的快照中进行的,如果不满足,分析垃圾的结果的准确性就无法保证,所以通过挂起所有用户线程来保证,这个挂起是JVM在后台自动发起的,也就是我们说的Stop the World
- 进而引出什么时候挂起用户线程: 是在一个特定的位置挂起用户线程,这个特定的位置被称为安全点SefePoint,
- 怎么取确定安全点,如果太少可能会导致一直寻找安全点GC时间等待过长,如果太多又可能出现运行时的性能问题: 通常以"是否具有让程序长时间执行的特征"为标准进行选择,例如选择一些执行时间较长的指令作为安全点如: 法调用,循环跳转,异常跳转等等
- GC发生时检查所有线程都跑到最近的安全点停顿下来的,有两种方式: 抢先式中断(现已不采用), 主动式中断
- 抢先式中断: 首先中断线程,如果线程还未到达安全点,再恢复未到达安全点的线程
- 主动式中断: 设置一个中断标志,各个线程运行到安全点后主动轮询这个标志,当中断标志位true时才挂起线程
- STW
- 进而提出了"串行回收, 并发回收,并行回收"的三种回收方式,那么: 什么是串行回收,并发回收,并行回收
- 串行回收: 是指同一时间内只允许一个垃圾收集线程执行(挂起用户线程)
- 并行回收: 是指多个垃圾收集线程同时执行(挂起用户线程)
- 并发回收: 是指用户线程与垃圾收集线程同时或交替执行,
- 解决STW考虑增量回收算法,也就是并发,用户线程垃圾回收线程交替执行
- 对应串行,并行,并发的垃圾收集器
- 串行: 挂起用户线程,同一时间内只允许一个垃圾收集线程执行:
- 并行: 挂起用户线程,多条垃圾收集线程并行工作: ParNew, Parallel Scavenge, Parallel Old
- 并发: 用户线程与垃圾收集线程同时或交替执行的: CMS, G1
垃圾收集算法
- 垃圾收集算法可以分为判断垃圾阶段与垃圾清除阶段两种类型
- 在判断垃圾阶段有:
- 引用计数法
- 根搜索法
- 三色可达性分析法
- 垃圾清除阶段有:
- 标记清除
- 标记压缩
- 复制算法
- 分代收集
- 增量收集
- 注意golang中我们只关注: 根搜索算法,与根搜索算法升级出来的三色可达性分析法,还有标记清除算法
1. 根搜索算法
- 根搜索算法,又叫可达性分析法,GC Roots
- 首先会根据级别定义出不同级别的根节点,根对象
java中的根节点由: 虚拟机栈, 本地方法栈, 类静成员, 方法区中常量, 所有被synchronized持有的对象, java虚拟机内部的引用(Root 采用栈方式存放遍历和指针,如果一个指针保存了堆内存里的对象,但是自己又不存放在对内存中,就可以将它看成根),golang栈上的对象在垃圾收集中也会被认为是根对象
- go中哪些是根对象
- 全局变量:程序在编译期就能确定的那些存在于程序整个生命周期的变量。
- 执行栈:每个 goroutine 都包含自己的执行栈,这些执行栈上的变量及指向分配的堆内存区块的指针(注意是占上的指针)
- 寄存器:寄存器的值可能表示一个指针,参与计算的这些指针可能指向某些赋值器分配的堆内存区块。
- 与根节点有直接或者间接引用的就说明该对象是存活对象,不可回收
- 该算法优点: 相较于引用计数算法,可达性分析算法同样具有实现简单,执行效率高的优点,并且解决了循环引用的问题,只要对象实例与根之间的引用链有断开,即使对象间相互引用,整个引用环都会被回收掉
- 可达性分析算法应用场景: 在java, C#中采用该垃圾回收算法
- 根搜索算法是导致 Stop The World 原因的其中一个: 判定内存是否可以回收时分析工作必须在一个能保障一致性的快照中进行的,如果不满足,分析结果的准确性就无法保证,即使号称几乎不会发生停顿的GMS收集器,在枚举根节点时也必须停顿
2. 标记清除法
- 标记清除算法中可以分两个阶段: 标记阶段 与 清除阶段
- 标记阶段: 先根据判断垃圾算法,标记出所有存活对象,例如:有根节点引用的可存活对象标记加1(初始化时为0), 无引用的不可存活对象标记减1
- 清除阶段:将没有标记的对象也就是不可存活的清除(注意此处的清除并不是真的清除,而是把需要清除的对象地址保存到一个列表中进行记录,下次有新对象时判断这个列表中记录的对象的空间是否够,如果够再存放)
- 优点: 由根节点开始解决引用计数法的循环依赖问题,必要时才进行内存回收(内存不足时)
- 缺点:
- 由根节点开始存在两次全堆遍历: 第一次遍历标记不可回收对象,第二次遍历获取未被标记的可回收对象进行回收效率比较低,
- 不连续造成内存空间碎片化,将内存空间割裂成很多小部分,碎片化的最大空间可能小于当前创建的这个大对象,出现存不下的情况
- GC时为了保证一致性需要停止整个应用程序Stop The World,用户体验差
3. 三色可达性分析算法
- 为了解决标记清除算法带来的STW问题,Go和Java通过三色可达性分析标记算法的变种来缩短STW的时间。
- 三色可达性分析算法又叫三色抽象算法,在三色可达性分析算法中会将程序中的对象分成白色、黑色和灰色
- 白色对象: 潜在的垃圾,可以被回收;
- 黑色对象: 根对象(即使根对象没有引用任何外部对象,也是黑色),被根对象引用的可达对象,不可被回收
- 灰色对象 : 是指已经被遍历到,但是还没有确定其可达性的对象,也可能是存在指向白色对象的外部指针,垃圾收集器会扫描这些对象,不可被回收
- 三色可达性分析算法的执行过程
- 首先获取所有对象,标记为白色
- 从根节点(全局变量,每个goroutine的执行栈,寄存器等)出发,开始遍历整个对象图,将访问到的对象染成灰色
- 然后获取每个灰色对象,遍历它引用的其他对象,并将这些对象染成灰色或黑色
- 一直迭代寻找,最终将所有灰色对象标记为黑色,没有被引用的对象就是不被标记的白色可回收对象
- 如下图,D对象是垃圾对象
- 三色标记清除算法不支持并发或者增量执行的,会产生STW,否则可能造成:
- 假设在垃圾收集过程中,被标记为白色的a对象,由于没有stw,可能会被后续的执行再次引用,最终造成错误回收,将这种错误称为悬挂指针
- 为提高垃圾收集性能,解决悬挂指针错误回收,与垃圾未被回收的问题,引出了屏障技术
收集器收集方式
- 前面说了STW程序挂起, 根据是否挂起,垃圾收集器在go中又分为增量收集与并发收集(java中 是串行,并发,并行收集)
- 串行收集: 是指同一时间内只允许一个垃圾收集线程执行(挂起用户线程)
- 增量垃圾收集: 增量地标记和清除垃圾,降低应用程序暂停的最长时间;
- 并发垃圾收集: 利用多核的计算资源,在用户程序执行时并发标记和清除垃圾,是指用户线程与垃圾收集线程同时或交替执行
- 增量和并发两种方式都可以与用户程序交替运行,所以需要使用屏障技术保证垃圾收集的正确性;增量和并发的垃圾收集需要提前触发并在内存不足前完成整个循环,避免程序的长时间暂停
1. 增量收集器
- 增量式的垃圾收集,是指多个垃圾收集线程同时执行(挂起用户线程)
- 与三色标记法一起使用,为了保证垃圾收集的正确性,需要在垃圾收集开始前打开写屏障,这样用户程序对内存的修改都会先经过写屏障的处理,保证了堆内存中对象关系的强三色不变性或者弱三色不变性。
- 虽然增量式的垃圾收集能够减少最大的程序暂停时间,但是增量式收集也会增加一次 GC 循环的总时间,在垃圾收集期间,因为写屏障的影响用户程序也需要承担额外的计算开销,所以增量式的垃圾收集也不是只有优点的
2. 并发收集器
- 并发式的垃圾收集,是指用户线程与垃圾收集线程同时或交替执行
- 不仅能够减少程序的最长暂停时间,还能减少整个垃圾收集阶段的时间,通过开启读写屏障、利用多核优势与用户程序并行执行,并发垃圾收集器确实能够减少垃圾收集对应用程序的影响
- 虽然并发收集器能够与用户程序一起运行,但是并不是所有阶段都可以与用户程序一起运行,部分阶段还是需要暂停用户程序的,不过与传统的算法相比,并发的垃圾收集可以将能够并发执行的工作尽量并发执行;当然,因为读写屏障的引入,并发的垃圾收集器也一定会带来额外开销,不仅会增加垃圾收集的总时间,还会影响用户程序,这是我们在设计垃圾收集策略时必须要注意的。
屏障技术
- 屏障技术指一种同步机制,是一种屏障指令,可以确保对共享变量的写操作在被其他goroutine读取之前完成,进而解决数据并发安全问题,提高程序的可靠性和稳定性,比如通过屏障技术解决三色标记时出现的悬挂指针问题
- 为什么要出现屏内存屏障技术:通常情况下为提高性,在不影响最终结果的前提下会对代码指令进行优化,cpu指针是乱序执行的,通过屏障技术可以让 CPU 或者编译器按照特定的约束进行优化执行
- 根据读取对象、创建新对象以及更新对象指的操作类型的不同,提出了读屏障Read barrier和写屏障Write barrier,
- 注意: 读操作时加入读屏障对性能影响很大,所以编程语言往往都会采用写屏障保证三色不变性
- 在golang中存在两种写屏障技术, 插入写屏障与删除写屏障
- 在垃圾收集角度说一下屏障技术出现的原因: 为了减少stw的时间,提高垃圾收集的性能提出了并发收集,并发收集就可能带来指针悬挂的问题,可以简单理解为由于并发收集程序不会被挂起,原本被标记为垃圾的对象在程序后续的执行中又重新引用了,造成不应该被回收的对象,错误回收了,解决这个问题提出了插入写屏障与删除写屏障
1. 强三色不变性与针对新增与修改时的插入写屏障
- 什么是强三色不变性: 在垃圾收集时,尤其是并发收集过程中,为了防止错误收集,出现指针悬挂问题,不能让黑色对象再次变成白色或灰色,或者要求不存在黑色对象引用白色对象指针的情况,在黑色对象引用白色对象时,将白色对象标记为灰色,这就是强三色不变性, 如果黑色对象引用了白色对象,那么白色对象可能会被垃圾回收器误判为不可达而被回收
- 为了实现强三色不变性,在特定的写操作点,垃圾回收器会自动插入写屏障指令。这些指令会检查对象的引用关系,并在涉及到指向堆上对象的指针时,将白色对象标记为灰色,以满足强三色不变性的要求。总结触发插入写屏障的几种情况:
- 内存分配: 当程序需要为某个变量分配内存时,编译器会自动在该操作之前插入写屏障指令。这些指令会检查新分配的对象是否为 Heap 对象,并将其标记为灰色以便后续扫描。
- Slice 和 Map 扩容: 在进行 Slice 或 Map 扩容操作时,编译器会自动插入写屏障,在每次扩容时,编译器会为新分配的内存区域插入写屏障指令,以确保扩容后的对象能够正确地被追踪和标记。
- 变量赋值: 如果程序员对某个变量进行赋值操作,编译器会自动插入写屏障指令,以确保被赋值的对象能够被正确地追踪和标记。
- 并发情况下的写屏障: 在并发模式下,写屏障需要确保线程安全性。因此,Golang 的垃圾收集器使用原子操作来保护写屏障指令的执行过程,以避免多个 goroutine 并发执行而导致数据竞争等问题。具体来说,它会使用基本的操作(如 Load、Store、CompareAndSwap 等)来更新对象的标记并确保线程安全
- 例如:垃圾收集与程序并发执行时插入或修改对象引用:
- 有一个黑色对象A,引用了一个白色对象B,B又引用了一个白色对象C。
- 如果没有插入写屏障,当垃圾回收器扫描到A时,它会认为A没有引用任何其他对象,因为A的指针指向的是白色对象B,导致B和C被误判为不可达而被回收
- 当使用了插入写屏障,在A引用B时,会将B标记为灰色,在B引用C时,会将C标记为灰色,在垃圾回收扫描到A时,它会发现A引用了一个灰色对象B,并将B标记为黑色,并继续扫描B的子对象C,最终,A、B和C都会被标记为黑色,不会被回收
- 通过一个模拟更新对象指针的伪代码解释什么是插入写屏障:
- writePointer()函数接收两个变量,用于更新指针字段
- 在更新指针前先调用一个shade(ptr)函数触发插入写屏障,将指向堆上对象 ptr 的指针标记为灰色,以确保这个对象会被垃圾回收器扫描到
- 然后再执行 *field = ptr 将指针字段更新为新的值 ptr
- 假设当前程序中存在a对象,为黑色,a对象指向b对象,b为灰色
- 此时代码执行创建c对象,将a对象原本执行b对象的引用指向c对象,触发插入写屏障,将b对象修改为灰色
- 最后垃圾收集器执行标记,将所有灰对象标记为黑色,最终a,b,c(有引用)都是存活对象
writePointer(slot, ptr):
shade(ptr)
*field = ptr
//伪代码
添加下游对象(当前下游对象slot, 新下游对象ptr) {
//1先执行一个插入写屏障函数,将堆上的对象标记为灰色
标记灰色(新下游对象ptr)
//2然后再更新
当前下游对象slot = 新下游对象ptr
}
- 插入写屏障优缺点:
- 可以减少STW的时间,程序执行过程中新建或修改对象引用时,将黑色对象引用的白色对象都涂灰,不允许黑色对象引用白色对象,对象的引用都在栈中,在GC结束时重新扫描栈空间按照三色标记算法垃圾标记即可,对象一旦被标记为灰色,就不会被回收,不需要在GC开始时扫描整个根节点集合
- 缺点是性能损耗大,因为每次黑色对象引用白色对象时都需要多执行一个插入写屏障的操作,栈空间对性能要求更高,而且栈空间的对象数量较少,如果在栈空间的对象操作中使用插入写屏障,会增加赋值的开销和复杂度,而且对GC的效果不大,因此插入写屏障只适用于堆空间的对象操作,而在栈空间的对象操作中不使用
- 在垃圾收集角度为了保障内存安全,必须为栈上的对象增加写屏障或者在标记阶段完成重新扫描栈上的对象判断存活状态,前者会大幅度增加写入指针的额外开销,后者重新扫描栈对象时需要暂停程序,垃圾收集算法的设计者需要在这两者之前做出权衡
2. 弱三色不变性与针对删除时的删除写屏障
- 什么是弱三色不变性: 是一种保证三色标记法正确性的原则,要求所有被黑色对象引用的白色对象都处于灰色保护状态,即如果黑色对象引用了白色对象,但是白色对象没有被灰色对象引用,白色对象可能会被垃圾回收器误判为不可达而被回收,导致对象丢失。
- 为了实现弱三色不变性,在特定的操作点,垃圾收集器会插入删除写屏障,通过删除写屏障保证弱三色不变性, 简单来说删除写屏障就是在删除老对象的引用时,将白色的老对象涂成灰色,这样删除写屏障就可以保证弱三色不变性,老对象引用的下游对象一定可以被灰色对象引用,删除写屏障也叫做基于起始快照的写屏障方案,在GC开始时,STW扫描整个根节点集合,保证所有堆上在用的对象都处于灰色保护下,保证起始时刻的所有存活对象不会被丢失,常见插入删除写屏障的操作点
- 对象引用修改:当一个黑色对象修改了其引用的白色对象时,需要插入写屏障。这样可以将被引用的白色对象标记为灰色,以确保其不会被错误地回收。
- 对象分配:当新的对象被分配并初始化后,需要将其标记为灰色,插入写屏障来更新引用关系。这样可以确保新分配的对象处于灰色状态,准备进行可达性分析。
- 对象销毁:当对象被销毁或者引用关系被删除时,需要插入写屏障来更新相关引用的状态。这样可以确保被删除的引用不再保持对象的灰色状态,从而允许垃圾回收器正确识别不再可达的对象。
- 举例:
- 假设当前程序中存在一个灰色可达对象b,指向c对象
- 后续程序执行,删除b对象指向c对象的引用,会触发删除写屏障,将b标记为灰色,保证b与b下面的对象不是垃圾
- 最终都变为黑色
- 删除写屏障优缺点
- 优点是可以减少STW的时间,在GC开始时扫描一次根节点,保证起始时刻的所有存活对象不会被丢失,后续通过删除写屏障防止对象丢失,减少了一次GC结束时重新扫描栈空间重新判断的流程,
- 缺点收精度低,一个对象即使被删除了最后一个指向它的指针,也依然可以活一轮GC,在下一轮GC中才被清除
3. 混合写屏障
- 在Go语言中由于栈上的对象只能由当前协程访问,不存在并发竞争的情况,同时在函数返回时,所有栈上的对象都会被自动清理,并且栈对性能要求较高,使用插入写屏障时黑色对象引用白色对象时都需要多执行一个插入写屏障的操,会增加赋值的开销和复杂度,性能损耗较大,所以栈上的对象没有插入屏障。堆上的对象可能会被多个协程共享所以需要使用写屏障来确保正确地管理内存和避免竞争条件
- 插入写屏障是指当黑色对象插入新的指向白色对象的引用时,就将白色对象标记为灰色。这样可以保证强三色不变式,即不存在黑色对象直接指向白色对象的情况。但是由于栈上对象无法使用写屏障,所以在标记阶段结束后,还需要 STW 重新扫描栈,以避免漏标或错标,重新扫描栈空间会增加 GC 的总时间,延长 GC 的周期,并且运行时并没有在所有的垃圾收集根对象上开启插入写屏障。因为应用程序可能包含成百上千的Goroutine,而垃圾收集的根对象一般包括全局变量和栈对象,如果运行时需要在几百个Goroutine的栈上都开启写屏障,会带来巨大的额外开销
- 删除写屏障是指当灰色对象删除原有的指向白色对象的引用时,就将白色对象标记为灰色。这样可以保证弱三色不变式,即所有被黑色对象引用的白色对象都处于灰色保护状态。但是为了实现这种方式,需要在标记阶段开始时,STW 扫描整个栈,保证所有堆上在用的对象都处于灰色保护下
- Go 在1.8采用插入写屏障和删除写屏障组合的混合写屏障,避免了两次 STW 的开销,减少了标记终止阶段的重扫成本和暂停时间
- GC开始时,STW扫描堆栈来记录初始快照,这个过程会保护开始时刻的所有存活对象,将所有新创建的对象标记为黑色,防止它们被错误地回收
- 从根对象开始,将可达的白色对象标记为灰色,放入灰色集合
- 遍历灰色集合,将灰色对象引用的白色对象标记为灰色,放入灰色集合,将此灰色对象标记为黑色,放入黑色集合
- 重复遍历直到灰色集合为空,回收所有剩余的白色对象
- 在这个过程中,如果程序对对象引用关系进行了修改,例如添加修改或删除一个下游对象,会触发混合写屏障机制
- 例如在堆上对象的赋值操作时,使用插入写屏障,将新对象标记为灰色,保证强三色不变式(不存在黑色对象引用白色对象的情况)
- 在删除对象引用时或栈上对象的赋值操作时,例如在A对象删除对B对象的引用时,使用删除写屏障,将被覆盖的对象标记为灰色,保证弱三色不变式(所有被黑色对象引用的白色对象都处于灰色保护状态)
- 在标记阶段结束时,不需要重新扫描栈空间,只需要扫描灰色集合中的对象,将它们标记为黑色,并继续追踪它们引用的白色对象
- Go 1.9版本的混合写屏障,在1.8版本的基础上做了一些优化(1.8版本的混合写屏障是固定地在堆上使用插入写屏障,在栈上使用删除写屏障),提出了一个标记队列, 可以根据堆与标记队列占比选择使用删除写屏障还是插入写屏障:
- 当堆大小和标记队列大小的比例小于 1/4 时,因为此时删除写屏障会导致起始时的STW扫描堆栈过长,增加暂停时间和扫描精度,所以使用插入写屏障,将新引用的对象标记为灰色,并将其加入标记队列,在使用插入写屏障时,因为栈上的对象都已经被标记为黑色或灰色,并且没有黑色对象指向白色对象的情况,所以不需要结束时重新扫描栈,而1.8版本的混合写屏障在使用插入写屏障时,需要结束时重新扫描栈,以确保没有遗漏的白色对象。
- 当堆大小和标记队列大小的比例大于 1/2 时,因为此时插入写屏障会导致标记队列过长,增加扫描时间和内存开销,所以使用删除写屏障,在使用删除写屏障时,不需要一开始就扫描整个栈,而是在扫描到某个栈的时候,暂停该goroutine,扫描该栈,并记录初始快照。这样可以减少起始时的STW扫描堆栈的时间和精度。而1.8版本的混合写屏障在使用删除写屏障时,需要一开始就扫描整个栈,并记录初始快照。
- 1.9版本的混合写屏障实现了全程无STW的并发标记,降低了GC的暂停时间和内存占用
- “删除屏障缓存”
- 在删除写屏障模式下,如果一个对象被多次引用和解引用,可能会被多次加入标记队列,导致重复扫描和浪费资源,为了避免该问题,混合写屏障机制使用了一个布尔数组来记录哪些对象已经被加入过标记队列,这个数组称为“删除屏障缓存”
- 删除屏障缓存的大小是堆大小的1/64,每个位对应一个64字节的内存块,如果一个对象被加入过标记队列,那么它所在内存块对应的位就会被置为1
- 在删除写屏障模式下,每次将一个对象加入标记队列之前,都会先检查删除屏障缓存中对应的位是否为1,如果是,则跳过该对象,否则置为1并加入标记队列
- 在切换回插入写屏障模式之前,会清空删除屏障缓存,以便下次使用
- java中也是采用这种方式执行的:
- 增量更新:破坏第一个条件;当用户新插入黑色到白色的新引用时,记录下来,等并发扫描结束后,再将记录的这些黑色对象作为根重新扫描一次,这样白色对象就会变成黑色从而保留下来;典型的垃圾回收器:CMS
- 原始快照:破坏第二个条件;当用户删除灰色到白色的引用时,记录下来,等并发扫描结束后,再将记录的这些灰色对象作为根重新扫描一次,这样白色对象就会变成黑色从而保留下来;典型的垃圾回收器:G1
4. golang中收集器的演进过程
- 演讲过程:
v1.0 — 完全串行的标记和清除过程,需要暂停整个程序;
v1.1 — 在多核主机并行执行垃圾收集的标记和清除阶段;
v1.3 — 运行时基于只有指针类型的值包含指针的假设增加了对栈内存的精确扫描支持,实现了真正精确的垃圾收集;将unsafe.Pointer类型转换成整数类型的值认定为不合法的,可能会造成悬挂指针等严重问题;
v1.5 — 实现了基于三色标记清扫的并发垃圾收集器:大幅度降低垃圾收集的延迟从几百 ms 降低至 10ms 以下;计算垃圾收集启动的合适时间并通过并发加速垃圾收集的过程;
v1.6 — 实现了去中心化的垃圾收集协调器:基于显式的状态机使得任意Goroutine都能触发垃圾收集的状态迁移;使用密集的位图替代空闲链表表示的堆内存,降低清除阶段的CPU占用;
v1.7 — 通过并行栈收缩将垃圾收集的时间缩短至2ms以内;
v1.8 — 使用混合写屏障将垃圾收集的时间缩短至0.5ms以内;
v1.9 — 彻底移除暂停程序的重新扫描栈的过程;
v1.10 — 更新了垃圾收集调频器(Pacer)的实现,分离软硬堆大小的目标;
v1.12 — 使用新的标记终止算法简化垃圾收集器的几个阶段;
v1.13 — 通过新的 Scavenger 解决瞬时内存占用过高的应用程序向操作系统归还内存的问题;
v1.14 — 使用全新的页分配器优化内存分配的速度;
v1.15 — 改进编译器和运行时内部的CL 226367,它使编译器可以将更多的x86寄存器用于垃圾收集器的写屏障调用;
v1.16 — Go runtime默认使用MADV_DONTNEED更积极的将不用的内存释放给OS。
v1.8-三色标记法,混合写屏障机制, 栈空间不启动,堆空间启动。整个过程几乎不需要STW,效率较高。
v1.5 中引入的并发的垃圾收集器
- v1.5 中引入了并发的垃圾收集器,三色抽象和写屏障技术保证垃圾收集器执行的正确性:
- 垃圾收集器执行时会占用 25% 计算资源在后台来扫描并标记内存中的对象
- 会在扫描对象之前stw暂停程序做一些标记对象的准备工作,其中包括启动后台标记的垃圾收集器以及开启写屏障,如果在后台执行的垃圾收集器不够快,应用程序申请内存的速度超过预期,运行时就会让申请内存的应用程序辅助完成垃圾收集的扫描阶段,
- 在标记和标记终止阶段结束之后就会进入异步的清理阶段,将不用的内存增量回收
- v1.5 版本实现的并发垃圾收集策略由专门的 Goroutine 负责在处理器之间同步和协调垃圾收集的状态。当其他的 Goroutine 发现需要触发垃圾收集时,它们需要将该信息通知给负责修改状态的主 Goroutine,然而这个通知的过程会带来一定的延迟,这个延迟的时间窗口很可能是不可控的,用户程序会在这段时间分配界面很多内存空间
- 注意Go1.5版本使用的是插入屏障,但是这种方式需要在标记结束时进行一次栈的重新扫描,这个过程需要STW (stop the world)
v1.6 引入了去中心化的垃圾收集协调机制
- v1.6 引入了去中心化的垃圾收集协调机制,将垃圾收集器变成一个显式的状态机,任意的 Goroutine 都可以调用方法触发状态的迁移,常见的状态迁移方法包括以下几个
- runtime.gcStart — 从 _GCoff 转换至 _GCmark 阶段,进入并发标记阶段并打开写屏障;
- runtime.gcMarkDone — 如果所有可达对象都已经完成扫描,调用 runtime.gcMarkTermination;
- runtime.gcMarkTermination — 从 _GCmark 转换 _GCmarktermination 阶段,进入标记终止阶段并在完成后进入 _GCoff
- 去中心化的垃圾收集协调机制举例:
- 当程序的内存占用达到一定阈值时,任意一个 Goroutine 都可以调用 runtime.gcStart 方法来触发垃圾收集器的状态转换,并开始并发标记阶段。
- 在这个阶段中,垃圾收集器会使用三色标记算法来标记堆中所有存活的对象,并使用写屏障技术来保证三色不变性。
- 当所有的 Goroutine 都完成了自己的标记工作后,任意一个 Goroutine 都可以调用 runtime.gcMarkDone 方法来结束并发标记阶段,并进入 STW 的标记终止阶段。在这个阶段中,垃圾收集器会扫描并发标记期间发生变化的对象,并将它们也标记为存活。
- 当这个过程结束后,任意一个 Goroutine 都可以调用 runtime.gcMarkTermination 方法来结束 STW 并重新启动用户程序。此时,垃圾收集器会进入并发清除阶段,并使用密集的位图来表示堆内存的使用情况,从而降低清除阶段的 CPU 占用。> 5. 当所有的堆内存都被扫描并清除后,任意一个 Goroutine 都可以调用 runtime.gcSweepDone 方法来结束并发清除阶段,并将垃圾收集器的状态转换为 _GCoff
- 去中心化的协调机制可以分散垃圾收集器的压力,并且可以让每个 Goroutine 都参与垃圾收集的过程
v1.8
- 三⾊标记法,混合写屏障机制,栈空间不启动,堆空间启动,整体过程⼏乎不需要STW具体操作: 参考混合写屏障
二. GC过程
总结性的说一下垃圾收集的多个阶段
1.sweep termination 清理终止阶段
- 为下一个阶段的并发标记做准备工作,这个节点程序是挂起的,启动写屏障,所有的P处理器进入safe-point安全点
- 清理未被清理的 span ,如果当前垃圾收集是强制触发的,需要处理还未被清理的内存管理单元;
2.the mark phase 扫描标记阶段- 将GC状态gcphase从_GCoff改成_GCmark、开启写屏障、启用协助线程(mutator assists),将根对象入队;
- 恢复程序执行,标记进程mark workers,和协助程序会开始并发标记内存中的对象,写屏障会覆盖的重写指针和新指针(标记成灰色)而所有新创建的对象都会被直接标记成黑色;
- GC执行根节点的标记,这包括扫描所有的栈、全局对象以及不在堆中的运行时数据结构。扫描goroutine栈会导致goroutine停止,并对栈上找到的所有指针加置灰,然后继续执行goroutine;
- GC遍历灰色对象队列,会将灰色对象变成黑色,并将该指针指向的对象置灰;
- 由于GC工作分布在本地缓存中,GC会使用分布式终止算法(distributed termination algorithm)来检测何时不再有根标记作业或灰色对象,如果没有了GC会转为mark termination(标记终止)。
- mark termination 标记终止阶段
- STW,这个阶段会挂起程序,保证一个周期内标记任务完成,停止写屏障
- 将GC状态gcphase切换至_GCmarktermination,关闭gc工作线程和协助程序;
- 执行housekeeping,例如刷新mcaches。
- the sweep phase清理阶段
- 将GC状态gcphase切换至_GCoff来准备清理阶段,初始化清理阶段并关闭写屏障;
- 恢复用户程序,从现在开始,所有新创建的对象会标记成白色;如果有必要,在使用前分配清理spans;
- 后台并发清理所有的内存管理类单元。
垃圾收集时的几种状态
- _GCoff:表示垃圾收集器处于关闭状态,没有进行任何垃圾收集的工作;
- _GCmark:表示垃圾收集器处于并发标记阶段,它会从根对象开始,使用三色标记算法来标记所有存活的对象,并使用写屏障技术来保证三色不变性;
- _GCmarktermination:表示垃圾收集器处于标记终止阶段,它会暂停所有用户程序(STW),并扫描并发标记期间发生变化的对象,并将它们也标记为存活;
- _GCsweep:表示垃圾收集器处于并发清除阶段,它会使用密集的位图来表示堆内存的使用情况,并清除所有未被标记为存活的对象;
垃圾收集过程中涉及到的全局变量解释
- 在垃圾收集中有一些比较重要的全局变量,在分析其过程之前,先逐一介绍这些重要的变量,这些变量在垃圾收集的各个阶段中会反复出现,所以先理解一下
- runtime.gcphase 是垃圾收集器当前处于的阶段,可能处于 _GCoff、_GCmark 和 _GCmarktermination,Goroutine 在读取或者修改该阶段时需要保证原子性;
- runtime.gcBlackenEnabled 是一个布尔值,当垃圾收集处于标记阶段时,该变量会被置为 1,在这里辅助垃圾收集的用户程序和后台标记的任务可以将对象涂黑;
- runtime.gcController 实现了垃圾收集的调步算法,它能够决定触发并行垃圾收集的时间和待处理的工作;
- runtime.gcpercent 是触发垃圾收集的内存增长百分比,默认情况下为 100,即堆内存相比上次垃圾收集增长 100% 时应该触发 GC,并行的垃圾收集器会在到达该目标前完成垃圾收集;
- runtime.writeBarrier 是一个包含写屏障状态的结构体,其中的 enabled 字段表示写屏障的开启与关闭;
- runtime.worldsema 是全局的信号量,获取该信号量的线程有权利暂停当前应用程序;
- 还需要简单了解一下 runtime.work,该结构体中包含大量垃圾收集的相关字段,例如:表示完成的垃圾收集循环的次数、当前循环时间和 CPU 的利用率、垃圾收集的模式等等,我们会在后面的小节中见到该结构体中的更多的字段
var work struct {
full lfstack
empty lfstack
pad0 cpu.CacheLinePad
//wbufSpans这个属性是在Go语言1.16版本中引入的,用于管理workbuf的span分配和回收。
//在之前的版本中,workbuf的span分配和回收是直接使用mheap_.allocManual和mheap_.freeManual函数进行的,
//wbufSpans这个属性可以提高workbuf的分配和回收的效率,减少内存碎片
wbufSpans struct {
lock mutex
free mSpanList
busy mSpanList
}
...
nproc uint32
tstart int64
nwait uint32
ndone uint32
...
mode gcMode
cycles uint32
...
stwprocs, maxprocs int32
...
}
GC 底层执行详解
1. 垃圾收集的触发条件
- 在程序运行时通过调用runtime.gcTrigger.test ()方法,判断是否需要触发垃圾收集
//mgc.go 文件 runtime.gcTrigger.test
func (t gcTrigger) test() bool {
if !memstats.enablegc || panicking != 0 || gcphase != _GCoff {
return false
}
switch t.kind {
case gcTriggerHeap:
// 满足内存条件触发,当前已分配的内存memstats.heap_live超过某个设定的值memstats.gc_trigger时触发
return memstats.heap_live >= memstats.gc_trigger
case gcTriggerTime:
// 定时周期性触发,即上次GC完成到当前的时间差超过forcegcperiod会执行,forcegcperiod为2分钟
// 也就是每2分钟会强制触发执行GC
if gcpercent < 0 {
return false
}
lastgc := int64(atomic.Load64(&memstats.last_gc_nanotime))
return lastgc != 0 && t.now-lastgc > forcegcperiod
case gcTriggerCycle:
// 手动触发,用户程序手动调用runtime.GC(),否则进入新的循环
return int32(t.n-work.cycles) > 0
}
return true
}
- 在gcTrigger的test ()方法中根据三种不同的方式检查是否需要触发垃圾收集
- gcTriggerHeap: 堆内存的分配达到控制器计算的触发堆大小,初始大小由环境变量 GOGC 决定,默认是 100 表示当内存的增加值小于等于 100% 时会强制进行一次垃圾回收,之后堆内存达到上一次垃圾收集的 2 倍时才会触发垃圾收集
- gcTriggerTime: 如果一定时间内没有触发,就会触发新的循环,该出发条件由 runtime.forcegcperiod 变量控制,默认为 2 分钟;
- gcTriggerCycle: 如果当前没有开启垃圾收集,则触发新的循环,它的作用是保证在调用 runtime.GC() 时,能够启动一轮新的 GC,而不是跳过已经在进行中的 GC。
- forcegcperiod 间隔时间在src/runtime/proc.go:forcegcperiod文件中
var forcegcperiod int64 = 2 * 60 * 1e9
- 当判断需要开启垃圾收集时会执行gcStart()函数,因此所有调用该函数的地方都是触发GC的代码
2. 什么情况下会执行gcStart()启动垃圾收集
- 在一下几种函数中都会调用gcStart()判断是否需要垃圾收集,如果需要启动收集
- runtime.mallocgc(): 申请内存时根据堆大小触发垃圾收集;
- runtime.forcegchelper() 和 runtime.sysmon(): 后台运行定时检查和垃圾收集;
- runtime.GC()/ System.gc : 手动执行GC()方法时触发垃圾收集;
runtime.mallocgc(): 申请内存时根据堆大小触发
- Go运行时会将堆上的对象按大小分成微对象、小对象和大对象三类,这三类对象的创建都可能会触发新的GC
- 当前线程的内存管理单元中不存在空闲空间时
- 创建微对象(noscan &&size<maxTinySize)和小对象需要调用 runtime.mcache.nextFree从中心缓存或者页堆中获取新的管理单元,这时如果span满了就会导致返回的shouldhelpgc=true,就可能触发垃圾收集;
- 当用户程序申请分配32KB以上的大对象时,一定会构建 runtime.gcTrigger结构体也会尝试触发垃圾收集
- 申请内存时底层会执行mallocgc()这个函数,
func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
省略代码 ...
shouldhelpgc := false
dataSize := size
//尝试获取mCache。如果没启动或者没有P,返回nil;
c := getMCache()
省略代码 ...
if size <= maxSmallSize {
if noscan && size < maxTinySize {
//微对象分配
省略代码 ...
v := nextFreeFast(span)
if v == 0 {
v, span, shouldhelpgc = c.nextFree(tinySpanClass)
}
省略代码 ...
} else {
//小对象分配
省略代码 ...
if v == 0 {
v, span, shouldhelpgc = c.nextFree(spc)
}
省略代码 ...
}
} else {
//大对象分配
shouldhelpgc = true
省略代码 ...
}
省略代码 ...
//是否应该触发gc
if shouldhelpgc {
//如果满足gc触发条件就调用gcStart()
if t := (gcTrigger{kind: gcTriggerHeap}); t.test() {
gcStart(t)
}
}
省略代码 ...
return x
}
- 在mallocgc()函数中,首先会判断对象大小,根据不同的对象大小执行对应的分配策略,根据不同的分配策略,是否触发垃圾收集有两个路径
- 如果当前分配的微对象,或者小对象,当内存块中不包含空闲的内存时,底层会执行到一个runtime.mcache.nextFree()函数,从中心缓存或者页堆中获取新的管理单元,这时如果span满了就会导致返回的shouldhelpgc=true,然后执行runtime.gcTrigger.test()判断是否需要执行来及收集(具体查看内存分配章节)
- 如果当前分配的是大对象,shouldhelpgc写死为true,这时候会获取gcTriggerHeap判断gcController.heapLive堆内存中已分配的对象的大小 >= gcController.trigger上一轮设置的垃圾收集的阈值,如果为true则通过test()判断是否需要执行来及收集
- 大对象触发垃圾收集时heapLive与trigger的解释
- heapLive: 是指当前堆内存中已分配的对象的大小,是一个原子变量,为了减少锁竞争,运行时只会在中心缓存分配或者释放内存管理单元以及在堆上分配大对象时才会更新;
- trigger: 是指触发下一轮垃圾收集的阈值,在标记终止阶段调用runtime.gcSetTriggerRatio更新触发下一次垃圾收集的堆大小,它能够决定触发垃圾收集的时间以及用户程序和后台处理的标记任务的多少,利用反馈控制的算法根据堆的增长情况和垃圾收集CPU利用率确定触发垃圾收集的时机
runtime.forcegchelper(): 后台运行定时检查触发
- 在proc.go文件中有一个init()方法,init()方法中开启协程执行forcegchelper(),也就是说在程序启动时,执行forcegchelper(),它的作用是强制触发一轮垃圾收集,并等待它完成,forcegchelper() 函数中会
- 获取当前的 g,并将其赋值给 forcegc.g,然后初始化 forcegc.lock,并加锁
- forcegchelper() 函数会将 forcegc.idle 设置为 1,表示当前没有垃圾收集任务,并调用 goparkunlock() 函数将自己阻塞在 forcegc.lock 上,等待被唤醒
- 比如当用户程序显式调用 runtime.GC() 函数或者系统监控线程 runtime.sysmon() 检测到一定时间内没有触发垃圾收集时,会调用 wakep() 函数唤醒 forcegchelper() 函数。
- forcegchelper() 函数被唤醒后,会调用 gcStart() 函数启动一轮垃圾收集,并传入 gcBackgroundMode 和 gcTriggerCycle 作为参数。
- gcStart() 函数会根据参数设置垃圾收集的模式和触发条件,并执行垃圾收集的各个阶段,包括标记、清除、终止等。
- 当垃圾收集结束后,forcegchelper() 函数会重新加锁 forcegc.lock,并重复上述步骤,等待下一次被唤醒
// start forcegc helper goroutine
func init() {
go forcegchelper()
}
func forcegchelper() {
forcegc.g = getg()
lockInit(&forcegc.lock, lockRankForcegc)
for {
lock(&forcegc.lock)
if forcegc.idle != 0 {
throw("forcegc: phase error")
}
atomic.Store(&forcegc.idle, 1)
//该 Goroutine 会在循环中调用runtime.goparkunlock主动陷入休眠等待其他 Goroutine 的唤醒
goparkunlock(&forcegc.lock, waitReasonForceGCIdle, traceEvGoBlock, 1)
if debug.gctrace > 0 {
println("GC forced")
}
// Time-triggered, fully concurrent.
gcStart(gcTrigger{kind: gcTriggerTime, now: nanotime()})
}
}
- 注意为了减少对计算资源的占用,该 Goroutine 会在循环中调用 runtime.goparkunlock 主动陷入休眠等待其他 Goroutine 的唤醒,runtime.forcegchelper 在大多数时间都是陷入休眠的,但是它会被系统监控器 runtime.sysmon 在满足垃圾收集条件时唤醒,在sysmon()这个函数中,会执行test()判断是否需要进行垃圾收集
func sysmon() {
...
for {
...
if t := (gcTrigger{kind: gcTriggerTime, now: now}); t.test() && atomic.Load(&forcegc.idle) != 0 {
lock(&forcegc.lock)
forcegc.idle = 0
var list gList
list.push(forcegc.g)
injectglist(&list)
unlock(&forcegc.lock)
}
}
}
runtime.GC(): 手动调用触发
- 可以手动调用runtime下的GC()函数, 运行期间主动通知运行时执行,该方法在调用时会阻塞调用方直到当前垃圾收集循环完成,在垃圾收集期间也可能会通过 STW 暂停整个程序
func GC() {
//在正式开始垃圾收集前,运行时需要通过runtime.gcWaitOnMark等待上一个循环的标记终止、标记和清除终止阶段完成;
n := atomic.Load(&work.cycles)
gcWaitOnMark(n)
//调用 `runtime.gcStart` 触发新一轮的垃圾收集
gcStart(gcTrigger{kind: gcTriggerCycle, n: n + 1})
//`runtime.gcWaitOnMark` 等待该轮垃圾收集的标记终止阶段正常结束;
gcWaitOnMark(n + 1)
// 持续调用 `runtime.sweepone` 清理全部待处理的内存管理单元并等待所有的清理工作完成
for atomic.Load(&work.cycles) == n+1 && sweepone() != ^uintptr(0) {
sweep.nbgsweep++
Gosched() //等待期间会调用 `runtime.Gosched` 让出处理器
}
//
for atomic.Load(&work.cycles) == n+1 && !isSweepDone() {
Gosched()
}
// 完成本轮垃圾收集的清理工作后,通过 `runtime.mProf_PostSweep` 将该阶段的堆内存状态快照发布出来,我们可以获取这时的内存状态
mp := acquirem()
cycle := atomic.Load(&work.cycles)
if cycle == n+1 || (gcphase == _GCmark && cycle == n+2) { //仅限于没有启动其他标记终止过程
mProf_PostSweep()
}
releasem(mp)
}
- 手动触发时GC()函数内部执行流程:
- 首先会执行 runtime.gcWaitOnMark()函数等待上一个循环的标记终止、标记和标记终止阶段完成;
- 然后调用 runtime.gcStart() 触发新一轮的垃圾收集并通过 runtime.gcWaitOnMark() 等待该轮垃圾收集的标记终止阶段正常结束;
- 持续调用 runtime.sweepone() 清理全部待处理的内存管理单元并等待所有的清理工作完成,等待期间会调用 runtime.Gosched()让出处理器
- 完成本轮垃圾收集的清理工作后,通过runtime.mProf_PostSweep 将该阶段的堆内存状态快照发布出来,我们可以获取这时的内存状态
gcStart(): 启动垃圾收集后,内部执行细节
- 查看gcStart()源码,该函数的实现比较复杂,但是它的主要职责就是修改全局的垃圾收集状态到 _GCmark并发标记, 并做一些准备工作,大致可以分为:
- gcStart() 函数是垃圾收集的入口函数,它接受一个 gcTrigger 结构体作为参数,表示垃圾收集的触发条件和类型
- 内部首先会检查当前的 GC 状态是否为 _GCoff处于关闭状态,如果不是,就会返回或者等待上一轮的 GC 结束。调用trigger.test()验证是否开启垃圾收集(会调用两次),并且内部会在循环中不断调用 runtime.sweepone 清理已经被标记的内存单元,完成上一个垃圾收集循环的收尾工作
- 然后会调用 semacquire(&worldsema) 等待 worldsema 的所有权,以确保没有其他线程正在执行暂停或恢复操作
- 接着会调用 stopTheWorldWithSema() 函数暂停所有的用户线程,并设置 GC 状态为 _GCmark。
- 接下来会调用 gcResetMarkState() 函数重置 GC 的标记状态,并开启写屏障和辅助标记。
- 调用 startTheWorldWithSema() 函数恢复所有的用户线程,并释放 worldsema 的所有权。
- 最后会调用 gcController.startCycle() 函数启动 GC 的循环,并创建后台的标记和清扫线程
- 接下来我们查看gcStart()源码分成几个节点去理解
- 调用 runtime.gcTrigger.test 方法检查是否满足垃圾收集条件;
- 暂停程序、在后台启动用于处理标记任务的工作 Goroutine、确定所有内存管理单元都被清理以及其他标记阶段开始前的准备工作
- 进入标记阶段、准备后台的标记工作、根对象的标记工作以及微对象、恢复用户程序,进入并发扫描和标记阶段;
1. 验证垃圾收集条件并判断完成上一次垃圾收尾工作
- 首先会test()验证是否开启垃圾收集(会调用两次),并且内部会在循环中不断调用 runtime.sweepone 清理已经被标记的内存单元,完成上一个垃圾收集循环的收尾工作
func gcStart(trigger gcTrigger) {
//test()验证是否开启垃圾收集
//执行循环不断调用 runtime.sweepone()清理已经被标记的内存单元,完成上一个垃圾收集循环的收尾工作
for trigger.test() && sweepone() != ^uintptr(0) {
sweep.nbgsweep++
}
semacquire(&work.startSema)
if !trigger.test() {
semrelease(&work.startSema)
return
}
...
}
2. 启动后台标记任务并暂停程序
- 在验证了垃圾收集的条件并完成了收尾工作后,gcStart()方法继续向下执行查看源码:
func gcStart(trigger gcTrigger) {
...
//1.获取全局的 worldsema 信号量
semacquire(&worldsema)
//2.启动后台标记任务
gcBgMarkStartWorkers()
work.stwprocs, work.maxprocs = gomaxprocs, gomaxprocs
...
//3.暂停用户程序
systemstack(stopTheWorldWithSema)
systemstack(func() {
finishsweep_m()
})
work.cycles++
gcController.startCycle()
...
}
- 调用 semacquire() 获取全局的 worldsema 信号量
- worldsema 是一个全局的信号量,用于控制程序的暂停和恢复
- 当垃圾收集开始时,需要暂停所有的用户线程,这时会调用 stopTheWorldWithSema() 函数,该函数会获取 worldsema 的所有权,并调用 stopTheWorld() 函数执行暂停操作
- 当垃圾收集结束时,需要恢复所有的用户线程,这时会调用 startTheWorldWithSema() 函数,该函数会调用 startTheWorld() 函数执行恢复操作,并释放 worldsema 的所有权
- semacquire(&worldsema) 是在 gcStart() 函数中调用的,它的作用是等待 worldsema 的所有权,以确保没有其他线程正在执行暂停或恢复操作
- 调用 runtime.gcBgMarkStartWorkers 启动后台标记协程gcBgMarkWorker,用于执行并发的标记工作
- 函数内部会根据当前的处理器P数量和后台标记协程的数量,计算出需要启动的后台标记协程的数量
- 函数内部会循环调用runtime.startm函数,为每个需要启动的后台标记协程创建一个M,并将其加入到调度器的空闲列表
- 函数内部会设置一些标记相关的参数,如后台标记协程是否可以被抢占,当前活跃的标记协程的数量等
- 执行systemstack(stopTheWorldWithSema)在系统栈中调用 runtime.stopTheWorldWithSema 主要是用来暂停用户程序下方有详解
- 调用 runtime.finishsweep_m 保证上一个内存单元的正常回收
- 并且内部会获取全局变量 runtime.work,进行同步更新,包括垃圾收集需要的 Goroutine 数量以及已完成的循环数
详解 runtime.gcBgMarkStartWorkers() 启动后台标记模式
- 在gcStart会执行 runtime.gcBgMarkStartWorkers()开启后台标记, 在该函数中会获取所有处理器p,为每个处理器创建用于执行后台标记任务的 Goroutine,每一个 Goroutine 都会执行runtime.gcBgMarkWorker()进入后台并发标记阶段
func gcBgMarkStartWorkers() {
// 遍历所有 P
for _, p := range allp {
// 如果已启动则不重复启动
if p.gcBgMarkWorker == 0 {
// 为全局每个处理器创建用于执行后台标记任务的 Goroutine
go gcBgMarkWorker(p)
// 启动后等待该任务通知信号量 bgMarkReady 再继续
notetsleepg(&work.bgMarkReady, -1)
noteclear(&work.bgMarkReady)
}
}
}
- 此时用于执行后台标记任务的Goroutine与处理器是一 一对应的关系,这些Goroutine 在启动后都会陷入休眠等待调度器的唤醒
- 执行runtime.gcBgMarkWorker()进入后台并发标记阶段有三种模式,通过runtime.gcMarkWorkerMode变量表示
- 当gcMarkWorkerMode等于gcMarkWorkerDedicatedMode也就是0时表示专用模式,该模式下后台标记工作线程是专用的,且不会被调度器抢占,通过让专用的工作线程尽可能独立地执行标记任务来加速垃圾收集过程可以提高并发标记效率
- 当gcMarkWorkerMode等于gcMarkWorkerFractionalMode也就是1时表示分数化模式,该模式下后台标记工作线程的数量会根据系统的负载自动调整,以达到标记线程占用率的目标,默认为25%,(也就是说虽然为每个处理器P启动了一个后台标记任务, 但是可以同时工作的默认只有25%),例如如果 CPU 核数不是 4 的倍数,无法得到整数的占用率,就会启动分数化模式帮助垃圾收集器达到预设的占用率目标
- 当gcMarkWorkerMode等于gcMarkWorkerIdleMode也就是2时表示空闲模式,该模式下后台标记工作线程会在处理器没有可以执行的 Goroutine 时被调度运行,直到被调度器抢占,主要目的是利用闲置的处理器资源,确保在处理器没有其他任务执行时能够执行垃圾收集的标记任务,提高整体的收集效率
- 那么gcMarkWorkerMode垃圾收集工作模式变量是怎么计算出来的,这里要查看调度器源码,在go服务启动时底层会执行一个runtime.schedule()启动调度器,进入调度循环,在该函数中会调用gcController.findRunnableGCWorker来决定后台标记工作线程的运行模式,拿到gcMarkWorkerMode(这个方法好像是1.8版本,1.8以前用的好像是runtime.gcControllerState.findRunnabledGCWorker),查看该函数源码:
- 会先执行decIfPositive()函数,通过dedicatedMarkWorkersNeeded变量判断是否是专用模式,如果是设置gcMarkWorkerMode = gcMarkWorkerDedicatedMode
- 并根据执行标记任务的时间和总时间决定是否启动 gcMarkWorkerFractionalMode分数化模式
func (c *gcControllerState) findRunnableGCWorker(_p_ *p) *g {
//......
// 判断是否需要专门执行标记任务的 Goroutine 数量
if decIfPositive(&c.dedicatedMarkWorkersNeeded) {
_p_.gcMarkWorkerMode = gcMarkWorkerDedicatedMode // 设置工作模式为专用模式
} else if c.fractionalUtilizationGoal == 0 { // 当前没有设置分数利用率目标,则返回 nil
return nil
} else {
// 计算自最后一次标记开始以来的时间差
delta := nanotime() - gcController.markStartTime
// 如果时间差大于0且 _p_ 的分数标记时间与时间差之比大于分数利用率目标
// 则返回 nil
if delta > 0 && float64(_p_.gcFractionalMarkTime)/float64(delta) > c.fractionalUtilizationGoal {
return nil
}
_p_.gcMarkWorkerMode = gcMarkWorkerFractionalMode // 设置工作模式为分数模式
}
gp := _p_.gcBgMarkWorker.ptr()
casgstatus(gp, _Gwaiting, _Grunnable) // 将 Goroutine 状态更新为可运行状态
return gp
}
- 并且为了更好地利用系统资源并加快垃圾收集的执行,在调度循环的每一次迭代中,调度器会调用 findrunnable()函数来寻找可运行的 Goroutine,当发现存在后台标记任务时,调度器会将这个标记任务的Goroutine设置为gcMarkWorkerMode = gcMarkWorkerIdleMode空闲模式,空闲的处理器会根据调度策略来决定是否执行该空闲模式的垃圾收集任务Goroutine
func findrunnable() (gp *g, inheritTime bool) {
// ...
stop:
// 如果启用了黑色标记,并且当前 P 上存在后台标记工作线程,并且存在可执行的标记任务
if gcBlackenEnabled != 0 && _p_.gcBgMarkWorker != 0 && gcMarkWorkAvailable(_p_) {
_p_.gcMarkWorkerMode = gcMarkWorkerIdleMode // 设置工作模式为空闲模式
//_p_.gcBgMarkWorker 是指向后台标记工作线程的指针,通过.ptr()方法可以获取到这个指针对应的Goroutine对象
//赋值给变量gp
gp := _p_.gcBgMarkWorker.ptr()
casgstatus(gp, _Gwaiting, _Grunnable) // 将 Goroutine 状态更新为可运行状态
return gp, false // 返回 Goroutine 和继承时间(false表示不继承)
}
// ...
}
- 三种不同模式的工作协程会相互协同保证垃圾收集的 CPU 利用率达到期望的阈值,在到达目标堆大小前完成标记任务
- 不同模式的工作协程的数量是如何确定的:
- runtime.gcControllerState.startCycle 会根据全局处理器的个数以及垃圾收集的 CPU 利用率计算出上述的 dedicatedMarkWorkersNeeded 和 fractionalUtilizationGoal 以决定不同模式的工作协程的数量
- 后台标记任务的 CPU 利用率为 25%,如果主机是 4 核或者 8 核,那么垃圾收集需要 1 个或者 2 个专门处理相关任务的 Goroutine;不过如果主机是 3 核或者 6 核,因为无法被 4 整除,所以这时需要 0 个或者 1 个专门处理垃圾收集的 Goroutine,运行时需要占用某个 CPU 的部分时间,使用 gcMarkWorkerFractionalMode 模式的协程保证 CPU 的利用率
详解 runtime.stopTheWorldWithSema() 暂停用户程序
- 在上方执行垃圾收集过程中会调用runtime.stopTheWorldWithSema() 暂停用户程序
- 查看stopTheWorldWithSema()源码,会依次停止当前处理器,等待处于系统调用的处理器以及获取并抢占空闲的处理器,处理器的状态在该函数返回时都会被更新至 _Pgcstop,等待垃圾收集器的重新唤醒。
- 主要会调用 runtime.preemptall 函数,进而执行函数中的 runtime.preemptone()
- 获取程序中活跃的最大处理数为 gomaxprocs, runtime.stopTheWorldWithSema 在每次发现停止的处理器时都会对该变量减一,直到所有的处理器都停止运行
- stopTheWorldWithSema详细逻辑
- 该函数在需要在停止所有用户协程的情况下执行,比如在清理终止阶段Sweep Termination或标记终止阶段Mark Termination
- 函数内部首先会获取worldsema全局的信号量,用于防止多个协程同时尝试stw
- 然后会调用runtime.preemptall函数,向所有的处理器P发送抢占信号,让它们尽快进入安全点safe-point
- 调用runtime.stopTheWorldWithSema0函数,等待所有的处理器P进入安全点后,将它们从调度器中移除
- 调用runtime.gcResetMarkState函数,重置一些标记相关的状态和参数
- 调用runtime.systemstack函数,在系统栈上执行一些回调函数
- 最终函数会释放全局的信号量worldsema,并返回stw的时间
3. 进入标记状态扫描标记所有根与恢复程序运行
- 上方启动后台标记任务并暂停程序后,继续查看gcStart源码:
func gcStart(trigger gcTrigger) {
...
//1.修改垃圾收集状态到_GCmark并发标记状态
setGCPhase(_GCmark)
gcBgMarkPrepare()
gcMarkRootPrepare()
atomic.Store(&gcBlackenEnabled, 1)
systemstack(func() {
now = startTheWorldWithSema(trace.enabled)
work.pauseNS += now - work.pauseStart
work.tMark = now
})
semrelease(&work.startSema)
}
- 调用runtime.setGCPhase设置GC的状态从_GCoff修改为_GCmark标记状态,设置写屏障为启用状态
- 调用runtime.gcBgMarkPrepare(),后台扫描需要的状态的初始化,内部重点完成了
- 该函数内部首先会创建一个全局的标记队列gcWork,用来存储需要标记的对象
- 为每个处理器P分配一个本地的标记队列p.gcw,用于缓存需要标记的对象
- 为每个处理器P创建一个后台标记协程gcBgMarkWorker用于执行并发的标记工作
- 调用runtime.gcMarkRootPrepare()进行标记准备,进行根初始化,将栈上和全局变量等根对象加入到标记队列,函数内部
- 首先会始化一些标记所需的数据结构和参数
- 会计算出所有的标记根mark root包括全局变量,活跃的goroutine的栈,finalizer队列等
- 将所有的标记栈mark stack清空,并将计算出的所有的标记根压入标记栈
- 设置标记相关的参数,如标记阶段的目标堆大小,下一次GC的触发阈值,写屏障的状态等
- 最终返回一个表示是否需要执行标记阶段的布尔值
- 将gcBlackenEnabled设置为1,表示用户程序和标记任务可以将对象进行涂黑操作了
- 如果GC处于_GCmark标记阶段,或_GCmarktermination标记终止阶段,并且GC百分比不小于0,返回true表示启用并发标记
- 如果GC不是标记或标记终止阶段,或者GC百分比小于0,返回false,表示不启用并发标记
- 调用runtime.startTheWorldWithSema()恢复程序,启动goroutine的执行,这个时候用户程序可以运行了,后台任务也会开始标记堆中的对象
详解 runtime.startTheWorldWithSema() 恢复程序
- 在垃圾收集完毕后,会执行runtime.startTheWorldWithSema() 恢复程序,这个函数是在需要恢复所有用户协程的情况下执行,比如在清理终止阶段或者标记终止阶段
- 函数内部首先会获取worldsema全局信号量,用于防止多个协程同时尝试恢复stw
- 调用semrelease(&worldsema),进而通过汇编方式调用runtime.startTheWorldWithSema0函数调用 startTheWorld() 释放 worldsema 的所有权,恢复所有的处理器P并将它们重新加入调度器
- 调用runtime.systemstack函数,在系统栈上执行一些回调函数
- 最终释放worldsema全局的信号量,并返回恢复stw的时间
并发扫描与标记辅助
- 在上方通过协程启动了runtime.gcBgMarkWorker(),它是后台的标记任务执行的函数,该函数的循环中执行了对内存中对象图的扫描和标记,接下来我们查看gcBgMarkWorker()源码,分三个部分介绍该函数的实现原理:
- 获取当前处理器以及 Goroutine 打包成 parkInfo 类型的结构体并主动陷入休眠等待唤醒;
- 根据处理器上的 gcMarkWorkerMode 模式决定扫描任务的策略;
- 所有标记任务都完成后,调用 runtime.gcMarkDone 方法完成标记
1. 准备阶段 获取Goroutine封装parkInfo
- 在准备阶段,运行时在这里创建了一个 parkInfo 结构体,该结构体会预先存储处理器和当前 Goroutine,当我们调用 runtime.gopark 触发休眠时,运行时会在系统栈中安全地建立处理器和后台标记任务的绑定关系
func gcBgMarkWorker(_p_ *p) {
// 获取当前协程的 Goroutine 对象并将其指针保存在 gp 中
gp := getg()
type parkInfo struct {
m muintptr // 用于保存关联的 M 线程的字段
attach puintptr // 用于保存关联的 P 处理器的字段
}
park := new(parkInfo)
park.m.set(acquirem()) // 将 park.m 设置为当前协程所属的 M
park.attach.set(_p_) // 将 park.attach 设置为调用 gcBgMarkWorker 函数时传入的参数 _p_,即处理器 P
notewakeup(&work.bgMarkReady) // 唤醒后台标记任务的准备就绪通知
for {
// 首先调用 gopark() 函数使当前协程进入休眠状态,等待被唤醒
gopark(func(g *g, parkp unsafe.Pointer) bool {
park := (*parkInfo)(parkp)
releasem(park.m.ptr()) // 释放之前关联的 M,重新允许抢占
// 检查 park.attach 是否为非零值,如果非零,则说明有关联的 P 需要被设置
if park.attach != 0 {
// 获取关联的 P
p := park.attach.ptr()
park.attach.set(nil) // 设置为空,下次循环时将无法再获取到关联的 P
// 通过cas操作尝试将 gcBgMarkWorker 标记任务的 Goroutine 绑定到这个 P 上运行
if !p.gcBgMarkWorker.cas(0, guintptr(unsafe.Pointer(g))) {
// P 获取到了新的 worker,退出当前 worker。
return false
}
}
return true
}, unsafe.Pointer(park), waitReasonGCWorkerIdle, traceEvGoBlock, 0)
}
...
}
- 注意: 通过 runtime.gopark 陷入休眠的 Goroutine 不会进入运行队列,它只会等待垃圾收集控制器或者调度器的直接唤醒
在gcBgMarkWorker函数中,gopark()函数被用于暂停当前goroutine的执行,等待新的任务等待或者信号。如果在这个等待期间,被等待的任务或信号通知到了,gopark()函数会返回true,goroutine会恢复执行。如果超过了指定时间,或者收到了其他的信号,gopark()函数会返回false,表示等待超时或者被中断,goroutine会退出等待状态,继续执行后续的任务。
2. 唤醒 Goroutine 根据不同的gcMarkWorkerMode模式扫描工作缓冲区
- 在Goroutine唤醒后,会根据处理器 gcMarkWorkerMode 选择不同的标记执行策略,不同的执行策略都会调用 runtime.gcDrain 扫描工作缓冲区 runtime.gcWork
}, unsafe.Pointer(park), waitReasonGCWorkerIdle, traceEvGoBlock, 0)
//此处代码还是在上方的for循环内,代码继续向下执行
//检查P的gcBgMarkWorker是否和当前的G一致, 不一致时结束当前的任务
if _p_.gcBgMarkWorker.ptr() != gp {
break
}
//gopark在上面释放了m,这里再抢占回来
park.m.set(acquirem())
atomic.Xadd(&work.nwait, -1)
切换到g0工作
systemstack(func() {
//设置G的状态为waiting,以便于另一个g扫描它的栈(两个g可以互相扫描对方的栈)
casgstatus(gp, _Grunning, _Gwaiting)
switch _p_.gcMarkWorkerMode {
case gcMarkWorkerDedicatedMode: //如果是专用模式
//执行专门模式的标记工作(不可抢占)
gcDrain(&_p_.gcw, gcDrainUntilPreempt|gcDrainFlushBgCredit)
if gp.preempt {
lock(&sched.lock)
for {
gp, _ := runqget(_p_)
if gp == nil {
break
}
globrunqput(gp)
}
unlock(&sched.lock)
}
gcDrain(&_p_.gcw, gcDrainFlushBgCredit)
case gcMarkWorkerFractionalMode: //分数和模式
//执行分数模式下的标记工作,直到被抢占
gcDrain(&_p_.gcw, gcDrainFractional|gcDrainUntilPreempt|gcDrainFlushBgCredit)
case gcMarkWorkerIdleMode: //空闲模式
//执行空闲模式下的标记工作
gcDrain(&_p_.gcw, gcDrainIdle|gcDrainUntilPreempt|gcDrainFlushBgCredit)
}
//把G的waiting状态转换到runing状态
casgstatus(gp, _Gwaiting, _Grunning)
})
//累加耗时
duration := nanotime() - startTime
switch _p_.gcMarkWorkerMode {
case gcMarkWorkerDedicatedMode:
atomic.Xaddint64(&gcController.dedicatedMarkTime, duration)
atomic.Xaddint64(&gcController.dedicatedMarkWorkersNeeded, 1)
case gcMarkWorkerFractionalMode:
atomic.Xaddint64(&gcController.fractionalMarkTime, duration)
atomic.Xaddint64(&_p_.gcFractionalMarkTime, duration)
case gcMarkWorkerIdleMode:
atomic.Xaddint64(&gcController.idleMarkTime, duration)
}
incnwait := atomic.Xadd(&work.nwait, +1)
- 注意gcMarkWorkerDedicatedMode 模式的任务是不能被抢占的,为了减少额外开销,第一次调用 runtime.gcDrain 方法时是允许抢占的,但是一旦处理器被抢占,当前 Goroutine会将处理器上的所有可运行的 Goroutine 转移至全局队列中,保证垃圾收集占用的 CPU 资源
- 当所有的后台工作任务都陷入等待并且没有剩余工作时,我们就认为该轮垃圾收集的标记阶段结束了,这时我们会调用 runtime.gcMarkDone 函数
incnwait := atomic.Xadd(&work.nwait, +1)
if incnwait > work.nproc {
println("runtime: p.gcMarkWorkerMode=", _p_.gcMarkWorkerMode,
"work.nwait=", incnwait, "work.nproc=", work.nproc)
throw("work.nwait > work.nproc")
}
if incnwait == work.nproc && !gcMarkWorkAvailable(nil) {
//取消p m的关联
_p_.gcBgMarkWorker.set(nil)
releasem(park.m.ptr())
//完成标记
gcMarkDone()
park.m.set(acquirem())
park.attach.set(_p_)
}
}
}
- runtime.gcDrain 是用于扫描和标记堆内存中对象的核心方法
3. 工作池与缓冲区
gcWork缓冲区
- 在调用 runtime.gcDrain 函数时,运行时会传入处理器上的 runtime.gcWork,这个结构体是垃圾收集器中工作池的抽象,它实现了一个生产者和消费者的模型,我们可以以该结构体为起点从整体理解标记工作, runtime.gcWork 为垃圾收集器提供了生产和消费任务的抽象,该结构体持有了两个重要的工作缓冲区 wbuf1 和 wbuf2,这两个缓冲区分别是主缓冲区和备缓冲区:
type gcWork struct {
wbuf1, wbuf2 *workbuf
...
}
type workbufhdr struct {
node lfnode // must be first
nobj int
}
type workbuf struct {
workbufhdr
obj [(_WorkbufSize - unsafe.Sizeof(workbufhdr{})) / sys.PtrSize]uintptr
}
- 当我们向该结构体中增加或者删除对象时,它总会先操作主缓冲区,一旦主缓冲区空间不足或者没有对象,就会触发主备缓冲区的切换;而当两个缓冲区空间都不足或者都为空时,会从全局的工作缓冲区中插入或者获取对象
工作池
- 为了减少锁竞争,运行时在每个处理器上会保存独立的待扫描工作,会出现与调度器一样的问题,不同处理器的资源不平均,导致部分处理器无事可做,调度器引入了工作窃取来解决这个问题,垃圾收集器也使用了差不多的机制平衡不同处理器上的待处理任务,解决这个问题,会执行runtime.gcWork.balance 方法,会将处理器本地一部分工作放回全局队列中,让其他的处理器处理,保证不同处理器负载的平衡
- 写屏障、根对象扫描和栈扫描都会向工作池中增加额外的灰色对象等待处理,而对象的扫描过程会将灰色对象标记成黑色,同时也可能发现新的灰色对象,当工作队列中不包含灰色对象时,整个扫描过程就会结束
总结缓冲区与工作池
- gcWork 缓冲区是一个用于存储灰色对象指针的固定大小的数组,每个 gcWork 缓冲区可以存储 256 个指针
- 工作池是一个全局的数据结构,用于管理 gcWork 缓冲区的分配和回收,以及提供并发的获取和放回操作
- 在 GC 的标记阶段,每个标记线程都会持有一个 gcWork 缓冲区,用于存储从根对象或者其他灰色对象扫描到的灰色对象指针
- 当一个标记线程的 gcWork 缓冲区满了,它会将该缓冲区放回到工作池中,并从工作池中获取一个新的缓冲区
- 当一个标记线程的 gcWork 缓冲区空了,它会尝试从工作池中获取一个已经填充了灰色对象指针的缓冲区
- 当工作池中没有可用的缓冲区时,标记线程会尝试从其他标记线程或者辅助标记线程窃取一半的灰色对象指针
- 当所有的标记线程和辅助标记线程都没有可用的灰色对象指针时,标记阶段结束
4. runtime.gcDrain()用于扫描和标记堆内存中对象的核心方法
- runtime.gcDrain函数会从G的本地工作缓冲区或者全局工作队列中获取一个灰色对象,并将其扫描为黑色,同时将其指向的白色对象标记为灰色并放入工作缓冲区或者工作队列中
- runtime.gcDrain函数会接收一个gcDrainFlags类型变量flags,根据这个变量选择不同的扫描模式,有以下几种:
- 标记终止模式:该模式下gcDrain()函数会扫描所有的对象,直到标记队列为空或者达到一定的时间限制。需要STW,因此要求扫描速度尽快。gcDrain()函数会使用gcDrainUntilPreempt和gcDrainFlushBgCredit两个参数,表示当Goroutine的preempt字段被设置为true时返回,并计算后台完成的标记任务量。
- 并发标记模式:在这个模式下,gcDrain()函数会以一定的比例扫描对象,同时允许用户程序继续运行。这个模式不需要STW,但是需要写屏障来保证对象的可达性。gcDrain()函数会使用gcDrainIdle和gcDrainFractional两个参数,表示当处理器上包含其他待执行的协程时退出,或者当完成目标时间后退出。
- 标记终止+并发标记:在这个模式下,gcDrain()函数会结合上面两种模式,先以标记终止的方式扫描一部分对象,然后以并发标记的方式扫描剩余的对象。这个模式可以减少STW的时间,但是也需要写屏障。
func gcDrain(gcw *gcWork, flags gcDrainFlags) {
gp := getg().m.curg
// 看到抢占标志时是否要返回
preemptible := flags&gcDrainUntilPreempt != 0
// 是否计算后台的扫描量来减少协助线程和唤醒等待中的G
flushBgCredit := flags&gcDrainFlushBgCredit != 0
// 是否只执行一定量的工作
idle := flags&gcDrainIdle != 0
// 记录初始的已扫描数量
initScanWork := gcw.scanWork
checkWork := int64(1<<63 - 1)
var check func() bool
if flags&(gcDrainIdle|gcDrainFractional) != 0 {
// drainCheckThreshold 默认 100000
checkWork = initScanWork + drainCheckThreshold
if idle {
check = pollWork
} else if flags&gcDrainFractional != 0 {
check = pollFractionalWorkerExit
}
}
...
}
开始扫描根对象
- 做完准备工作后,执行markroot()开始扫描全局变量中的根对象,是标记阶段中需要最先被执行的任务
func gcDrain(gcw *gcWork, flags gcDrainFlags) {
...
// 如果根对象未扫描完, 则先扫描根对象
if work.markrootNext < work.markrootJobs {
// 一直循环直到被抢占或 STW
for !(gp.preempt && (preemptible || atomic.Load(&sched.gcwaiting) != 0)) {
// 从根对象扫描队列取出一个值
job := atomic.Xadd(&work.markrootNext, +1) - 1
if job >= work.markrootJobs {
break
}
// 执行根对象扫描工作
markroot(gcw, job)
if check != nil && check() {
goto done
}
}
}
...
}
- runtime.markroot()函数会扫描缓存、数据段、存放全局变量和静态变量的 BSS 段以及 Goroutine 的栈内存;
为活跃的对象上色
当完成了对根对象的扫描,当前 Goroutine 会开始从本地和全局的工作缓存池中获取待执行的任务, 函数中会执行runtime.scanobject()从传入的位置开始扫描,扫描期间会调用 runtime.greyobject 为找到的活跃对象上色。
func gcDrain(gcw *gcWork, flags gcDrainFlags) {
...
for !(preemptible && gp.preempt) {
if work.full == 0 {
gcw.balance()
}
b := gcw.tryGetFast()
if b == 0 {
b = gcw.tryGet()
if b == 0 {
wbBufFlush(nil, 0)
b = gcw.tryGet()
}
}
if b == 0 {
break
}
//实际的扫描与上色函数
scanobject(b, gcw)
if gcw.scanWork >= gcCreditSlack {
atomic.Xaddint64(&gcController.scanWork, gcw.scanWork)
if flushBgCredit {
gcFlushBgCredit(gcw.scanWork - initScanWork)
initScanWork = 0
}
checkWork -= gcw.scanWork
gcw.scanWork = 0
if checkWork <= 0 {
checkWork += drainCheckThreshold
if check != nil && check() {
break
}
}
}
}
...
}
func gcDrain(gcw *gcWork, flags gcDrainFlags) {
...
done:
if gcw.scanWork > 0 {
atomic.Xaddint64(&gcController.scanWork, gcw.scanWork)
if flushBgCredit {
gcFlushBgCredit(gcw.scanWork - initScanWork)
}
gcw.scanWork = 0
}
}
扫描上色结束
- 当本轮的扫描因为外部条件变化而中断时,该函数会通过 runtime.gcFlushBgCredit 记录这次扫描的内存字节数用于减少辅助标记的工作量
- 写屏障、根对象扫描和栈扫描都会向工作池中增加额外的灰色对象等待处理,而对象的扫描过程会将灰色对象标记成黑色,同时也可能发现新的灰色对象,当工作队列中不包含灰色对象时,整个扫描过程就会结束
标记阶段
- 垃圾收集前,全局变量 runtime.writeBarrier 中的 enabled 字段会被置成开启,所有的写操作都会调用 runtime.gcWriteBarrier汇编函数,开启 Dijkstra 和 Yuasa混合写屏障,
- 所有新创建的对象都需要被直接涂成黑色
- 新增或修改时触发插入写屏障,
- 删除时触发删除写屏障
- 这里的标记过程是由 runtime.gcmarknewobject 完成的
- runtime.mallocgc 会在垃圾收集开始后调用该函数,获取对象对应的内存单元以及标记位 runtime.markBits
- 调用runtime.markBits.setMarked 直接将新的对象涂成黑色
func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
...
if gcphase != _GCoff {
gcmarknewobject(uintptr(x), size, scanSize)
}
...
}
func gcmarknewobject(obj, size, scanSize uintptr) {
markBitsForAddr(obj).setMarked()
gcw := &getg().m.p.ptr().gcw
gcw.bytesMarked += uint64(size)
gcw.scanWork += int64(scanSize)
}
1. 标记辅助
- 为了保证程序分配内存的速度不会超出后台任务的标记速度,运行时引入了标记辅助技术: 分配多少内存就需要完成多少标记任务
- 每一个 Goroutine 都持有 gcAssistBytes 字段,这个字段存储了当前 Goroutine 辅助标记的对象字节数。在并发标记阶段期间,当 Goroutine 调用 runtime.mallocgc 分配新的对象时,该函数会检查申请内存的 Goroutine 是否处于入不敷出的状态
- gcAssistBytes 表示当前协程辅助标记的字节数,全局垃圾收集控制器持有的 bgScanCredit 表示后台协程辅助标记的字节数,当本地 Goroutine 分配了较多的对象时,可以使用公用的信用 bgScanCredit 偿还
- 查看分配新的对象时执行的mallocgc()函数,内部调用了gcAssistAlloc()
func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
...
var assistG *g
if gcBlackenEnabled != 0 {
assistG = getg()
if assistG.m.curg != nil {
assistG = assistG.m.curg
}
assistG.gcAssistBytes -= int64(size)
if assistG.gcAssistBytes < 0 {
gcAssistAlloc(assistG)
}
}
...
return x
}
- gcAssistAlloc() 可以理解为借债,该函数会先根据 Goroutine 的 gcAssistBytes 和垃圾收集控制器的配置计算需要完成的标记任务数量,如果全局信用 bgScanCredit 中有可用的点数,那么就会减去该点数,因为并发执行没有加锁,所以全局信用可能会被更新成负值,然而在长期来看这不是一个比较重要的问题,如果全局信用不足以覆盖本地的债务,运行时会在系统栈中调用 runtime.gcAssistAlloc1 执行标记任务,该函数会直接调用 runtime.gcDrainN 完成指定数量的标记任务并返回
func gcAssistAlloc(gp *g) {
}
- gcFlushBgCredit()可以理解为还债,如果辅助队列中不存在等待的 Goroutine,那么当前的信用会直接加到全局信用 bgScanCredit 中
func gcFlushBgCredit(scanWork int64) {
}
- 如果辅助队列不为空,上述函数会根据每个 Goroutine 的债务数量和已完成的工作决定是否唤醒这些陷入休眠的 Goroutine;如果唤醒所有的 Goroutine 后,标记任务量仍然有剩余,这些标记任务都会加入全局信用中
- 程序辅助标记的核心目的就是避免用户程序分配内存影响垃圾收集器完成标记工作的期望时间,它通过维护账户体系保证用户程序不会对垃圾收集造成过多的负担,一旦用户程序分配了大量的内存,该用户程序就会通过辅助标记的方式平衡账本,这个过程会在最后达到相对平衡,保证标记任务在到达期望堆大小时完成
标记终止
- 当所有处理器的本地任务都完成并且不存在剩余的工作 Goroutine 时,后台并发任务或者辅助标记的用户程序会调用 runtime.gcMarkDone 通知垃圾收集器
- 当所有可达对象都被标记后,该函数会将垃圾收集的状态切换至 _GCmarktermination
- 如果本地队列中仍然存在待处理的任务,当前方法会将所有的任务加入全局队列并等待其他 Goroutine 完成处理
func gcMarkDone() {
...
top:
if !(gcphase == _GCmark && work.nwait == work.nproc && !gcMarkWorkAvailable(nil)) {
return
}
gcMarkDoneFlushed = 0
systemstack(func() {
gp := getg().m.curg
casgstatus(gp, _Grunning, _Gwaiting)
forEachP(func(_p_ *p) {
wbBufFlush1(_p_)
_p_.gcw.dispose()
if _p_.gcw.flushedWork {
atomic.Xadd(&gcMarkDoneFlushed, 1)
_p_.gcw.flushedWork = false
}
})
casgstatus(gp, _Gwaiting, _Grunning)
})
if gcMarkDoneFlushed != 0 {
goto top
}
...
}
1. 触发垃圾收集迁移与标记终止
- 如果运行时中不包含全局任务、处理器中也不存在本地任务,那么当前垃圾收集循环中的灰色对象也就都标记成了黑色,我们就可以开始触发垃圾收集的阶段迁移了
func gcMarkDone() {
...
getg().m.preemptoff = "gcing"
systemstack(stopTheWorldWithSema)
...
atomic.Store(&gcBlackenEnabled, 0)
gcWakeAllAssists()
schedEnableUser(true)
nextTriggerRatio := gcController.endCycle()
gcMarkTermination(nextTriggerRatio)
}
- 会关闭混合写屏障、唤醒所有协助垃圾收集的用户程序、恢复用户 Goroutine 的调度,调用runtime.gcMarkTermination 进入标记终止阶段
2. 标记终止
- 查看gcMarkTermination()调用 runtime.gcSweep 重置清理阶段的相关状态并在需要时阻塞清理所有的内存管理单元;_GCmarktermination 状态在垃圾收集中并不会持续太久,它会迅速转换至 _GCoff 并恢复应用程序,到这里垃圾收集的全过程基本上就结束了,用户程序在申请内存时才会惰性回收内存
func gcMarkTermination(nextTriggerRatio float64) {
atomic.Store(&gcBlackenEnabled, 0)
setGCPhase(_GCmarktermination)
_g_ := getg()
gp := _g_.m.curg
casgstatus(gp, _Grunning, _Gwaiting)
systemstack(func() {
gcMark(startTime)
})
systemstack(func() {
setGCPhase(_GCoff)
gcSweep(work.mode)
})
casgstatus(gp, _Gwaiting, _Grunning)
gcSetTriggerRatio(nextTriggerRatio)
wakeScavenger()
...
injectglist(&work.sweepWaiters.list)
systemstack(func() { startTheWorldWithSema(true) })
prepareFreeWorkbufs()
systemstack(freeStackSpans)
systemstack(func() {
forEachP(func(_p_ *p) {
_p_.mcache.prepareForSweep()
})
})
...
}
内存清理
- 垃圾收集的清理中包含对象回收器Reclaimer和内存单元回收器,这两种回收器使用不同的算法清理堆内存:
- 对象回收器在内存管理单元中查找并释放未被标记的对象,但是如果 runtime.mspan 中的所有对象都没有被标记,整个单元就会被直接回收,该过程会被 runtime.mcentral.cacheSpan 或者 runtime.sweepone 异步触发;
- 内存单元回收器会在内存中查找所有的对象都未被标记的 runtime.mspan,该过程会被 runtime.mheap.reclaim 触发;
- 内存清理时底层会执行runtime.sweepone(),该函数会在堆内存中查找待清理的内存管理单元
会通过 state 和 sweepgen 两个字段判断当前单元是否需要处理。如果内存单元的 sweepgen 等于 mheap.sweepgen - 2,那么就意味着当前单元需要被清理,如果等于 mheap.sweepgen - 1,那么当前管理单元就正在被清理
func sweepone() uintptr {
...
var s *mspan
sg := mheap_.sweepgen
for {
s = mheap_.sweepSpans[1-sg/2%2].pop()
if s == nil {
break
}
if state := s.state.get(); state != mSpanInUse {
continue
}
if s.sweepgen == sg-2 && atomic.Cas(&s.sweepgen, sg-2, sg-1) {
break
}
}
npages := ^uintptr(0)
if s != nil {
npages = s.npages
if s.sweep(false) {
atomic.Xadduintptr(&mheap_.reclaimCredit, npages)
} else {
npages = 0
}
}
_g_.m.locks--
return npages
}
- 所有的回收工作最终都是靠 runtime.mspan.sweep 完成的,该函数会根据并发标记阶段回收内存单元中的垃圾并清除标记以免影响下一轮垃圾收集
四. 总结
- 参考博客
- 为什么会出现垃圾收集算法:
- 在运行服务,对外提供功能时,需要创建结构体对象缓存数据,实现功能,当一个功能执行完毕后,这个结构体对象不再需要,为了删除这些不需要的对象,提供了垃圾收集功能
- 什么是stw,或者为什么出现stw: 垃圾收集中可以分为判断垃圾和垃圾清除两个阶段,会出现一个问题,假设某一个结构体对象在判断阶段认为是垃圾,在收集阶段由于程序执行又重新给这块内存赋值了,如果此时执行垃圾收集就会造成空指针问题,为了解决这个问题提出了stw,也就是在垃圾收集过程中挂起用户程序,不堆外提供服务,垃圾收集器在回收垃圾时,必须在一个能保障一致性的快照中进行
- 为了防止错误收集的出现,提出了stw,但是stw时,程序不对外提供服务,影响客户体验,为了解决这个问题提出了三种收集方式: 串行回收,并行回收,并发回收,提出了不同的垃圾收集算法,最终目的都是为了提供更高效,更准确,更合理的内存管理的垃圾收集,减少stw的时间
- 垃圾收集可以分为判断垃圾和垃圾清除两个阶段:
- 判断垃圾阶段有: 引用计数法, 根搜索法, 三色可达性分析法(可以详细说一下不同算法的优缺点)
- 清除阶段有: 标记清除, 标记压缩, 复制算法, 分代收集(可以详细说一下不同算法的优缺点)
- golang中我们只关注: 根搜索算法,与根搜索算法升级出来的三色可达性分析法,还有标记清除算法,为提高垃圾收集性能,解决悬挂指针错误回收,与垃圾未被回收的问题,引出了屏障技术
- golang中采用三色可达性分析算法,详细说一下:
- 在引用计数法时可能会出现循环依赖问题,
- 根搜索法通常是采用从根对象开始的广度优先搜索方式,感觉三色就是通过根搜索算法升级出来的,提出了黑白灰三种状态,可以根据不同的状态进行增量地执行垃圾收集操作,而不需要在短时间内一次性完成整个垃圾收集,可以减少STW的时间
- 三色算法中提出了黑白灰三种状态,首先获取所有对象,标记为白色也就是潜在的垃圾,然后从根节点(全局变量,每个goroutine的执行栈,寄存器等)出发,根节点对象时黑色也就是确认的可达对象,开始遍历整个对象图,将访问到的对象染成灰色也就是已经被遍历到,等待下一步确认可达的对象,然后获取每个灰色对象,遍历它引用的其他对象,并将这些对象染成灰色或黑色,一直迭代寻找,最终将所有灰色对象标记为黑色,没有被引用的对象就是不被标记的白色可回收对象
- 三色可达性分析算法实际不支持并发收集的,因为由于没有stw,可能会被后续的执行再次引用,最终造成错误回收,将这种错误称为悬挂指针,为了解决这个问题或者为了让三色支持并发收集引出屏障技术
- 屏障技术: 指一种同步机制,屏障指令,通常情况下为提高性,在不影响最终结果的前提下编译器会对代码指令进行优化,cpu指针是乱序执行的,通过屏障技术可以让 CPU 或者编译器按照特定的约束进行优化执行,另外还有为了解决数据并发安全问题,通过写屏障确保写操作在被其他goroutine读取之前完成,进而,提高程序的可靠性和稳定性,还有通过屏障技术解决三色标记时出现的悬挂指针问题
- 通过插入写屏障保证强三色不变性解决指针悬挂问题
- 通过删除写屏障保证弱三色不变性解决指针悬挂问题
- 简述一下插入写屏障与删除写屏障的确定,引出golang中对混合写屏障的应用
- Go1.8时如何使用混合写屏障减少STW,在1.9版本如何优化混合写屏障,做到几乎没有STW的
- 接下来说一下Go垃圾收集时重点问题
- 什么时候会触发执行垃圾收集, 首先垃圾收集执行时会调用gcStart()函数,该函数是垃圾收集的入口,以下三种情况下会调用:
- 申请内存时会调用runtime.mallocgc(),该函数中会根据堆大小决定是否触发垃圾收集,如果触发则调用gcStart()
- runtime.forcegchelper() 和 runtime.sysmon()配合完成的: 后台运行定时检查和垃圾收集;
- runtime.GC()/ System.gc : 手动执行GC()方法时触发垃圾收集;
- 虽然gcStart()是垃圾收集的入口,但是不是每次调用该函数都会进行实际的垃圾收集,该函数接收一个gcTrigger 结构体变量,用于判断是否满足触发垃圾收集条件,查看gcStart()源码内部首先会调用gcTrigger.test()方法,通过gcTrigger的test()方法判断是否满足垃圾收集触发条件,满足返回true,查看源码发现有三种触发条件判断:
- 基于内存占用率判断, 在golang的垃圾收集角度存在一个gcTriggerHeap全局变量,表示触发垃圾回收的堆内存占用比例的阈值。当堆内存占用超过该阈值时,就会触发垃圾回收, 还存在一个GOGC环境变量,表示垃圾回收开始前允许堆增长的倍数,默认为100,表示堆内存的增长不得超过上次垃圾回收时的 100%。当堆内存增长超过这个阈值时,会触发垃圾回收
- 根据时间间隔,定期运行垃圾收集,在golang中存在一个forcegcperiod变量,默认2分钟执行一次
- 手动调用runtime.GC()/ System.gc手动触发垃圾收集(不常用)
- 创建对象申请内存时会执行mallocgc()函数,该函数中首先会判断对象大小,Go中根据对象大小,将对象分为大,小,微三个级别,根据不同的级别执行对应的分配策略,根据不同的分配策略,是否触发垃圾收集有两个路径
- 如果当前分配的微对象,或者小对象,当内存块中不包含空闲的内存时,底层会执行到一个runtime.mcache.nextFree()函数,从中心缓存或者页堆中获取新的管理单元,这时如果span满了就会导致返回的shouldhelpgc=true,然后执行runtime.gcTrigger.test()判断是否需要执行来及收集(具体查看内存分配章节)
- 如果当前分配的是大对象,shouldhelpgc写死为true,这时候会获取gcTriggerHeap判断gcController.heapLive堆内存中已分配的对象的大小 >= gcController.trigger上一轮设置的垃圾收集的阈值,如果为true则通过test()判断是否需要执行来及收集
- runtime.forcegchelper(): 在proc.go文件中有一个init()方法,init()方法中开启协程执行forcegchelper(),也就是说在程序启动时,执行forcegchelper(),它的作用是强制触发一轮垃圾收集,并等待它完成,forcegchelper() 函数中会
- 获取当前的 g,并将其赋值给 forcegc.g,然后初始化 forcegc.lock,并加锁
- forcegchelper() 函数会将 forcegc.idle 设置为 1,表示当前没有垃圾收集任务,并调用 goparkunlock() 函数将自己阻塞在 forcegc.lock 上,等待被唤醒
- 比如当用户程序显式调用 runtime.GC() 函数或者系统监控线程 runtime.sysmon() 检测到一定时间内没有触发垃圾收集时,会调用 wakep() 函数唤醒 forcegchelper() 函数。
- forcegchelper() 函数被唤醒后,会调用 gcStart() 函数启动一轮垃圾收集,并传入 gcBackgroundMode 和 gcTriggerCycle 作为参数。
- gcStart() 函数会根据参数设置垃圾收集的模式和触发条件,并执行垃圾收集的各个阶段,包括标记、清除、终止等。
- 当垃圾收集结束后,forcegchelper() 函数会重新加锁 forcegc.lock,并重复上述步骤,等待下一次被唤醒
1.gcStart()垃圾收集细节
- 首先开启一个for循环,调用gcTrigger的test()方法验证是否满足垃圾收触发条件,并且内部会在循环中不断调用sweepone()清理已经被标记的内存单元,完成上一个垃圾收集循环的收尾工作
- 当验证需要触发垃圾收集时gcStart()函数内部继续向下执行,调用semacquire()等待 worldsema 的所有权,通过worldsema一个全局的信号量,用于控制程序的暂停和恢复
2.1 当垃圾收集开始时,需要暂停所有的用户线程,这时会调用 stopTheWorldWithSema() 函数,该函数会获取 worldsema 的所有权,并调用 stopTheWorld() 函数执行暂停操作
2.2 当垃圾收集结束时,需要恢复所有的用户线程,这时会调用 startTheWorldWithSema() 函数,该函数会调用 startTheWorld() 函数执行恢复操作,并释放 worldsema 的所有权
2.3 semacquire(&worldsema) 是在 gcStart() 函数中调用的,它的作用是等待 worldsema 的所有权,以确保没有其他线程正在执行暂停或恢复操作
- 当semacquire(&worldsema)获取到权限后,继续向下执行,调用 runtime.gcBgMarkStartWorkers 在该函数中会获取所有处理器p,为每个处理器创建用于执行后台标记任务的 Goroutine,每一个 Goroutine 都会执行runtime.gcBgMarkWorker()是后台的标记任务执行的函数
- 通过systemstack(stopTheWorldWithSema)在系统栈中调用stopTheWorldWithSema()函数主要是用来暂停用户程序
- 当暂停用户程序后,gcStart()内部继续向下执行,调用runtime.setGCPhase设置GC的状态从_GCoff修改为_GCmark标记状态,设置写屏障为启用状态
- 调用runtime.gcMarkRootPrepare()进行标记准备工作,进行根初始化,将栈上和全局变量等根对象加入到标记队列,最终返回一个表示是否需要执行标记阶段的布尔值
- 调用runtime.startTheWorldWithSema()恢复程序,启动goroutine的执行,这个时候用户程序可以运行了,后台任务也会开始标记堆中的对象
- 最终再次调用semrelease()释放worldsema全局的信号量,调用gcController.startCycle() 函数启动 GC 的循环,并创建后台的标记和清扫线程
- 当gcStart()内通过gcTrigger的test()判断满足垃圾收集触发条件,调用semacquire(&worldsema)获取到worldsema权限后,会暂停用户程序,接着调用会执行 runtime.gcBgMarkStartWorkers()开启后台标记,进入后台并发标记阶段,在该函数中会获取所有处理器p,为每个处理器创建用于执行后台标记任务的Goroutine,每一个Goroutine都会执行runtime.gcBgMarkWorker()函数执行后台的标记任务,注意gcBgMarkWorker()后台标记有三种模式
过runtime.gcMarkWorkerMode变量表示
- 当gcMarkWorkerMode等于gcMarkWorkerDedicatedMode也就是0时表示专用模式,该模式下后台标记工作线程是专用的,且不会被调度器抢占,通过让专用的工作线程尽可能独立地执行标记任务来加速垃圾收集过程可以提高并发标记效率
- 当gcMarkWorkerMode等于gcMarkWorkerFractionalMode也就是1时表示分数化模式,该模式下后台标记工作线程的数量会根据系统的负载自动调整,以达到标记线程占用率的目标,默认为25%,(也就是说虽然为每个处理器P启动了一个后台标记任务, 但是可以同时工作的默认只有25%),例如如果 CPU 核数不是 4 的倍数,无法得到整数的占用率,就会启动分数化模式帮助垃圾收集器达到预设的占用率目标
- 当gcMarkWorkerMode等于gcMarkWorkerIdleMode也就是2时表示空闲模式,该模式下后台标记工作线程会在处理器没有可以执行的 Goroutine 时被调度运行,直到被调度器抢占,主要目的是利用闲置的处理器资源,确保在处理器没有其他任务执行时能够执行垃圾收集的标记任务,提高整体的收集效率
- 那么gcMarkWorkerMode这个用来表示并发标记工作模式的变量是如何计算得来的?这里要看调度器源码,在go服务启动时底层会执行一个runtime.schedule()启动调度器,进入调度循环,在该函数中会调用gcController.findRunnableGCWorker来决定后台标记工作线程的运行模式,拿到gcMarkWorkerMode(这个方法好像是1.8版本,1.8以前用的好像是runtime.gcControllerState.findRunnabledGCWorker),查看该函数源码:
- 会先执行decIfPositive()函数,通过dedicatedMarkWorkersNeeded变量判断是否是专用模式,如果是设置gcMarkWorkerMode = gcMarkWorkerDedicatedMode
- 并根据执行标记任务的时间和总时间决定是否启动 gcMarkWorkerFractionalMode分数化模式;
- 并且为了更好地利用系统资源并加快垃圾收集的执行,在调度循环的每一次迭代中,调度器会调用 findrunnable()函数来寻找可运行的 Goroutine,当发现存在后台标记任务时,调度器会将这个标记任务的Goroutine设置为gcMarkWorkerMode = gcMarkWorkerIdleMode空闲模式,空闲的处理器会根据调度策略来决定是否执行该空闲模式的垃圾收集任务Goroutine
- 我们看一下gcBgMarkWorker()函数源码,了解一下后台标记任务执行时的细节
- 首先会获取当前协程g对象,封装成 parkInfo 类型的结构体,
- 然后开启一个无限for循环,在循环中先调用gopark()让当前g进入休眠,在休眠前会将 P 的 gcBgMarkWorker 与 G 进行绑定,等待唤醒
- 当唤醒后代码继续向下执行,会判断gcMarkWorkerMode标记模式,调用gcDrain()函数用于扫描和标记堆内存中对象的核心方法
- 所有标记任务都完成后,调用 runtime.gcMarkDone 标记终止
- 我们继续查看用于扫描和标记堆内存中对象的核心函数gcDrain()
- 该函数中首先进行一下准备工作,例如会根据接收到的gcDrainFlags类型变量flags计算当前扫描模式,有标记终止模式, 并发标记模式和标记终止+并发标记三种
- 做完准备工作后gcDrain()中代码继续执行调用markroot()开始扫描全局变量中的根对象,markroot()函数会扫描缓存、数据段、存放全局变量和静态变量的 BSS 段以及 Goroutine 的栈内存
- 根对象扫描完成后,当前 Goroutine 会开始从本地和全局的工作缓存池中获取待执行的任务, 函数中会执行runtime.scanobject()从传入的位置开始扫描,扫描期间会调用 runtime.greyobject 为找到的活跃对象上色
- 所有标记任务都完成后,调用 runtime.gcMarkDone 标记终止
- 然后进行内存清理, 内存清理时底层会执行runtime.sweepone(),该函数会在堆内存中查找待清理的内存管理单元
会通过 state 和 sweepgen 两个字段判断当前单元是否需要处理。如果内存单元的 sweepgen 等于 mheap.sweepgen - 2,那么就意味着当前单元需要被清理,如果等于 mheap.sweepgen - 1,那么当前管理单元就正在被清理