Go语言中常见100问题-#26 切片引发的内存泄露问题

2 阅读6分钟

前言

本文主要介绍切片在两种情况下可能导致内存泄露问题。一是切片容量泄露,二是与指针相关。

场景1:容量引发的泄露问题

假设要实现一个自定义的二进制协议,消息长度可能有100万个字节,其中前5个字节表示消息类型。在我们的程序中,将对每条消息进行处理,为了进行审计,需要在内存中至少存储1000种消息类型,整个程序实现框架如下。

func consumeMessages() {
    for {
        msg := receiveMessage()
        // Do something with msg
        storeMessageType(getMessageType(msg))
    }
}

func getMessageType(msg []byte) []byte {
    return msg[:5]
}

func receiveMessage() []byte {
    return make([]byte, 1_000_000)
}

func storeMessageType([]byte) {}

getMessageType函数直接截取输入切片msg前5个元素返回,实现看起来好像没有什么问题。然而实际部署后,程序占用高达1GB内存,为啥会这样呢?

msg[:5]操作创建了一个长度为5个元素的切片,但是它的容量大小仍然与msg的大小一样,即有100万个字节,剩余(100万-5)个字节的内存并不会被释放。此时,占用的内存如下图所示。因此,如果保存1000条消息在内存,实际占用的内存大小不是5KB,而是1GB.

3_13.jpg

场景1:解决方法

如何解决上面程序占用内存过大的问题呢?可以采用深拷贝的方法,具体代码如下。

func getMessageType(msg []byte) []byte {
    msgType := make([]byte, 5)
    copy(msgType, msg)
    return msgType
}

msgType的大小和容量都是5,通过copy操作,无论输入的切片msg有多大,msgType只保存前5个字节的消息类型。除了采用深拷贝的方法,是否可以采用下面全量表达式来避免泄露呢?

func getMessageType(msg []byte) []byte {
    return msg[:5:5]
}

上述 getMessageType 实现采用全量表达式的方法,返回一个长度为5、容量为5的切片。GC能否回收除了5个有效字节之外的内存呢?Go语言规范没有指定其行为。因此,采用全量表达式并不是一种有效的方法(除非Go语言在未来处理该问题)。

经验表明,对大切片或数组进行截取操作可能会导致潜在的大内存消耗。尽管只使用了几个字节,但底层占用很大的数组内存空间不会被GC回收。使用深拷贝方法可以防止这种大内存消耗问题。

场景2:指针引发的泄露问题

前面一部分内容分析了截取切片可能导致内存泄露。对于切片,如果它的元素是指针或者是结构体,但某个字段是一个切片,当只引用部分数据的时候,GC会将无效数据占用的内存回收吗?

下面通过如下程序进行验证,结构体 Foo 含有一个字段v,该字段是一个切片类型。

type Foo struct {
    v []byte
}

执行如下操作后,检查内存使用情况。

  1. 分配一个大小为1000的Foo类型的切片

  2. 分配1000个Foo对象,赋值到1中的切片中,每个Foo对象字段v分配1MB内存

  3. 调用keepFirstTwoElementsOnly函数,该函数截取输入切片的前两个元素,然后调用runtime.GC进行垃圾回收

具体程序实现如下,调用keepFirstTwoElementsOnly函数,并执行垃圾回收操作之后,通过printAlloc函数打印内存占用情况。最后调用 runtime.KeepAlive 函数保持对变量two的引用,使得它不被GC回收。

func main() {
    foos := make([]Foo, 1_000)
    printAlloc()
    for i := 0; i < len(foos); i++ {
        foos[i] = Foo{
        v: make([]byte, 1024*1024),
        }
    }

    printAlloc()
    two := keepFirstTwoElementsOnly(foos)
    runtime.GC()
    printAlloc()
    runtime.KeepAlive(two)
}

func keepFirstTwoElementsOnly(foos []Foo) []Foo {
    return foos[:2]
}

func printAlloc() {
    var m runtime.MemStats
    runtime.ReadMemStats(&m)
    fmt.Printf("%d KB\n", m.Alloc/1024)
}

我们预期的执行结果是GC将对切片中剩余的998个Foo对象进行回收,因为它们不再被使用到。然而实际输出如下,与我们预期的不一致。

83 KB
1024072 KB
1024072 KB

创建foos后占用的内存大小为83KB, 此时foos保存了1000个Foo零值对象。当foos中每个Foo对象初始为拥有1MB空间后,占用的内存增加到1024072KB. 然而执行GC操作之后,占用的内存仍然为1024072KB,说明剩余的998个Foo对象并没有被回收。为啥是这样呢?

记住一条原则:当对切片进行截取操作时,如果切片中的元素是指针或者含有指针的结构体,将不会被GC回收。上面的例子中,因为结构体Foo中含有切片类型字段v(切片是一种指针类型,它是对底层数组的引用),剩余的998个Foo类型元素及其切片未被回收。因此,尽管剩余的998个元素不会再被访问,它们仍然占用内存,直到变量two不在被引用。

场景2:解决方法1

方法一采用深拷贝,keepFirstTwoElementsOnly 创建一个新的切片。新的切片只拷贝了foos中前2个元素,通过深拷贝,新切片res与原切片foos没有关联,GC会回收它们。

func keepFirstTwoElementsOnly(foos []Foo) []Foo {
    res := make([]Foo, 2)
    copy(res, foos)
    return res
}

场景2:解决方法2

如果我们想继续保持 keepFirstTwoElementsOnly 函数返回的切片容量仍然为1000,可以将剩余元素明确地设置为nil. 虽然返回的切片长度为2容量为1000,但是通过将不再使用的元素置为nil, GC可以进行回收处理。

func keepFirstTwoElementsOnly(foos []Foo) []Foo {
    for i := 2; i < len(foos); i++ {
        foos[i].v = nil
    }
    return foos[:2]
}

上述两种处理方法,哪种更好呢?如果我们不想返回的切片容量继续为1000,方法一是最佳选择。其它情况下,依赖于截取元素的多少。如下图所示,假设有n个元素,我们想保留前i个元素。方法一会对i个元素进行拷贝,从元素0到元素i进行迭代处理。方法二是将剩余的元素设置为nil,所以它迭代处理的元素为i到n. 如果程序性能是我们的关注点,并且i更靠近n,可以考虑方法二,因为它进行迭代处理的次数更少,最佳判断方法是通过 benchmark 进行性能测试。

3_14.jpg

思考总结

本文分析了两类可能导致切片内存泄露问题。第一类问题是对切片进行截取操作,虽然截取后的切片长度比原切片小,但是它们的容量还是一样的,已分配的元素不再使用,但是不会被GC回收。第二类问题是当切片中的元素是指针或者结构体(字段含义指针类型)时,我们需要清楚GC不会回收不再使用的元素,可以采用深拷贝或者显示的将元素设置为nil, 防止内存泄露。