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

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

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

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

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

  1. var cache = map[interface{}]interface{}{}
  2. func keepalloc() {
  3. for i := 0; i < 10000; i++ {
  4. m := make([]byte, 1<<10)
  5. cache[i] = m
  6. }
  7. }

形式2:goroutine 泄漏

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

  1. func keepalloc2() {
  2. for i := 0; i < 100000; i++ {
  3. go func() {
  4. select {}
  5. }()
  6. }
  7. }

验证

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

  1. package main
  2. import (
  3. "os"
  4. "runtime/trace"
  5. )
  6. func main() {
  7. f, _ := os.Create("trace.out")
  8. defer f.Close()
  9. trace.Start(f)
  10. defer trace.Stop()
  11. keepalloc()
  12. keepalloc2()
  13. }

运行程序:

  1. go run main.go

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

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

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

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

  1. var ch = make(chan struct{})
  2. func keepalloc3() {
  3. for i := 0; i < 100000; i++ {
  4. // 没有接收方,goroutine 会一直阻塞
  5. go func() { ch <- struct{}{} }()
  6. }
  7. }