7. 有了 GC,为什么还会发生内存泄露?

在一个具有 GC 的语言中,我们常说的内存泄漏,用严谨的话来说应该是:预期的能很快被释放的内存由于附着在了长期存活的内存上、或生命期意外地被延长,导致预计能够立即回收的内存而长时间得不到回收。

在 Go 中,由于 goroutine 的存在,所谓的内存泄漏除了附着在长期对象上之外,还存在多种不同的形式。

形式1:预期能被快速释放的内存因被根对象引用而没有得到迅速释放

当有一个全局对象时,可能不经意间将某个变量附着在其上,且忽略的将其进行释放,则该内存永远不会得到释放。例如:

var cache = map[interface{}]interface{}{}

func keepalloc() {
    for i := 0; i < 10000; i++ {
        m := make([]byte, 1<<10)
        cache[i] = m
    }
}

形式2:goroutine 泄漏

Goroutine 作为一种逻辑上理解的轻量级线程,需要维护执行用户代码的上下文信息。在运行过程中也需要消耗一定的内存来保存这类信息,而这些内存在目前版本的 Go 中是不会被释放的。因此,如果一个程序持续不断地产生新的 goroutine、且不结束已经创建的 goroutine 并复用这部分内存,就会造成内存泄漏的现象,例如:

func keepalloc2() {
    for i := 0; i < 100000; i++ {
        go func() {
            select {}
        }()
    }
}

验证

我们可以通过如下形式来调用上述两个函数:

package main

import (
    "os"
    "runtime/trace"
)

func main() {
    f, _ := os.Create("trace.out")
    defer f.Close()
    trace.Start(f)
    defer trace.Stop()
    keepalloc()
    keepalloc2()
}

运行程序:

go run main.go

会看到程序中生成了 trace.out 文件,我们可以使用 go tool trace trace.out 命令得到下图:

可以看到,途中的 Heap 在持续增长,没有内存被回收,产生了内存泄漏的现象。

值得一提的是,这种形式的 goroutine 泄漏还可能由 channel 泄漏导致。而 channel 的泄漏本质上与 goroutine 泄漏存在直接联系。Channel 作为一种同步原语,会连接两个不同的 goroutine,如果一个 goroutine 尝试向一个没有接收方的无缓冲 channel 发送消息,则该 goroutine 会被永久的休眠,整个 goroutine 及其执行栈都得不到释放,例如:

var ch = make(chan struct{})

func keepalloc3() {
    for i := 0; i < 100000; i++ {
        // 没有接收方,goroutine 会一直阻塞
        go func() { ch <- struct{}{} }()
    }
}
文档更新时间: 2020-12-30 22:00   作者:kuteng