关于内存逃逸那些事儿

129 阅读9分钟

背景

又某一日,葫芦被十三问的Golang基础知识吊打

十三: 葫芦你知道Golang的内存逃逸么,举几个栗子,以及你在项目中如何优化的

葫芦: Golang会自己进行内存分配,开发者不需要关系内存分配到哪了。内存逃逸指的是内存从栈上逃到了堆上

十三: 还有吗,可以具体聊聊么

葫芦: 我无了

又一次GG

内存分配

堆和栈的定义

Go 有两个地方可以分配内存:一个全局堆空间用来动态分配内存,另一个是每个 goroutine 都有的自身栈空间。

  • 栈 栈区的内存一般由编译器自动进行分配和释放,其中存储着函数的入参以及局部变量,这些参数会随着函数的创建而创建,函数的返回而销毁。(通过 CPU push & release)

  • 堆 堆区的内存一般由编译器和工程师自己共同进行管理分配,交给 Runtime GC 来释放。堆上分配必须找到一块足够大的内存来存放新的变量数据。后续释放时,垃圾回收器扫描堆空间寻找不再被使用的对象

栈分配廉价,堆分配昂贵

变量在堆上还是栈上

Go 声明语法并没有提到栈和堆,而是交给 Go 编译器决定在哪分配内存,保证程序的正确性

存储位置对于写出高性能的程序确实有影响。如果可能,Go 编译器将为该函数的堆栈侦(stack frame)中的函数分配本地变量。但是如果编译器在函数返回后无法证明变量未被引用,则编译器必须在会被垃圾回收的堆上分配变量以避免悬空指针错误。此外,如果局部变量非常大,将它存储在堆而不是栈上可能更有意义。在当前编译器中,如果变量存在取址,则该变量是堆上分配的候选变量。但是基础的逃逸分析可以将那些生存不超过函数返回值的变量识别出来,并且因此可以分配在栈上

连续栈

Go 应用程序运行时,每个 goroutine 都维护着一个自己的栈区,这个栈区只能自己使用不能被其他 goroutine 使用。栈区的初始大小是2KB,在 goroutine 运行的时候栈区会按照需要增长和收缩,占用的内存最大限制的默认值在64位系统上是1GB

早期的分段栈存在着 hot split, 在栈扩容时需要频繁的 alloc/free, 为解决这个问题,连续栈出现了。

连续栈: 采用复制栈的实现方式,在热分裂场景中不会频发释放内存,分配一个两倍大的内存块并把老的内存块内容复制到新的内存块里, 如果栈区的空间使用率不超过1/4,那么在垃圾回收的时候使用 runtime.shrinkstack 进行栈缩容

内存结构

image.png

  • Go 在程序启动时,会向操作系统申请一大块内存,由 mheap 结构全局管理(现在 Go 版本不需要连续地址了,所以不会申请一大堆地址)

  • Go 内存管理的基本单元是 mspan,每种 mspan 可以分配特定大小的 object

  • mcache, mcentral, mheap 是 Go 内存管理的三大组件,mcache 管理线程在本地缓存的 mspan;mcentral 管理全局的 mspan 供所有线程

相关概念:

  • page: 内存页,一块 8K 大小的内存空间。Go 与操作系统之间的内存申请和释放,都是以 page 为单位的。
  • span: 内存块,一个或多个连续的 page 组成一个 span。
  • sizeclass: 空间规格,每个 span 都带有一个 sizeclass,标记着该 span 中的 page 应该如何使用。
  • object: 对象,用来存储一个变量数据内存空间,一个 span 在初始化时,会被切割成一堆等大的 object。假设 object 的大小是 16B,span 大小是 8K,那么就会把 span 中的 page 就会被初始化 8K / 16B = 512 个 object

小于32kb的内存分配

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

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

image.png

程序申请内存的时候,mcache 里已经没有合适的空闲 mspan了,那么工作线程就会像下图这样去 mcentral 里去申请。mcache 从 mcentral 获取和归还 mspan 的流程:

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

image.png

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

image.png

大于32kb的内存分配

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

image.png

小于16b的内存分配

对于小于16字节的对象(且无指针),Go 语言将其划分为了tiny 对象。划分 tiny 对象的主要目的是为了处理极小的字符串和独立的转义变量。对 json 的基准测试表明,使用 tiny 对象减少了12%的分配次数和20%的堆大小。tiny 对象会被放入class 为2的 span 中

image.png

如果分配内存时 mcachce 里没有空闲的对口 sizeclass 的 mspan 了,Go 里还为每种类别的 mspan 维护着一个 mcentral

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

mcentral 被所有的工作线程共同享有,存在多个 goroutine 竞争的情况,因此从 mcentral 获取资源时需要加锁。mcentral 里维护着两个双向链表,nonempty 表示链表里还有空闲的 mspan 待分配。empty 表示这条链表里的 mspan 都被分配了object 或缓存 mcache 中

image.png

所有 mcentral 的集合则是存放于 mheap 中的。 mheap 里的 arena 区域是真正的堆区,运行时会将 8KB 看做一页,这些内存页中存储了所有在堆上初始化的对象。运行时使用二维的 runtime.heapArena 数组管理所有的内存,每个 runtime.heapArena 都会管理 64MB 的内存

image.png

内存逃逸

原因

Go语言采用自动内存管理,即通过垃圾回收器自动分配和释放内存。这种方式使得Go语言的内存管理较为简便,但也引入了一个新的问题:内存逃逸。

内存逃逸指的是在函数内部分配的变量却在函数结束后依然被外部引用,使得变量所占用的内存无法被及时回收,导致内存泄漏等问题。Go编译器能够检测到内存逃逸,并会将逃逸对象分配到堆上,在垃圾回收器中进行管理,但这会带来一定的性能损失。

逃逸分析

go build -gcflags '-m'

通过上面的指令可以查看项目中的内存逃逸

发生逃逸的场景

下面列举一些可能引起内存逃逸的场景:

  1. 将局部变量(包括数组、结构体等)作为参数传递给函数,并在函数内部对该变量进行引用,这可能会导致逃逸。

优化方式:将变量在函数内部生命为指针类型,避免变量被复制。

  1. 在函数内部创建指针类型,返回指针类型的地址,可能会导致逃逸。

优化方式:使用值类型代替指针类型。

  1. 在函数内部创建闭包,并将闭包作为返回值,可能会导致逃逸。

优化方式:将闭包中引用的变量声明为局部变量,避免闭包中的变量被分配到堆上。

  1. 在函数内部创建对象,并将对象作为参数传递给其他函数,可能会导致逃逸。

优化方式:使用对象池或者同步池,避免频繁创建和销毁对象。

  1. 在函数内部创建切片或者map,并将切片或者map作为返回值,可能会导致逃逸。

优化方式:在函数外部创建切片或者map,并将其作为参数传递给函数进行操作。

  1. 在函数内部通过new或者make分配内存,并将分配的内存返回,可能会导致逃逸。

优化方式:使用栈分配技术,避免分配内存到堆上。

对于上述场景,可以采取以下优化方式,避免内存逃逸:

  1. 避免将局部变量作为参数传递给函数,同时避免使用指针类型的参数。

  2. 使用值类型代替指针类型,避免在函数内部创建指针类型。

  3. 避免在函数内部创建闭包,避免在闭包中引用对象。

  4. 使用对象池或者同步池,避免频繁创建和销毁对象。

  5. 避免在函数内部创建切片或者map,并将其作为返回值,而是在函数外部创建切片或者map,并将其作为参数传递给函数进行操作。

  6. 尽可能使用栈分配技术,避免分配内存到堆上。可以使用sync.Pool来复用分配的内存。

通过优化避免内存逃逸,可以减少系统中堆上的内存分配和垃圾回收的频率,提高程序的性能和响应速度。