Featured image of post Go语言——内存管理

Go语言——内存管理

Go语言内存对齐、内存分配、垃圾回收、内存泄漏的概念及代码和问题分析

前言

  这篇文章主要整合了 Go 语言的一些内存管理的知识,只是浅显笼统概括,并且用一些面试题来总结。

注:以下内容和图片基本来自公众号:网管叨bi叨,一个我经常看的博主,推荐关注。

内存对齐

  我们编程的任何一个变量在内存中存放都按照一定的规则,这里主要介绍的就是内存对齐的规则,下面为简单概括,详情见文章Go语言——内存对齐

  理论上计算机可以访问任意地址的变量,但是在访问特定类型通常在特定的内存地址中,数据存放并不是随意存放,是有规则的顺序,我们能够分析出,内存对齐是为了能够快速访问内存进行数据的存取,但是会消耗内存空间,用空间换时间的一种内存存储规则。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
func main() {
	s1 := []string{"1", "2", "3"}
	s2 := []string{"1"}
	fmt.Println(unsafe.Sizeof(s1)) // 24
	fmt.Println(unsafe.Sizeof(s2)) // 24

	type t1 struct {
		a int32
		b int64
		c int32
	}
	type t2 struct {
		a int32
		b int32
		c int64
	}
	fmt.Println(unsafe.Sizeof(t1{})) // 24
	fmt.Println(unsafe.Sizeof(t2{})) // 16
}

  是不是对这个结果有疑问呢?为什么s1s2的数据长度不同,但是打印出的内存长度一样?为什么t1t2的结构体只是定义的顺序不同,内存长度却不一样?

  这里要说明一下,go语言通过unsafe.Sizeof(x)打印的变量占用的内存字节数,和底层数据无关,不包含x所指向的内容大小,所以第一个疑问解决了。也同理我们可以通过unsafe.Sizeof()打印出各个类型的内存占用大小。

  注:这里使用的是64位系统。

类型 字节数
bool 1
intN, uintN, floatN, complexN N/8 个字节 (int32 是 4 个字节)
int, uint, uintptr 计算机字长/8 (64位 是 8 个字节)
*T, map, func, chan 计算机字长/8 (64位 是 8 个字节)
string (data、len) 2 * 计算机字长/8 (64位 是 16 个字节)
interface (tab、data 或 _type、data) 2 * 计算机字长/8 (64位 是 16 个字节)
[]T (array、len、cap) 3 * 计算机字长/8 (64位 是 24 个字节)
1
2
3
4
5
type t struct {
    a bool // 1个字节
    b int     // 8个字节
    c string // 16个字节
}

  对于上面的结构,如果是没有进行过内存对齐,则按照存放的顺序,以64位系统的每8个字节取数据的规则,会发现除了abc都不是从头取的,过程如图:

  这里就有一个问题,对于bc没有做到从起始位开始取数据,所以会造成之后在再次拼接整理的操作,需要多次内存访问和整理的步骤。而如果经过内存对齐,就会如图:

  所以我们能发现,内存对齐减少了操作步骤,但是却浪费了内存空间占用的资源。

内存对齐规则

  • 成员对齐规则:针对一个基础类型变量,如果unsafe.AlignOf()返回的值是m,那么该变量的地址需要被m整除(如果当前地址不能整除,填充空白字节,直至可以整除)。
  • 整体对齐规则:针对一个结构体,如果unsafe.AlignOf()返回值是m,需要保证该结构体整体内存占用是m的整数倍,如果当前不是整数倍,需要在后面填充空白字节。

  针对该规则,我们再把上述的结构体和unsafe.Offsetof()拿出来分析一下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
func main() {
	type t1 struct {
		a int32   // 4个字节
		b int64   // 8个字节
		c float32 // 4个字节
		d bool    // 1个字节
	}
	t := t1{}
	fmt.Println(unsafe.Offsetof(t.a)) // 0
	fmt.Println(unsafe.Offsetof(t.b)) // 8
	fmt.Println(unsafe.Offsetof(t.c)) // 16
	fmt.Println(unsafe.Offsetof(t.d)) // 20
	fmt.Println(unsafe.Alignof(t))    // 8
	fmt.Println(unsafe.Sizeof(t))     // 24
	// 假设从地址0开始
	// unsafe.Sizeof(int32(1)) = 4,unsafe.Alignof(int32(1)) = 4,地址0开始,可以被4整除
	// unsafe.Sizeof(int64(1)) = 8,unsafe.Alignof(int64(1)) = 8,地址需要从8开始,才可以被8整除,[4,8]的位置用0来补充
	// unsafe.Sizeof(float32(1)) = 4,unsafe.Alignof(float32(1)) = 4,地址需要从16开始,可以被4整除,[8,16]的位置被t.b占满
	// unsafe.Sizeof(true) = 1, unsafe.Alignof(true) = 1, 地址从20开始即可,[16,20]的位置被c沾满。
	// 由于结构体也需要对齐,要被8整除,所以要补0到24。
}

内存对齐例子

内存分配

知道了内存存放的规则后,那么我们应该清楚是计算机是如何给程序分配内存的。

  Golang运行时的内存分配算法主要源自 Google 为 C 语言开发的TCMalloc算法,全称Thread-Caching Malloc。核心思想就是把内存分为多级管理,从而降低锁的粒度。它将可用的堆内存采用二级分配的方式进行管理:每个线程都会自行维护一个独立的内存池,进行内存分配时优先从该内存池中分配,当内存池不足时才会向全局内存池申请,以避免不同线程对全局内存池的频繁竞争。

  在Go里面有两种内存分配策略,一种适用于程序里小内存块的申请,另一种适用于大内存块的申请,大内存块指的是大于32KB。

基础概念

mspan:Go中内存管理的基本单元,是由一片连续的8KB的页组成的大块内存。注意,这里的页和操作系统本身的页并不是一回事,它一般是操作系统页大小的几倍。一句话概括:mspan是一个包含起始地址、mspan规格、页的数量等内容的双端链表。每个mspan按照它自身的属性Size Class的大小分割成若干个object,每个object可存储一个对象。

mcache:每个工作线程都会绑定一个mcache,本地缓存可用的mspan资源,这样就可以直接给Goroutine分配,因为不存在多个Goroutine竞争的情况,所以不会消耗锁资源。

mcentral:为所有mcache提供切分好的mspan资源。每个central保存一种特定大小的全局mspan列表,包括已分配出去的和未分配出去的。 每个mcentral对应一种mspan,而mspan的种类导致它分割的object大小不同。当工作线程的mcache中没有合适(也就是特定大小的)的mspan时就会从mcentral获取。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
//path: /usr/local/go/src/runtime/mcentral.go

type mcentral struct {
    // 互斥锁
    lock mutex 
    // 规格
    sizeclass int32 
    // 尚有空闲object的mspan链表
    nonempty mSpanList 
    // 没有空闲object的mspan链表,或者是已被mcache取走的msapn链表
    empty mSpanList 
    // 已累计分配的对象个数
    nmalloc uint64 
}

mheap:代表Go程序持有的所有堆空间,Go程序使用一个mheap的全局对象_mheap来管理堆内存。

  当mcentral没有空闲的mspan时,会向mheap申请。而mheap没有资源时,会向操作系统申请新内存。mheap主要用于大对象的内存分配,以及管理未切割的mspan,用于给mcentral切割成小对象。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
package main

type smallStruct struct {
   a, b int64
   c, d float64
}

func main() {
   smallAllocation()
}

//go:noinline
func smallAllocation() *smallStruct {
   return &smallStruct{}
}

小于 32 KB 内存块的分配策略

  当程序里发生了32kb以下的小块内存申请时,Go会从一个叫做的 mcache 的本地缓存给程序分配内存。这个本地缓存 mcache 持有一系列的大小为 32kb 的内存块,这样的一个内存块里叫做 mspan,它是要给程序分配内存时的分配单元。

  在 Go 的调度器模型里,每个线程M会绑定给一个处理器P,在单一粒度的时间里只能做多处理运行一个goroutine,每个P都会绑定一个上面说的本地缓存mcache。当需要进行内存分配时,当前运行的goroutine会从mcache中查找可用的mspan。从本地mcache里分配内存时不需要加锁,这种分配策略效率更高。

  但是有的变量很小就是数字,有的却是一个复杂的结构体,申请内存时都分给他们一个mspan这样的单元会不会产生浪费。其实mcache持有的这一系列的mspan并不都是统一大小的,而是按照大小,从8字节到32KB分了67类的msapn。

  结构体刚好是32字节,所以直接分配到其中一个,但是如果mcachce里没有空闲的32字节的mspan了该怎么办?Go里还为每种类别的mspan维护着一个mcentral。

  刚才说过mcentral的作用是为所有mcache提供切分好的mspan资源。每个central会持有一种特定大小的全局mspan列表,包括已分配出去的和未分配出去的。 每个mcentral对应一种mspan,当工作线程的mcache中没有合适(也就是特定大小的)的mspan时就会从mcentral 去获取。mcentral被所有的工作线程共同享有,存在多个goroutine竞争的情况,因此从mcentral获取资源时需要加锁。

  mcentral里维护着两个双向链表,nonempty表示链表里还有空闲的mspan待分配。empty表示这条链表里的mspan都被分配了object。

mcache从mcentral获取和归还mspan的流程:

  • 获取 加锁;从nonempty链表找到一个可用的mspan;并将其从nonempty链表删除;将取出的mspan加入到empty链表;将mspan返回给工作线程;解锁。
  • 归还 加锁;将mspan从empty链表删除;将mspan加入到nonempty链表;解锁。

当mcentral没有空闲的mspan时,会向mheap申请。而mheap没有资源时,会向操作系统申请新内存。mheap主要用于大对象的内存分配,以及管理未切割的mspan,用于给mcentral切割成小对象。mheap中含有所有规格的mcentral,所以,当一个mcache从mcentral申请mspan时,只需要在独立的mcentral中使用锁,并不会影响申请其他规格的mspan。

mheap里的arena 区域是真正的堆区,运行时会将 8KB 看做一页,这些内存页中存储了所有在堆上初始化的对象。

大于 32 KB 内存块的内存分配

  Go没法使用工作线程的本地缓存mcache和全局中心缓存mcentral上管理超过32KB的内存分配,所以对于那些超过32KB的内存申请,会直接从堆上(mheap)上分配对应的数量的内存页(每页大小是8KB)给程序。

逃逸分析

  通常情况下,编译器是倾向于将变量分配到栈上的,因为它的开销小,最极端的就是"zero garbage",所有的变量都会在栈上分配,这样就不会存在内存碎片,垃圾回收之类的东西。变量是在栈上分配还是在堆上分配,是由逃逸分析的结果决定的。

Go 官方上有这么一段内存逃逸分析的QA

Q:如何得知变量是分配在栈(stack)上还是堆(heap)上?

A: 准确地说,你并不需要知道。Golang 中的变量只要被引用就一直会存活,存储在堆上还是栈上由内部实现决定而和具体的语法没有关系。知道变量的存储位置确实对程序的效率有帮助。如果可能,Golang 编译器会将函数的局部变量分配到函数栈帧(stack frame)上。然而,如果编译器不能确保变量在函数 return 之后不再被引用,编译器就会将变量分配到堆上。而且,如果一个局部变量非常大,那么它也应该被分配到堆上而不是栈上。当前情况下,如果一个变量被取地址,那么它就有可能被分配到堆上。然而,还要对这些变量做逃逸分析,如果函数 return 之后,变量不再被引用,则将其分配到栈上。

  Go编译器会跨越函数和包的边界进行全局的逃逸分析。它会检查是否需要在堆上为一个变量分配内存,还是说可以在栈本身的内存里对其进行管理。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
package main

import "fmt"

func main() {
  fmt.Println("Called heapAnalysis", heapAnalysis())
}
//go:noinline
func heapAnalysis() *int {
   data := 55
   return &data
}
1
2
3
4
5
6
7
go build -gcflags "-m -l"

./scratch.go:9:9: &data escapes to heap
./scratch.go:8:2: moved to heap: data
./scratch.go:4:14: "Called heapAnalysis" escapes to heap
./scratch.go:4:49: heapAnalysis() escapes to heap
./scratch.go:4:13: main ... argument does not escape

main和heapAnalysis函数分配在一个栈上。由于函数具有自己的变量,因此也会将变量分配到栈的某个地方。但是编译器检查到该值是返回了它的指针,并且已用于另一个函数,因此变量被移到了堆中,主函数会从堆中访问该变量。

有个特殊说明,

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
package main

import "fmt"

func main() {
    fmt.Println("Called stackAnalysis", stackAnalysis())
}
//go:noinline (加一行特殊的注释让编译器不对函数进行内联,内联是一种手动或编译器优化,用于将简短函数的调用替换为函数体本身。这么做的原因是它可以消除函数调用本身的开销,也使得编译器能更高效地执行其他的优化策略)
func stackAnalysis() int {
    data := 55
    return data
}
1
2
3
4
5
go build -gcflags "-m -l"

./scratch.go:4:14: "Called stackAnalysis" escapes to heap
./scratch.go:4:51: stackAnalysis() escapes to heap
./scratch.go:4:13: main ... argument does not escape

第4行14个字符处的字符串标量"Called stackAnalysis"逃逸到堆上。

第4行51个字符串的函数调用stackAnalysis()逃逸到了堆上。

escapes to heap"的意思是变量需要在函数栈之间共享,上面的例子就是在mainfmt.Println之间共享。main和stackAnalysis函数分配在一个栈上。由于函数具有自己的变量,因此也会将变量分配到栈的某个地方。当函数返回时,与该函数关联的所有变量也会从内存中删除。这里并没有逃逸到堆上,而是在栈上。这里是因为fmt.Print系列的函数问题,变量逃逸了,如果是print,会打印的是does not escape

垃圾回收

  内存分配了之后要回收,这就需要 GC。什么是 GC?PHP、Java 和 Go 等语言使用自动的内存管理系统,有内存分配器和垃圾收集器来代为分配和回收内存,其中垃圾收集器就是我们常说的GC。

  主要原因是栈是一块专用内存,专门为了函数执行而准备的,存储着函数中的局部变量以及调用栈。除此以外,栈中的数据都有一个特点——简单。比如局部变量不能被函数外访问,所以这块内存用完就可以直接释放。正是因为这个特点,栈中的数据可以通过简单的编译器指令自动清理,并不需要通过 GC 来回收。

  Go的垃圾收集器从一开始到现在一直在演进,在v1.5版本开始三色标记法作为垃圾回收算法前使用Mark-And-Sweep(标记清除)算法。从v1.5版本Go实现了基于三色标记清除的并发垃圾收集器,大幅度降低垃圾收集的延迟从几百 ms 降低至 10ms 以下。在v1.8又使用混合写屏障将垃圾收集的时间缩短至 0.5ms 以内。

三色标记法

三色标记算法将程序中的对象分成白色、黑色和灰色三类:

  • 白色对象 — 潜在的垃圾,其内存可能会被垃圾收集器回收;
  • 黑色对象 — 活跃的对象,包括不存在任何引用外部指针的对象以及从根对象可达的对象,垃圾回收器不会扫描这些对象的子对象;
  • 灰色对象 — 活跃的对象,因为存在指向白色对象的外部指针,垃圾收集器会扫描这些对象的子对象;

第一步:在进入GC的三色标记阶段的一开始,所有对象都是白色的。

垃圾回收1

第二步, 遍历根节点集合里的所有根对象,把根对象引用的对象标记为灰色,从白色集合放入灰色集合。

垃圾回收2

第三步, 遍历灰色集合,将灰色对象引用的对象从白色集合放入灰色集合,之后将此灰色对象放入黑色集合

垃圾回收3

第四步:重复第三步, 直到灰色集合中无任何对象。

垃圾回收4

第五步:回收白色集合里的所有对象,本次垃圾回收结束。

垃圾回收5

写屏障

  Go 在GC阶段执行三色标记前,还需要先做一个准备工作——打开写屏障(Write Barrier)。那么写屏障是什么呢?我们知道三色标记法是一种可以并发执行的算法。所以在GC运行过程中程序的函数栈内可能会有新分配的对象,那么这些对象该怎么通知到 GC,怎么给他们着色呢?如果还是按照之前新建的对象标记为白色就有可能出现下图中的问题:

垃圾回收6

  在 GC 进行的过程中,应用程序新建了对象 I,此时如果已经标记成黑的对象F引用了对象 I,那么在本次 GC 执行过程中因为黑色对象不会再次扫描,所以如果I着色成白色的话,会被回收掉,这显然是不允许的。

  这个时候就需要我们的写屏障出马了。写屏障主要做一件事情,修改原先的写逻辑,然后在对象新增的同时给它着色,并且着色为灰色。因此打开了写屏障可以保证了三色标记法在并发下安全正确地运行。那么有人就会问这些写屏障标记成灰色的对象什么时候回收呢?答案是后续的 GC 过程中回收,在新的 GC 过程中所有已存对象就又从白色开始逐步被标记啦。

三色不变性

  想要在并发或者增量的标记算法中保证正确性,我们需要达成以下两种三色不变性(Tri-color invariant)中的任意一种:

  • 强三色不变性 — 黑色对象不会指向白色对象,只会指向灰色对象或者黑色对象;
  • 弱三色不变性 — 黑色对象指向的白色对象必须包含一条从灰色对象经由多个白色对象的可达路径

屏障技术

  垃圾收集中的屏障技术更像是一个钩子方法,它是在用户程序读取对象、创建新对象以及更新对象指针时执行的一段代码,根据操作类型的不同,我们可以将它们分成读屏障(Read barrier)和写屏障(Write barrier)两种,因为读屏障需要在读操作中加入代码片段,对用户程序的性能影响很大,所以编程语言往往都会采用写屏障保证三色不变性。

Go 的混合写屏障

  在Go 语言 v1.7 版本之前,使用的是Dijkstra插入写屏障保证强三色不变性,但是运行时并没有在所有的垃圾收集根对象上开启插入写屏障。因为 Go 语言的应用程序可能包含成百上千的 goroutine,而垃圾收集的根对象一般包括全局变量和栈对象,如果运行时需要在几百个 goroutine 的栈上都开启写屏障,会带来巨大的额外开销,所以 Go 团队在实现上选择了在标记阶段完成时暂停程序、将所有栈对象标记为灰色并重新扫描,在活跃 goroutine 非常多的程序中,重新扫描的过程需要占用 10 ~ 100ms 的时间。

Go 语言在 v1.8 组合 Dijkstra 插入写屏障和 Yuasa 删除写屏障构成了如下所示的混合写屏障,该写屏障会将被覆盖的对象标记成灰色并在当前栈没有扫描时将新对象也标记成灰色:

1
2
3
4
5
writePointer(slot, ptr):
    shade(*slot)
    if current stack is grey:
        shade(ptr)
    *slot = ptr

  为了移除栈的重扫描过程,除了引入混合写屏障之外,在垃圾收集的标记阶段,我们还需要将创建的所有新对象都标记成黑色,防止新分配的栈内存和堆内存中的对象被错误地回收,因为栈内存在标记阶段最终都会变为黑色,所以不再需要重新扫描栈空间。

一次完整的GC过程

  Go的垃圾回收器在使用了三色标记清除算法和混合写屏障后大大减少了暂停程序(STW)的时间,主要是在开启写屏障前和移除写屏障前暂停应用程序。

  Go的垃圾收集的整个过程可以分成标记准备、标记、标记终止和清除四个不同阶段,每个阶段完成的工作如下:

标记准备阶段

  暂停程序,所有的处理器在这时会进入安全点(Safe point)

标记阶段
  • 将状态切换至 _GCmark、开启写屏障、用户程序协助(Mutator Assiste)并将根对象入队;
  • 恢复执行程序,标记进程和用于协助的用户程序会开始并发标记内存中的对象,标记用的算法就是上面介绍的三色标记清除法。写屏障会将被覆盖的指针和新指针都标记成灰色,而所有新创建的对象都会被直接标记成黑色;
  • 开始扫描根对象,包括所有 goroutine 的栈、全局对象以及不在堆中的运行时数据结构,扫描 goroutine 栈期间会暂停当前处理器;
  • 依次处理灰色队列中的对象,将对象标记成黑色并将它们指向的对象标记成灰色;
  • 使用分布式的终止算法检查剩余的工作,发现标记阶段完成后进入标记终止阶段;

  在标记开始的时候,收集器会默认抢占 25% 的 CPU 性能,剩下的75%会分配给程序执行。但是一旦收集器认为来不及进行标记任务了,就会改变这个 25% 的性能分配。这个时候收集器会抢占程序额外的 CPU,这部分被抢占 goroutine 有个名字叫 Mark Assist。而且因为抢占 CPU的目的主要是 GC 来不及标记新增的内存,那么抢占正在分配内存的 goroutine 效果会更加好,所以分配内存速度越快的 goroutine 就会被抢占越多的资源。

  除此以外 GC 还有一个额外的优化,一旦某次 GC 中用到了 Mark Assist,下次 GC 就会提前开始,目的是尽量减少 Mark Assist 的使用,从而避免影响正常的程序执行。

标记终止阶段
  • 暂停程序、将状态切换至 _GCmarktermination 并关闭辅助标记的用户程序;
  • 清理处理器上的线程缓存;
清理阶段
  • 将状态切换至_GCoff开始清理阶段,初始化清理状态并关闭写屏障;
  • 恢复用户程序,所有新创建的对象会标记成白色;
  • 后台并发清理所有的内存管理单元,当 goroutine 申请新的内存管理单元时就会触发清理;

清理这个过程是并发进行的。清扫的开销会增加到分配堆内存的过程中,所以这个时间也是无感知的,不会与垃圾回收的延迟相关联。

总结

  Go的GC最早期使用的回收算法是标记-清除算法,该算法需要在执行期间需要暂停应用程序(STW),无法满足并发程序的实时性。后面Go的GC转为使用三色标记清除算法,并通过混合写屏障技术保证了Go并发执行GC时内存中对象的三色一致性(这里的并发指的是GC和应用程序的goroutine能同时执行)。

  一次完整的垃圾回收会分为四个阶段,分别是标记准备、标记、结束标记以及清理。在标记准备和标记结束阶段会需要 STW,标记阶段会减少程序的性能,而清理阶段是不会对程序有影响的。

内存泄漏

  尽管有垃圾回收机制,而且 Go 语言的垃圾回收已经做的非常优秀了,很多情况都不需要我们手动的释放内存,但是真的就完全没有问题了么?当然不是,Go 还面临着非常容易出现的内存泄露问题。

观察如下代码,是否有问题?

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
var a []int

func f(b []int) []int {
	a = b[:2]
	return a
}

func main() {
	...
}

举个生产环境中的例子,使用 Go 语言开发了一个 Gateway 服务,在发布测试环境后,假设开了3000个 TCP 连接到游戏,游戏数据没有问题,但是发现内存使用量会随着时间的推移持续增加,因此服务的Pod会隔一段时间重启一次。原因是 Gateway 是一个读写分离的 TCP 服务,每一个连接要有两个 goroutine,一个读一个写,但是 TCP 连接断开后,因为时序问题,goroutine会阻塞,一直没有结束,没有释放掉。

所以,内存泄漏(Memory Leak)是指程序中已动态分配的堆内存由于某种原因程序未释放或无法释放,造成系统内存的浪费,导致程序运行速度减慢甚至系统崩溃等严重后果。可以是用 pprof 分析代码性能,配合 graphviz,二者配合能分析程序运行中的系统参数和程序参数等,定位到每个函数,更直观的看出占用内存、使用耗时等情况。

回到刚才的问题,由于a和b指向同一个底层数组,a是静态存储变量被分配了固定的内存空间,如果程序f(b []int)结束了,b这种动态变量应该被回收了,但是由于a还在使用,尽管只是使用两个元素,后面的元素毫无作用,但是依然不会被GC,所以导致了泄漏。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
var a []int // 静态存储变量

func f(b []int) []int {
	// b 是动态存储变量
	a = b[:2]  // a b 都指向同一个底层数组,只不过a只包含[:2],b是全部
	return a
}

func main() {
	...
}

模拟一个内存泄漏的例子,

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
func main() {
    go func() {
        http.ListenAndServe("0.0.0.0:8090", nil)
    }()

    c := make(chan struct{})
    var wg sync.WaitGroup
    wg.Add(1)
    for i := 0; i < 10000; i++ {
        go one(c)
    }
    wg.Wait()
}

func one(c chan struct{}) {
    var a []int64
    for i := 0; i < 10000; i++ {
        a = append(a, rand.Int63())
    }
  
    <-c
}

这里的 goroutine 用 channel 来模拟阻塞,一直没有关闭,所以内存会爆炸增长。

提个问题,假设我们正常关闭 goroutine 了,内存就会降下来了么?

当所有的 goroutine 都结束时,GC 会开始回收切片,但是被回收的内存不会直接换给操作系统,而是由 Go 的 runtime 暂时保管(在 pprof 分析中,参数 HeapIdle 值会变大),接下来如果再次需要分配空间,Go 的 runtime 可以不向操作系统申请内存,直接从自己保管的闲置内存中分配,这样可以提高程序性能。至于 Go 的 runtime 什么时候把这部分内存还给操作系统,不同的分配策略和不同的系统不太一样。

内存泄漏的常见场景

内存泄漏主要就是 goroutine 泄漏,这里把可能出现的场景都总结一下:

  • 获取长字符串中的一段导致长字符串未释放
  • 获取长slice中的一段导致长slice未释放
  • 在长slice新建slice导致泄漏
  • goroutine泄漏
  • time.Ticker未关闭导致泄漏
  • Finalizer导致泄漏
  • Deferring Function Call 导致泄漏

Goroutine泄漏

Go 语言项目中很常见的内存泄漏是 goroutine 泄漏,导致 goroutine 泄漏有两点原因:

  • goroutine 本身的堆栈大小是2 KB,我们开启一个新的 goroutine,至少会占用2KB的内存大小。当长时间的累积,数量较大时,比如开启了 100 万个 goroutine,那么至少就会占用2 GB的内存。
  • goroutine 中的变量若指向了堆内存区,那么,当该 goroutine未被销毁,系统会认为该部分内存还不能被垃圾回收,那么就可能会占用大量的堆区内存空间。

goroutine 泄漏大概有以下场景:

  1. 从channel中读或写,但没有对应的写或读

channel 分为两种类型,unbuffered channel和buffered channel,先讨论unbuffered channel。在 channel 被创建后未被关闭前,我们若从 channel 中读取数据,但又一直没有数据写入 channel 中,那么 channel 就会进入等待状态,对应的 goroutine 也就会一直阻塞着了。对应的,当我们往 channel 中写数据,但又一直没有从 channel 中读。那么也会出现被阻塞的情况。至于 buffered channel,其实和 unbuffered channel 情况是类似的,只是 buffered channel 是读完缓存后,或写完缓存后会导致阻塞。

  1. 在使用select时,所有的case都阻塞
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
func add(c, quit chan int) {
    x, y := 0, 1
    for {
        select {
        case c <- x:
            x = x + y
        case <-quit:
            fmt.Println("quit")
            return
        }
    }
}

func Add() {
    c := make(chan int)
    quit := make(chan int)

    go add(c, quit)

    for i := 0; i < 10; i++ {
        fmt.Println(<-c)
    }

    // close(quit)
}

当 Add 函数 for 循环了 10 次之后,add 函数就会一直阻塞了,也就出现了 goroutine 泄漏。正确的做法应该是在合适的时间将 quit 关闭,那么 add 协程就可以安全退出了。

  1. goroutine 进入死循环

由于代码逻辑的 bug,导致 goroutine 进入了死循环,导致资源无法释放。

1
2
3
4
5
6
7
func loop() {
    for {
        fmt.Println("loop")
    }
}

go loop()

如何预防 goroutine 泄漏呢?这个问题就要交给开发者自己了,每当我们使用 goroutine 或者 channel 的时候就要想好该如何结束或是如何将其关闭,想好 channel 何时可能出现阻塞,以及阻塞的具体情况,而且避免以死循环的逻辑写代码。

常见内存面试题

  1. Golang 的内存模型 ( 米哈游 )

将对象分为微小对象、小对象、大对象,使用三级管理结构mcache、mcentral、mheap用于管理、缓存加速span对象的访问和分配,使用精准的位图管理已分配的和未分配的对象及对象的大小。

Go语言运行时依靠细微的对象切割、极致的多级缓存、精准的位图管理实现了对内存的精细化管理以及快速的内存访问,同时减少了内存的碎片。

注:如果问的深入,要答四级内存块管理和Mheap的缓存查找、基数树查找等

  1. 简述一下 GC 的原理,三色标记法?( b站 )
  • 初始化状态下所有对象都是白色的。

  • 从根节点开始遍历所有对象,把遍历到的对象变成灰色对象

  • 遍历灰色对象,将灰色对象引用的对象也变成灰色对象,然后将遍历过的灰色对象变成黑色对象

  • 循环上一步骤,知道灰色对象全部变黑色。

  • 通过写屏障检测对象有变化。重复以上操作

  • 收集所有的白色对象(垃圾)

  1. Go的垃圾回收,什么时候触发?( 滴滴 )

主动触发(手动触发),通过调用 runtime.GC 来触发 GC,此调用阻塞式地等待当前 GC 运行完毕。

被动触发,分为两种方式:

  • 使用步调(Pacing)算法,其核心思想是控制内存增长的比例,每次内存分配时检查当前内存分配量是否已达到阈值(环境变量GoGC):默认100%,即当内存扩大一倍时启用GC。
  • 使用系统监控,当超过两分钟没有产生任何GC时,强制触发 GC。
  1. 介绍一下 Go 的 GC ?( 深信服、腾讯、小米、学而思、Aibee、阿里、字节跳动、滴滴、蚂蚁、快手、猿辅导、Shoppe、哔哩哔哩 )

标记清除:

此算法主要有两个主要的步骤:

标记(Mark phase)

清除(Sweep phase)

第一步,找出不可达的对象,然后做上标记。

第二步,回收标记好的对象。

操作非常简单,但是有一点需要额外注意:mark and sweep 算法在执行的时候,需要程序暂停!即 stop the world。

也就是说,这段时间程序会卡在哪儿。故中文翻译成 卡顿.

标记-清扫(Mark And Sweep)算法存在什么问题?

标记-清扫(Mark And Sweep)算法这种算法虽然非常的简单,但是还存在一些问题:

STW,stop the world;让程序暂停,程序出现卡顿。

标记需要扫描整个heap

清除数据会产生heap碎片

这里面最重要的问题就是:mark-and-sweep 算法会暂停整个程序。

三色并发标记法:

  • 首先:程序创建的对象都标记为白色。
  • gc开始:扫描所有可到达的对象,标记为灰色
  • 从灰色对象中找到其引用对象标记为灰色,把灰色对象本身标记为黑色
  • 监视对象中的内存修改,并持续上一步的操作,直到灰色标记的对象不存在
  • 此时,gc回收白色对象
  • 最后,将所有黑色对象变为白色,并重复以上所有过程。

混合写屏障:

当gc进行中时,新创建一个对象,按照三色标记法的步骤,对象会被标记为白色,这样新生成的对象最后会被清除掉,这样会影响程序逻辑.

golang引入写屏障机制.可以监控对象的内存修改,并对对象进行重新标记.

gc一旦开始,无论是创建对象还是对象的引用改变,都会先变为灰色。

  1. 介绍一下逃逸分析?为什么要逃逸分析?常见的逃逸类型?Go 中的逃逸准则 ( 百度、哔哩哔哩、字节跳动、蚂蚁、网易、阿里 )

为什么需要:通过逃逸分析,那些不需要分配到堆上的变量直接分配到栈上,堆上的变量少了不但同时减少 GC 的压力,还减轻了内存分配的开销。

常见的类型:func 和 interface 数据类型,channel 或者栈空间不足逃逸。

准则:如果函数外部没有引用,则优先放到栈中;如果函数外部存在引用,则必定放到堆中;

  1. 如何避免内存逃逸?( 哔哩哔哩、蚂蚁 )
  • 不要盲目使用变量指针作为参数,虽然减少了复制,但变量逃逸的开销更大。
  • 预先设定好slice长度,避免频繁超出容量,重新分配。
  • 一个经验是,指针指向的数据大部分在堆上分配的,请注意。

出现内存逃逸的情况有:

  • 发送指针或带有指针的值到channel,因为编译时候无法知道那个goroutine会在channel接受数据,编译器无法知道什么时候释放。

  • 在一个切片上存储指针或带指针的值。比如[]*string,导致切片内容逃逸,其引用值一直在堆上。

  • 切片的append导致超出容量,切片重新分配地址,切片背后的存储基于运行时的数据进行扩充,就会在堆上分配。

  • 调用接口类型时,接口类型的方法调用是动态调度,实际使用的具体实现只能在运行时确定,如一个接口类型为io.Reader的变量r,对r.Read(b)的调用将导致r的值和字节片b的后续转义并因此分配到堆上。

  • 在方法内把局部变量指针返回,被外部引用,其生命周期大于栈,导致内存溢出。


  OK!以上就是对于内存部分的浅显说明,我是将很多大牛的文章内容让自己理解的更好做了总结,如有问题可以联系我进行修改和讨论。   Life is fantastic !

Licensed under CC BY-NC-SA 4.0
comments powered by Disqus
Built with Hugo
Theme Stack designed by Jimmy