Golang 内存分配
Go 的内存分配器是其高性能并发能力的核心基石之一。它并非直接向操作系统申请和释放每一次内存,而是实现了一套高效、分层的内存管理机制。其设计深受 Google 自家的 TCMalloc (Thread-Caching Malloc) 影响,核心思想是通过多级缓存来减少锁的竞争,从而提高并发分配的性能。
一、 内存管理的层次结构
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进行分配。
-
二、 结构图
下图展示了 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
三、 内存分配流程
流程总览
核心流程类似于读多级缓存的过程,由上而下,每一步只要成功则直接返回. 若失败,则由下层方法兜底.
对于微对象的分配流程:
(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)步.
根据要分配的对象大小,流程分为三类:
A. 微对象分配 (Tiny Objects, size < 16B)
- 为了节省空间和对齐,小于 16 字节的对象会先被分配到一个 16 字节的块中,然后在这个块里进行指针偏移来分配。这个 16 字节的块本身还是来自于
mcache的 16B size class。
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。
C. 大对象分配 (Large Objects, size > 32KB)
-
大对象不会通过
mcache和mcentral进行分配,因为缓存小对象才有意义。 -
大对象的分配请求会直接交给
mheap。 -
mheap会找到能容纳这个大对象的最小的连续页数,并直接分配。这个过程需要加mheap全局锁。
四、 内存回收与 GC 的关系
-
Go 语言中,内存的释放是自动的,由垃圾回收器 (GC) 完成。
-
当 GC 的清理 (Sweeping) 阶段执行时,它会扫描
mspan,找出其中哪些 object 是不再使用的“垃圾”。 -
这些垃圾 object 所占用的 slot 会被标记为空闲,并被回收到
mspan自己的freelist中。 -
如果一个
mspan中的所有 object 都被回收了,这个mspan就变成了完全空闲状态。它会被从mcache归还到mcentral。 -
如果
mcentral发现一个mspan长时间空闲,它可能会将其归还给mheap,mheap可以将这些连续的空闲页合并成更大的内存块,或者在适当的时候通过madvise等系统调用将物理内存归还给操作系统。
总结
Go 的内存分配器是一个为高并发而生的精密系统。其核心优势在于:
-
分层缓存:通过
mcache,mcentral,mheap的三层结构,将内存请求逐级处理。 -
无锁分配:绝大多数的小对象分配都可以在无锁的
mcache中快速完成,这是 Go 并发性能的关键。 -
按规格管理 (Size Class):避免了内存碎片,提高了内存利用率和分配效率。
-
与 GC 协同:分配器和垃圾回收器紧密配合,高效地重用已回收的内存。
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