深⼊理解GO语⾔:GC原理及源码分析
Go 中的runtime 类似 Java的虚拟机,它负责管理包括内存分配、垃圾回收、栈处理、goroutine、channel、切⽚(slice)、map 和反射(reflection)等。Go 的可执⾏⽂件都⽐相对应的源代码⽂件要⼤很多,这是因为 Go 的 runtime 嵌⼊到了每⼀个可执⾏⽂件当中。
常见的⼏种gc算法:
引⽤计数:对每个对象维护⼀个引⽤计数,当引⽤该对象的对象被销毁时,引⽤计数减1,当引⽤计数器为0是回收该对象。
优点:对象可以很快的被回收,不会出现内存耗尽或达到某个阀值时才回收。
缺点:不能很好的处理循环引⽤,⽽且实时维护引⽤计数,有也⼀定的代价。
代表语⾔:Python、PHP、Swift保持微笑 英文
标记-清除:从根变量开始遍历所有引⽤的对象,引⽤的对象标记为"被引⽤",没有被标记的进⾏回收。
优点:解决了引⽤计数的缺点。
缺点:需要STW,即要暂时停掉程序运⾏。
代表语⾔:Golang(其采⽤三⾊标记法)
分代收集:按照对象⽣命周期长短划分不同的代空间,⽣命周期长的放⼊⽼年代,⽽短的放⼊新⽣代,不同代有不能的回收算法和回收频率。
优点:回收性能好
pursue
缺点:算法复杂
代表语⾔: JAVA
每种算法都不是完美的,都是折中的产物。
Gc流程图:造价员报考
Stack scan:收集根对象(全局变量,和G stack),开启写屏障。全局变量、开启写屏障需要STW,G stack只需要停⽌该G就好,时间⽐较少。
Mark: 扫描所有根对象, 和根对象可以到达的所有对象, 标记它们不被回收
Mark Termination: 完成标记⼯作, 重新扫描部分根对象(要求STW)
Sweep: 按标记结果清扫span
从上图中我们可以看到整个GC流程会进⾏两次STW(Stop The World), 第⼀次是Mark阶段的开始, 第⼆次是Mark Termination阶段.
第⼀次STW会准备根对象的扫描, 启动写屏障(Write Barrier)和辅助GC(mutator assist).
efficiently第⼆次STW会重新扫描部分根对象, 禁⽤写屏障(Write Barrier)和辅助GC(mutator assist).
需要注意的是, 不是所有根对象的扫描都需要STW, 例如扫描栈上的对象只需要停⽌拥有该栈的G.
三⾊标记
有⿊、灰、⽩三个集合,每种颜⾊的含义:
⽩⾊:对象未被标记,gcmarkBits对应的位为0
灰⾊:对象已被标记,但这个对象包含的⼦对象未标记,gcmarkBits对应的位为1
⿊⾊:对象已被标记,且这个对象包含的⼦对象也已标记,gcmarkBits对应的位为1
灰⾊和⿊⾊的gcmarkBits都是1,如何区分⼆者呢?
标记任务有标记队列,在标记队列中的是灰⾊,不在标记队⾥中的是⿊⾊。标记过程见下图:
上图中根对象A是栈上分配的对象,H是堆中分配的全局变量,根对象A、H内部有分别引⽤了其他对象,⽽其他对象内部可能还引⽤额其他对象,各个对象见的关系如上图所⽰。
1. 初始状态下所有对象都是⽩⾊的。
2. 接着开始扫描根对象,A、H是根对象所以被扫描到,A,H变为灰⾊对象。
3. 接下来就开始扫描灰⾊对象,通过A到达B,B被标注灰⾊,A扫描结束后被标注⿊⾊。同理J,K都被标注灰⾊,H被标注⿊⾊。
4. 继续扫描灰⾊对象,通过B到达C,C 被标注灰⾊,B被标注⿊⾊,因为J,K没有引⽤对象,J,K标注⿊⾊结束
5. 最终,⿊⾊的对象会被保留下来,⽩⾊对象D,E,F会被回收掉。
屏障
上图,假如B对象变⿊后,⼜给B指向对象G,因为这个时候G对象已经扫描过了,所以G 对象还是⽩⾊,会被误回收。怎么解决这个问题呢?
最简单的⽅法就是STW(stop the world)。也就是说,停⽌所有的协程。这个⽅法⽐较暴⼒会引起程序的卡顿,并不友好。让GC回收器,满⾜下⾯两种情况之⼀时,可保对象不丢失. 所以引出强-弱三⾊不变式:
强三⾊不变式:⿊⾊不能引⽤⽩⾊对象。
弱三⾊不变式:被⿊⾊引⽤的⽩⾊对象都处于灰⾊保护。
如何实现这个两个公式呢?这就是屏障机制。
GO1.5 采⽤了插⼊屏障、删除屏障。到了GO1.8采⽤混合屏障。⿊⾊对象的内存槽有两种位置, 栈和堆. 栈空间的特点是容量⼩,但是要求相应速度快,因为函数调⽤弹出频繁使⽤, 所以“插⼊屏障”机制,在栈空间的对象操作中不使⽤. ⽽仅仅使⽤在堆空间对象的操作中。
插⼊屏障:插⼊屏障只对堆上的内存分配起作⽤,栈空间先扫描⼀遍然后启动STW后再重新扫描⼀遍扫描后停⽌STW。如果在对象在插⼊平展期间分配内存会⾃动设置成灰⾊,不⽤再重新扫描。
删除屏障:删除屏障适⽤于栈和堆,在删除屏障机制下删除⼀个节点该节点会被置成灰⾊,后续会继续扫描该灰⾊对象的⼦对象。该⽅法就是精准度不够⾼
混合屏障:
插⼊写屏障和删除写屏障的短板:
插⼊写屏障:结束时需要STW来重新扫描栈,标记栈上引⽤的⽩⾊对象的存活;
删除写屏障:回收精度低,GC开始时STW扫描堆栈来记录初始快照,这个过程会保护开始时刻的所有存活对象。
混合写屏障规则
具体操作:
1、GC开始将栈上的对象全部扫描并标记为⿊⾊(之后不再进⾏第⼆次重复扫描,⽆需STW),
2、GC期间,任何在栈上创建的新对象,均为⿊⾊。
3、被删除的对象标记为灰⾊。
4、被添加的对象标记为灰⾊。
满⾜: 变形的弱三⾊不变式.
伪代码如下:
添加下游对象(当前下游对象slot, 新下游对象ptr) {
//1
标记灰⾊(当前下游对象slot) //只要当前下游对象被移⾛,就标记灰⾊
//2
标记灰⾊(新下游对象ptr)
//3
当前下游对象slot = 新下游对象ptr
}
上⾯说到整个GC有两次STW,采⽤混合屏障后可以⼤幅压缩第⼆次STW的时间。
Gc pacer
触发gc的时机:
阈值gcTriggerHeap:默认内存扩⼤⼀倍,启动gc
定期gcTriggerTime:默认2min触发⼀次gc,src/:forcegcperiod
hydra
⼿动gcTriggerCycle:()
当然了阀值是根据使⽤内存的增加动态变化的。假如前⼀次GC之后内存使⽤Hm(n-1)为1GB,默认GCGO=100,那么下⼀次会在接近Hg(2GB)的位置发起新⼀轮的GC。如下图:
Ht的时候开始GC,Ha的时候结束GC,Ha⾮常接近Hg。
(1)如何保证在Ht开始gc时所有的span都被清扫完?
除了有⼀个后台清扫协程外,⽤户的分配内存时也需要辅助清扫来保证在开启下⼀轮的gc时span都被清扫完毕。假设有k page的span需要sweep,那么距离下⼀次gc还有Ht-Hm(n-1)的内存可供分配,
那么平均每申请1byte内存需要清扫k/ Ht-Hm(n-1) page 的span。(k值会根据sweep进度更改)
altar
辅助清扫申请新span时才会检查,,辅助清扫的触发可以看cacheSpan函数, 触发时G会帮助回收"⼯作量"页的对象, ⼯作量的计算公式是: spanBytes * sweepPagesPerByte
意思是分配的⼤⼩乘以系数sweepPagesPerByte, sweepPagesPerByte的计算在函数gcSetTriggerRatio中, 公式是:
// 当前的Heap⼤⼩
heapLiveBasis := atomic.Load64(&memstats.heap_live)
// 距离触发GC的Heap⼤⼩ = 下次触发GC的Heap⼤⼩ - 当前的Heap⼤⼩
heapDistance := int64(trigger) - int64(heapLiveBasis)
heapDistance -= 1024 * 1024
if heapDistance < _PageSize {
heapDistance = _PageSizei love you like a love song
}
// 已清扫的页数健身培训学校
pagesSwept := atomic.Load64(&mheap_.pagesSwept)
// 未清扫的页数 = 使⽤中的页数 - 已清扫的页数
sweepDistancePages := int64(mheap_.pagesInU) - int64(pagesSwept)
if sweepDistancePages <= 0 {
mheap_.sweepPagesPerByte = 0
} el {
// 每分配1 byte(的span)需要辅助清扫的页数 = 未清扫的页数 / 距离触发GC的Heap⼤⼩
mheap_.sweepPagesPerByte = float64(sweepDistancePages) / float64(heapDistance)
}
(2)如何保证在Ha时gc都被mark完?
Gc在Ht开始,在到达Hg时尽量标记完所有的对象,除了后台的标记协程外还需要在分配内存是进⾏辅助mark。从Ht到Hg的内存可以分配,这个时候还有scanWorkExpected的对象需要scan,那么平均分配1byte内存需要辅助mark量:scanWorkExpected/(Hg-Ht) 个对象,scanWorkExpected会根据mark进度更改。
辅助标记的触发可以查看上⾯的mallocgc函数, 触发时G会帮助扫描"⼯作量"个对象, ⼯作量的计算公式是:
debtBytes * assistWorkPerByte
光合作用的意义意思是分配的⼤⼩乘以系数assistWorkPerByte, assistWorkPerByte的计算在函数中, 公式是:
// 等待扫描的对象数量 = 未扫描的对象数量 - 已扫描的对象数量
scanWorkExpected := int64(memstats.heap_scan) - c.scanWork
if scanWorkExpected < 1000 {
scanWorkExpected = 1000
}
// 距离触发GC的Heap⼤⼩ = 期待触发GC的Heap⼤⼩ - 当前的Heap⼤⼩
// 注意next_gc的计算跟gc_trigger不⼀样, next_gc等于heap_marked * (1 + gcpercent / 100)
heapDistance := _gc) - int64(atomic.Load64(&memstats.heap_live))
if heapDistance <= 0 {
heapDistance = 1
}
// 每分配1 byte需要辅助扫描的对象数量 = 等待扫描的对象数量 / 距离触发GC的Heap⼤⼩
c.assistWorkPerByte = float64(scanWorkExpected) / float64(heapDistance)
c.assistBytesPerWork = float64(heapDistance) / float64(scanWorkExpected)
根对象
消耗英文
在GC的标记阶段⾸先需要标记的就是"根对象", 从根对象开始可到达的所有对象都会被认为是存活的.
根对象包含了全局变量, 各个G的栈上的变量等, GC会先扫描根对象然后再扫描根对象可到达的所有对象.
Fixed Roots: 特殊的扫描⼯作 :
fixedRootFinalizers: 扫描析构器队列
fixedRootFreeGStacks: 释放已中⽌的G的栈
Flush Cache Roots: 释放mcache中的所有span, 要求STW
Data Roots: 扫描可读写的全局变量
BSS Roots: 扫描只读的全局变量
Span Roots: 扫描各个span中特殊对象(析构器列表)
Stack Roots: 扫描各个G的栈
标记阶段(Mark)会做其中的"Fixed Roots", "Data Roots", "BSS Roots", "Span Roots", "Stack Roots".
完成标记阶段(Mark Termination)会做其中的"Fixed Roots", "Flush Cache Roots".
对象扫描
当拿到⼀个对象的p时如何找到该对象的span和heapbit。以下分析是基于go1.10
我们在内存分配部分介绍过2 bit表⽰⼀个字,⼀个字节就可以表⽰4个字。2bit中⼀个表⽰是否被scan另⼀个表⽰该对象内是否有指针类型,根据地址p可以根据固定偏移计算出该p对应的hbit: