内存泄漏是指在程序执行过程中,已不再使用的内存空间没有被及时释放或者释放时出现了错误,导致这些内存无法被使用,直到程序结束这些内存才被释放。
如果出现内存泄漏问题,程序将会因为占据大量内存而变得异常缓慢,严重时可能会导致程序崩溃。在go语言中,可以通过runtime包里的freeosmemory()函数来进行内存回收和清理。此外,也可以使用一些工具来检测内存泄漏问题,例如go tool pprof等。
需要注意的是,内存泄漏不是语言本身的问题,而通常是程序编写者忘记释放内存或者处理内存时出现错误导致的。
Go虽然有GC来回收不再使用的堆内存,减轻了开发人员对内存的管理负担,但这并不意味着Go程序不再有内存泄漏问题。在Go程序中,如果没有Go语言的编程思维,也不遵守良好的编程实践,就可能埋下隐患,造成内存泄漏问题。
关于Go的内存泄漏有这么一句话:10次内存泄漏,有9次是goroutine泄漏。为避免goroutine泄漏造成内存泄漏,启动goroutine前要思考清楚:goroutine如何退出?是否会有阻塞造成无法退出?如果有,那么这个路径是否会创建大量的goroutine?
Goroutine 泄漏的本质是 goroutine 阻塞,无法继续向下执行,导致此 goroutine 关联的内存都无法释放,进一步造成内存泄漏。
每个goroutine占用2KB内存,泄漏1百万goroutine至少泄漏2KB * 1000000 = 2GB内存,为什么说至少呢?
Goroutine执行过程中还存在一些变量,如果这些变量指向堆内存中的内存,GC会认为这些内存仍在使用,不会对其进行回收,这些内存谁都无法使用,造成了内存泄漏。
goroutine泄漏有2种方式造成内存泄漏:goroutine本身的栈所占用的空间造成内存泄漏;goroutine中的变量所占用的堆内存导致堆内存泄漏,这一部分是能通过heap profile体现出来的。
利用好go pprof获取goroutine profile文件,然后利用3个命令top、traces、list定位内存泄漏的原因。
判断依据:在节点正常运行的情况下,隔一段时间获取goroutine的数量,如果后面获取的那次,某些goroutine比前一次多,如果多获取几次,是持续增长的,就极有可能是goroutine泄漏。
泄漏的场景有很多,但因channel阻塞导致泄漏的场景是最多的。
channel 阻塞:
无缓冲 channel 阻塞。写操作因为没有读操作而阻塞。
有缓冲 channel 阻塞。缓冲区满了,写操作阻塞。
期待从channel读数据,结果没有goroutine写。
select 操作
select里也是channel操作,如果所有case上的操作阻塞,且没有default分支进行处理,goroutine也无法继续执行。
互斥锁没有释放,互斥锁死锁
多个协程由于竞争资源或者彼此通信而造成阻塞,不能退出。
申请过多的goroutine来不及释放
在Go中内存泄漏分为暂时性内存泄漏和永久性内存泄漏。
暂时性泄漏,指的是该释放的内存资源没有及时释放,对应的内存资源仍然有机会在更晚些时候被释放,即便如此在内存资源紧张情况下,也会是个问题。这类主要是 string、slice 底层 buffer 的错误共享,导致无用数据对象无法及时释放,或者 defer 函数导致的资源没有及时释放。
s0 = s1[:50],
s0 = s1[len(s1)-30:]
获取长string或者切片中的一段内容,由于新生成的对象和老的string或者切片共用一个内存空间,会导致老的string和切片资源暂时得不到释放,造成短暂的内存泄漏。
字符串——两次拷贝,s0 = string([]byte(s1[:50])),s0 := (" " + s1[:50])[1:]
切片——使用copy函数,copy(s0, s1[len(s1)-30:])
s := []*int{new(int), new(int), new(int), new(int)}
return s[1:3:3]
这种情况下:当函数返回后,只要s还存活,s中的所有元素都不能被释放,即使s中的第一个元素和最后一个元素没有被使用了,也不能被释放。我们可以将不要的元素置为nil就能解决这个问题。
s := []*int{new(int), new(int), new(int), new(int)}
s[0], s[len(s)-1] = nil, nil
return s[1:3:3]
在for中存在很多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
}
// 正确
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
}
永久性泄漏,指的是在进程后续生命周期内,泄漏的内存都没有机会回收,如 goroutine 内部预期之外的for-loop或者chan select-case导致的无法退出的情况,导致协程栈及引用内存永久泄漏问题。
channel 阻塞导致 goroutine 阻塞
select 操作导致 goroutine 阻塞
互斥锁没有释放,互斥锁死锁
申请过多的goroutine来不及释放
time.After在定时器到达时,会自动内回收。time.Ticker 钟摆不使用时,一定要Stop,不然会造成内存泄漏。
由于数组是Golang的基本数据类型,每个数组占用不同的内存空间,生命周期互不干扰,很难出现内存泄漏的情况。但是数组作为形参传输时,遵循的是值拷贝,如果函数被多次调用且数组过大时,则会导致内存使用激增。
func countTarget(nums [1000000]int, target int) int{
num := 0
for i:=0; i<len(nums) && nums[i] == target; i++{
num ++
}
return num
}
例如上面的函数中,每次调用countTarget函数传参时都需要新建一个大小为100万的int数组,大约为8MB内存,如果在短时间内调用100次就需要约800MB的内存空间了。(未达到GC时间或者GC阀值是不会触发GC的)如果是在高并发场景下每个协程都同时调用该函数,内存占用量是非常恐怖的。
因此对于大数组放在形参场景下,通常使用切片或者指针进行传递,避免短时间的内存使用激增。
生产者消费者模式下,10个生产者1秒生产一次,同时只有1个消费者1秒消费一次,导致9个生产者都在阻塞等待,浪费内存资源。