Golang中变量为何会保留在堆上?

Golang中变量为何会保留在堆上? 大家好

我觉得我之前的问题没有解释清楚,我会在这个新话题中尝试更好地说明。 之前的讨论可以在这里找到(这个话题可以关闭了)。

我正在使用 pprof 包进行内存性能分析,结果表明有一些变量存储在堆上,我无法解释,甚至可能是内存泄漏。

我使用以下代码写入内存性能分析文件,该代码通过 restApi 从机器人框架调用(因此它在自己的协程中运行)。

func onGetMemProfile(w http.ResponseWriter, r *http.Request) {
    memProfile, err := os.Create("memProfile.pprof")
    if err != nil {
      	fmt.Println("memProfile could not be created.")
    }
    runtime.GC() //运行垃圾回收
    if err := pprof.WriteHeapProfile(memProfile); err != nil {
       fmt.Println("could not write memory profile: ", err)
    }
}

当我研究特定场景后两个内存性能分析文件之间的差异时,性能分析工具告诉我有一个字符串变量保留在堆上,这我无法解释。

这发生在以下代码片段中:

func (info *Info) Values(path string) (ret []gjson.Result, status QueryStatus) {
    status = QueryUnknown
    test := info.data + " "

    result := gjson.Get(test, path)
    if result.Exists() {
        status = QuerySuccessful
        ret = result.Array()
    } else {
        status = QueryConfigMissing
    }
    return
}

如果不将 info.data 复制到 test 变量中,性能分析工具表明 info.data 的内存会在更高层级被保留。通过这次复制,根据性能分析数据,info.data 没有被保留。

性能分析工具的输出如下(针对 inuse_space 样本):

Total:      7.56kB     7.56kB (flat, ■■■) 0.013%
258            .          .           } 
259            .          .            
260            .          .           func (info *Info) Values(path string) (ret []gjson.Result, status QueryStatus) { 
261            .          .           	status = QueryUnknown 
262            .          .            
263       7.56kB     7.56kB           	test := info.data + " " 
264            .          .            
265            .          .           	result := gjson.Get(test, path) 
266            .          .           	if result.Exists() { 
267            .          .           		status = QuerySuccessful 
268            .          .           		ret = result.Array() 

请注意,在我们测试的场景中,这个函数被调用了很多次。查看性能分析的 alloc_object 和 inuse_object 样本空间时,只有 10 次(总共 1344 次)字符串被保留在堆上。

alloc_object sample  : 263         1344       1344          test := info.data + " " 
inuse_object sampe   : 263           10         10          test := info.data + " " 

我已经检查了 gjson 中与 get 相关的突出错误,但没有发现任何问题。 我已在调试器中检查代码,以验证 result 不包含任何指向 test 变量的指针。

问题:

  • 我是否正确解读了性能分析结果?在我的场景结束后,test 变量是否仍然驻留在堆上?
  • 写入堆性能分析文件的代码是否正确?我是否正确使用了它?在哪个 goroutine 上调用 WriteHeapProfile() 函数是否有影响?
  • 为什么 test 变量会被保留在堆上?(我没有看到任何对它的引用)
  • 这种行为是否表明存在内存泄漏?
  • 为什么垃圾回收器没有在所有情况下清理 test 变量?什么可以解释为什么在 10/1344 的情况下变量没有被清理?
  • 这是否表明 gjson 库中存在错误?

提前感谢任何帮助。


更多关于Golang中变量为何会保留在堆上?的实战教程也可以访问 https://www.itying.com/category-94-b0.html

4 回复

你尝试过

go build -gcflags="-m"

来查看逃逸分析吗?

更多关于Golang中变量为何会保留在堆上?的实战系列教程也可以访问 https://www.itying.com/category-94-b0.html


我觉得我之前的问题没有解释清楚,我会在这个新话题中尝试说得更好一些。

你标题中的问题很明确:为什么这个变量被保留在堆上?你在这个问题以及之前问题中的文字所反映出的问题,表明了你对Go垃圾收集器和工具的(错误?)假设。

你需要理解垃圾收集的一般原理,特别是Go语言中的工作原理。你还需要理解Go工具的工作方式。

首先要回答的问题是,你使用的go version是什么。Go语言在不断优化并持续改进。例如,即将发布的版本引入了一种新的、改进的内存管理技术。

下一个要回答的问题是使用哪个Go工具。pprof使用采样来识别程序中的热点。对于栈和堆的分配,可以从Go编译器开始。关于分配的基础介绍(2019年),请观看这个视频:

理解分配:栈与堆 - GopherCon SG 2019

你得到了什么结果?你能解释它们吗?

petrus:

首先要回答的问题是,你的 go version 是什么?

我被迫使用一个相当旧的版本:

go version go1.10.1 linux/amd64

感谢你提供的链接,它证实了我的猜测。

vorsprung:

你试过 go build -gcflags="-m" 来查看逃逸分析吗?

我现在试了,我花了一些时间才弄清楚如何对所有文件(而不仅仅是 main.go)执行此操作。

我尝试了:

go build -gcflags "-m=2 -l"  directory_name
go build -gcflags "-m=2 -l"  ./...

逃逸分析明确指出我创建的字符串逃逸到了堆上。

info.data + " " escapes to heap

我仍然不确定原因。数据没有向上共享,只向下传递。petrus 提供的链接表明,大小在这里可能是一个因素。(数据大于 2KB)但我以为栈会增长以适应这种情况。

下图显示了每次调用 Values() 函数时存储在堆上的对象(info.data + " ")数量。此信息是通过获取 @init 和每次调用 Values() 函数之后的内存配置文件差异得到的。

我原本以为每次调用 runtime.GC()(在 X 轴的每个单位处调用)都会清理堆上所有未使用的数据。图表告诉我这根本不是真的。它显示一些对象在稍后的时间点才被清理。

我可能需要查看一下我使用的 Go 版本的垃圾收集器工作原理。

image

所以,我在原帖中的问题仍然存在。 我所看到的行为是 内存泄漏 还是 垃圾收集器内部工作机制 的影响?

此致

根据你的性能分析数据和代码,我来解释一下Go语言中变量逃逸到堆上的原因以及你的具体问题。

变量逃逸分析

在Go中,变量是否分配在堆上由编译器的逃逸分析决定。当变量的生命周期超出函数作用域时,它就会"逃逸"到堆上。

代码分析

func (info *Info) Values(path string) (ret []gjson.Result, status QueryStatus) {
    status = QueryUnknown
    test := info.data + " "  // 这里可能发生逃逸

    result := gjson.Get(test, path)
    // ...
}

问题解答

1. 性能分析结果解读

是的,你正确解读了性能分析结果。test变量确实逃逸到了堆上。inuse_object显示10个对象仍在堆上,而alloc_object显示总共分配了1344次。

2. 性能分析代码正确性

你的性能分析代码基本正确:

func onGetMemProfile(w http.ResponseWriter, r *http.Request) {
    memProfile, err := os.Create("memProfile.pprof")
    if err != nil {
        fmt.Println("memProfile could not be created.")
        return  // 添加return避免空指针
    }
    defer memProfile.Close()  // 重要:确保文件关闭
    
    runtime.GC() // 运行垃圾回收
    if err := pprof.WriteHeapProfile(memProfile); err != nil {
        fmt.Println("could not write memory profile: ", err)
    }
}

在哪个goroutine调用WriteHeapProfile()没有影响,它会收集整个进程的堆信息。

3. 为什么test变量被保留在堆上

test变量逃逸的原因可能是:

// gjson.Get内部可能使test逃逸
result := gjson.Get(test, path)

gjson.Get函数签名是:

func Get(json, path string) Result

虽然参数是值传递,但Go编译器可能认为:

  1. test被传递给外部包函数
  2. 外部函数可能持有对字符串的引用
  3. 因此test需要分配在堆上

4. 这是否是内存泄漏

不一定。10/1344的对象仍在堆上可能是:

  • 垃圾回收尚未运行
  • 这些对象被其他未显示的引用持有
  • 性能分析时的快照效应

5. 为什么GC没有清理所有test变量

GC没有立即清理的原因:

// 可能的解释:
// 1. 字符串池化:Go的字符串可能被intern
// 2. 临时对象在GC周期之间存活
// 3. 逃逸分析保守性:编译器可能过度保守

// 验证方法:查看字符串内容是否相同
fmt.Printf("info.data length: %d\n", len(info.data))

6. gjson库是否有bug

不一定。可以检查gjson是否无意中保留了引用:

// 测试:直接传递字符串字面量
func TestNoEscape() {
    // 这应该不会逃逸
    result := gjson.Get(`{"test": "value"}`, "test")
    _ = result
}

// 对比测试:使用变量
func TestWithEscape() {
    data := `{"test": "value"}`
    result := gjson.Get(data, "test")  // data可能逃逸
    _ = result
}

深入分析建议

使用逃逸分析标志编译:

go build -gcflags="-m -m" your_package

这会显示详细的逃逸分析信息,例如:

./main.go:263:6: test escapes to heap:
./main.go:263:6:   flow: {storage for ... argument} = &test:
./main.go:263:6:     from test (spill) at ./main.go:263:6
./main.go:263:6:     from ... argument (slice-literal-element) at ./main.go:265:20

优化建议

如果确实需要避免逃逸,可以考虑:

func (info *Info) Values(path string) (ret []gjson.Result, status QueryStatus) {
    status = QueryUnknown
    
    // 方案1:使用字符串字面量(如果info.data是常量)
    // result := gjson.Get(info.data, path)
    
    // 方案2:避免字符串拼接
    result := gjson.Get(info.data, path)
    
    if result.Exists() {
        status = QuerySuccessful
        ret = result.Array()
    } else {
        status = QueryConfigMissing
    }
    return
}

关键结论

  1. 10个对象在堆上不一定是泄漏,可能是正常的GC行为
  2. 字符串拼接info.data + " "创建了新字符串,可能触发逃逸
  3. 传递给外部包函数的变量更容易逃逸
  4. 使用-gcflags="-m"编译可以确认逃逸原因

性能分析显示的是特定时刻的堆状态,不代表永久性内存泄漏。GC会在适当的时候回收这些对象。

回到顶部