Golang中GC如何处理内部指针

Golang中GC如何处理内部指针 Golang的垃圾回收如何处理内部指针?考虑以下代码:

type Foo struct {
  a,b,c,d int
  bar     Bar
  e,f,g,h int
}

type Bar struct {
  a,b,c,d int
}

a := &Foo{}
b := &a.bar
a = nil
// b 现在指向 `Foo` 的一小部分,但本身没有指向 `Foo` 的指针

在垃圾回收过程中,Go需要标记所有存活对象,并释放死亡对象。在上面的例子中,我们只能知道Bar是存活的,因为有一个指向它的指针。但是,我们不能释放它,因为我们从未单独分配它。我们只能释放Foo,但从技术上讲,没有指向Foo的指针。垃圾回收器如何知道b中的地址实际上来自对Foo的分配,而不是对Bar的分配? 我能想到一种可能的方法是:通过检查指针本身的位置,如果它属于一个用于96字节大小对象的区域,我们可以将其向下舍入到最接近的96的倍数以找到头部。但是,如果Bar本身是一个非常大的切片的一部分,就像这样:

a := make([]Foo, 1000000)
b := &a[1000].bar
a = nil

Golang如何知道b实际上来自a?分配本身的大小太大,无法放入用于固定大小对象的区域中。


更多关于Golang中GC如何处理内部指针的实战教程也可以访问 https://www.itying.com/category-94-b0.html

11 回复

这是最简单的实现方式,但实际的源代码并非如此简单。如果你想深入了解,应该查看这部分源代码,这是最直接的方法。

更多关于Golang中GC如何处理内部指针的实战系列教程也可以访问 https://www.itying.com/category-94-b0.html


因为第一个 a 产生的数组切片会有一个内存区域,Go 的垃圾收集器会记录内存区域的范围。只要有一个指针指向该内存区域的任何部分,整个内存区域就会被标记为可达,并且不会被回收。

或许我之前说Go的垃圾回收器是基于内存区域范围而非特定变量对象来管理时,误导了你。只要存在指向内存区域任何部分的指针,该内存区域就会被标记为可达且不会被回收。 虽然我谈论的是变量对象,但其含义是指内存,不是抽象的对象,而是底层的内存。

例如,当指向0x1333的指针出现时,垃圾回收器将0x1234到0x2345的内存区域记录为活跃状态。 然而,在实践中,只需要记录变量对象之间的引用关系,就能理解何时释放的问题,这也是最直观的思考方式,尽管底层实现并非如此。

很抱歉,距离我上次查看源代码已经过去很久了,因为我通常工作在应用层,没有复习过这部分知识。我现在也不清楚具体发生了什么(关于版本的替换),我只知道它工作原理的大致思路,并且目前没有精力去深入研究(顺便说一句,我的算法能力也不是很好)。

剩下的探索需要你自己完成了,但如果你在阅读源代码方面有困难,也许可以尝试借助GPT来分析源代码,或者阅读一些博客文章。

我刚刚查看了Go的运行时代码。它相当庞大,包含了许多细节。但如果我理解正确的话,他们似乎使用了某种两级基数树。指针的某些部分被用来在基数树中查找元素。这个元素是一个span元数据。这个span元数据包含了它们属于哪个页面的信息。一个span通常用于存储固定大小的对象,因此包含诸如大小类、元素数量等信息。大对象只是包含一个元素的span的一种特殊情况。我不太确定我的理解是否正确。

由于第一个 a 产生的数组切片会拥有一个内存区域,Go 的垃圾收集器会记录内存区域的范围。只要存在指向该内存区域任何部分的指针,整个内存区域就会被标记为可达且不会被回收。

这是否意味着,对于大型对象,知道指针的头部地址不是 O(1) 操作?因为我们需要遍历所有已分配的对象才能确切知道哪个是头部。

func main() {
    fmt.Println("hello world")
}

例如,当指向0x1333的指针出现时,GC将0x1234到0x2345记录为一个标记为活跃的内存区域。

是的,但是如何从0x1333得到0x1234呢?你是扫描整个已分配区域,并检查0x1333是否在它们之间吗?类似这样:

func markAddress(p uintptr) { // p = 0x1333
  for _, memoryRegion := allMemoryRegionAllocated {
    // 这个循环是O(N)的,其中N是存活内存区域的数量
    if memoryRegion.start <= p && p < memoryRegion.end {
       mark(memoryRegion)
    }
  }
}

我认为你应该阅读Go的实现文档,Go的垃圾回收机制通过标记变量对象来决定是否释放对象内存,运行时会检查上下文,引用变量对象,当存在未被标记的变量时,意味着该变量将被释放。

	a := make([]Foo, 1000000)
	b := &a[1000].bar
	a = nil

	ms := &runtime.MemStats{}
	for {
		fmt.Println(b.d) // b -> a so not free
		// _ = b // nobody -> so a and b will free
		time.Sleep(1 * time.Second)
		runtime.GC()
		runtime.ReadMemStats(ms)
		fmt.Println(float64(ms.Alloc) / 1024 / 1024)
	}

peakedshout:

Go语言的实现文档中提到,Go的垃圾回收器通过标记变量对象来决定是否释放对象内存。运行时会检查上下文,引用变量对象,当存在未被标记的变量时,意味着该变量…

Golang 如何知道 b 是从 a 分配而来的? 假设 a 的地址是 0x17700。b 的地址应该是 0x2ee20。现在,当垃圾回收器运行时,在标记阶段,只有一个根,即变量 b,其值为 0x2ee20。因此 Go 的垃圾回收器需要访问 0x2ee20。但是,Go 应该标记 0x17700,而不是 0x2ee20。Go 是如何知道 0x2ee20 实际上是 0x17700 的内部指针的呢?

Go的垃圾回收器通过使用边界信息(boundary information)来处理内部指针。每个内存分配都有一个头部,其中包含该分配的大小和类型信息。当GC遇到一个指针时,它会检查该指针指向的地址,然后通过查找分配起始地址来确定完整的对象。

对于你的示例:

type Foo struct {
    a, b, c, d int
    bar        Bar
    e, f, g, h int
}

type Bar struct {
    a, b, c, d int
}

func main() {
    a := &Foo{}
    b := &a.bar
    a = nil
    // b 仍然保持Foo对象存活
}

GC的工作流程如下:

  1. 查找分配起始地址:当GC扫描到指针b时,它会查找b指向地址所属的原始分配块。这是通过维护分配区域的元数据实现的。

  2. 标记整个对象:一旦找到原始分配(Foo对象),GC会标记整个Foo对象为存活,而不仅仅是Bar部分。

  3. 处理大对象:对于大切片的情况:

a := make([]Foo, 1000000)
b := &a[1000].bar
a = nil

Go的内存分配器将大切片分配在堆段(heap span)中,每个堆段都有管理信息。GC可以通过指针地址找到对应的堆段,然后计算对象在切片中的位置。

实现机制的关键代码层面,Go运行时使用findObject函数来查找指针所属的对象:

// 简化的查找逻辑(实际实现更复杂)
func findObject(p unsafe.Pointer) (base unsafe.Pointer, size uintptr) {
    // 1. 找到指针所在的堆段
    span := heapSpanForPointer(p)
    
    // 2. 计算对象起始地址
    // 对于小对象:base = p & ~(size-1)
    // 对于数组中的对象:base = span.base() + index*elemSize
    
    return base, size
}

对于切片中的内部指针,GC会:

  1. 确定切片底层数组的起始地址
  2. 通过指针算术计算具体元素的位置
  3. 标记整个元素(整个Foo结构)为存活

这种机制确保即使只有内部指针存在,整个包含对象也不会被错误回收。Go的GC是精确的(precise),意味着它知道堆上每个字(word)是指针还是标量值,这通过类型信息实现。

实际测试验证:

package main

import (
    "runtime"
    "time"
)

type Foo struct {
    a, b, c, d int
    bar        Bar
    e, f, g, h int
}

type Bar struct {
    a, b, c, d int
}

func main() {
    // 强制GC以便观察
    runtime.GC()
    
    a := &Foo{}
    b := &a.bar
    a = nil
    
    // 保持b的引用,但只有内部指针
    runtime.KeepAlive(b)
    
    // 再次GC,Foo对象应该仍然存活
    runtime.GC()
    
    // 大切片情况
    aSlice := make([]Foo, 1000000)
    b2 := &aSlice[1000].bar
    aSlice = nil
    
    runtime.KeepAlive(b2)
    runtime.GC()
    
    time.Sleep(time.Second)
}

在这个例子中,两个Foo对象都不会被回收,因为GC通过内部指针找到了完整的对象分配。

回到顶部