1. 何为内存泄漏#

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

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

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

2. slice造成内存泄漏#

2.1 slice简介#

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

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

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

2.2 slice内存泄漏案例#

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

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

那么这里的内存泄漏主要体现在哪里呢?上面的代码是通过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不会直接引用原来的数组,而是会新申请内存来存放数据,这样

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

2.3.2 copy#

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

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

3. time.Ticker造成内存泄漏#

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

如下是一个错误的案例:

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

4. goroutine造成内存泄漏#

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

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

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

4.1 向满的channel发送#

4.1.1 无缓存#

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

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

4.1.2 有缓存#

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

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

4.2 从空的channel接收#

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

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

4.3 向nil的channel发送或接收#

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

send to chanrecv from chan

4.4 解决办法#

发生泄漏前

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

发生泄漏后

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

5. 参考链接#