引言
要搞明白 Go 语言的内存管理,就必须先理解操作系统以及机器硬件是如何管理内存的。因为 Go 语言的内部机制是建立在这个基础之上的,它的设计,本质上就是尽可能的会发挥操作系统层面的优势,而避开导致低效情况。
操作系统内存管理
其实现在计算机内存管理的方式都是一步步演变来的,最开始是非常简单的,后来为了满足各种需求而增加了各种各样的机制,越来越复杂。这里我们只介绍和开发者息息相关的几个机制。
最原始的方式
我们可以把内存看成一个数组,每个数组元素的大小是 1B,也就是 8 位(bit)。CPU 通过内存地址来获取内存中的数据,内存地址可以看做成数组的游标(index)。

CPU 在执行指令的时候,就是通过内存地址,将物理内存上的数据载入到寄存器,然后执行机器指令。但随着发展,出现了多任务的需求,也就是希望多个任务能同时在系统上运行。这就出现了一些问题:
- 内存访问冲突:程序很容易出现 bug,就是 2 或更多的程序使用了同一块内存空间,导致数据读写错乱,程序崩溃。更有一些黑客利用这个缺陷来制作病毒。
- 内存不够用:因为每个程序都需要自己单独使用的一块内存,内存的大小就成了任务数量的瓶颈。
- 程序开发成本高:你的程序要使用多少内存,内存地址是多少,这些都不能搞错,对于人来说,开发正确的程序很费脑子。
举个例子,假设有一个程序,当代码运行到某处时,需要使用 100M 内存,其他时候 1M 内存就够;为了避免和其他程序冲突,程序初始化时,就必须申请独立 100M 内存以保证正常运行,这就是一种很大的浪费,因为这 100M 它大多数时候用不上,其他程序还不能用。
虚拟内存
虚拟内存的出现,很好的为了解决上述的一些列问题。用户程序只能使用虚拟的内存地址来获取数据,系统会将这个虚拟地址翻译成实际的物理地址。
所有程序统一使用一套连续虚拟地址,比如 0x0000 ~ 0xffff。从程序的角度来看,它觉得自己独享了一整块内存。不用考虑访问冲突的问题。系统会将虚拟地址翻译成物理地址,从内存上加载数据。
对于内存不够用的问题,虚拟内存本质上是将磁盘当成最终存储,而主存作为了一个 cache。程序可以从虚拟内存上申请很大的空间使用,比如 1G;但操作系统不会真的在物理内存上开辟 1G 的空间,它只是开辟了很小一块,比如 1M 给程序使用。
这样程序在访问内存时,操作系统看访问的地址是否能转换成物理内存地址。能则正常访问,不能则再开辟。这使得内存得到了更高效的利用。
如下图所示,每个进程所使用的虚拟地址空间都是一样的,但他们的虚拟地址会被映射到主存上的不同区域,甚至映射到磁盘上(当内存不够用时)。

虚拟地址
其实本质上很简单,就是操作系统将程序常用的数据放到内存里加速访问,不常用的数据放在磁盘上。这一切对用户程序来说完全是透明的,用户程序可以假装所有数据都在内存里,然后通过虚拟内存地址去访问数据。在这背后,操作系统会自动将数据在主存和磁盘之间进行交换。
虚拟地址翻译
虚拟内存的实现方式,大多数都是通过页表来实现的。操作系统虚拟内存空间分成一页一页的来管理,每页的大小为 4K(当然这是可以配置的,不同操作系统不一样)。磁盘和主内存之间的置换也是以页为单位来操作的。4K 算是通过实践折中出来的通用值,太小了会出现频繁的置换,太大了又浪费内存。
虚拟地址 -> 物理地址 的映射关系由页表(Page Table)记录,它其实就是一个数组,数组中每个元素叫做页表条目(Page Table Entry,简称 PTE),PTE 由一个有效位和 n 位地址字段构成,有效位标识这个虚拟地址是否分配了物理内存。
页表被操作系统放在物理内存的指定位置,CPU 上有个 Memory Management Unit(MMU) 单元,CPU 把虚拟地址给 MMU,MMU 去物理内存中查询页表,得到实际的物理地址。当然 MMU 不会每次都去查的,它自己也有一份缓存叫Translation Lookaside Buffer (TLB),是为了加速地址翻译。

虚拟地址翻译
你慢慢会发现整个计算机体系里面,缓存是无处不在的,整个计算机体系就是建立在一级级的缓存之上的,无论软硬件。
让我们来看一下 CPU 内存访问的完整过程:
- CPU 使用虚拟地址访问数据,比如执行了 MOV 指令加载数据到寄存器,把地址传递给 MMU。
- MMU 生成 PTE 地址,并从主存(或自己的 Cache)中得到它。
- 如果 MMU 根据 PTE 得到真实的物理地址,正常读取数据。流程到此结束。
- 如果 PTE 信息表示没有关联的物理地址,MMU 则触发一个缺页异常。
- 操作系统捕获到这个异常,开始执行异常处理程序。在物理内存上创建一页内存,并更新页表。
- 缺页处理程序在物理内存中确定一个牺牲页,如果这个牺牲页上有数据,则把数据保存到磁盘上。
- 缺页处理程序更新 PTE。
- 缺页处理程序结束,再回去执行上一条指令(导致缺页异常的那个指令,也就是 MOV 指令)。这次肯定命中了。
内存命中率
你可能已经发现,上述的访问步骤中,从第 4 步开始都是些很繁琐的操作,频繁的执行对性能影响很大。毕竟访问磁盘是非常慢的,它会引发程序性能的急剧下降。如果内存访问到第 3 步成功结束了,我们就说页命中了;反之就是未命中,或者说缺页,表示它开始执行第 4 步了。
假设在 n 次内存访问中,出现命中的次数是 m,那么 m / n * 100% 就表示命中率,这是衡量内存管理程序好坏的一个很重要的指标。
如果物理内存不足了,数据会在主存和磁盘之间频繁交换,命中率很低,性能出现急剧下降,我们称这种现象叫内存颠簸。这时你会发现系统的 swap 空间利用率开始增高, CPU 利用率中 iowait 占比开始增高。
大多数情况下,只要物理内存够用,页命中率不会非常低,不会出现内存颠簸的情况。因为大多数程序都有一个特点,就是局部性。
局部性就是说被引用过一次的存储器位置,很可能在后续再被引用多次;而且在该位置附近的其他位置,也很可能会在后续一段时间内被引用。
前面说过计算机到处使用一级级的缓存来提升性能,归根结底就是利用了局部性的特征,如果没有这个特性,一级级的缓存不会有那么大的作用。所以一个局部性很好的程序运行速度会更快。
golang内存管理
了解操作系统对内存的管理机制后,现在可以去看下 Go 语言是如何利用底层的这些特性来优化内存的。Go 的内存管理基本上参考 tcmalloc 来实现的,只是细节上根据自身的需要做了一些小的优化调整。
Go 的内存是自动管理的,我们可以随意定义变量直接使用,不需要考虑变量背后的内存申请和释放的问题。本文意在搞清楚 Go 在方面帮我们做了什么,使我们不用关心那些复杂内存的问题,还依旧能写出较为高效的程序。
池
程序动态申请内存空间,是要使用系统调用的,比如 Linux 系统上是调用 mmap 方法实现的。但对于大型系统服务来说,直接调用 mmap 申请内存,会有一定的代价。比如:
- 系统调用会导致程序进入内核态,内核分配完内存后(也就是上篇所讲的,对虚拟地址和物理地址进行映射等操作),再返回到用户态。
- 频繁申请很小的内存空间,容易出现大量内存碎片,增大操作系统整理碎片的压力。
- 为了保证内存访问具有良好的局部性,开发者需要投入大量的精力去做优化,这是一个很重的负担。
如何解决上面的问题呢?有经验的人,可能很快就想到解决方案,那就是我们常说的对象池(也可以说是缓存)。
假设系统需要频繁动态申请内存来存放一个数据结构,比如 [10]int。那么我们完全可以在程序启动之初,一次性申请几百甚至上千个 [10]int。这样完美的解决了上面遇到的问题:
- 不需要频繁申请内存了,而是从对象池里拿,程序不会频繁进入内核态
- 因为一次性申请一个连续的大空间,对象池会被重复利用,不会出现碎片。
- 程序频繁访问的就是对象池背后的同一块内存空间,局部性良好。
这样做会造成一定的内存浪费,我们可以定时检测对象池的大小,保证可用对象的数量在一个合理的范围,少了就提前申请,多了就自动释放。
如果某种资源的申请和回收是昂贵的,我们都可以通过建立资源池的方式来解决,其他比如连接池,内存池等等,都是一个思路。
Golang 内存管理
Golang 的内存管理本质上就是一个内存池,只不过内部做了很多的优化。比如自动伸缩内存池大小,合理的切割内存块等等。
内存池 mheap
Golang 的程序在启动之初,会一次性从操作系统那里申请一大块内存作为内存池。这块内存空间会放在一个叫 mheap 的 struct 中管理,mheap 负责将这一整块内存切割成不同的区域,并将其中一部分的内存切割成合适的大小,分配给用户使用。
我们需要先知道几个重要的概念:
page: 内存页,一块8K大小的内存空间。Go 与操作系统之间的内存申请和释放,都是以page为单位的。span: 内存块,一个或多个连续的page组成一个span。如果把page比喻成工人,span可看成是小队,工人被分成若干个队伍,不同的队伍干不同的活。sizeclass: 空间规格,每个span都带有一个sizeclass,标记着该span中的page应该如何使用。使用上面的比喻,就是sizeclass标志着span是一个什么样的队伍。object: 对象,用来存储一个变量数据内存空间,一个span在初始化时,会被切割成一堆等大的object。假设object的大小是16B,span大小是8K,那么就会把span中的page就会被初始化8K / 16B = 512个object。所谓内存分配,就是分配一个object出去。
示意图:

上图中,不同颜色代表不同的 span,不同 span 的 sizeclass 不同,表示里面的 page 将会按照不同的规格切割成一个个等大的 object 用作分配。
使用 Go1.11.5 版本测试了下初始堆内存应该是 64M 左右,低版本会少点。
测试代码:
package main
import "runtime"
var stat runtime.MemStats
func main() {
runtime.ReadMemStats(&stat)
println(stat.HeapSys)
}
内部的整体内存布局如下图所示:

mheap.spans:用来存储page和span信息,比如一个 span 的起始地址是多少,有几个 page,已使用了多大等等。mheap.bitmap存储着各个span中对象的标记信息,比如对象是否可回收等等。mheap.arena_start: 将要分配给应用程序使用的空间。
再说明下,图中的空间大小,是 Go 向操作系统申请的虚拟内存地址空间,操作系统会将该段地址空间预留出来不做它用;而不是真的创建出这么大的虚拟内存,在页表中创建出这么大的映射关系。
mcentral
用途相同的 span 会以链表的形式组织在一起。 这里的用途用 sizeclass 来表示,就是指该 span 用来存储哪种大小的对象。比如当分配一块大小为 n 的内存时,系统计算 n 应该使用哪种 sizeclass,然后根据 sizeclass 的值去找到一个可用的 span 来用作分配。其中 sizeclass 一共有 67 种(Go1.5 版本,后续版本可能会不会改变不好说),如图所示:

找到合适的 span 后,会从中取一个 object 返回给上层使用。这些 span 被放在一个叫做 mcentral 的结构中管理。
mheap 将从 OS 那里申请过来的内存初始化成一个大 span(sizeclass=0)。然后根据需要从这个大 span 中切出小 span,放在 mcentral 中来管理。大 span 由 mheap.freelarge 和 mheap.busylarge 等管理。如果 mcentral 中的 span 不够用了,会从 mheap.freelarge 上再切一块,如果 mheap.freelarge 空间不够,会再次从 OS 那里申请内存重复上述步骤。下面是 mheap 和 mcentral 的数据结构:
type mheap struct {
// other fields
lock mutex
free [_MaxMHeapList]mspan // free lists of given length, 1M 以下
freelarge mspan // free lists length >= _MaxMHeapList, >= 1M
busy [_MaxMHeapList]mspan // busy lists of large objects of given length
busylarge mspan // busy lists of large objects length >= _MaxMHeapList
central [_NumSizeClasses]struct { // _NumSizeClasses = 67
mcentral mcentral
// other fields
}
// other fields
}
// Central list of free objects of a given size.
type mcentral struct {
lock mutex // 分配时需要加锁
sizeclass int32 // 哪种 sizeclass
nonempty mspan // 还有可用的空间的 span 链表
empty mspan // 没有可用的空间的 span 列表
}
这种方式可以避免出现外部碎片(文章最后面有外部碎片的介绍),因为同一个 span 是按照固定大小分配和回收的,不会出现不可利用的一小块内存把内存分割掉。这个设计方式与现代操作系统中的伙伴系统有点类似。
mcache
如果你阅读的比较仔细,会发现上面的 mcentral 结构中有一个 lock 字段;因为并发情况下,很有可能多个线程同时从 mcentral 那里申请内存的,必须要用锁来避免冲突。
但锁是低效的,在高并发的服务中,它会使内存申请成为整个系统的瓶颈;所以在 mcentral 的前面又增加了一层 mcache。
每一个 mcache 和每一个处理器(P) 是一一对应的,也就是说每一个 P 都有一个 mcache 成员。 Goroutine 申请内存时,首先从其所在的 P 的 mcache 中分配,如果 mcache 没有可用 span,再从 mcentral 中获取,并填充到 mcache 中。
从 mcache 上分配内存空间是不需要加锁的,因为在同一时间里,一个 P 只有一个线程在其上面运行,不可能出现竞争。没有了锁的限制,大大加速了内存分配。
所以整体的内存分配模型大致如下图所示:

其他优化
zero size
有一些对象所需的内存大小是0,比如 [0]int, struct{},这种类型的数据根本就不需要内存,所以没必要走上面那么复杂的逻辑。
系统会直接返回一个固定的内存地址。源码如下:
func mallocgc(size uintptr, typ *_type, flags uint32) unsafe.Pointer {
// 申请的 0 大小空间的内存
if size == 0 {
return unsafe.Pointer(&zerobase)
}
//.....
}
测试代码:
package main
import (
"fmt"
)
func main() {
var (
a struct{}
b [0]int
c [100]struct{}
d = make([]struct{}, 1024)
)
fmt.Printf("%p\n", &a)
fmt.Printf("%p\n", &b)
fmt.Printf("%p\n", &c)
fmt.Printf("%p\n", &(d[0]))
fmt.Printf("%p\n", &(d[1]))
fmt.Printf("%p\n", &(d[1000]))
}
// 运行结果,6 个变量的内存地址是相同的:
0x1180f88
0x1180f88
0x1180f88
0x1180f88
0x1180f88
0x1180f88
大对象
如上面所述,最大的 sizeclass 最大只能存放 32K 的对象。如果一次性申请超过 32K 的内存,系统会直接绕过 mcache 和 mcentral,直接从 mheap 上获取,mheap 中有一个 freelarge 字段管理着超大 span。
总结
内存的释放过程,没什么特别之处。就是分配的返过程,当 mcache 中存在较多空闲 span 时,会归还给 mcentral;而 mcentral 中存在较多空闲 span 时,会归还给 mheap;mheap 再归还给操作系统。这里就不详细介绍了。
总结一下,这种设计之所以快,主要有以下几个优势:
- 内存分配大多时候都是在用户态完成的,不需要频繁进入内核态。
- 每个 P 都有独立的 span cache,多个 CPU 不会并发读写同一块内存,进而减少 CPU L1 cache 的 cacheline 出现 dirty 情况,增大 cpu cache 命中率。
- 内存碎片的问题,Go 是自己在用户态管理的,在 OS 层面看是没有碎片的,使得操作系统层面对碎片的管理压力也会降低。
- mcache 的存在使得内存分配不需要加锁。
为什么内存碎片可能影响性能?
Linux 利用 Intel CPU的保护模式,采用页表的方式对内存进行管理。 虚拟线性地址对应着某个页。这之间的对应关系存在于页表之中。 由于几乎每次对虚拟内存中的页面访问都必须先解析页,从而得到物理内存中的对应地址,所以页表操作的性能非常关键。因此,Intel MMU 系统结构中实现了一个TLB(translate lookaside buffer)作为一个将虚拟地址映射到物理地址的硬件缓存,当请求访问一个虚拟地址时,处理器将首先检查TLB是否缓存了该虚拟地址到物理地址的映射,如果命中则直接返回,否则,就需要通过页表搜索需要的物理地址。
TLB很小,只有64 entries 。当内存碎片化后,一个进程的虚拟线性地址空间对应于数量众多的小片的页,TLB不能容纳这么多的页面表项,这就意味在这个进程的运行期内,MMU在寻址时,TLB总是不能命中,而需要不断的更新。这就大大的降低了执行的效率。