掘金 后端 ( ) • 2024-03-30 10:51

前言

Go语言中常见100问题-#26 切片引发的内存泄露问题讨论切片可能导致的内存泄露问题,其实对于字符串也可能会导致内存泄露。下面来分析在操作字符串的时候如何防止内存泄露。

获取一个字符串的子串可以采用下面的语句。

s1 := "Hello, World!"
s2 := s1[:5] // Hello

s2截取了s1的前5个字符,注意这里s1中的字符都是简单字符,所以直接基于s1的前5个byte创建一个字符串,如果子串的字符存在非简单字符的情况,所谓的非简单字符是指该字符(rune)编码后由多个字节构成。如果s1中含有非简单字符,截取前5个字符需要采用下面的编码。

s1 := "Hêllo, World!"
s2 := string([]rune(s1)[:5]) // Hêllo

案例引入

现在我们对字符串的子串操作有了新的认识,下面通过例子说明可能导致内存泄露问题。handleLog完成这样的功能:接收一个字符串类型的入参,表示log日志信息,每条log有两部分构成:uuid+日志内容,uuid具有唯一标识性,可以理解为每条log的uuid都是不同的,我们想在内存中存储每条日志的uuid信息,例如保留最近的n个uuid信息,需要注意,日志的内容可能很长,高达几千个字节。

func (s store) handleLog(log string) error {
    if len(log) < 36 {
        return errors.New("log is not correctly formatted")
    }

    uuid := log[:36]
    s.store(uuid)
    // Do something
}

通过截取子串的方式获取log的uuid(log[:36]),然后将uuid信息传递给s.store存储起来。看起来代码没有问题,实际上是存在问题的。

在Go语言中,对字符串子串操作,没有规范字符串和原串是否共享相同的数据,即是否复用底层的back array. 但是标准的Go编译器处理方法是让它们共享相同的back array, 这样做兼顾了内存和性能两方面,即减少内存开销又提高了性能。由于log可能很长,log[:36]创建的子串引用了log底层的数组,因此整个log占用的内存都不会释放,导致内存泄露问题。

解决方法

如何修复呢?采用深度拷贝,截取的uuid不再共享log底层数组,而是新创建back array.

方法1:采用string([]byte(log[:36]))转换
func (s store) handleLog(log string) error {
    if len(log) < 36 {
        return errors.New("log is not correctly formatted")
    }

    uuid := string([]byte(log[:36]))
    s.store(uuid)
    // Do something
}

通过将log[:36]转换为[]byte,然后在将[]byte转成string,防止内存泄露问题,此时得到的uuid字符串底层是一个含有36个字节的数组,而不是与log共享底层数组。

注意,一些编辑器或linters会提示string([]byte(s))是不必要的操作,例如,像Goland会提示 redundant type conversion. 在大部分场景下这样处理确实是冗余操作,但是在本文场景中这样处理确实是有必要的,我们需要识别有时候IDE给出的告警提示是不准确的。

NOTE字符串是指针类型,将字符串传给函数时,并不是深拷贝,函数内操作的字符串和外部共享同一个底层back array.

func prints(s string) {
    sh := (*reflect.StringHeader)(unsafe.Pointer(&s))
    fmt.Printf("data:%v,len:%d\n", sh.Data, sh.Len)
}

func main() {
    s := "hello world"
    sh := (*reflect.StringHeader)(unsafe.Pointer(&s))
    fmt.Printf("data:%v,len:%d\n", sh.Data, sh.Len)
    prints(s)
}

上述程序可以验证传递给prints的字符串变量s和main函数中的s执行的地址(data)是相同的,在我的电脑上运行输出结果如下。

data:17468238,len:11
data:17468238,len:11
方法2:采用strings.Clone方法

Go1.18版本开始,标准库提供strings.Clone方法,它返回一个新的字符串。本文问题采用strings.Clone实现关键语句如下,将log[:36]内容拷贝到一个新分配的内存空间中,防止内存泄露。

uuid := strings.Clone(log[:36])

思考总结

对于字符串的子串操作,我们需要留意两个事情。一是字符串内部是基于byte实现的,而不是rune。二是,不恰当的子串操作会导致内存泄露问题,因为截取的子串和原串仍然在共享底层内存,处理方法是采用深拷贝,或者使用strings.Clone(Go1.18版本开始提供)。