Golang 内存分配
Go 的内存分配器是其高性能并发能力的核心基石之一。它并非直接向操作系统申请和释放每一次内存,而是实现了一套高效、分层的内存管理机制。其设计深受 Google 自家的 TCMalloc (Thread-Caching Malloc) 影响,核心思想是通过多级缓存来减少锁的竞争,从而提高并发分配的性能。
0.1 一、 内存管理的层次结构
Go 的内存管理可以看作一个金字塔形的层次结构,从上到下分别是 mcache
、mcentral
和 mheap
。
-
mspan (Memory Span - 内存块)
在深入了解三层结构之前,必须先理解最基本的内存管理单元 mspan。
-
定义:
mspan
是 Go 内存管理的基本单位,它是由一个或多个连续的物理页 (Page) 组成的内存块。Go 中一页大小为 8KB。 -
用途:
mspan
用于管理一组相同大小的对象(例如,一个mspan
只负责分配 32 字节大小的对象)。这种按“规格”管理的方式称为 size class (spanClass)。 -
状态:
mspan
可以在不同的空闲链表(freelist)中,也可以被某个mcache
持有。
-
-
mcache
(Memory Cache - Per-P 缓存)-
定义:
mcache
是每个 P (Processor) 私有的内存缓存。回顾 GMP 模型,每个 P 绑定一个操作系统线程 M 来执行 Goroutine。因此,mcache
也可以理解为线程本地缓存。 -
核心特点:无锁分配 (Lock-Free)。当一个 Goroutine 需要分配小对象时,它会直接从其当前运行的 P 所绑定的
mcache
中获取。因为每个 P 只有一个 M 在执行,所以这个过程完全不需要加锁,速度极快。 -
结构:
mcache
内部有一个包含约 70 个mspan
指针的数组。每个数组元素对应一个 size class。例如,mcache.alloc[3]
可能指向一个专门用来分配 32 字节对象的mspan
。
-
-
mcentral
(Central Freelist - 中心空闲列表)-
定义:
mcentral
是一个全局的、供所有 P 共享的资源。每种 size class 都有一个对应的mcentral
。 -
用途:当某个 P 的
mcache
中缺少某种规格的mspan
时,它会向对应的mcentral
申请。 -
锁机制:因为
mcentral
是全局共享的,所以对它的访问需要加锁,以避免多个 P 同时来申请资源导致竞态。 -
结构:每个
mcentral
维护着两个mspan
链表:nonempty
(包含有空闲空间的mspan
) 和empty
(包含完全空闲或已被归还的mspan
)。
-
-
mheap
(Heap Arena - 堆区)-
定义:
mheap
是内存分配器的最高层,它代表了 Go 程序持有的所有堆内存。它统一管理着所有从操作系统申请来的大块内存(称为 Arena,在 64 位系统上一个 Arena 是 64MB)。 -
用途:当
mcentral
中没有可用的mspan
时,mcentral
会向mheap
申请。mheap
会从其管理的 Arena 中切出一块连续的页分配给mcentral
。 -
锁机制:
mheap
是全局唯一的,对它的访问需要加全局锁 (mheap_.lock
)。 -
大对象分配:超过 32KB 的大对象会绕过
mcache
和mcentral
,直接由mheap
进行分配。
-
0.2 二、 结构图
下图展示了 mcache
, mcentral
, mheap
之间的层次关系和内存流动方向。
graph TD subgraph OS [操作系统] os_mem[物理内存] end subgraph Go_Runtime_Heap [Go 运行时堆区] mheap["mheap (全局唯一, 管理所有 Arenas)"] subgraph Centrals ["mcentral (全局共享, 按 Size Class 分类)"] mc0["mcentral (8B)"] mc1["mcentral (16B)"] mc_etc["..."] mcN["mcentral (32KB)"] end subgraph P1 ["Processor 1 (P)"] g1[Goroutine] --> cache1["mcache (P1私有, 无锁)"] end subgraph P2 ["Processor 2 (P)"] g2[Goroutine] --> cache2["mcache (P2私有, 无锁)"] end mheap -- "分配大对象 (>32KB)" --> g1 mheap -- "分配大对象 (>32KB)" --> g2 mheap -- "申请 pages" --> mc0 mheap -- "申请 pages" --> mc1 mheap -- "申请 pages" --> mc_etc mheap -- "申请 pages" --> mcN os_mem -- "申请大块内存 (Arenas)" --> mheap cache1 -- "申请 mspan (加锁)" --> mc1 cache2 -- "申请 mspan (加锁)" --> mc0 end style OS fill:#e6e6fa,stroke:#333,stroke-width:2px style Go_Runtime_Heap fill:#f0f8ff,stroke:#333,stroke-width:2px style P1 fill:#fff0f5,stroke:#333,stroke-width:2px style P2 fill:#fff0f5,stroke:#333,stroke-width:2px
0.3 三、 内存分配流程
0.3.1 流程总览
核心流程类似于读多级缓存的过程,由上而下,每一步只要成功则直接返回. 若失败,则由下层方法兜底.
对于微对象的分配流程:
(1)从 P 专属 mcache 的 tiny 分配器取内存(无锁)
(2)根据所属的 spanClass,从 P 专属 mcache 缓存的 mspan 中取内存(无锁)
(3)根据所属的 spanClass 从对应的 mcentral 中取 mspan 填充到 mcache,然后从 mspan 中取内存(spanClass 粒度锁)
(4)根据所属的 spanClass,从 mheap 的页分配器 pageAlloc 取得足够数量空闲页组装成 mspan 填充到 mcache,然后从 mspan 中取内存(全局锁)
(5)mheap 向操作系统申请内存,更新页分配器的索引信息,然后重复(4).
对于小对象的分配流程是跳过(1)步,执行上述流程的(2)-(5)步;
对于大对象的分配流程是跳过(1)-(3)步,执行上述流程的(4)-(5)步.
根据要分配的对象大小,流程分为三类:
0.3.2 A. 微对象分配 (Tiny Objects, size < 16B)
- 为了节省空间和对齐,小于 16 字节的对象会先被分配到一个 16 字节的块中,然后在这个块里进行指针偏移来分配。这个 16 字节的块本身还是来自于
mcache
的 16B size class。
0.3.3 B. 小对象分配 (Small Objects, 16B <= size <= 32KB)
这是最常见、也是 Go 内存分配器优化的核心路径。
-
尝试
mcache
(无锁):-
Goroutine 需要分配内存,它当前正在 P 上运行。
-
Go 运行时将请求的
size
向上取整到最接近的 size class。 -
它直接访问 P 的
mcache
中对应 size class 的mspan
链表。 -
如果链表上的
mspan
有空闲的 object slot,分配成功,返回指针。这个过程完全无锁,速度极快。
-
-
mcache
缺货,求助mcentral
(加锁):-
如果
mcache
中对应 size class 的mspan
已经用完,mcache
会向对应的全局mcentral
申请一个新的mspan
。 -
这个过程需要对
mcentral
加锁。 -
mcentral
从它的nonempty
链表中取出一个mspan
交给mcache
。
-
-
mcentral
缺货,求助mheap
(全局锁):-
如果
mcentral
的nonempty
链表也为空,mcentral
必须向mheap
申请内存。 -
它会调用
mheap.alloc
,这个过程需要加mheap
全局锁。 -
mheap
会从其管理的 Arena 中找到一片连续的空闲页,切割成一个mspan
,然后交给mcentral
。 -
如果
mheap
也没有足够的空闲页,它会通过mmap
(在 Unix-like 系统上) 等系统调用向操作系统申请一大块新的内存 (Arena)。
-
-
内存回流:
内存最终从 mheap -> mcentral -> mcache -> Goroutine。
0.3.4 C. 大对象分配 (Large Objects, size > 32KB)
-
大对象不会通过
mcache
和mcentral
进行分配,因为缓存小对象才有意义。 -
大对象的分配请求会直接交给
mheap
。 -
mheap
会找到能容纳这个大对象的最小的连续页数,并直接分配。这个过程需要加mheap
全局锁。
0.4 四、 内存回收与 GC 的关系
-
Go 语言中,内存的释放是自动的,由垃圾回收器 (GC) 完成。
-
当 GC 的清理 (Sweeping) 阶段执行时,它会扫描
mspan
,找出其中哪些 object 是不再使用的“垃圾”。 -
这些垃圾 object 所占用的 slot 会被标记为空闲,并被回收到
mspan
自己的freelist
中。 -
如果一个
mspan
中的所有 object 都被回收了,这个mspan
就变成了完全空闲状态。它会被从mcache
归还到mcentral
。 -
如果
mcentral
发现一个mspan
长时间空闲,它可能会将其归还给mheap
,mheap
可以将这些连续的空闲页合并成更大的内存块,或者在适当的时候通过madvise
等系统调用将物理内存归还给操作系统。
0.5 总结
Go 的内存分配器是一个为高并发而生的精密系统。其核心优势在于:
-
分层缓存:通过
mcache
,mcentral
,mheap
的三层结构,将内存请求逐级处理。 -
无锁分配:绝大多数的小对象分配都可以在无锁的
mcache
中快速完成,这是 Go 并发性能的关键。 -
按规格管理 (Size Class):避免了内存碎片,提高了内存利用率和分配效率。
-
与 GC 协同:分配器和垃圾回收器紧密配合,高效地重用已回收的内存。
0.6 REF.
https://golang.design/under-the-hood/zh-cn/part2runtime/ch07alloc/basic/ https://goog-perftools.sourceforge.net/doc/tcmalloc.html https://mp.weixin.qq.com/s/2TBwpQT5-zU4Gy7-i0LZmQ