1. 何为内存泄漏

内存泄漏并不是指物理上的内存消失,而是在写程序的过程中,由于程序的设计不合理导致对之前使用的内存失去控制,无法再利用这块内存区域;短期内的内存泄漏可能看不出什么影响,但是当时间长了之后,日积月累,浪费的内存越来越多,导致可用的内存空间减少,轻则影响程序性能,严重可导致正在运行的程序突然崩溃。

一般一个进程结束之后,内存会自动回收,同时也会自动回收那些被泄露的内存,当进程重新启动后,这些内存又可以重新被分配使用。但是正常情况下企业的程序是不会经常重启的,所以最好的办法就是从源头上解决内存泄漏的问题。

go虽然是自动GC类型的语言,但在书写过程中如果不注意,很容易造成内存泄漏的问题。比较常见的是发生在 slice、time.Ticker、goroutine 等的使用过程中

2. slice造成内存泄漏

2.1 slice简介

我们知道,go语言默认是值传递类型,也就是赋值和函数传参操作都会复制整个数据,但有一些采用的是引用传递类型,比如slice、map、channel、interface等。没错,slice采用的就是引用传递类型,slice本身是一个只读对象,它通过指针引用底层数组,类似数组指针的一种封装。slice的结构定义如下:

type slice struct {
    array unsafe.Pointer
    len   int
    cap   int
}

Pointer 是指向一个数组的指针,len 代表当前切片的长度,cap 是当前切片的容量,而且 cap 总是大于等于 len 的。上面我们说到slice是通过指针引用底层数组的,如下图所示:

图片来源:https://www.topgoer.com

那这样设计有什么好处呢,相比于数组来说,切片的长度是可变的,在使用上更灵活,而且,前面也说到切片本质是对数组的引用,在传递过程中是引用传递,在传递大容量的切片时是可以节省空间的,只需要传递一个地址,但是正因为这一特性,也使得slice在使用不当的情况下会发生内存泄漏

2.2 slice内存泄漏案例

如下是一个切片的使用案例

func TestSlice(t *testing.T) {
	slice1 := []int{3, 4, 5, 6, 7}
	slice2 := initSlice[1:3]

	fmt.Printf("slice1 addr: %p", &slice1)
	fmt.Println()
	fmt.Printf("slice2 addr: %p", &slice2)
	fmt.Println()

	for i := 0; i < len(slice1); i++ {
		fmt.Printf("%v:[%v]  ", slice1[i], &slice1[i])
	}
	fmt.Println()
	for i := 0; i < len(slice2); i++ {
		fmt.Printf("%v:[%v]  ", slice2[i], &slice2[i])
	}
	fmt.Println()
}

// output
// slice1 addr: 0xc00000c090
// slice2 addr: 0xc00000c0a8
// 3:[0xc00001e1b0]  4:[0xc00001e1b8]  5:[0xc00001e1c0]  6:[0xc00001e1c8]  7:[0xc00001e1d0]  
// 4:[0xc00001e1b8]  5:[0xc00001e1c0]  

从打印的地址可以看出,两个切片的地址是不一样的,但是里面的元素地址是一样的,如下图所示:

那么这里的内存泄漏主要体现在哪里呢?上面的代码是通过slice1去初始化了一个数组,然后slice1引用了这个数组,再然后是slice2只取了slice1中的一部分,也就是数组中的一部分。

  • 只有一个slice1的时候,即没有任何其他切片对数组的引用,若此时slice1不再使用了,slice1和数组都可以被gc掉
  • 当还有其它slice对数组的引用的时候,如上例中的slice2,若此时slice1不再使用了,而slice2还要使用,那么数组还能gc掉吗?答案是不能的,因为还有切片对它的引用,也就是说,slice1可以被gc掉,但是数组和slice2无法被gc。那么这个时候就发生了内存泄漏,因为slice2的切片范围是[1:3],也就是下标为1和2的位置被引用了,而数组的其它位置没有被引用,此时slice1又被gc掉了,从此以后这几个位置上的数据就再也无法被读取到了,也就是开头说的对内存的控制失控了,这种情况就是slice的内存泄漏。如果数组大小不大,内存泄漏造成的影响不易察觉,但是如果数组长度上了十万、百万,那么内存泄漏造成的影响将是巨大的

2.3 解决办法

2.3.1 append

可以采用append的方法,append不会直接引用原来的数组,而是会新申请内存来存放数据,这样

func TestSlice(t *testing.T) {
	initSlice := []int{3, 4, 5, 6, 7}
	//partSlice := initSlice[1:3]

	var partSlice []int
	partSlice = append(partSlice, initSlice[1:3]...)	// append

	fmt.Printf("initSlice addr: %p", &initSlice)
	fmt.Println()
	fmt.Printf("partSlice addr: %p", &partSlice)
	fmt.Println()

	for i := 0; i < len(initSlice); i++ {
		fmt.Printf("%v:[%v]  ", initSlice[i], &initSlice[i])
	}
	fmt.Println()
	for i := 0; i < len(partSlice); i++ {
		fmt.Printf("%v:[%v]  ", partSlice[i], &partSlice[i])
	}
	fmt.Println()
}

// output
// initSlice addr: 0xc00011c078
// partSlice addr: 0xc00011c090
// 3:[0xc00012e030]  4:[0xc00012e038]  5:[0xc00012e040]  6:[0xc00012e048]  7:[0xc00012e050]  
// 4:[0xc00010c1d0]  5:[0xc00010c1d8]  

可见slice1和slice2相应位置上的内存地址是不一样的,即新开辟了内存空间

2.3.2 copy

如下是使用copy代替直接切片的写法

func TestSlice(t *testing.T) {
	initSlice := []int{3, 4, 5, 6, 7}
	//partSlice := initSlice[1:3]

	partSlice := make([]int, 2)
	copy(partSlice, initSlice[1:3])		//copy

	fmt.Printf("initSlice addr: %p", &initSlice)
	fmt.Println()
	fmt.Printf("partSlice addr: %p", &partSlice)
	fmt.Println()

	for i := 0; i < len(initSlice); i++ {
		fmt.Printf("%v:[%v]  ", initSlice[i], &initSlice[i])
	}
	fmt.Println()
	for i := 0; i < len(partSlice); i++ {
		fmt.Printf("%v:[%v]  ", partSlice[i], &partSlice[i])
	}
	fmt.Println()
}

// output
// initSlice addr: 0xc0000b0078
// partSlice addr: 0xc0000b0090
// 3:[0xc0000a6060]  4:[0xc0000a6068]  5:[0xc0000a6070]  6:[0xc0000a6078]  7:[0xc0000a6080]  
// 4:[0xc0000b81c0]  5:[0xc0000b81c8] 

可见slice1和slice2相应位置上的内存地址是不一样的,即新开辟了内存空间

3. time.Ticker造成内存泄漏

go语言的time.Ticker主要用来实现定时任务,time.NewTicker(duration) 可以初始化一个定时任务,里面填写的时间长度duration就是指每隔 duration 时间长度就会发送一次值,可以在 ticker.C 接收到,这里容易造成内存泄漏的地方主要在于编写代码过程中没有stop掉这个定时任务,导致定时任务一直在发送,从而导致内存泄漏

如下是一个错误的案例:

func TestTicker(t *testing.T) {
	ticker := time.NewTicker(time.Second)
	defer ticker.Stop()	// 这个stop一定不能漏了
	go func(ticker *time.Ticker) {
		for {
			select {
			case value := <-ticker.C:
				fmt.Println(value)
			}
		}
	}(ticker)
	time.Sleep(time.Second * 5)
	fmt.Println("finish!!!")
}

// output
// 2022-09-25 18:26:14.389209 +0800 CST m=+1.002042233
// 2022-09-25 18:26:15.388206 +0800 CST m=+2.001142653
// 2022-09-25 18:26:16.388425 +0800 CST m=+3.001458610
// 2022-09-25 18:26:17.388717 +0800 CST m=+4.001840387
// finish!!!

解决办法就是不要忘记stop ticker

4. goroutine造成内存泄漏

在平时开发过程中,goroutine泄漏通常是最常见也最频繁的,goroutine是协程,本身占用内存不大,一般就2KB只有,但是当goroutine开的数量多了之后,如果处理不当导致内存泄漏,一样会对服务造成严重问题

提到goroutine,一般都是和channel配合使用的,关于channel的介绍可以看我之前写的一篇文章: Title Golang中的channel解析与实战

总体来说,goroutine泄漏一般可分为如下几种情况:

4.1 向满的channel发送

4.1.1 无缓存

仍然向满了的channel发送消息,导致了阻塞,从而导致内存泄漏,如下是无缓存channel的案例:

func TestSend(t *testing.T) {
	ch := make(chan int)
	fmt.Println("num of go start: ", runtime.NumGoroutine())
	time.Sleep(time.Second)

	for i := 0; i < 5; i++ {	// 向channel发送5次
		go func(ii int) {
			ch <- ii
			fmt.Println("send to chan: ", ii)
		}(i)
	}

	go func() {		// 只从channel接收一次
		value := <-ch
		fmt.Println("recv from chan: ", value)
	}()

	time.Sleep(time.Second)
	fmt.Println("num of go end: ", runtime.NumGoroutine())
}

// output
// num of go start:  2
// recv from chan:  0
// send to chan:  0
// num of go end:  6

由结果可以看出结束的时候goroutine的数量比开始的时候多了4个,而且不管运行多少次都是这个结果,这4个goroutine就会造成内存泄漏,因为channel只被接收了1次,但是向channel发送了5次,其中4goroutine个都被阻塞了,如果这4个goroutine没有被接收,那么就会一直阻塞直到程序结束,内存在这期间就被浪费了

4.1.2 有缓存

现在初始化一个缓存为2的channel

func TestSend(t *testing.T) {
	ch := make(chan int, 2)
	fmt.Println("num of go start: ", runtime.NumGoroutine())
	time.Sleep(time.Second)

	for i := 0; i < 5; i++ {
		go func(ii int) {
			ch <- ii
			fmt.Println("send to chan: ", ii)
		}(i)
	}

	go func() {
		value := <-ch
		fmt.Println("recv from chan: ", value)
	}()

	time.Sleep(time.Second)
	fmt.Println("num of go end: ", runtime.NumGoroutine())
}

// output
// num of go start:  2
// send to chan:  0
// send to chan:  1
// recv from chan:  0
// send to chan:  2
// num of go end:  4

由运行结果可知,运行结束后多了2个goroutine,即造成了2个goroutine泄漏;这次的channel缓存为2,所以有2个goroutine发送的消息放到了缓存中,所以最后的goroutine个数才会比无缓存的案例少了2个

4.2 从空的channel接收

从空的channel接收,导致了阻塞,从而导致内存泄漏,如下是案例:

func TestRecv(t *testing.T) {
	ch := make(chan int)
	fmt.Println("num of go start: ", runtime.NumGoroutine())
	time.Sleep(time.Second)

	go func() {
		ch <- 1
		fmt.Println("send to chan")
	}()

	for i := 0; i < 5; i++ {
		go func() {
			value := <-ch
			fmt.Println("recv from chan: ", value)
		}()
	}

	time.Sleep(time.Second)
	fmt.Println("num of go end: ", runtime.NumGoroutine())
}

// output
// num of go start:  2
// recv from chan:  1
// send to chan
// num of go end:  6

由结果可知结束的时候多了4个goroutine,即泄漏了4个goroutine

4.3 向nil的channel发送或接收

当channel没有初始化的时候就会处于nil状态,如下例:

func TestNil(t *testing.T) {
	var ch chan int		// 只命名而不通过make初始化
	fmt.Println("num of go start: ", runtime.NumGoroutine())
	time.Sleep(time.Second)

	go func() {
		ch <- 1
		fmt.Println("send to chan")
	}()

	go func() {
		value := <-ch
		fmt.Println("recv from chan", value)
	}()

	time.Sleep(time.Second)
	fmt.Println("num of go end: ", runtime.NumGoroutine())
}

// output
// num of go start:  2
// num of go end:  4

由结果可知,运行结束时多了2个goroutine,即造成了2个goroutine泄漏,send to chanrecv from chan都没有打印,因为ch没有初始化,处于nil状态

4.4 解决办法

发生泄漏前

发送者和接收者的数量最好要一致,channel记得初始化,不给程序发生内存泄漏的机会

发生泄漏后

采用go tool pprof分析内存的占用和变化,细节不在本篇文章讲解

5. 参考链接

https://gfw.go101.org/article/memory-leaking.html

https://www.topgoer.com/go%E5%9F%BA%E7%A1%80/Slice%E5%BA%95%E5%B1%82%E5%AE%9E%E7%8E%B0.html