文章详情

短信预约-IT技能 免费直播动态提醒

请输入下面的图形验证码

提交验证

短信预约提醒成功

Golang怎么实现GC扫描对象

2023-07-05 19:09

关注

这篇文章主要介绍了Golang怎么实现GC扫描对象的相关知识,内容详细易懂,操作简单快捷,具有一定借鉴价值,相信大家阅读完这篇Golang怎么实现GC扫描对象文章都会有所收获,下面我们一起来看看吧。

扫描的目的

扫描到底是为了什么?

之前的文章我们深入剖析了垃圾回收的理论和实现,可以总结这么节点:

我们通过地毯式的扫描,从一些 root 起点开始,不断推进搜索,最终形成了一张有向可达的网,那些不在网里的就是没有被引用到的,也就是可回收的内存。

扫描的实现

扫描对象代码逻辑其实不简单,但主体线索很清晰,可以分为三部分:

编译阶段

结构体对齐

要理解编译阶段做的事情,那么首先要理解结构体对齐的基础知识。这个和 C 语言类似,golang 的结构体是有对齐规则的,也就是说,必要的时候可能会填充一些内存空间来满足对齐的要求。总结来说两条规则:

“长度要对齐”怎么理解?

结构体的长度要至少是内部最长的基础字段的整数倍。

举例:

type TestStruct struct {ptr uintptr     // 8 f1  uint32      // 4f2  uint8       // 1}

这个结构体内存占用 size 多大?

答案是:16个字节,因为字段 ptr 是 uintptr 类型,占 8 字节,是内部字段最大的,TestStruct 整体长度要和 8 字节对齐。那么就是 16 字节了,而不是有些人想的 13 字节(8+4+1)。

dlv 调试如下:

(dlv) p typ*runtime._type {size: 16,    ...

字节示意图:

|--8 Byte--|--4 Byte--|--4 Byte--|

“地址要对齐”怎么理解?

字段的地址偏移要是自身长度的整数倍。

举例:

type TestStruct struct {ptr uintptr   // 8f1  uint8     // 1 f2  uint32    // 4}

假设 new 一个 TestStruct 结构体 a 的地址是 0xc00008a010 ,那么 &a.ptr 是 0xc00008a010 (= a + 0),&a.f1 是 0xc00008a018 (= a + 8) ,&a.f2 是 0xc00008a01c (= a + 8 + 4) 。

dlv 调试如下:

(dlv) p &a.ptr(*uintptr)(0xc00008a010)(dlv) p &a.f1(*uint8)(0xc00008a018)(dlv) p &a.f2(*uint32)(0xc00008a01c)

假设 TestStruct 分配对象 a 的地址是 0xc00008a010 ,解释如下:

所以这样算下来,整个 TestStruct 的占用空间长度是 16字节 (8+1+3+4)。

指针位标记

golang 的所有类型都对应一个 _type 结构,可以在 runtime/type.go 里面找到,定义如下:

type _type struct {size       uintptrptrdata    uintptr // size of memory prefix holding all pointershash       uint32tflag      tflagalign      uint8fieldalign uint8kind       uint8alg        *typeAlg// gcdata stores the GC type data for the garbage collector.// If the KindGCProg bit is set in kind, gcdata is a GC program.// Otherwise it is a ptrmask bitmap. See mbitmap.go for details.gcdata    *bytestr       nameOffptrToThis typeOff}

比如我们定义了一个 Struct 如下:

type TestStruct struct {ptr uintptrf1  uint8f2  *uint8f3  uint32f4  *uint64f5  uint64}

该结构 dlv 调试如下:

(dlv) p typ*runtime._type {size: 48,ptrdata: 40,hash: 4075663022,tflag: tflagUncommon|tflagExtraStar|tflagNamed (7),align: 8,fieldalign: 8,kind: 25,alg: *runtime.typeAlg {hash: type..hash.main.TestStruct, equal: type..eq.main.TestStruct},gcdata: *20,str: 28887,ptrToThis: 49504,}

在编译期间,编译器就会在内部生成一个 _type 结构体与之对应。_type 里面重点解释几个和本次扫描主题相关的字段:

size:类型长度,我们上面这个类型长度应该是 32 字节;

这里理解要应用上上面讲的结构体字节对齐的知识,这里就不再复述;

ptrdata:指针截止的长度位置,我们 f4 是指针,所以包含指针的字段最多也就到 40 字节的位置,ptrdata==40;

要理解字节对齐哈;

kind:表明类型,我们是自定义struct类型,所以 kind == 25

kind 枚举定义在 runtime/typekind.go 文件里;

gcdata:这个就重要了,这个就是指针的 bitmap,因为编译器他在编译分析的时候,肯定就知道了所有的类型结构,那么自然知道所有的指针位置。gcdata 是 *byte 类型(byte 数组),当前值是 20,20 转换成二进制数据就是 00010100 ,这个眼熟不?这个你要从右往左看就是 00101000(从低 bit 往高 bit 看),这个不就是刚好是 TestStruct 的指针 bitmap 嘛,每个 bit 表示一个指针大小(8 字节)的内存,00101000 第 3 个 bit 和第 5 个 bit 是 1,表示 第 3 个字段(第 3 个 8 字节的位置)和第 5 个字段(第 5 个 8 字节的位置)是存储的是指针类型,这里刚好就和 TestStruct.f2 和 TestStruct.f4 对应起来。

划重点:这里重点回顾一下 uintptr 类型的问题,这里注意到,第一个字段 ptr(uintptr 类型)在指针的 bitmap 上是没有标记成指针类型的,这里一定要注意了,uintptr 是数值类型,非指针类型,用这个存储指针是无法保护对象的(扫描的时候 uintptr 指向的对象不会被扫描),这里就是实锤了。

小结

编译阶段给每个类型生成 _type 类型,内部对类型字段生成指针的 bitmap,这个是后面扫描行为的基础依据。

思考题:是否可以不用 bitmap,其实有个最简单最笨拙的扫描方式,我们可以不搞这个指针的 bitmap,我上来就直接扫描,每 8 字节的读取内存,然后去看这个内存块存储的值是否指向了一个对象?如果是我就保护起来。

这个实现理论上可以满足,但是有两个不能接受的缺陷:

运行期内存分配

下一步就是赋值器的做的事情,也就是业务运行的过程中分配内存。分配内存的时候肯定要指定类型,调用 runtime.newobject 函数进行分配,本质上调用 mallocgc 函数来操作。mallocgc 函数做几件事情:

我们这里重点分析给 gc 做扫描做的准备。在分配完堆内存之后,会调用一个函数 heapBitsSetType ,这个函数逻辑非常复杂,但是做的事情其实一句话能概括:“给 gc 扫描做准备,对分配的内存块做好标记,这小块内存中,哪些位置是指针,我们用一个 bitmap 对应记录下来”。这就是 heapBitsSetType 500 多行代码做的所有事情,之所以这么复杂是因为要判断各种情况。

heapBitsSetType 主要逻辑解析:

func heapBitsSetType(x, size, dataSize uintptr, typ *_type) {    // ...    // 最重要的两个步骤:    // 通过分配地址反查获取到 heap 的 heapBits 结构(回忆下 golang 的内存地址管理)    h := heapBitsForAddr(x)    // 获取到类型的指针 bitmap;    ptrmask := typ.gcdata // start of 1-bit pointer mask (or GC program, handled below)    var (        // ...    )    // 把 h.bitp 这个堆上的 bitmap 取出来;    hbitp = h.bitp    // 该类型的指针 bitmap    p = ptrmask        // ...    if p != nil {        // 把 bitmap 第一个字节保存起来        b = uintptr(*p)        // p 指向下一个字节        p = add1(p)        //         nb = 8    }        // 我们的是简单的 Struct 结构(48==48)    if typ.size == dataSize {        // nw == 5 == 40/8,说明扫描到第 5 个字段为止即可。        // ptrdata 指明有指针的范围在[0, 40]以内,再往外确定就没有指针字段了;        nw = typ.ptrdata / sys.PtrSize    } else {        nw = ((dataSize/typ.size-1)*typ.size + typ.ptrdata) / sys.PtrSize    }    switch {    default:        throw("heapBitsSetType: unexpected shift")    case h.shift == 0:        // b 是类型的   ptr bitmap  =>  00010100        //              bitPointerAll   =>  00001111        // hb => 0000 0100        hb = b & bitPointerAll        // bitScan => 0001 0000         // 0001 0000 | 0100 0000 | 1000 0000         // hb => 1101 0100        hb |= bitScan | bitScan<<(2*heapBitsShift) | bitScan<<(3*heapBitsShift)        // 赋值 hbitp => 1101 0100        *hbitp = uint8(hb)        // 指针往后一个字节(递进一个字节)        hbitp = add1(hbitp)        // b => 0000 0001        b >>= 4        // nb => 4        nb -= 4    case sys.PtrSize == 8 && h.shift == 2:        // ...    }    // ...    // 处理完了前 4 bit,接下来处理后 4 bit    nb -= 4    for {        // b => 0000 0001        // hb => 0000 0001        hb = b & bitPointerAll        // hb => 1111 0001        hb |= bitScanAll        if w += 4; w >= nw {            // 处理完了,有指针的字段都包含在已经处理的 ptrmask 范围内了            break        }        // ...    }Phase3:    // Phase 3: Write last byte or partial byte and zero the rest of the bitmap entries.    // 8 > 5    if w > nw {        // mask => 1        mask := uintptr(1)<<(4-(w-nw)) - 1        // hb => 0001 0001        hb &= mask | mask<<4 // apply mask to both pointer bits and scan bits    }    // nw => 6    nw = size / sys.PtrSize    // ...    if w == nw+2 {        // 赋值 hbitp => 0001 0001        *hbitp = *hbitp&^(bitPointer|bitScan|(bitPointer|bitScan)<<heapBitsShift) | uint8(hb)    }Phase4:    // Phase 4: Copy unrolled bitmap to per-arena bitmaps, if necessary.    // ...}

所以,上面函数调用完,h.bitp 就给设置上了:

低字节 -> 高字节 [ 1101 0100 ], [ 0001 0001 ] |&ndash;前4*8字节&ndash;|&ndash;后4*8字节&ndash;|

这个就是 mallocgc 内存的时候做的事情。

总结就一句话:根据编译期间针对每个 struct 生成的 type 结构,来设置 gc 需要扫描的位图,也就是指针 bitmap。(旁白:每分配一块内存出去,我都会有一个 bitmap 对应到这个内存块,指明哪些地方有指针)。

运行扫描阶段

扫描以 markroot 开始,从栈,全局变量,寄存器等根对象开始扫描,创建一个有向引用图,把根对象投入到队列中,重点的一个函数就是 scanstack 。

另外异步的 goroutine 运行 gcDrain 函数,从队列里消费对象,并且扫描这个对象;

扫描调用的就是 scanobject 函数

下面重点介绍:scanstackscanobject 这个函数怎么扫描对象。

scanstack

这个函数是起点函数( 起始最原始的还是 markroot,但是我们这里梳理主线 ),该扫描栈上所有可达对象,因为栈是一个根,因为你做事情总要有个开始的地方,那么“栈”就是 golang 的起点。

func scanstack(gp *g, gcw *gcWork) {    // ...    // 扫描栈上所有的可达的对象    state.buildIndex()    for {        p := state.getPtr()        if p == 0 {            break        }        // 获取一个到栈上对象        obj := state.findObject(p)        if obj == nil {            continue        }        // 获取到这个对象的类型        t := obj.typ        // ...        // 获取到这个类型内存块的 ptr 的 bitmap(编译期间编译器设置好)        gcdata := t.gcdata        var s *mspan        if t.kind&kindGCProg != 0 {            s = materializeGCProg(t.ptrdata, gcdata)            gcdata = (*byte)(unsafe.Pointer(s.startAddr))        }        // 扫描这个对象        // 起点:对象起始地址 => state.stack.lo + obj.off        // 终点:t.ptrdata (还记得这个吧,这个指明了指针所在内的边界)        // 指针 bitmap:t.gcdata        scanblock(state.stack.lo+uintptr(obj.off), t.ptrdata, gcdata, gcw, &state)        if s != nil {            dematerializeGCProg(s)        }    }    // ...}

小结:

scanblock

scanblock 这个函数不说你应该知道,这是一个非常底层且通用的函数,他的一切参数都是传入的,这个函数作为一个基础函数被很多地方调用:

func scanblock(b0, n0 uintptr, ptrmask *uint8, gcw *gcWork, stk *stackScanState) {    b := b0    n := n0    // 扫描到长度 n 为止;    for i := uintptr(0); i < n; {        // 每个 bit 标识一个 8 字节,8个 bit (1个字节)标识 64 个字节;        // 这里计算到合适的 bits        bits := uint32(*addb(ptrmask, i/(sys.PtrSize*8)))        // 如果整个 bits == 0,那么说明这 8 个 8 字节都没有指针引用,可以直接跳到下一轮        if bits == 0 {            i += sys.PtrSize * 8            continue        }        // bits 非0,说明内部有指针引用,就必须一个个扫描查看;        for j := 0; j < 8 && i < n; j++ {            // 指针类型?只有标识了指针类型的,才有可能走到下面的逻辑去;            if bits&1 != 0 {                p := *(*uintptr)(unsafe.Pointer(b + i))                if p != 0 {                    if obj, span, objIndex := findObject(p, b, i); obj != 0 {                        // 如果这 8 字节指向的是可达的内存对象,那么就投入扫描队列(置灰)保护起来;                        greyobject(obj, b, i, span, gcw, objIndex)                    } else if stk != nil && p >= stk.stack.lo && p < stk.stack.hi {                        stk.putPtr(p)                    }                }            }            bits >>= 1            i += sys.PtrSize        }    }}

如果以上面的 TestStruct 结构举例的话,假设在栈上分配了对象 TestStruct{},地址是 0xc00007cf20,那么会从这个地址扫描 scanblock ( 0xc00007cf20, 40, 20, xxx)

type TestStruct struct {ptr uintptrf1  uint8f2  *uint8f3  uint32f4  *uint64f5  uint64}

示意图如下:

Golang怎么实现GC扫描对象

最外层 for 循环一次就够了,里面 for 循环 5 次,扫描到 f4 字段就完了(还记得 type.ptrdata == 40 吧 )。只有 f2 ,f4 字段才会作为指针去扫描。如果 f2, f4 字段存储的是有效的指针,那么指向的对象会被保护起来(greyobject)。

小结:

scanobject

gcDrain 这个函数就是从队列里不断获取,处理这些对象,最重要的一个就是调用 scanobject 继续扫描对象。

Golang怎么实现GC扫描对象

markroot 从根(栈)扫描,把扫描到的对象投入扫描队列。gcDrain 等函数从里面不断获取,不断处理,并且扫描这些对象,进一步挖掘引用关系,当扫描结束之后,那些没有扫描到的就是垃圾了。

还是 TestStruct 举例:

type TestStruct struct {ptr uintptrf1  uint8f2  *uint8f3  uint32f4  *uint64f5  uint64}

如果一个创建在堆上的 TestStruct 对象被投入到扫描队列,对应的 type.gcdata 是 0001 0100 ,TestStruct 对应编译器创建的 type 类型如下:

(dlv) p typ*runtime._type {size: 48,ptrdata: 40,    ...gcdata: *20,... }

scanobject 逻辑如下:

func scanobject(b uintptr, gcw *gcWork) {    // 通过对象地址 b 获取到这块内存地址对应的 hbits     hbits := heapBitsForAddr(b)    // 通过对象地址 b 获取到这块内存地址所在的 span    s := spanOfUnchecked(b)    // span 的元素大小    n := s.elemsize    if n == 0 {        throw("scanobject n == 0")    }    // ...    var i uintptr    // 每 8 个字节处理递进处理(因为堆上对象分配都是 span,每个 span 的内存块都是定长的,所以扫描边界就是 span.elemsize )    for i = 0; i < n; i += sys.PtrSize {        if i != 0 {            hbits = hbits.next()        }        // 获取到内存块的 bitmap        bits := hbits.bits()                // 确认该整个内存块没有指针,直接跳出,节约时间;        if i != 1*sys.PtrSize && bits&bitScan == 0 {            break // no more pointers in this object        }        // 确认 bits 对应的小块内存没有指针,所以可以直接到下一轮        // 如果是指针,那么就往下看看这 8 字节啥情况        if bits&bitPointer == 0 {            continue // not a pointer        }        // 把这 8 字节里面存的值取出来;        obj := *(*uintptr)(unsafe.Pointer(b + i))        // 如果 obj 有值,并且合法(不在一个 span 的内存块里)        if obj != 0 && obj-b >= n {            // 如果 obj 指向一个有效的对象,那么把这个对象置灰色,投入扫描队列,等待处理            if obj, span, objIndex := findObject(obj, b, i); obj != 0 {                greyobject(obj, b, i, span, gcw, objIndex)            }        }    }    // ...}

小结:

关于“Golang怎么实现GC扫描对象”这篇文章的内容就介绍到这里,感谢各位的阅读!相信大家对“Golang怎么实现GC扫描对象”知识都有一定的了解,大家如果还想学习更多知识,欢迎关注编程网行业资讯频道。

阅读原文内容投诉

免责声明:

① 本站未注明“稿件来源”的信息均来自网络整理。其文字、图片和音视频稿件的所属权归原作者所有。本站收集整理出于非商业性的教育和科研之目的,并不意味着本站赞同其观点或证实其内容的真实性。仅作为临时的测试数据,供内部测试之用。本站并未授权任何人以任何方式主动获取本站任何信息。

② 本站未注明“稿件来源”的临时测试数据将在测试完成后最终做删除处理。有问题或投稿请发送至: 邮箱/279061341@qq.com QQ/279061341

软考中级精品资料免费领

  • 历年真题答案解析
  • 备考技巧名师总结
  • 高频考点精准押题
  • 2024年上半年信息系统项目管理师第二批次真题及答案解析(完整版)

    难度     813人已做
    查看
  • 【考后总结】2024年5月26日信息系统项目管理师第2批次考情分析

    难度     354人已做
    查看
  • 【考后总结】2024年5月25日信息系统项目管理师第1批次考情分析

    难度     318人已做
    查看
  • 2024年上半年软考高项第一、二批次真题考点汇总(完整版)

    难度     435人已做
    查看
  • 2024年上半年系统架构设计师考试综合知识真题

    难度     224人已做
    查看

相关文章

发现更多好内容

猜你喜欢

AI推送时光机
位置:首页-资讯-后端开发
咦!没有更多了?去看看其它编程学习网 内容吧
首页课程
资料下载
问答资讯