看到个好文章,翻译一遍分享一下。
此为https://go101.org/article/memory-leaking.html的翻译,侵删。
当使用支持自动垃圾回收的语言进行编程时,通常我们不需要关心内存泄漏问题,因为运行时会定期手机无用的内存。但是我们确实需要了解一些特殊场景,这些场景可能导致似(kind-of)/真内存泄漏。文章的后面部分会列出一些这样的场景。
Go语言规范没有指明一个子字符串表达式中的基字符串和结果字符串是否应该共用同个底层内存块来管理两个字符串的底层字节序列。标准的Go编译器/运行时确实是让他们共享了同个底层内存块。这是一个很好的设计,不管是从内存还是CPU消化角度。但是有时它可能会导致某种内存泄漏。
比如,在下例中的demo
函数被调用后,会有大约1M字节的似内存,知道package级变量s0在其他地方被修改。
var s0 string // 一个package级变量
// 一个用作示例的函数
func f(s1 string) {
s0 = s1[:50]
// 现在,s0与s1共享了同个底层内存块。尽管s1现在不存活了,
// s0仍然存活。所以尽管只用到了50个字节,其他字节访问不到,
// 他们共享的内存块并不会被回收。
}
func demo() {
s := createStringWithLengthOnHeap(1 << 20) // 1M字节
f(s)
}
为了避免这种似内存泄漏,我们可以转换底层字符串为一个[]byte
,然后把[]byte
转换回string
.
func f(s1 string) {
s0 = string([]byte(s1[:50]))
}
上面这种避免这似内存泄漏的方法的缺点是,在会话处理过程中,会有两个50字节的副本,其中之一是没必要的。
我们可以利用标准Go编译器的其中一个优化来避免非必要的副本,只需要浪费一个字节的内存。
func f(s1 string) {
s0 = (" " + s1[:50])[1:]
}
以上方法的缺点是,这个编译器优化在以后可能会被取消,并且这个优化可能在其他编译器中没有实现。
第三种避免这个似内存泄漏的方法是使用strings.Builder
,从Go 1.10开始有的。
import "strings"
func f(s1 string) {
var b strings.Builder
b.Grow(50)
b.WriteString(s1[:50])
s0 = b.String()
}
第三种方式的缺点是相比前两种有些麻烦。一个好消息是,从Go 1.12起,我们可以调用strings
标准包的Repeat
函数,传递count
参数为1
来克隆一个字符串。从Go 1.12起,string.Repeat
的底层实现会使用strings.Builder
来避免不必要的复制。
从Go 1.17起,strings
标准包里加入了一个Clone
函数。它成了做这个事的最好方法。
与子字符串类似,子切片也可能导致似内存泄漏。在以下代码中,在g
函数被调用后,占据s1
大部分内存块的大量元素将会丢失(如果没有更多值指向这个内存块)。
var s0 []int
func g(s1 []int) {
// 假设s1的长度远大于30
s0 = s1[len(s1)-30:]
}
如果我们想要避免这个似内存泄漏,我们必须为s0
复制30个元素,这样s0
的存活就不会阻止s1
的内存块被回收了。
func g(s1 []int) {
s0 = make([]int, 30)
copy(s0, s1[len(s1)-30:])
// 现在,如果没有其他值引用着存着s1的元素的内存块,
// 它就可以被回收了。
}
在以下代码中,在h
函数被调用后,为s
的第一个和最后一个元素分配的内存块将会丢失。
func h() []*int {
s := []*int{new(int), new(int), new(int), new(int)}
// 做一些 s 相关的事情 ...
return s[1:3:3]
}
只要返回的切片仍然存活,它就会阻止s
的任何元素被回收,这就会导致被s
的第一个和最后一个元素指向的内存块无法被回收。
如果我们想要避免这种似内存泄漏,我们必须重置丢失的元素中的指针。
func h() []*int {
s := []*int{new(int), new(int), new(int), new(int)}
// 做一些 s 相关的事情 ...
// 重置指针值.
s[0], s[len(s)-1] = nil, nil
return s[1:3:3]
}
我们常常需要在切片元素删除操作中为一些旧切片元素重置指针。
有时,一个Go程序中的一些goroutine会永远留在阻塞状态。这种goroutine被称为挂起的goroutine。Go运行时不会杀掉挂起的goroutine,所以为挂起的goroutine分配的资源(以及其引用的内存块)将永远不会被垃圾回收。
Go运行时不杀死挂起的goroutine有两个原因。一是有时Go运行时很难判断是否一个阻塞中的goroutine会永远被阻塞。二是,有时我们会故意让goroutine被挂起。比如,有时我们也许会让主goroutine挂起来避免程序退出。
我们应该避免代码设计上的逻辑错误造成的挂起的线程。
time.Ticker
导致的真内存泄漏当一个time.Timer
值不再使用。它在一段时间后会被垃圾回收。但是对于time.Tikcer
不是这样的。当不再使用其时,我们应该stop这个time.Ticker
。
为一个是循环引用组的成员之一的值设置finalizer可能会阻止回收为这个循环引用组分配的所有内存块。这是真内存泄漏,不是似。
例如,在以下函数被调用并退出后,为x
和y
分配的内存块们不保证会被之后的垃圾回收回收掉。
func memoryLeaking() {
type T struct {
v [1<<20]int
t *T
}
var finalizer = func(t *T) {
fmt.Println("finalizer called")
}
var x, y T
// SetFinalizer调用导致x逃逸到堆上。
runtime.SetFinalizer(&x, finalizer)
// 下面这一行形成了一个有两个成员,x和y,的循环引用组,
// 这导致x和y不可回收。
x.t, y.t = &y, &x // y 也逃逸到了堆上。
}
所以请避免为一个循环引用组中的值设置finalizers。
顺便一说,我们不应该使用finalizers作为对象析构器。
一个非常大的defer调用栈也可能消耗很多内存,未执行的defer调用可能会阻止一些资源被及时释放。例如,如果有许多文件需要在对以下函数的一次调用中被处理,大量文件句柄将在函数退出前无法被释放。
func writeManyFiles(files []File) error {
for _, file := range files {
f, err := os.Open(file.path)
if err != nil {
return err
}
defer f.Close()
_, err = f.WriteString(file.content)
if err != nil {
return err
}
err = f.Sync()
if err != nil {
return err
}
}
return nil
}
对于这种情况,我们可以使用一个匿名函数来包裹defer调用,这样defer函数调用就能被更早的执行。比如,以上函数可以被重写提升为:
func writeManyFiles(files []File) error {
for _, file := range files {
if err := func() error {
f, err := os.Open(file.path)
if err != nil {
return err
}
// The close method will be called at
// the end of the current loop step.
defer f.Close()
_, err = f.WriteString(file.content)
if err != nil {
return err
}
return f.Sync()
}(); err != nil {
return err
}
}
return nil
}