浅谈Golang内存泄漏
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 chan
和recv 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
相关文章
- golang中的map并发读写问题: Golang 协程并发使用 Map 的正确姿势
- 教大家一个WPJAM Basic如何开启Memcacached内存缓存和对应的 WordPress 插件
- 一文读懂 HugePages(大内存页)的原理
- 不背锅运维:Go语言切片内存优化技巧和实战案例
- golang使用缓存库go-cache的测试用例-短期内存缓存数据类似memcache/redis-【唯一客服】
- 【Linux 内核 内存管理】优化内存屏障 ① ( barrier 优化屏障 | 编译器优化 | CPU 执行优化 | 优化屏障源码 barrier 宏 )
- SQLServer 错误 41359 当数据库选项 READ_COMMITTED_SNAPSHOT 设置为 ON 时,使用 COMMITTED 隔离级别访问内存优化表的查询不能访问基于磁盘的表。 使用表提示(例如 WITH (SNAPSHOT))为内存优化表提供一种支持的隔离级别。 故障 处理 修复 支持远程
- 使用Golang快速连接MySQL数据库(golang连接mysql)
- Redis:极快速的内存数据库 (redis.io)
- 结构优化 Oracle 数据库性能:大页内存结构(oracle大页内存)
- 使用Oracle创建内存表一步一步学习(oracle内存表创建)
- 一种有效的监控Redis内存的方式(监控redis内存)
- PNY宣布DDR5-4800台式内存模组 2021年4季度上市