Go GC如何检测内存对象中是否包含指针

众所周知,Go是带垃圾回收(GC)的编程语言,开发者通常不需要考虑对内存的管理,降低了心智负担。Go程序运行的时候,GC在背后默默辛劳地为开发者“擦屁股”:把无法reach到的内存对象定期地释放掉以备后续重用。

GC只关心指针,只要被扫描到的内存对象中有指针,它就会“顺藤摸瓜”,把该内存对象所在的“关系网”摸个门儿清,而那些被孤立在这张“网”之外的内存对象就是要被“清扫”的对象。

那么GC在扫描时如何判断某个内存对象中是否有指针呢?这篇文章我们就来说说这事儿!

内存对象中有指针与无指针的差别

在Gopher Academy Blog[1] 2018年发表的一篇文章《Avoiding high GC overhead with large heaps》[2]中作者曾用两个例子来对比了内存对象中有指针与无指针时GC的行为差别。我们摘录一下其中的这两个例子,第一个例子如下:

// demo1.go
func main() {
    a := make([]*int, 1e9) 
                                        
    for i := 0; i < 10; i++ {
        start := time.Now()
        runtime.GC()
        fmt.Printf("GC took %s\n", time.Since(start))
    }

    runtime.KeepAlive(a)
}

程序中调用runtime.KeepAlive函数用于保证在该函数调用点之前切片a不会被GC释放掉。

我们看到:demo1中声明了一个包含10亿个*int的切片变量a,然后调用runtime.GC函数手工触发GC过程,并度量每次GC的执行时间,我们看看这个程序的执行结果(virtualbox 虚拟机ubuntu 20.04/go 1.18beta2):

$ go run demo1.go
GC took 698.46522ms
GC took 325.315425ms
GC took 321.959991ms
GC took 326.775531ms
GC took 333.949713ms
GC took 332.350721ms
GC took 328.1664ms
GC took 329.905988ms
GC took 328.466344ms
GC took 330.327066ms

我们看到,每轮GC调用都相当耗时。我们再来看第二个例子:

// demo2.go
func main() {
    a := make([]int, 1e9) 
                                        
    for i := 0; i < 10; i++ {
        start := time.Now()
        runtime.GC()
        fmt.Printf("GC took %s\n", time.Since(start))
    }

    runtime.KeepAlive(a)
}

这个例子仅是将切片的元素类型由*int改为了int。我们运行一下这第二个例子:

$ go run demo2.go
GC took 3.486008ms
GC took 1.678019ms
GC took 1.726516ms
GC took 1.13208ms
GC took 1.900233ms
GC took 1.561631ms
GC took 1.899654ms
GC took 7.302686ms
GC took 131.371494ms
GC took 1.138688ms

在我们的实验环境中demo2中每轮GC的性能是demo1的300多倍!两个demo源码唯一的不同就是切片中的元素类型,demo1中的切片元素类型为int型指针。GC每次触发后都会全量扫描切片中存储的这10亿个指针,这就是demo1 GC函数执行时间很长的原因。而demo2中的切片元素类型为int,从demo2的运行结果来看,GC根本没有搭理demo2中的a,这也是demo2 GC函数执行时间较短的原因(我测试了一下:在我的环境中,即便不声明切片a,只是执行10次runtime.GC函数,该函数的平均执行时间也在1ms左右)。

通过以上GC行为差异,我们知道GC可以通过切片a的类型知晓其元素是否包含指针,进而决定是否对其进行进一步扫描。下面我们就来看看GC是如何检测到某一个内存对象中包含指针的。

运行时类型信息(rtype)

Go是静态语言,每个变量都有自己的归属的类型,当变量被在堆上分配时,堆上的内存对象也就有了自己归属的类型。Go编译器在编译阶段就为Go应用中的每种类型建立了对应的类型信息,这些信息体现在runtime._rtype结构体中,Go reflect包的rtype结构体等价于runtime._rtype:

// $GOROOT/src/reflect/type.go

// rtype is the common implementation of most values.
// It is embedded in other struct types.
//
// rtype must be kept in sync with ../runtime/type.go:/^type._type.
type rtype struct {
    size       uintptr
    ptrdata    uintptr // number of bytes in the type that can contain pointers
    hash       uint32  // hash of type; avoids computation in hash tables
    tflag      tflag   // extra type information flags
    align      uint8   // alignment of variable with this type
    fieldAlign uint8   // alignment of struct field with this type
    kind       uint8   // enumeration for C
    // function for comparing objects of this type
    // (ptr to object A, ptr to object B) -> ==?
    equal     func(unsafe.Pointer, unsafe.Pointer) bool
    gcdata    *byte   // garbage collection data
    str       nameOff // string form
    ptrToThis typeOff // type for pointer to this type, may be zero
}

在这个结构体类型中的gcdata字段是为GC服务的,我们看看它究竟是什么!怎么看呢?由于reflect.rtype类型是非导出类型,我们需要对本地的Go语言源码做一些hack,我在reflect包的type.go文件中rtype结构体的定义之前添加一行代码:

type Rtype = rtype

我们用Go 1.9版本[3]引入的类型别名(type alias)机制将rtype导出,这样我们就可以在标准库外面使用reflect.Rtype了。

有童鞋可能会问:改了本地Go标准库源码后,Go编译器就会使用最新源码来编译我们的Go示例程序么?Go 1.18之前的版本都不会!大家可以自行试验一下,也可以通过《Go语言精进之路vol1》[4]第16条“理解包导入”一章了解有关于Go编译器构建过程的详尽描述。

下面我们来获取一个切片的类型对应的rtype,看看其中的gcdata究竟是啥?

// demo4.go

package main

import (
 "fmt"
 "reflect"
 "unsafe"
)

type tflag uint8
type nameOff int32 // offset to a name
type typeOff int32 // offset to an *rtype

type rtype struct {
 size       uintptr
 ptrdata    uintptr // number of bytes in the type that can contain pointers
 hash       uint32  // hash of type; avoids computation in hash tables
 tflag      tflag   // extra type information flags
 align      uint8   // alignment of variable with this type
 fieldAlign uint8   // alignment of struct field with this type
 kind       uint8   // enumeration for C
 // function for comparing objects of this type
 // (ptr to object A, ptr to object B) -> ==?
 equal     func(unsafe.Pointer, unsafe.Pointer) bool
 gcdata    *byte   // garbage collection data
 str       nameOff // string form
 ptrToThis typeOff // type for pointer to this type, may be zero
}

func bar() []*int {
 t := make([]*int, 8)
 return t
}

func main() {
 t := bar()
 v := reflect.TypeOf(t)

 rtyp, ok := v.(*reflect.Rtype)
 if !ok {
  println("error")
  return
 }

 r := (*rtype)(unsafe.Pointer(rtyp))
 fmt.Printf("%#v\n", *r)
 fmt.Printf("*gcdata = %d\n", *(r.gcdata))
}

bar函数返回一个堆上分配的切片实例t,我们通过reflect.TypeOf获取t的类型信息,通过类型断言我们得到该类型的rtype信息:rtyp,不过gcdata也是非导出字段并且是一个指针,我们要想对其解引用,我们这里又在本地定义了一个本地rtype类型,用于输出gcdata指向的内存的值。

运行这个示例:

$go run demo4.go
main.rtype{size:0x18, ptrdata:0x8, hash:0xaad95941, tflag:0x2, align:0x8, fieldAlign:0x8, kind:0x17, equal:(func(unsafe.Pointer, unsafe.Pointer) bool)(nil), gcdata:(*uint8)(0x10c1b58), str:3526, ptrToThis:0}
*gcdata = 1

我们看到gcdata指向的一个字节的内存的值为1(二进制为0b00000001)。好了,不卖关子了!gcdata所指的这个字节每一bit上的值代表一个8字节的内存块是否包含指针。这样的一个字节就可以标识在一个64字节的内存块中,每个8字节的内存单元是否包含指针。如果类型长度超过64字节,那么用于表示指针地图的gcdata指向的有效字节个数也不止1个字节。

读过我的“Go语言第一课”专栏[5]的童鞋都知道,切片类型在runtime层表示为下面结构:

// $GOROOT/src/runtime/slice.go

type slice struct {
    array unsafe.Pointer
    len   int
    cap   int
}

这里切片类型结构内存对齐后的size为24,小于64个字节,因此Go用一个字节就可以表示切片类型的指针地图。而*gcdata=1,即最低位上的bit为1,表示切片类型的第一个8字节中存储着一个指针。配合下面的示意图理解起来更easy一些:

0a2674e0fa981f1486db806bc172ff1b.png

我们也可以进一步查看切片中各元素是否包含指针,由于该切片的元素就是指针类型,所以每个元素的rtype.gcdata指向的bitmap的值都应该是1,我们来验证一下:

//demo5.go 
... ...
func main() {
    t := bar()
    v := reflect.ValueOf(t)

    for i := 0; i < len(t); i++ {
        v1 := v.Index(i)
        vtyp := v1.Type()

        rtyp, ok := vtyp.(*reflect.Rtype)
        if !ok {
            println("error")
            return
        }

        r := (*rtype)(unsafe.Pointer(rtyp))
        fmt.Printf("%#v\n", *r)
        fmt.Printf("*gcdata = %d\n", *(r.gcdata))
    }
}

这个例子输出了每个切片元素的bitmap,结果如下:

$go run demo5.go

gomain.rtype{size:0x8, ptrdata:0x8, hash:0x2522ebe7, tflag:0x8, align:0x8, fieldAlign:0x8, kind:0x36, equal:(func(unsafe.Pointer, unsafe.Pointer) bool)(0x1002c40), gcdata:(*uint8)(0x10c1be0), str:566, ptrToThis:0}
*gcdata = 1
main.rtype{size:0x8, ptrdata:0x8, hash:0x2522ebe7, tflag:0x8, align:0x8, fieldAlign:0x8, kind:0x36, equal:(func(unsafe.Pointer, unsafe.Pointer) bool)(0x1002c40), gcdata:(*uint8)(0x10c1be0), str:566, ptrToThis:0}
*gcdata = 1
main.rtype{size:0x8, ptrdata:0x8, hash:0x2522ebe7, tflag:0x8, align:0x8, fieldAlign:0x8, kind:0x36, equal:(func(unsafe.Pointer, unsafe.Pointer) bool)(0x1002c40), gcdata:(*uint8)(0x10c1be0), str:566, ptrToThis:0}
*gcdata = 1
main.rtype{size:0x8, ptrdata:0x8, hash:0x2522ebe7, tflag:0x8, align:0x8, fieldAlign:0x8, kind:0x36, equal:(func(unsafe.Pointer, unsafe.Pointer) bool)(0x1002c40), gcdata:(*uint8)(0x10c1be0), str:566, ptrToThis:0}
*gcdata = 1
main.rtype{size:0x8, ptrdata:0x8, hash:0x2522ebe7, tflag:0x8, align:0x8, fieldAlign:0x8, kind:0x36, equal:(func(unsafe.Pointer, unsafe.Pointer) bool)(0x1002c40), gcdata:(*uint8)(0x10c1be0), str:566, ptrToThis:0}
*gcdata = 1
main.rtype{size:0x8, ptrdata:0x8, hash:0x2522ebe7, tflag:0x8, align:0x8, fieldAlign:0x8, kind:0x36, equal:(func(unsafe.Pointer, unsafe.Pointer) bool)(0x1002c40), gcdata:(*uint8)(0x10c1be0), str:566, ptrToThis:0}
*gcdata = 1
main.rtype{size:0x8, ptrdata:0x8, hash:0x2522ebe7, tflag:0x8, align:0x8, fieldAlign:0x8, kind:0x36, equal:(func(unsafe.Pointer, unsafe.Pointer) bool)(0x1002c40), gcdata:(*uint8)(0x10c1be0), str:566, ptrToThis:0}
*gcdata = 1
main.rtype{size:0x8, ptrdata:0x8, hash:0x2522ebe7, tflag:0x8, align:0x8, fieldAlign:0x8, kind:0x36, equal:(func(unsafe.Pointer, unsafe.Pointer) bool)(0x1002c40), gcdata:(*uint8)(0x10c1be0), str:566, ptrToThis:0}
*gcdata = 1

输出结果与预期相符。

我们再来看一个例子,一个用单字节bitmap无法表示的类型:

// demo6.go
... ...
type S struct {  // 起始地址
    a  uint8     // 0
    b  uintptr   // 8
    p1 *uint8    // 16
    c  [3]uint64 // 24
    d  uint32    // 48
    p2 *uint64   // 56
    p3 *uint8    // 64
    e  uint32    // 72
    p4 *uint64   // 80
}

func foo() *S {
    t := new(S)
    return t
}

func main() {
    t := foo()
    println(unsafe.Sizeof(*t)) // 88
    typ := reflect.TypeOf(t)
    rtyp, ok := typ.Elem().(*reflect.Rtype)

    if !ok {
        println("error")
        return
    }
    fmt.Printf("%#v\n", *rtyp)

    r := (*rtype)(unsafe.Pointer(rtyp))
    fmt.Printf("%#v\n", *r)
    fmt.Printf("%d\n", *(r.gcdata))
    gcdata1 := (*byte)(unsafe.Pointer(uintptr(unsafe.Pointer(r.gcdata)) + 1))
    fmt.Printf("%d\n", *gcdata1)
}

在这个例子中,我们定义了一个很大的结构体类型S,其size为88,用一个字节无法表示出其bitmap,于是Go使用了两个字节,我们输出这两个字节的bitmap:

$go run demo6.go
88
reflect.rtype{size:0x58, ptrdata:0x58, hash:0xcdb468b2, tflag:0x7, align:0x8, fieldAlign:0x8, kind:0x19, equal:(func(unsafe.Pointer, unsafe.Pointer) bool)(0x108aea0), gcdata:(*uint8)(0x10c135b), str:3593, ptrToThis:19168}
main.rtype{size:0x58, ptrdata:0x58, hash:0xcdb468b2, tflag:0x7, align:0x8, fieldAlign:0x8, kind:0x19, equal:(func(unsafe.Pointer, unsafe.Pointer) bool)(0x108aea0), gcdata:(*uint8)(0x10c135b), str:3593, ptrToThis:19168}
132
5

我们将结果转换成一幅示意图,如下图:

8347350af3d9ec28f2732514efbb6f8d.png

理解上面这个结构体size以及各字段起始地址的前提是理解内存对齐,这个大家可以在我的博客内搜索以前撰写的有关内存对齐的相关内容,当然也可以参考我在专栏第17讲讲解结构体类型[6]时对Go内存对齐的系统讲解。

96a6eb72c3026d7d8545522abeebce19.png

3152d01c238438576fa0df278239cdb6.png

Gopher Daily(Gopher每日新闻)归档仓库 - https://github.com/bigwhite/gopherdaily

我的联系方式:

  • 微博:https://weibo.com/bigwhite20xx

  • 微信公众号:iamtonybai

  • 博客:tonybai.com

  • github: https://github.com/bigwhite

  • “Gopher部落”知识星球:https://public.zsxq.com/groups/51284458844544

16608ca2434630485b5677adf3dbee5b.png

商务合作方式:撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。

参考资料

[1] 

Gopher Academy Blog: https://blog.gopheracademy.com/

[2] 

《Avoiding high GC overhead with large heaps》: https://blog.gopheracademy.com/advent-2018/avoid-gc-overhead-large-heaps/

[3] 

Go 1.9版本: https://tonybai.com/2017/07/14/some-changes-in-go-1-9/

[4] 

《Go语言精进之路vol1》: https://book.douban.com/subject/35720728/

[5] 

“Go语言第一课”专栏: http://gk.link/a/10AVZ

[6] 

专栏第17讲讲解结构体类型: http://gk.link/a/10AVZ

<think>嗯,用户想了解Go语言垃圾回收中的根对象组成和作用。这个问题涉及到GC机制的核心概念,需要结合站内引用内容进行系统梳理。用户之前讨论过Go的面向对象特性,现在转向内存管理,说明ta可能在深入学习Go的底层机制。从问题表述看,用户已经有一定基础,但需要更清晰的根对象分类说明。引用[1][2]提供了关键信息:根对象GC遍历的起点,包括全局变量、finalizer任务和所有协程栈。这里需要特别注意:-全局变量包含.bss(未初始化数据)和.data(已初始化数据)段-finalizer是特殊的析构任务队列,即使对象本身不可达,但任务本身仍作为根-协程栈包含当前所有goroutine的栈帧变量引用[4][5]补充了技术细节:GC开始时会把所有栈对象标记为黑色,且屏障机制不作用于栈。这点很重要,解释了为什么栈对象必须作为根——它们是GC安全点。用户可能隐含想知道根对象的选择原理。可以强调三点设计哲学:1)全局变量始终存活2)栈对象代表当前执行上下文3)finalizer是资源清理的最后保障在回答时需要区分概念层次:先明确定义,再分类说明,最后解释设计考量。表格形式能清晰对比不同类型根对象的存储位置和生命周期特征。用户似乎偏好结构化信息,之前的OOP回答中表格反馈良好。潜在延伸问题可能是根对象GC性能的关系,比如大量全局变量是否影响GC时间。这点引用[3]提到过GOGC参数调整,但当前问题聚焦在组成而非优化,放在相关提问里更合适。最后注意避免术语堆砌。比如解释".bss/.data段"时要关联到"全局变量存储区",毕竟用户问的是Go而非编译原理。</think>在Go语言垃圾回收(GC)过程中,**根对象GC Roots)** 是GC遍历的起点,用于确定所有可达对象的存活状态。根据引用内容,Go语言GC对象的组成和作用如下: --- ### **根对象组成**(引用[1][2][4][5]) | **类型** | **具体内容** | **作用说明** | |---------------------|-----------------------------------------------------------------------------|-----------------------------------------------------------------------------| | **全局变量** | 存储在`.bss`(未初始化数据段)和`.data`(已初始化数据段)中的全局变量 | 程序生命周期内始终存活,是GC必须扫描的持久化对象[^2] | | **协程栈(Goroutine Stacks)** | 所有正在运行的Goroutine的栈帧中的局部变量、参数和临时对象 | 代表当前执行上下文中的活跃对象,是GC最频繁扫描的区域[^4][^5] | | **Finalizer任务** | `runtime`中注册的析构器任务(如`os.File`的关闭描述符任务) | 确保资源释放,即使对象本身不可达,其析构任务仍被视为根对象[^2] | | **寄存器持有对象** | 当前CPU寄存器中引用的对象(如函数调用中的参数) | 反映即时执行状态的对象引用,防止活跃对象被误回收[^4] | --- ### **根对象的核心作用** 1. **存活对象判定起点** GC从根对象出发,通过**三色标记法**遍历对象图: - **黑色**:已扫描且存活的对象(根对象初始标记为黑) - **灰色**:已扫描但子引用未完全处理的对象 - **白色**:未被访问的对象(最终被回收)[^4][^5] 2. **保障弱三色不变式** Go使用**混合写屏障**确保GC期间对象引用变更的安全性: - 当堆对象引用栈对象时,触发屏障将栈对象标灰 - 避免因并发修改导致存活对象被误回收[^5] 3. **栈扫描优化** GC开始时**直接标记所有栈对象为黑色**(引用[4][5]),因为: - 栈对象生命周期短暂且明确 - 避免屏障操作栈以保证执行效率 - 后续通过写屏障处理栈到堆的引用变更 --- ### **技术细节补充** - **全局变量扫描**:GC会遍历整个程序的`.bss`和`.data`段,识别所有全局指针[^2]。 - **Finalizer机制**: ```go // 示例:为文件对象注册析构器 f, _ := os.Open("file.txt") runtime.SetFinalizer(f, func(f *os.File) { f.Close() }) ``` 即使`f`不可达,GC仍会将其关联的析构任务视为根对象[^2]。 - **协程栈扫描**:每个Goroutine栈被视为独立根对象集,GC并行扫描以提升效率。 > **设计意义**:根对象定义了GC的安全边界——任何从根对象不可达的对象均视为垃圾[^1]。Go通过精准识别根对象,在高效回收与程序安全间取得平衡。 --- ### **GC对象与性能优化** - **减少根对象影响**: - 避免滥用全局变量(尤其是大对象) - 及时清理不再需要的Finalizer任务 - **监控工具**: 通过`GODEBUG=gctrace=1`查看GC日志,关注`roots`相关指标(如栈扫描耗时)[^3]。 ```bash # 示例输出 gc 8 @0.251s 2%: ... roots=524KB stack=312KB ... ```
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值