内存管理
# 内存分配
程序中的数据和变量都会被分配到程序所在的虚拟内存中,内存空间包含两个重要区域:栈区(Stack)和堆区(Heap)。函数调用的参数、返回值以及局部变量大都会被分配到栈上,这部分内存会由编译器进行管理;其他的比如对象则由内存分配器分配并由垃圾收集器回收。
# 基本概念
# 内存管理由三部分组成
- 用户程序(Mutator)
- 分配器(Allocator)
- 收集器(Collector)
当 用户程序申请内存 时,它会通过 内存分配器申请新内存 ,而分配器会负责从堆中初始化相应的内存区域。收集器就负责回收垃圾
# 分配的方法
内存分配主要分为下面两种方法
线性分配器(Sequential Allocator,Bump Allocator) 维护一大块内存,需要的时候就从这块内存中去分配,实现比较简单,但是已经分配的内存无法重新利用(需要配合垃圾回收算法使用)
空闲链表分配器(Free-List Allocator) 这个可以重用已经释放过的内存,内部会维护一个链表的结构,申请内存时会依次遍历空闲的内存块,找到足够大的内存,然后申请新的资源并修改链表
链表分配器有四种分配策略
- 首次适应(First-Fit)— 从链表头开始遍历,选择第一个大小大于申请内存的内存块;
- 循环首次适应(Next-Fit)— 从上次遍历的结束位置开始遍历,选择第一个大小大于申请内存的内存块;
- 最优适应(Best-Fit)— 从链表头遍历整个链表,选择最合适的内存块;
- 隔离适应(Segregated-Fit)— 将内存分割成多个链表,每个链表中的内存块大小相同,申请内存时先找到满足条件的链表,再从链表中选择合适的内存块
go使用的是类似于隔离适应的策略,下图是这个策略的简单介绍
# 如何分配
上面说了一下内存分配的方法,下面简单介绍一下内存是如何分配的,go借鉴线程缓存分配(Thread-Caching Malloc,TCMalloc)的方法来进行内存分配,它的核心理念是使用多级缓存将对象根据大小分类,并按照类别实施不同的分配策略。
Go 语言的内存分配器会根据 申请分配的内存大小选择不同的处理逻辑 ,运行时根据对象的大小将对象分成微对象、小对象和大对象三种(因为程序中的绝大多数对象的大小都在 32KB 以下,而申请的内存大小影响 Go 语言运行时分配内存的过程和开销,所以分别处理大对象和小对象有利于提高内存分配器的性能。)
类别 | 大小 |
---|---|
微对象 | (0, 16B) |
小对象 | [16B, 32KB] |
大对象 | (32KB, +∞) |
内存分配器不仅会区别对待大小不同的对象,还会 将内存分成不同的级别分别管理 ,TCMalloc 和 Go 运行时分配器都会引入线程缓存(Thread Cache)、中心缓存(Central Cache)和页堆(Page Heap)三个组件分级管理内存,示意图如下
线程缓存属于每一个独立的线程,它能够满足线程上绝大多数的内存分配需求,因为不涉及多线程,所以也不需要使用互斥锁来保护内存,这能够减少锁竞争带来的性能损耗。当线程缓存不能满足需求时,运行时会使用中心缓存作为补充解决小对象的内存分配,在遇到 32KB 以上的对象时,内存分配器会选择页堆直接分配大内存。
# 虚拟内存布局
前面说了分配的方法和如何分配,下面说一下go的堆区内存是如何布局的
go1.10之前使用的是线性内存,虽然简单且方便,但是c和go混用的时候会导致程序奔溃。所以下面要讲的是Go的1.11提出的稀疏内存。
使用稀疏的内存布局不仅能移除堆大小的上限5 (opens new window),还能解决 C 和 Go 混合使用时的地址空间冲突问题6 (opens new window)。不过因为基于稀疏内存的内存管理失去了内存的连续性这一假设,这也使内存管理变得更加复杂,由于内存的管理变得更加复杂,上述改动对垃圾回收稍有影响,大约会增加 1% 的垃圾回收开销,不过这也是我们为了解决已有问题必须付出的成本7 (opens new window)。
# 地址空间
因为所有的内存最终都是要从操作系统中申请的,所以 Go 语言的运行时构建了操作系统的内存管理抽象层,该抽象层将运行时管理的地址空间分成以下四种状态8 (opens new window):
状态 | 解释 |
---|---|
None | 内存没有被保留或者映射,是地址空间的默认状态 |
Reserved | 运行时持有该地址空间,但是访问该内存会导致错误 |
Prepared | 内存被保留,一般没有对应的物理内存访问该片内存的行为是未定义的可以快速转换到 Ready 状态 |
Ready | 可以被安全访问 |
# 内存管理组件
前面说了一些基本概念,这里就总结一下整个GO的内存分配器结构
Go 语言的内存分配器包含内存管理单元、线程缓存、中心缓存和页堆几个重要组件,整个结构图如下
所有的 Go 语言程序都会在启动时初始化如上图所示的内存布局,每一个处理器都会分配一个线程缓存 runtime.mcache
(opens new window) 用于处理微对象和小对象的分配,它们会持有内存管理单元 runtime.mspan
(opens new window)。
每个类型的内存管理单元都会管理特定大小的对象,当内存管理单元中不存在空闲对象时,它们会从 runtime.mheap
(opens new window) 持有的 134 个中心缓存 runtime.mcentral
(opens new window) 中获取新的内存单元,中心缓存属于全局的堆结构体 runtime.mheap
(opens new window),它会从操作系统中申请内存。
在 amd64 的 Linux 操作系统上,runtime.mheap
(opens new window) 会持有 4,194,304 runtime.heapArena
(opens new window),每个 runtime.heapArena
(opens new window) 都会管理 64MB 的内存,单个 Go 语言程序的内存上限也就是 256TB。
# 内存管理单元
runtime.mspan
(opens new window) 是 Go 语言内存管理的基本单元,该结构体中包含 next
和 prev
两个字段,它们分别指向了前一个和后一个 runtime.mspan
(opens new window):
当结构体管理的内存不足时,运行时会以页为单位向堆申请内存:
startAddr
和npages
— 确定该结构体管理的多个页所在的内存,每个页的大小都是 8KB;
当用户程序或者线程向 runtime.mspan
(opens new window) 申请内存时,它会使用 allocCache
字段以对象为单位在管理的内存中快速查找待分配的空间:
如果我们能在内存中找到空闲的内存单元会直接返回,当内存中不包含空闲的内存时,上一级的组件 runtime.mcache
(opens new window) 会为调用 runtime.mcache.refill
(opens new window) 更新内存管理单元以满足为更多对象分配内存的需求
# 跨度类
runtime.spanClass
(opens new window) 是 runtime.mspan
(opens new window) 的跨度类,它决定了内存管理单元中存储的对象大小和个数:
Go 语言的内存管理模块中一共包含 67 种跨度类,每一个跨度类都会存储特定大小的对象并且包含特定数量的页数以及对象,所有的数据都会被预选计算好并存储在 runtime.class_to_size
(opens new window) 和 runtime.class_to_allocnpages
(opens new window) 等变量中:
lass | bytes/obj | bytes/span | objects | tail waste | max waste |
---|---|---|---|---|---|
1 | 8 | 8192 | 1024 | 0 | 87.50% |
2 | 16 | 8192 | 512 | 0 | 43.75% |
3 | 24 | 8192 | 341 | 0 | 29.24% |
4 | 32 | 8192 | 256 | 0 | 46.88% |
5 | 48 | 8192 | 170 | 32 | 31.52% |
6 | 64 | 8192 | 128 | 0 | 23.44% |
7 | 80 | 8192 | 102 | 32 | 19.07% |
… | … | … | … | … | … |
67 | 32768 | 32768 | 1 | 0 | 12.50% |
除了上述 67 个跨度类之外,运行时中还包含 ID 为 0 的特殊跨度类,它能够管理大于 32KB 的特殊对象。
# 线程缓存
runtime.mcache
(opens new window) 是 Go 语言中的线程缓存,它会与线程上的处理器一一绑定,主要用来缓存用户程序申请的微小对象。每一个线程缓存都持有 68 * 2 个 runtime.mspan
(opens new window),这些内存管理单元都存储在结构体的 alloc
字段中:
线程缓存在刚刚被初始化时是不包含 runtime.mspan
(opens new window) 的,只有当用户程序申请内存时才会从上一级组件获取新的 runtime.mspan
(opens new window) 满足内存分配的需求。
# 中心缓存
runtime.mcentral
(opens new window) 是内存分配器的中心缓存,与线程缓存不同,访问中心缓存中的内存管理单元需要使用互斥锁:
线程缓存会通过中心缓存的 runtime.mcentral.cacheSpan
(opens new window) 方法获取新的内存管理单元。该方法的最后都会更新内存单元的 allocBits
和 allocCache
等字段,让运行时在分配内存时能够快速找到空闲的对象。
# 页堆
runtime.mheap
(opens new window) 是内存分配的核心结构体,Go 语言程序会将其作为全局变量存储,而堆上初始化的所有对象都由该结构体统一管理,该结构体中包含两组非常重要的字段,其中一个是全局的中心缓存列表 central
,另一个是管理堆区内存区域的 arenas
以及相关字段。
页堆中包含一个长度为 136 的 runtime.mcentral
(opens new window) 数组,其中 68 个为跨度类需要 scan
的中心缓存,另外的 68 个是 noscan
的中心缓存:
# 垃圾收集
# 基本概念
在开始介绍之前需要先一些基本的概念,方便对后面的内容进行理解
# 标记清除
标记清除(Mark-Sweep)算法是最常见的垃圾收集算法,标记清除收集器是跟踪式垃圾收集器,其执行过程可以分成标记(Mark)和清除(Sweep)两个阶段。
# 三色抽象
为了解决原始标记清除算法带来的长时间 STW,多数现代的追踪式垃圾收集器都会实现三色标记算法的变种以 缩短 STW 的时间 三色标记算法将程序中的对象分成白色、黑色和灰色三类:
- 白色对象 — 潜在的垃圾,其内存可能会被垃圾收集器回收;
- 黑色对象 — 活跃的对象,包括不存在任何引用外部指针的对象以及从根对象可达的对象;
- 灰色对象 — 活跃的对象,因为存在指向白色对象的外部指针,垃圾收集器会扫描这些对象的子对象;
三色标记垃圾收集器的工作原理很简单,我们可以将其归纳成以下几个步骤:
- 从灰色对象的集合中选择一个灰色对象并将其标记成黑色;
- 将黑色对象指向的所有对象都标记成灰色,保证该对象和被该对象引用的对象都不会被回收;
- 重复上述两个步骤直到对象图中不存在灰色对象;
当标记结束后,应用程序就不存在任何的灰色对象,这个时候,垃圾收集器就会回收白色垃圾。
使用三色标记时,为了避免用户程序修改对象指针,所以我们需要STW,如果想并发或者增量来标记对象时,我们需要使用屏障技术
# 屏障技术
内存屏障技术是一种屏障指令,它可以让 CPU 或者编译器在执行内存相关操作时遵循特定的约束,目前多数的现代处理器都会乱序执行指令以最大化性能,但是该技术能够保证内存操作的顺序性,在内存屏障前执行的操作一定会先于内存屏障后执行的操作6 (opens new window)。
要想在并发标记时确保正确性,我们就必须要达成下面两种三色不变性中的一种:
- 强三色不变性 — 黑色对象不会指向白色对象,只会指向灰色对象或者黑色对象;
- 弱三色不变性 — 黑色对象指向的白色对象必须包含一条从灰色对象经由多个白色对象的可达路径
下图展示了这两种三色不变性:
怎么保证三色不变性呢?答案是使用屏障技术
垃圾收集中的屏障技术更像是一个钩子方法,它是在用户程序读取对象、创建新对象以及更新对象指针时执行的一段代码,根据操作类型的不同,我们可以将它们分成读屏障(Read barrier)和写屏障(Write barrier)两种,因为读屏障需要在读操作中加入代码片段,对用户程序的性能影响很大,所以编程语言往往都会采用写屏障保证三色不变性。
Go 语言中使用的两种写屏障技术,分别是 Dijkstra 提出的插入写屏障8 (opens new window)和 Yuasa 提出的删除写屏障9 (opens new window)。
具体过程就不细表,可以参考:Go 语言垃圾收集器的实现原理 | Go 语言设计与实现 (draveness.me) (opens new window)
# 增量和并发
传统的垃圾收集算法会在垃圾收集的执行期间暂停应用程序,一旦触发垃圾收集,垃圾收集器会抢占 CPU 的使用权占据大量的计算资源以完成标记和清除工作,然而很多追求实时的应用程序无法接受长时间的 STW,而现在我们计算机往往是多核的,所以我们可以使用下面两种策略来优化我们的垃圾回收器:
- 增量垃圾收集 — 增量地标记和清除垃圾,降低应用程序暂停的最长时间;(把原本较长的暂停时间切分为多个更小的时间片)
- 并发垃圾收集 — 利用多核的计算资源,在用户程序执行时并发标记和清除垃圾;(收集器直接和程序一起运行,但是部分阶段也需要暂停程序)
因为增量和并发两种方式都可以与用户程序交替运行,所以我们需要使用屏障技术保证垃圾收集的正确性;与此同时,增量和并发的垃圾收集需要提前触发并在内存不足前完成整个循环,避免程序的长时间暂停。
演进过程,这部分内容比较多,所以就不展开讲了,直接跳到最新的垃圾回收器
# 实现原理
- 清理终止阶段;
- 暂停程序,所有的处理器在这时会进入安全点(Safe point);
- 如果当前垃圾收集循环是强制触发的,我们还需要处理还未被清理的内存管理单元;
- 标记阶段;
- 将状态切换至
_GCmark
、开启写屏障、用户程序协助(Mutator Assiste)并将根对象入队; - 恢复执行程序,标记进程和用于协助的用户程序会开始并发标记内存中的对象,写屏障会将被覆盖的指针和新指针都标记成灰色,而所有新创建的对象都会被直接标记成黑色;
- 开始扫描根对象,包括所有 Goroutine 的栈、全局对象以及不在堆中的运行时数据结构,扫描 Goroutine 栈期间会暂停当前处理器;
- 依次处理灰色队列中的对象,将对象标记成黑色并将它们指向的对象标记成灰色;
- 使用分布式的终止算法检查剩余的工作,发现标记阶段完成后进入标记终止阶段;
- 将状态切换至
- 标记终止阶段;
- 暂停程序、将状态切换至
_GCmarktermination
并关闭辅助标记的用户程序; - 清理处理器上的线程缓存;
- 暂停程序、将状态切换至
- 清理阶段;
将状态切换至
_GCoff
开始清理阶段,初始化清理状态并关闭写屏障;恢复用户程序,所有新创建的对象会标记成白色;
后台并发清理所有的内存管理单元,当 Goroutine 申请新的内存管理单元时就会触发清理;
(注意go使用的是OS虚拟内存,不需要挪动对象,只需要标记它为垃圾就行了)
# 触发时机
后台触发 运行时会在应用程序启动时在后台开启一个用于强制触发垃圾收集的 Goroutine,该 Goroutine 的职责非常简单 — 调用
runtime.gcStart
(opens new window) 尝试启动新一轮的垃圾收集。(这个线程大部分是休眠状态,但是会被系统监视器在满足条件时唤醒)手动触发 用户程序会通过
runtime.GC
(opens new window) 函数在程序运行期间主动通知运行时执行,该方法在调用时会阻塞调用方直到当前垃圾收集循环完成,在垃圾收集期间也可能会通过 STW 暂停整个程序申请内存 申请内存时也可以触发垃圾回收
# 内存清理
垃圾收集的清理中包含对象回收器(Reclaimer)和内存单元回收器,这两种回收器使用不同的算法清理堆内存:
- 对象回收器在内存管理单元中查找并释放未被标记的对象,但是如果
runtime.mspan
(opens new window) 中的所有对象都没有被标记,整个单元就会被直接回收,该过程会被runtime.mcentral.cacheSpan
(opens new window) 或者runtime.sweepone
(opens new window) 异步触发; - 内存单元回收器会在内存中查找所有的对象都未被标记的
runtime.mspan
(opens new window),该过程会被runtime.mheap.reclaim
(opens new window) 触发;
# 栈内存管理
栈区的内存一般由编译器自动分配和释放,其中存储着函数的入参以及局部变量,这些参数会随着函数的创建而创建,函数的返回而消亡,一般不会在程序中长期存在,这种线性的内存分配策略有着极高地效率,但是工程师也往往不能控制栈内存的分配,这部分工作基本都是由编译器完成的。
Go语言的运行环境(runtime)会在goroutine需要的时候 动态地分配栈空间,而不是给每个goroutine分配固定大小的内存空间。 这样就避免了需要程序员来决定栈的大小。
# 逃逸分析
在编译器优化中,逃逸分析是用来决定指针动态作用域的方法5 (opens new window)。Go 语言的编译器使用逃逸分析决定哪些变量应该在栈上分配,哪些变量应该在堆上分配,其中包括使用 new
、make
和字面量等方法隐式分配的内存,Go 语言的逃逸分析遵循以下两个不变性:
- 指向栈对象的指针不能存在于堆中;
- 指向栈对象的指针不能在栈对象回收后存活;
在Go中逃逸分析是一种确定指针动态范围的方法,可以分析在程序的哪些地方可以访问到指针。它涉及到指针分析和形状分析。
当一个变量(或对象)在子程序中被分配时,一个指向变量的指针可能逃逸到其它执行线程中,或者去调用子程序。如果使用尾递归优化(通常在函数编程语言中是需要的),对象也可能逃逸到被调用的子程序中。 如果一个子程序分配一个对象并返回一个该对象的指针,该对象可能在程序中的任何一个地方被访问到——这样指针就成功“逃逸”了。
如果指针存储在全局变量或者其它数据结构中,它们也可能发生逃逸,这种情况是当前程序中的指针逃逸。 逃逸分析需要确定指针所有可以存储的地方,保证指针的生命周期只在当前进程或线程中。
导致内存逃逸的情况比较多,有些可能还是官方未能够实现精确的分析逃逸情况的 bug,通常来讲就是如果变量的作用域不会扩大并且其行为或者大小能够在编译的时候确定,一般情况下都是分配到栈上,否则就可能发生内存逃逸分配到堆上。
内存逃逸的五种情况:
- 发送指针的指针或值包含了指针到
channel
中,由于在编译阶段无法确定其作用域与传递的路径,所以一般都会逃逸到堆上分配。 - slices 中的值是指针的指针或包含指针字段。一个例子是类似
[]*string
的类型。这总是导致 slice 的逃逸。即使切片的底层存储数组仍可能位于堆栈上,数据的引用也会转移到堆中。 - slice 由于 append 操作超出其容量,因此会导致 slice 重新分配。这种情况下,由于在编译时 slice 的初始大小的已知情况下,将会在栈上分配。如果 slice 的底层存储必须基于仅在运行时数据进行扩展,则它将分配在堆上。
- 调用接口类型的方法。接口类型的方法调用是动态调度,实际使用的具体实现只能在运行时确定。考虑一个接口类型为 io.Reader 的变量 r。对 r.Read(b) 的调用将导致 r 的值和字节片b的后续转义并因此分配到堆上。
- 尽管能够符合分配到栈的场景,但是其大小不能够在编译时候确定的情况,也会分配到堆上.
有效的避免上述的五种逃逸的情况,就可以避免内存逃逸.
# 内存泄漏和逃逸分析
# goroutine 泄露
如果你启动了一个 goroutine,但并没有符合预期的退出,直到程序结束,此goroutine才退出,这种情况就是 goroutine 泄露。当 goroutine 泄露发生时,该 goroutine 的栈(一般 2k 内存空间起)一直被占用不能释放,goroutine 里的函数在堆上申请的空间也不能被 垃圾回收器 回收。这样,在程序运行期间,内存占用持续升高,可用内存越来也少,最终将导致系统崩溃。
回顾一下 goroutine 终止的场景:
- 当一个goroutine完成它的工作
- 由于发生了没有处理的错误
- 有其他的协程告诉它终止
那么当这三者同时没发生的时候,就会导致 goroutine 始终不会终止退出。
泄漏的原因
- 从 channel 里读,但是没有写 (程序一直在读channel)
- 向 unbuffered channel 写,但是没有读
- 向已满的 buffered channel 写,但是没有读
- select操作在所有case上阻塞
- goroutine进入死循环中,导致资源一直无法释放
goroutine 泄露检测和定位
- 监控工具:固定周期对进程的内存占用情况进行采样,数据可视化后,根据内存占用走势(持续上升),很容易发现是否发生内存泄露。可以使用云服务提供的内存使用监控服务或者自己实现一个 daemon 脚本周期采集内存占用数据。
- 使用Go提供的pprof工具分析是否发生内存泄露。使用 pprof 的 heap 能够获取程序运行时的内存信息,通过对运行的程序多次采样对比,分析出内存的使用情况。
goroutine 泄露的防范
- 创建goroutine时就要想好该goroutine该如何结束
- 使用channel时,要考虑到 channel 阻塞时协程可能的行为
- 实现循环语句时注意循环的退出条件,避免死循环
goroutine泄露:原理、场景、检测和防范 - SegmentFault 思否 (opens new window)
# 内存泄漏
内存泄露指的是程序运行过程中已不再使用的内存,没有被释放掉,导致这些内存无法被使用,直到程序结束这些内存才被释放的问题。
注意,如果被问到这个问题,第一个要答的就是goroutine泄漏,这个才是最重要的泄漏原因。一般我们可以回答channel
内存泄漏主要有下面几个原因
- 获取长字符串中的一段导致长字符串未释放
- 同样,获取长slice中的一段导致长slice未释放
- 在长slice新建slice导致泄漏
- goroutine泄漏
- time.Ticker未关闭导致泄漏
- Finalizer导致泄漏
- Deferring Function Call导致泄漏
参考:
# 逃逸分析
在计算机语言编译器优化原理中,逃逸分析是指分析指针动态范围的方法,它同编译器优化原理的指针分析和外形分析相关联。当变量(或者对象)在方法中分配后,其指针有可能被返回或者被全局引用,这样就会被其他过程或者线程所引用,这种现象称作指针(或者引用)的逃逸(Escape)。
说白了就是当一个对象的指针被多个方法或线程引用时,我们称这个指针发生了逃逸。
逃逸行为
- 方法逃逸:当一个对象在方法中定义之后,作为参数传递或返回值到其它方法中
- 线程逃逸:如类变量或实例变量,可能被其它线程访问到
这里主要对 方法逃逸 进行分析,通过逃逸分析来判断一个变量到底是分配在堆上还是栈上
# 发送逃逸的情况
指针发生逃逸的情况
- 在某个函数中new或字面量创建出的变量,将其指针作为函数返回值,则该变量一定发生逃逸(构造函数返回的指针变量一定逃逸);
- 被已经逃逸的变量引用的指针,一定发生逃逸;
- 被指针类型的slice、map和chan引用的指针,一定发生逃逸;
必然不会逃逸的情况
- 指针被未发生逃逸的变量引用;
- 仅仅在函数内对变量做取址操作,而未将指针传出;
# 如何进行分析
go的逃逸分析实在编译期间进行的,在 build 的时候,通过添加 -gcflags "-m" 编译参数就可以查看编译过程中的逃逸分析
# 逃逸的场景
指针逃逸
有些时候,因为变量太大等原因,我们会选择返回变量的指针,而非变量,这里其实就是逃逸的一个经典现象
func main() {
test()
}
func test() *int {
i := 1
return &i
}
/**
# command-line-arguments
./main.go:7:6: can inline test
./main.go:3:6: can inline main
./main.go:4:6: inlining call to test
./main.go:4:6: main &i does not escape
./main.go:9:9: &i escapes to heap
./main.go:8:2: moved to heap: i
**/
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
可以看到i逃逸到了堆上
栈空间不足逃逸
创建一个 长度较小的 slice是会分配到栈上,如果分配一个超大的slice,就会逃逸到堆上
动态类型逃逸
func main() {
dynamic()
}
func dynamic() interface{} {
i := 0
return i
}
2
3
4
5
6
7
8
闭包引用逃逸
func main() {
f := fibonacci()
for i := 0; i < 10; i++ {
f()
}
}
func fibonacci() func() int {
a, b := 0, 1
return func() int {
a, b = b, a+b
return a
}
}
/**
./main.go:11:9: can inline fibonacci.func1
./main.go:11:9: func literal escapes to heap
./main.go:11:9: func literal escapes to heap
./main.go:12:10: &b escapes to heap
./main.go:10:5: moved to heap: b
./main.go:12:13: &a escapes to heap
./main.go:10:2: moved to heap: a
**/
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
参考: