Golang 中容易发生内存泄露的例子

193 阅读5分钟

在 Go 语言中,尽管有垃圾回收(GC)机制自动管理内存,但仍可能发生内存泄漏。内存泄漏指的是程序中某些对象不再被使用,却仍然被引用,导致 GC 无法回收这些内存。Go 的内存泄漏通常与开发者代码中的逻辑错误或不正确的资源管理有关,而不是 GC 本身的缺陷。下面详细分析 Go 中常见的内存泄漏情况及其原因,并提供示例和解决方法。


Go 中常见的内存泄漏情况

1. Goroutine 泄漏

  • 原因
    • Goroutine 未正确结束,持续占用内存。
    • 通常发生在 Goroutine 被阻塞(如等待未关闭的通道或锁),无法退出。
  • 示例
    package main
    
    func leak() {
        ch := make(chan int)
        go func() {
            <-ch // 永久阻塞,因为 ch 没有发送数据或关闭
        }()
    }
    
    func main() {
        leak()
        select {} // 主程序不退出
    }
    
    • ch 未关闭或发送数据,Goroutine 永久运行,占用栈内存(初始 2KB,可能增长)。
  • 解决方法
    • 确保 Goroutine 有退出路径。
    • 关闭通道或使用上下文(context)控制生命周期。
    func fixed() {
        ch := make(chan int)
        go func() {
            <-ch
        }()
        close(ch) // 关闭通道,Goroutine 可退出
    }
    

2. 未释放的全局引用

  • 原因
    • 对象被全局变量、缓存或静态结构引用,GC 无法回收。
  • 示例
    package main
    
    var cache = make(map[string][]byte)
    
    func addToCache(key string, data []byte) {
        cache[key] = data // 永久存储,未删除
    }
    
    func main() {
        for i := 0; i < 1000; i++ {
            addToCache(string(i), make([]byte, 1024*1024)) // 每次添加 1MB
        }
        select {}
    }
    
    • cache 无清理机制,内存持续增长。
  • 解决方法
    • 定期清理无用条目,或使用带有过期机制的缓存(如 sync.Map 配合定时清理)。
    func addToCacheWithLimit(key string, data []byte) {
        if len(cache) > 100 { // 限制大小
            for k := range cache {
                delete(cache, k) // 清理
                break
            }
        }
        cache[key] = data
    }
    

3. 未关闭的资源

  • 原因
    • 文件、数据库连接、网络连接等资源未释放,间接占用内存。
  • 示例
    package main
    
    import "net/http"
    
    func leakHandler(w http.ResponseWriter, r *http.Request) {
        resp, err := http.Get("http://example.com")
        if err != nil {
            return
        }
        // 未调用 resp.Body.Close(),连接未释放
    }
    
    func main() {
        http.HandleFunc("/", leakHandler)
        http.ListenAndServe(":8080", nil)
    }
    
    • 未关闭 resp.Body,导致底层连接和缓冲区未释放。
  • 解决方法
    • 使用 defer 确保资源关闭。
    func fixedHandler(w http.ResponseWriter, r *http.Request) {
        resp, err := http.Get("http://example.com")
        if err != nil {
            return
        }
        defer resp.Body.Close() // 确保关闭
    }
    

4. 切片或字符串的子切片引用

  • 原因
    • 切片或字符串的子切片保留了对底层数组的引用,即使只需要一部分数据,GC 仍无法回收整个底层数组。
  • 示例
    package main
    
    func leakSlice() []byte {
        data := make([]byte, 1024*1024) // 1MB
        return data[0:10]               // 返回子切片
    }
    
    func main() {
        small := leakSlice() // small 只用 10 字节,但引用 1MB
        _ = small
        select {}
    }
    
    • small 引用了整个 data 底层数组,1MB 内存无法回收。
  • 解决方法
    • 复制需要的部分,释放原始引用。
    func fixedSlice() []byte {
        data := make([]byte, 1024*1024)
        result := make([]byte, 10)
        copy(result, data[0:10]) // 复制
        return result
    }
    

5. 定时器或 Ticker 未停止

  • 原因
    • time.Timertime.Ticker 未正确停止,持续运行并持有内存。
  • 示例
    package main
    
    import "time"
    
    func leakTimer() {
        ticker := time.NewTicker(time.Second)
        go func() {
            for range ticker.C {
                // 未停止 ticker
            }
        }()
    }
    
    func main() {
        leakTimer()
        select {}
    }
    
    • ticker 未停止,Goroutine 和相关内存无法回收。
  • 解决方法
    • 使用 ticker.Stop() 释放。
    func fixedTimer() {
        ticker := time.NewTicker(time.Second)
        go func() {
            for range ticker.C {
                ticker.Stop() // 停止 ticker
                return
            }
        }()
    }
    

6. Finalizer 未正确使用

  • 原因
    • 使用 runtime.SetFinalizer 设置终结器,但对象仍被引用,导致终结器未触发。
  • 示例
    package main
    
    import "runtime"
    
    type MyType struct {
        data []byte
    }
    
    var keep *MyType
    
    func leakFinalizer() {
        obj := &MyType{data: make([]byte, 1024*1024)}
        runtime.SetFinalizer(obj, func(o *MyType) {
            o.data = nil // 清理
        })
        keep = obj // 全局引用
    }
    
    func main() {
        leakFinalizer()
        select {}
    }
    
    • keep 持有 obj,终结器无法触发,内存泄漏。
  • 解决方法
    • 避免依赖终结器,手动清理或确保对象无引用。
    func fixedFinalizer() {
        obj := &MyType{data: make([]byte, 1024*1024)}
        runtime.SetFinalizer(obj, func(o *MyType) {
            o.data = nil
        })
        // 不持有全局引用
    }
    

7. 第三方库或外部资源问题

  • 原因
    • 使用第三方库时,未正确释放其内部资源(如连接池、缓存)。
  • 示例
    • 使用数据库驱动,未关闭连接:
    import "database/sql"
    
    func leakDB() {
        db, _ := sql.Open("mysql", "user:pass@/dbname")
        // 未调用 db.Close()
    }
    
  • 解决方法
    • 检查文档,确保释放资源。
    func fixedDB() {
        db, _ := sql.Open("mysql", "user:pass@/dbname")
        defer db.Close() // 关闭连接
    }
    

内存泄漏的检测方法

  1. runtime.NumGoroutine
    • 检查 Goroutine 数量是否异常增长。
    fmt.Println(runtime.NumGoroutine())
    
  2. pprof
    • 使用 runtime/pprofnet/http/pprof 分析内存和 Goroutine。
    go tool pprof http://localhost:6060/debug/pprof/heap
    
  3. GODEBUG
    • 查看 GC 行为:
    GODEBUG=gctrace=1 go run main.go
    
  4. 第三方工具
    • goleakmemleak 检测 Goroutine 和内存泄漏。

内存泄漏的根本原因

  • Go 的 GC 是追踪式(Tracing),依赖引用关系判断对象存活性。
  • 如果对象被意外引用(直接或间接),GC 无法回收,导致泄漏。
  • 常见根源包括 Goroutine、全局变量、未关闭的资源等。

预防措施

  1. 使用 context 控制 Goroutine
    • 通过 context.WithCancel 确保 Goroutine 可退出。
  2. 定期清理缓存
    • 使用 TTL 或容量限制。
  3. defer 释放资源
    • 确保文件、连接等及时关闭。
  4. 避免不必要的引用
    • 检查切片、map 等是否过度持有内存。

总结

Go 中内存泄漏的常见情况包括:

  1. Goroutine 未退出。
  2. 全局变量或缓存未清理。
  3. 资源未释放。
  4. 子切片引用底层数组。
  5. 定时器未停止。
  6. Finalizer 使用不当。
  7. 第三方库问题。

通过正确管理 Goroutine、资源和引用关系,可以有效避免内存泄漏。