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

前言

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

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

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

golang 复制代码
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.

场景1:解决方法

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

golang 复制代码
func getMessageType(msg []byte) []byte {
    msgType := make([]byte, 5)
    copy(msgType, msg)
    return msgType
}

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

golang 复制代码
func getMessageType(msg []byte) []byte {
    return msg[:5:5]
}

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

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

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

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

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

golang 复制代码
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回收。

golang 复制代码
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对象进行回收,因为它们不再被使用到。然而实际输出如下,与我们预期的不一致。

console 复制代码
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会回收它们。

golang 复制代码
func keepFirstTwoElementsOnly(foos []Foo) []Foo {
    res := make([]Foo, 2)
    copy(res, foos)
    return res
}

场景2:解决方法2

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

golang 复制代码
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 进行性能测试。

思考总结

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

相关推荐
睡觉z14 分钟前
Haproxy搭建web群集
前端
codingandsleeping1 小时前
重读《你不知道的JavaScript》(上)- this
前端·javascript
武子康2 小时前
Java-49 深入浅出 Tomcat 手写 Tomcat 实现【02】HttpServlet Request RequestProcessor
java·开发语言·后端·学习·spring cloud·tomcat
狮子也疯狂2 小时前
基于Spring Boot的宿舍管理系统设计与实现
java·spring boot·后端
孩子 你要相信光2 小时前
前端如何通过 Blob 下载 Excel 文件
前端·javascript
PetterHillWater3 小时前
研发技术之路回忆录之一
后端
IT猫咪酱3 小时前
【前端】yarn install error
前端
喜欢打篮球的普通人3 小时前
Flang:LLVM Fortran 前端简介
前端
喵喵侠w3 小时前
腾讯地图Web版解决热力图被轮廓覆盖的问题
前端·javascript
程序员一诺python3 小时前
【Django开发】django美多商城项目完整开发4.0第2篇:项目准备,配置【附代码文档】
后端·python·django·框架