03 内存分配器:从 tcmalloc 到 size class,小对象为什么快

开篇提问

先抛一个你可能答不上来的问题:

你在 Go 里 make([]byte, 16) 和 make([]byte, 1<<30)(1GB),走的是同一条分配路径吗?这两个操作,谁的”单位成本”更高?

直觉上你会说:分配大的肯定更贵。但答案没这么简单——小对象虽然绝对值便宜,但单位字节的成本往往比大对象贵得多。为什么?

因为小对象分配要频繁地、精确地处理”我从哪块内存里抠一小块出来”这个难题,而大对象直接向操作系统要一整块就行。这个反差,就是 Go 内存分配器存在的全部理由。

这一章,我们拆开 Go 内存分配器的每一层,看看它是怎么把”频繁分配小对象”这件事做到极致的。


子主题一:为什么不能直接用 malloc

先回答一个前提问题:Go 为什么不直接用 C 的 malloc?

malloc 是一个通用分配器,它不知道你的分配模式,只能假设”任何大小、任何生命周期、任何线程都可能来”。为了通用,它有全局锁或复杂的无锁结构,有合并空闲块、有 best-fit 搜索……这些开销,对”每秒分配百万个小对象”的 Go 来说,是不可接受的。

Go 的负载特点非常鲜明:

  1. 小对象占绝大多数(指针、字符串头、小切片、接口值……);
  2. 分配极频繁(一个请求可能触发成千上万次分配);
  3. 生命周期两极化(要么瞬间死,要么活很久)。

针对这种负载,Google 早就造过一个专门的分配器——tcmalloc(Thread-Caching Malloc),它给每个线程一个本地缓存,从缓存里分配小对象几乎不需要锁。Go 的分配器,就是 tcmalloc 思想的 Go 化实现。

所以第一个关键认知:Go 的内存分配器是为”海量小对象 + 高并发”这个特定负载设计的,不是万能的。 它的精妙,全在”如何让高并发下的小对象分配尽量无锁、尽量快”。


子主题二:size class——把大小”取整”到固定档位

分配器的第一个核心思想:不精确分配,而是把所有请求大小”归并”到有限个档位上。

比如你请求 17 字节,分配器不给你恰好 17 字节,而是给你 24 字节(最近的档位);你请求 100 字节,给你 112 字节。这些档位,就叫 size class。

为什么这么做?三个理由:

第一,消灭外部碎片。 如果每个对象都精确分配,释放后会留下各种奇怪大小的空洞,时间一长内存碎成渣。固定档位让所有对象都落在一组标准尺寸上,同一档位的对象互相替代,空洞就能被复用。

第二,简化管理和查找。 有固定档位,分配器就能用一张表(size class 表)直接查”请求大小 → 档位 → 从哪个 mspan 取”,不需要在空闲链里搜索,O(1)。

第三,方便复用内存块。 同一档位的对象,分配和释放可以走同一套快速路径,一个”位图标记”就能管理一批对象的占用状态。

Go 的 size class 有几十档(从 8 字节起步,一路到 32KB 左右,具体档位和档位数量随版本微调,不必背),每一档对应一个对象大小。超过 32KB 的大对象,直接走另一条”大对象”路径(子主题十讲)。

而”为什么档位是 8、16、24、32、48…这样歪歪扭扭的一串数,而不是等差或等比”,本身就是一门学问——我们专门开一节讲。


子主题三:span、mcache、mcentral、mheap——四层结构

Go 分配器的四层结构,是这个领域最经典的设计之一。从下往上:

mheap(堆,全局唯一):整个进程的内存来源。它向操作系统要内存(通过 mmap),然后切成一块块 span。大对象(>32KB)直接从这里分配。

mspan(span,内存的基本管理单元):一块连续的页(page),默认一页 8KB,一个 span 通常包含 1~若干页。一个 span 专门服务一个 size class,里面的对象都是同一大小,用位图标记哪些被占用。

mcentral(中心缓存,每个 size class 一个):mcentral 是”全局共享池”和”线程本地缓存”之间的中间层。它有全局锁,维护两个 span 列表——有空位可分配的、和已满的。当某个 P 的本地缓存空了,就来这里拿 span。

mcache(线程本地缓存,每个 P 一个):这是分配器最快的一层。每个 P(调度器里的逻辑处理器,后面 07 章细讲)有一个 mcache,里面为每个 size class 缓存了一个”正在使用的 span”和一个空闲对象列表。从 mcache 分配,不需要加锁——因为每个 P 的 mcache 是独占的。

分配流程(小对象)一句话:mcache 有 → 直接拿(无锁);mcache 空 → 从 mcentral 补一个 span(有锁,但频率低);mcentral 空 → 从 mheap 要页(更低频)。

这就是 tcmalloc 的精髓:把”高频的无锁本地分配”和”低频的加锁全局补货”分开。 绝大多数的分配,都命中第一层,无锁、极快。


子主题四:为什么每个 P 一个 mcache,而不是每个线程/goroutine

这个问题很关键,能看出 Go 设计者的清醒。

tcmalloc 原本是”每个线程一个缓存”。Go 也曾经是”每个M(操作系统线程)一个缓存”。但后来改成”每个 P 一个缓存”。为什么?

因为在 Go 的 GMP 调度模型里(07 章细讲),goroutine 会在不同的 M 上飘来飘去,但同一时刻一个 M 只会绑定一个 P。如果缓存跟 M 走,goroutine 从一个 M 换到另一个 M 时,缓存就”凉”了——本来热的缓存因为换线程而失效。

反过来,P 是相对稳定的”执行资源”:一个 goroutine 在生命周期里,绝大多数时间都待在某个 P 上,只有发生阻塞、抢占时才可能换 P。所以缓存跟 P 走,能最大化缓存的命中率。

这是一个非常精妙的观察:缓存应该绑定在”最稳定的执行上下文”上,而不是”变化最频繁的执行单元”上。 线程会频繁切换,goroutine 更频繁,而 P 的数量固定(默认等于 CPU 核数)、相对稳定——把缓存放在 P 上,命中率最高。


子主题五:大对象和微小对象

大对象(>32KB): 不走 size class,直接从 mheap 分配一个专用 span。它享受不到 mcache 的无锁快路径,但因为大对象分配频率低,这点开销无所谓。大对象的生命周期也往往很长(大的缓冲区、大的数组),GC 对它们的处理也不同(后面 04 章讲)。

微小对象(tiny object,<16 字节): Go 对极小对象做了额外优化——tiny allocator。因为很多分配小得可怜(一个 bool、一个指针、几个字节的字符串头),如果每个都独占一个 size class 槽位,浪费严重。tiny allocator 把多个极小对象塞进同一个槽位,只要它们的总大小不超过 16 字节。但有个前提:这些对象必须不含指针(否则 GC 没法区分槽里的哪部分是指针、哪部分是数据)。

tiny allocator 是”空间换时间”里少见的”时间换空间”——它牺牲了一点点管理的简单性,换来极小对象内存占用的大幅下降。这一层,最能体现 Go 设计者”抠到字节级”的细致。


子主题六:释放与复用——不是 free 了就还系统

你可能以为对象被 GC 回收后,内存就还给操作系统了。大部分时候不是。

Go 分配器的哲学是:内存一旦从操作系统要进来,就尽量留在进程里复用,不轻易还回去。 原因很简单:向操作系统 mmap/munmap 很贵(涉及内核、页表、TLB),频繁还了又要,不如留在手边。

所以释放的过程是”逐层回退”的:对象释放 → 回到 mcache 的空闲列表 → mcache 空闲太多 → 回退一部分给 mcentral → mcentral 空闲太多 → 回退 span 给 mheap → 只有 mheap 空闲页攒够一定量、且系统有内存压力时,才 munmap 还给操作系统。

这意味着一个常见现象:你的 Go 程序 RSS(常驻内存)涨上去之后,往往不会立刻降下来,即使 GC 已经把对象回收了。这不是内存泄漏,是分配器”持币观望”——它把内存留着复用。理解这一点,能帮你在排查”内存看起来一直很高”的问题时,少走很多弯路。

但”归还”这件事具体是谁干的、什么时候干、用什么系统调用干,子主题十一会专门拆开讲。


子主题七:size class 档位为什么是”歪”的——等差不行,等比也不行

回到那条档位序列:8、16、24、32、48、64、80……你第一眼看到它的反应应该是:这什么鬼?前面规规矩矩加 8,后面突然跳 16,再往后干脆乱跳。

好,那我们假装自己是设计者,来试错。

方案 A:纯等差,步长 8 字节

从 8 到 32768,步长 8,一共多少档?四千多档。

四千多档意味着什么?

第一,mcache 会被撑爆。 每个 P 的 mcache 里,得为每个 size class 留一个槽位(指向正在使用的 span)以及一些配套字段。四千档 × 每个 P × 几十个核,光是这份”空架子”就要吃掉可观的内存——而其中绝大多数档位,你的程序一辈子都不会用一次。这是纯浪费。

第二,mcentral 也有四千多个,每个带一把锁、两个 span 链表。虽然锁竞争不激烈,但元数据本身的体量、初始化成本、调试时的可读性,全都是代价。

第三,也是最致命的:档位越多,每个 span 的”服务对象种类”越分散。 span 是按档位专用的,一个 8KB 的 span 只服务某一个档位。档位一多,同一个档位在同一时刻的需求量就少,很可能一个 span 里只用了三个对象、剩下全空着,但你又不能把这些空位给别人用(尺寸不匹配)。档位细分 → 单个 span 的填充率下降 → 内存被”占着不用”的比例上升。

这个现象有个名字,叫内部碎片的另一面:span 级浪费。你以为等差细分降低了对象级浪费(请求 17 给 17,零浪费),实际上把浪费转移到了 span 级(一堆半空的 span)。

方案 B:纯等比,比如每次 ×1.5

等比有个绝妙的性质:相对浪费率是恒定的。请求 100 字节,档位是 128,浪费 22%;请求 10000 字节,档位是 15000,浪费 33%——不管在哪个数量级,浪费比例都在同一个量级上,不会失控。

而且档位数是 O(log N) 的,从 8 到 32768 只要十来档。太省了。

那为什么不用?把等比序列列出来你就懂了:8、12、18、27、40、61、91、137、205……

问题一:全是奇怪的数字,违反对齐。 现代 CPU 和 GC 都吃对齐。对象按 8 字节对齐时,指针位图、原子操作、cache line 的处理全都简单。18 字节这种档位,意味着对象可能跨 cache line、跨字边界,GC 的指针位图也要处理尴尬的偏移。对齐不是”优化”,在很多地方它是正确性前提。

问题二:小尺寸段浪费率爆炸。 8 和 12 之间只差 4 字节,但你请求 9 字节就得给 12——浪费 25%。而小对象恰恰是数量最庞大的那一群(想想你程序里有多少个指针、多少个 int、多少个 string header)。在数量最多的地方给出最高的浪费率,这是最坏的组合。

问题三(反过来):大尺寸段步长过大。 等比 ×1.5 在 32KB 附近,一跳就是上万字节。你请求 22KB,给你 33KB,一次浪费 11KB,还是连续的物理内存。这已经不是”浪费率”的问题,是”绝对值”的问题——11KB 够装下一千多个小对象了。

方案 C:Go 的真实答案——分段混合

所以真实的档位表是分段的:

  • 小尺寸段(大约 1KB 以下):近似等差,步长就是 8 字节(也就是对齐粒度本身)。这一段里,档位数量的代价可以接受(一百多档),换来的是”小对象的浪费率压到最低”。
  • 中/大尺寸段:切换到近似等比,步长随尺寸变大而变宽。这一段里,对象数量本来就少,浪费率高一点无所谓,但档位数必须控制。

这背后的真正约束条件,是”最坏相对浪费率有一个上界”。 设计者不是先定”步长是多少”,而是先定”我最多能容忍百分之十几的浪费”,然后反过来问:在保证浪费率不超过这个上界的前提下,每一段能用多大的步长? 答案自然就是”小处密、大处疏”——因为同样的绝对步长,在小尺寸上换算成的相对浪费率大得吓人,在大尺寸上小得可以忽略。

这就是那条歪歪扭扭的序列的全部秘密:它不是算出来的数列,是”浪费率上界”这条曲线在对数坐标系上的采样。

一个生活化类比:衣服尺码

服装厂的尺码是 S / M / L / XL,再来个 XXL。为什么不是每 1 厘米一档?

因为每 1 厘米一档的话,仓库里得备几十种规格,每种规格的库存都卖不动(对应:四千个 mcentral,每个 span 填充率低),成本全压在库存上。

那为什么不是 S 和 XXXXXL 两档?因为小码差一点就完全穿不上(对应:小对象浪费率爆炸)。

真实的尺码表是:贴身的、对尺寸敏感的部位用细档(领围、袖长),宽松的、不敏感的部位用粗档(衣长)。而”敏感”这件事是相对的——同一个 5 厘米的差,放在 40 厘米的领围上是灾难,放在 120 厘米的衣长上无所谓。

分配器的档位设计,和裁缝的尺码表,是同一个道理。

还有第二个维度:span 的页数也不是随便选的

很多人以为 size class 只是一张一维表(大小 → 档位)。其实它是二维的:每个档位还要决定”服务它的 span 由几个页组成”。

为什么要挑页数?因为 span 是整数个页,页是 8KB。如果一个档位的对象大小是 3KB,一个 1 页的 span(8KB)只能装 2 个,剩下 2KB 永远用不上——尾部浪费 25%。如果换成更大的 span 或者调整对象大小,尾部浪费就能降下来。

所以分配器在生成档位表时,是联合优化的:对每个候选的对象大小,挑一个让”span 尾部剩余”尽量小的页数组合。你看到的那个”歪”序列,其实是”对象大小”和”span 页数”两个变量互相迁就之后的结果。

记住这一点:size class 不是一个数列,是一张在”浪费率上界 / 档位数量 / 对齐要求 / span 尾部浪费 / mcache 元数据开销”五个约束下求出来的工程解表。 想”优化”它之前,先想清楚你优化的是哪个维度、牺牲的是哪个维度。


子主题八:span 的位图——为什么分配和回收能是 O(1)

问一个具体的问题:一个 span 里有几百个一样大的对象槽位,你怎么知道哪个空着、哪个被占用?

新手的第一反应是链表——把所有空闲槽位串成一个 free list,分配就是取链表头,O(1);释放就是插回链表头,也是 O(1)。听起来完美。

那 Go 为什么不用链表,而用位图?

链表的三个暗坑

第一,链表要占用对象本身的空间。 空闲槽位里得存一个 next 指针。对于 8 字节的槽位,你放不下指针——方案直接不可行。

第二,链表破坏局部性。 free list 的遍历顺序是”释放顺序”,和内存地址顺序完全无关。你连续分配十个对象,它们可能分散在 span 的十个角落,甚至跨越几个页。而位图扫描天然是按地址顺序的,连续分配的对象在内存里也连着——对 CPU cache 极其友好。

第三,也是最关键的:链表应付不了 GC。 GC 需要”遍历这个 span 的所有对象,判断哪些还活着”。用 free list,你只知道”哪些是空的”,但你不知道”哪些是占用的且可以被检查”——除非再遍历一遍整个 span 的所有槽位。而位图天然就是”全体槽位的可枚举视图”,GC 扫位图就能枚举存活候选。

于是 Go 的答案是:每个 span 带一张位图,一个 bit 对应一个槽位。

两份位图:allocBits 和 gcmarkBits

这里有个精妙得值得单独拎出来讲的设计:一个 span 里有两份位图,不是一份。

  • allocBits:分配视角的账本。某一位为 1,表示这个槽位已经被分配出去了(不管它还活不活着)。
  • gcmarkBits:GC 视角的账本。某一位为 1,表示这一轮 GC 标记阶段发现这个槽位里的对象还被引用着。

为什么要两份?一份不够吗?

不够。因为分配和标记是并发进行的。

想象一下图书馆:allocBits 是”借出登记簿”,记录哪些书被借走了;gcmarkBits 是”年度盘点清单”,记录这次盘点哪些书确实还在使用中。

盘点员(GC)在馆里一格一格走的时候,读者(你的 goroutine)还在不停地借书、还书。如果盘点员直接在借出登记簿上打勾,那盘点期间新借出的书、新还回来的书,和盘点员的勾就混在一起了——这账没法对。所以盘点员必须拿一本新本子记账,盘完之后再和借出登记簿对照。

对照(清扫阶段)的逻辑极其简单:

  • 在 allocBits 里是 0 的(没分配出去):不用管,本来就是空的。
  • 在 allocBits 里是 1、在 gcmarkBits 里也是 1 的:活着,保留。
  • 在 allocBits 里是 1、在 gcmarkBits 里是 0 的:没人引用了,回收——把 allocBits 的那一位清成 0,槽位空出来。

最漂亮的一手:账本互换,指针一换 O(1)

清扫做完了,gcmarkBits 是不是要清零,准备下一轮?

朴素做法:遍历位图清零。span 里几百个槽位,几百次写。每个 span 都来一遍,成本不低。

Go 的做法是:交换指针。

因为清扫完了之后,“这一轮的标记结果”恰好就等于”下一轮开始时谁是被占用的”(活着的留下,死了的清空)。所以:

  1. 把 gcmarkBits 和 allocBits 两个指针交换——gcmarkBits 变成新的 allocBits。
  2. 新的 gcmarkBits(也就是原来那本旧的 allocBits)清零,准备下一轮。

一次指针交换,O(1)。没有任何拷贝。

这就是我说的”该硬核时一句废话没有”:一次赋值,取代几百次内存写。而这种技巧之所以能用,是因为底层数据结构(两份同构的位图)从一开始就是为这个操作设计的。数据结构的形状,决定了算法能达到的上界。

分配快路径:把 64 个位一次性搬进寄存器

再往下钻一层。“从 allocBits 里找一个空位”这个操作,怎么做才最快?

朴素做法:从 freeindex(上次分配的位置)开始,一位一位往后找 0。最坏 O(n)。

Go 的做法妙在预取:span 里有个叫 allocCache 的字段,它一次性从 allocBits 里取出 64 个位(对齐后的一段),存成一个机器字。之后分配对象时:

  • 用一条找最低位 0的指令(现代 CPU 有对应的位指令,编译器会把它映射过去)在 allocCache 里直接定位空位;
  • 定位之后,把 allocCache 消费掉(把用掉的位清掉 / 逻辑上右移),freeindex 前进;

只有当这 64 个位全用完了,才回头再去 allocBits 里取下一批。

也就是说:绝大多数分配操作,根本不碰 allocBits 那块内存,只是在一个 CPU 寄存器里做一次位运算。这就是小对象分配能快到”几十条指令”量级的原因之一。

类比一下:链表方案像”每次有人来借伞,你就跑去伞架前从头看一遍哪把在”;位图 + allocCache 像”你一次性把 64 把伞的在架情况记在一张便签上,之后 64 个人来借,你都只看便签”。

释放为什么也快:不需要合并

最后补一个容易忽略的点:释放一个槽位,就是清掉一个 bit。完了。

不需要检查”相邻槽位是不是也空着、要不要合并成大块”。因为同一 span 里所有槽位一样大,合并没有任何意义——一个空槽位本来就能直接给下一个同样大小的请求用。

这正是 size class 换来的最大红利之一:消灭了通用分配器里最恶心的一段逻辑——空闲块合并。 malloc 要在 free 时看前看后、维护边界标记、处理各种合并情形;Go 一个 bit 搞定。你把复杂性挪到哪去了?挪到了”必须按尺寸分档、每个 span 只服务一个档位”这个约束上。又是那个老套路:用约束换简单。


子主题九:tiny allocator 再深入——它省了什么,又偷偷坑了你什么

子主题五讲了 tiny allocator 的基本思路(小于 16 字节、无指针的小对象合并塞进同一个槽位)。现在补上那些”知道之后你会重新审视自己代码”的细节。

为什么门槛是 16 字节?

因为最小的 size class 档位是 8 字节。

你要一个 bool(1 字节),走普通路径会得到一个 8 字节槽位,浪费 87.5%。你要一个 int64,正好 8 字节,零浪费。你要一个 12 字节的结构体,落到 16 字节档,浪费 25%。

看清楚规律:浪费率最高的是最小、最多的那批对象。 而这类对象在真实 Go 程序里多到离谱——每个 boxed 的基本类型、每个闭包捕获的小变量、每个 struct 里的标志位。

所以 tiny allocator 专门开一条路:把这些小家伙塞进同一个 8 字节(或 16 字节)槽位,只要总大小不超阈值。

具体怎么塞:一个游标在跑

mcache 里维护着一个专门的”当前 tiny 槽位”和它内部的偏移游标。分配 tiny 对象时的逻辑是:

  1. 看当前槽位剩下的空间够不够放下这个对象(还要满足对齐);
  2. 够:直接把对象放在游标处,游标前进。全程不碰 span、不碰位图——比普通小对象路径还快;
  3. 不够:去拿一个新的槽位,游标归零,重新放。

注意第 2 步的”还要满足对齐”。一个 8 字节的对象必须 8 字节对齐,所以游标要先向上取整到对齐边界。这个取整会产生小空洞——槽位里可能出现几个字节永远用不上。分配器接受了这点浪费,因为换来的是”省掉一整个槽位”。

这就是 tiny allocator 的第一层权衡:允许槽位内部有碎片,换取槽位数量的减少。

为什么必须是”无指针”?三个理由,第三个最要命

子主题五的回答是”GC 分不清哪部分是指针”。这个答案对,但只说了三分之一。完整的理由有三层:

理由一:指针位图的粒度是字,不是字节。

Go 的 GC 靠堆上的指针位图(heap bitmap)知道”这个字里是不是指针”。位图是按机器字(8 字节)记录的,不是一个字节一个标记。

所以一个 8 字节槽位里塞了两个 4 字节对象,其中一个含指针——这个 8 字节字在 GC 眼里就是”含指针”。GC 会去读整个 8 字节,把另一个对象的 4 字节数据当成一个指针去追踪。

后果轻则”误保活”(那个 4 字节的 int 恰好长得像个堆地址,GC 就以为有个对象还被引用着,不敢回收——内存泄漏);重则直接崩(追到一个非法地址,或者误改别人的对象)。

理由二:写屏障会失效。

Go 的并发 GC 靠写屏障捕获”标记期间新产生的指针写入”。而写屏障的触发是基于”这个 span 是否含指针”来决定的——含指针的 span 才会插屏障,不含指针的 span 直接跳过。

如果 tiny 槽位里能藏指针,而这个槽位又被标成”不含指针”(因为它是从 noscan 的 span 里来的),那么对它的写入就不会触发写屏障。GC 会漏掉这个指针,从而错误地回收一个还活着的对象。这是正确性错误,不是性能问题。

理由三(最要命):tiny 对象根本没有独立的账,无法单独回收。

这一条很少有人讲。位图里的一个 bit 对应一个槽位,不是对应一个 tiny 对象。三个 tiny 对象挤在同一个槽位里,共享同一个 bit。

这意味着:回收的粒度是槽位,不是对象。 只要这个槽位里还有任何一个 tiny 对象活着,整个槽位的 bit 就不能清——另外两个早就没人用的 tiny 对象,就跟着一起赖着不走。

你的 tiny 对象,实际生命周期 = 它所在槽位里”活得最久的那个对象”的生命周期。

现在把理由二和理由三合起来想:如果允许 tiny 槽位放指针,那这个指针会把一个本该死掉的对象额外钉住一段不确定的时间,直到整个槽位被回收。这就是一个隐蔽的、由分配器结构导致的逻辑泄漏。

所以”无指针”这条限制不是洁癖,是正确性 + 可回收性的双重刚需。

这条限制对你写代码的真实影响

  • new(int)、var p *int = &x:拿不到 tiny 优化,走 8 字节档。
  • []byte 转 string、小 struct、布尔标志、小整数包装:能拿到。
  • 一个含指针的小 struct(比如 2 个字段各 8 字节但塞在 16 字节里):只要含指针,就走普通路径。
  • 经典坑:map[bool]struct{}、map[int]bool 这类写法,value 是 bool,看似无害,但 map 的内部结构决定了它有自己的 bucket 分配策略,tiny allocator 帮不上多少忙——想省内存得从数据结构层面动手,别指望分配器。

一句话总结:tiny allocator 是一个”你什么都不用做就能拿到,但一旦你往对象里塞了指针就自动失效”的优化。 想稳定吃到它,就让你的极小对象保持”纯数据”。


子主题十:大对象路径(>32KB)——为什么它不配进 mcache

子主题五说”大对象直接从 mheap 分配”,太轻描淡写了。这一节把它掰开。

先回答”为什么不进 mcache”

mcache 之所以成立,靠的是三个隐含前提:

  1. 同档位的对象会被反复分配释放(所以缓存一个 span 是划算的);
  2. 一个 span 能装下很多个对象(所以缓存一个 span 能服务很多次分配);
  3. 分配频率高(所以无锁快路径的收益大)。

大对象把三条全踩碎了:

  • 大对象大小各异,没有”档位”可言,缓存 A 尺寸的 span 对 B 尺寸的请求毫无用处;
  • 一个几十 KB 的对象,一个 span 里可能只装得下一个,缓存这个 span 等于”拿一块大内存长期占着”;
  • 大对象分配频率低(一次请求里能有几次 1MB 的分配?),无锁省下的那点时间,占比微乎其微。

反过来算账:如果给大对象也做每 P 缓存,那么每个 P 都会攥着几个大块内存不放。8 核机器 × 每 P 缓存若干个几十 KB 的 span,就是一大笔”看得见但用不上”的内存。而且这些内存还额外抬高了 RSS,让运维的告警更难看。

结论:给大对象做本地缓存,是”用内存占用来换一个本来就无所谓的时间收益”,纯亏。 直接走全局,简单、省内存。

路径长什么样

请求一个大于 32KB 的对象,流程大致是:

  1. 把请求大小向上取整到页的整数倍(页 8KB)。要 40KB,就要 5 个页。
  2. 从 mheap 的页分配器里找一段连续的空闲页。
  3. 找不到足够长的连续页 → 向操作系统要新的堆空间(mmap 扩展),再来一次。
  4. 把这段页包成一个 span,标记为大对象专用,返回起始地址。

注意第 2 步的”连续”两个字——大对象要的是物理上连续的一段虚拟地址。这也是为什么大对象分配会有自己的麻烦:外部碎片。程序运行久了,堆里到处是小空洞,你要 10 个连续页,可能总空闲页有 100 个但就是凑不出连续的 10 个。

为了不退化成”线性扫描整个堆找空洞”,mheap 的页分配器维护了多级摘要结构(可以理解为”按不同粒度汇总空闲页数”的树/多层位图):先粗查哪一大片区域有足够的连续页,再逐级下钻到具体页。搜索成本是对数级甚至常数级的,而不是”扫遍整个堆”。

顺带说一句:这个多级摘要结构和 Linux 内核的伙伴系统(buddy allocator)思路是相通的——用”分层的空闲信息”把”找连续空间”这件事变成几次查表。大对象释放时,相邻空闲页会被合并回大的连续块,也正是为了对抗外部碎片。

一个反直觉的事实:make([]byte, 1<<30) 很快,但写一遍就慢了

这是面试和实战里都爱考的点。

b := make([]byte, 1<<30) // 1GB,几乎瞬间返回
for i := range b {
    b[i] = 1 // 这一行慢得多
}

为什么?

因为 Go(和操作系统)懒。

分配 1GB 的时候,分配器要的是虚拟地址空间:在页表里登记”这段地址归你了”,仅此而已。真正的物理内存,内核一页都还没给你。

等你在第二行真正写入时,CPU 访问一个还没映射的页 → 触发缺页异常 → 陷入内核 → 内核找一个空闲物理页、挂进页表、清零、返回。8KB 一页(或者更细的粒度),1GB 就是十几万次缺页异常。

分配 1GB 的”成本”,被推迟并摊到了你第一次写它的那一刻。 这就是为什么这两行的耗时差着一个数量级。

顺带一个推论:你的程序 RSS 是在”写入”的时候涨的,不是在”分配”的时候涨的。 你 make 一个巨大的 slice 然后只用了前 1KB,RSS 只会涨一点点。这点在排查内存问题时极其有用——“分配了 1GB”和”占用了 1GB”是两件完全不同的事。

零值保证的代价:什么时候必须 memset

Go 语言规范保证:新分配的内存是零值。

但”清零”不是无脑 memset 全部。分配器在 span 上记了一个标记:这一块页是不是需要清零。

  • 从操作系统刚要来的新页:内核保证给的是零页(安全考虑,不能把别的进程的残留数据给你),所以不需要清零,直接标记干净。
  • 复用的页(上次用过的 span 回收回来):里面有脏数据,必须清零才能交给新对象。

这个标记让分配器跳过了大量无意义的 memset。1GB 的分配如果真要 memset 一遍,那才是灾难——而它之所以快,正是因为”新页 + 不清零 + 惰性映射”三件事叠在一起。

大对象与 GC

大对象 span 也是标记-清扫,但有几个特点值得记:

  • 一个 span 里就一两个对象,位图管理退化成了”整块是否存活”,没什么好扫的;
  • 释放时是整块页还给 mheap,并且可以和相邻的空闲页合并——这是大对象唯一”贡献”给系统的好处:它释放得干净;
  • 大对象是堆高水位的主要贡献者。pprof 里经常看到”某个 []byte 占了 60% 的堆”——那基本就是一两个大缓冲区。优化这种问题的手段是复用(sync.Pool、预分配、分块),而不是指望分配器。

一句实践建议:大对象不怕”分配慢”,怕”分配得多”。一次分配 + 长期复用(池化),远比反复分配释放划算——因为反复大对象分配会同时压榨页分配器、制造外部碎片、抬高 GC 的堆目标。


子主题十一:scavenger——内存到底怎么还给操作系统

子主题六留下的问题:既然 mmap/munmap 很贵,那 Go 到底会不会把内存还给系统?什么时候还?用什么手段还?

答案是:会,但不是你想象的那种”还”。 干这件事的是一个常驻的后台角色,通常叫 scavenger(清扫者 / 拾荒者)。

先纠正一个直觉:不是 munmap,是 madvise

大多数人以为”归还内存 = munmap”。Go 主要不是这么干的,它用的是 madvise。

这两个系统调用的区别,值得用一个狠一点的类比:

  • munmap 是退租搬家:把房子退了,家具全搬走,钥匙交回去。虚拟地址区间都没了。下次你要内存?重新找房、重新签合同、重新装修。代价是:VMA(虚拟内存区域)的拆分与合并、页表的重建、以及最贵的 TLB 失效广播(多核之间要同步通知”这块映射没了”)。
  • madvise 是把房间清空,钥匙留着:你告诉内核”这屋里剩下的东西我不要了,你拿走吧”。内核把物理页回收,但虚拟地址区间原封不动。下次你访问这块地址,触发一次缺页,内核再给你一个全新的零页。

看明白了吗?madvise 只掏空内容,不拆结构。 对 Go 来说这太重要了:Go 的堆是按 arena(大块连续区域)组织的,span 元数据、页分配器的摘要结构都是按固定地址索引的。如果到处 munmap,堆的地址空间会被捅成筛子,元数据管理会复杂到爆炸。

用 madvise,地址空间的骨架永远完整,只有”内容”在虚实之间来回切换。代价小得多,所以可以高频、细粒度地做。

两种 madvise 策略:立刻还 vs 赊账

Linux 上常用的两个选项:

  • MADV_DONTNEED:内核立刻回收物理页,RSS 立刻下降。下次访问保证拿到的是零页。
  • MADV_FREE:内核可以回收,但不着急。如果系统内存不紧张,这些页还留在那,你的 RSS 不会立刻降。只有真的有压力时才被拿走。

听起来 MADV_FREE 更聪明(“反正不急”),但它有个致命的运维副作用:RSS 不再反映真实的内存压力。你的容器看着 RSS 4GB,其实有 3GB 是可回收的,但监控系统看不出来,k8s 的 OOM Killer 也看不出来——它可能先把你杀了,而不是先去回收那些”名义上还占着”的页。

Go 在这一点上摇摆过:某些版本为了性能选了 MADV_FREE,后来又因为 RSS 统计失真、容器环境下行为不可预测,又回到了 MADV_DONTNEED 的路线(具体版本差异不必记,但这个来回本身很值得品味:它说明了”性能优化”和”可观测性”打架时,Go 团队最终站在了可观测性这边。这是 Go 一贯的价值观:可预测 > 纸面最优)。

scavenger 什么时候动手?

它是一个低优先级的后台 goroutine(或者说由运行时调度的一个周期性任务),做的事大致是:

  1. 周期性醒来,看看**堆的”目标大小”**是多少。这个目标受 GC 的堆目标(下一轮 GC 触发阈值)和一个软上限影响。
  2. 计算”我应该保留多少空闲页作为缓冲”。不是把所有空闲页都还掉——留一部分是为了避免马上又要。
  3. 扫描 mheap 的空闲页,把超出缓冲额度、且已经空闲了一段时间的页挑出来,madvise 掉。

注意第 3 步里的”已经空闲了一段时间”。这是一个滞后(hysteresis)设计,目的是防抖动。

为什么不能还得太积极:抖动是真实的性能杀手

想象一个最笨的策略:只要有页空闲,立刻 madvise 还给系统。

然后你的程序马上又要分配,分配器发现页没了,触发缺页异常,内核要重新找页、清零、挂页表……

一次 madvise + 一次缺页清零的成本,远高于”把页留在手里等着复用”。 如果你的程序在某个区间反复横跳(malloc → free → malloc → free),积极的归还会让你的 CPU 全烧在”还内存、要内存、清零”上。这叫 thrashing(抖动)。

所以 scavenger 的两个核心参数是:保留多少缓冲(还的阈值)和等多久才还(滞后时间)。这两个值定得不好,要么内存白占着不还(运维骂你),要么 CPU 白烧在缺页上(用户骂你)。

这就是分配器领域永恒的那道权衡题的又一个实例:空间 vs 时间,在这里具体化成”RSS vs 缺页次数”。

你能干预的旋钮

Go 给了你几个可以调的东西(名字记住,具体签名自己查文档):

  • GOGC:经典旋钮,控制 GC 触发的堆增长比例。它间接决定了堆目标,从而影响 scavenger 的保留额度。调小 → GC 更勤 → 堆更小 → 但 CPU 花在 GC 上更多。
  • GOMEMLIMIT(以及对应的运行时接口):给 Go 一个软内存上限。有了它,Go 会在接近上限时更激进地 GC,也会让 scavenger 更积极地归还。这是容器环境里最该设的一个东西——没有它,Go 完全不知道自己身处一个 512MB 的容器里,它会一直以为内存很宽裕。
  • 手动触发一次归还(runtime/debug 里的相关函数):立刻清扫 + 归还。典型用法是”处理完一个吃掉 2GB 的大请求之后,主动还一次”。但注意它的代价:它会同步地把空闲页全还掉,如果紧接着又来一个大请求,你就得重新走一遍缺页清零。

实践建议:优先用 GOMEMLIMIT(设成容器 limit 的七八成,留出非堆内存的余地),而不是依赖手动调用。GOMEMLIMIT 是”持续的、温和的、有滞后设计的”;手动调用是”一次性的、粗暴的、可能引发抖动的”。


子主题十二:观测——MemStats 怎么帮你分清”真泄漏”和”持币观望”

来,一个真实的场景:

凌晨三点,告警响了:你的 Go 服务 RSS 4GB,容器 limit 是 5GB。 你睡眼惺忪地抓了个 pprof,看 inuse_space:只有 500MB。

谁在说谎?

都没说谎。 差的 3.5GB,是分配器手里”已经回收但没还给操作系统”的空闲内存。

这一节就教你:怎么用 runtime.MemStats 的字段,把这件事一眼看穿。

MemStats 的堆字段:一张包含关系图

先建立心智模型。堆相关的字段是一个层层包含的结构,像一组同心圆:

HeapSys                      从 OS 申请来的全部堆地址空间
├── HeapInuse                已经切成 span、正在服务对象的部分
│   ├── HeapAlloc            对象真正占用的字节(你的真实用量)
│   └── (HeapInuse - HeapAlloc)  span 里的空槽位、没填满的 span
└── HeapIdle                 完全空闲、随时可以还给 OS 的 span
    ├── HeapReleased         其中已经把物理内存还给 OS 的部分
    └── (HeapIdle - HeapReleased)  还攥在手里的空闲内存

逐个说清楚:

HeapAlloc — 最重要、最该看的字段。当前活着的对象占了多少字节。它对应 pprof 里的 inuse_space。判断内存泄漏,看的就是它。

HeapInuse — 分配器手里”已切成 span”的内存。它 ≥ HeapAlloc,差值是 span 内部的空槽位(内部碎片)和还没填满的 span。这个差值如果长期很大,说明你的对象大小分布和 size class 对不上(后面细说)。

HeapIdle — 完全没被 span 使用的、可以归还的内存。它是”分配器手里的闲钱”。

HeapReleased — HeapIdle 中已经通过 madvise 把物理页还给 OS 的那部分。RSS 的下降,就发生在这里。

HeapSys — 总账,约等于 HeapInuse + HeapIdle。

于是有一个极其实用的近似公式:

RSS(堆部分) ≈ HeapInuse + (HeapIdle - HeapReleased)
            ≈ HeapAlloc + 内部碎片 + 攥着不还的空闲内存

看明白了吗?RSS 高但 HeapAlloc 低,说明钱压在后面两项上。 这不是泄漏,这是分配器的选择。

一个生活化类比:钱包、抽屉和银行

  • HeapAlloc = 你这个月真花出去的钱。
  • HeapInuse = 你从银行取出来、放在家里抽屉里的现金(包括还没花的那部分)。
  • HeapIdle = 抽屉里已经空出来的信封(本来装过钱,现在空着)。
  • HeapReleased = 你把钱存回银行了。
  • RSS = 银行认为你”占用”的规模(大致等于你取出来还没还回去的)。

你取了 4 万块出来,花掉 5 千,剩下 3.5 万放在抽屉里。你老婆(运维)看你银行账户,觉得你”花了 4 万”。你说”我只花了 5 千”。你俩都对,只是一个看的是取款额,一个看的是消费额。

HeapReleased 就是把钱存回银行的动作——只有做了这一步,银行账户的数字才会降下来。

判断流程:五步定位法

第一步:看 HeapAlloc 在每次 GC 之后的”谷值”。

不要看峰值,峰值会被”GC 还没来得及跑”污染。要看每一轮 GC 刚结束时 HeapAlloc 落到的那个低点,把这些低点连成一条线:

  • 这条线平稳 → 没有泄漏,你的对象总量是稳定的;
  • 这条线单调上升 → 真泄漏。有东西在持续地、不可逆地持有内存(典型嫌疑:全局 map 只增不减、slice 一直 append 不截断、goroutine 泄漏持有栈、未关闭的 timer/ticker、缓存没有淘汰策略)。

这一步能解决 90% 的”是不是泄漏”之争。

第二步:如果 HeapAlloc 稳,但 RSS 高 → 看 HeapIdle - HeapReleased。

如果差值巨大(几个 GB),那就是分配器持币观望,不是泄漏。这时候你的问题不是”内存泄漏”,而是”内存没还”。解决方向是子主题十一的旋钮:设 GOMEMLIMIT、调 GOGC,或者在大请求后主动触发一次归还。

第三步:如果 HeapInuse - HeapAlloc 长期很大 → 你在为内部碎片买单。

这是一个非常有价值、但几乎没人看的性能信号。它意味着:你的对象被 size class 向上取整浪费掉了,或者 span 没被填满。

最常见的原因:对象大小恰好卡在档位边界上方。比如你大量分配 33 字节的对象,档位给的是 48 字节,浪费 30%;改成 32 字节(哪怕只是调整一下 struct 字段顺序、把 padding 压一压),就落进 32 字节档,零浪费。

这是少数几个”改几个字节的结构体定义就能省下几百 MB”的优化点。 调整 struct 字段排列(大字段在前、小字段在后,减少 padding)、把 int64 和 int32 混排的顺序理一理,都可能让你的对象从 48 字节档掉回 32 字节档。

第四步:看 HeapAlloc / HeapInuse 的比值,估算”档位浪费率”。

比值越低,说明你付给 size class 的税越多。低于 0.7 就值得回去看看数据结构了。

第五步:HeapSys 涨了但 HeapAlloc 和 HeapIdle 都稳定?

那大概率是非堆内存(goroutine 栈、runtime 内部结构、cgo 分配、mmap 的文件映射)在涨。MemStats 里有一堆 XxxSys 字段(栈、span 元数据、mcache、GC 元数据等)可以看。goroutine 泄漏是这类的头号嫌疑——每个 goroutine 起步就是几 KB 的栈。

怎么采集:别只看一次快照

MemStats 是瞬时的,单点采样没意义。正确做法:

  • 定时(比如每 5~10 秒)读一次,把 HeapAlloc / HeapSys / HeapIdle / HeapReleased 打成时间序列;
  • 重点观察 GC 之后的谷值,而不是任意时刻的值;
  • 和 pprof 配合:pprof 告诉你是谁在分配(alloc_space 是累计分配量,inuse_space 是当前存活量),MemStats 告诉你整体水位。

一个高频误判:alloc_space 巨大就以为有泄漏。不是。alloc_space 是累计值,跑一天几个 TB 很正常,它衡量的是”分配压力”(GC 频率、CPU 开销),不是”内存占用”。判断占用看 inuse_space,判断泄漏看 HeapAlloc 的谷值序列。

一句话结论

RSS 告诉你”操作系统觉得你占了多少”,HeapAlloc 告诉你”你真的用了多少”。两者之间的差额,是分配器的策略选择,不是 bug。

泄漏的判据只有一个:GC 之后 HeapAlloc 的谷值是否持续上升。


子主题十三:业界对照补强(一)——jemalloc 的 arena 与 tcache

子主题(业界对照)里提到 jemalloc 只是带过。这里补上它的骨架,因为它和 Go 的对照极其有启发性。

jemalloc 的分层

jemalloc 的层级从大到小是:

  • arena:若干个(通常按 CPU 核数的某个倍数创建)。线程在首次分配时被绑定到某个 arena(轮询或哈希),不同线程尽量分散到不同 arena。arena 之间几乎不共享,所以锁竞争被打了第一层散。
  • bin:每个 arena 内部,按 size class 分出若干 bin(相当于”档位桶”)。bin 管理着服务于该档位的若干 run。
  • run:一段连续的页,被切成等大小的 region。这一层就是 Go 的 mspan。
  • region:run 里的最小分配单元,就是用户拿到的那个槽位。
  • tcache(thread cache):每个线程一份的无锁缓存,从 arena 的 bin 里批量预取一批 region 进来,之后分配全走 tcache,不碰 arena 的锁。

和 Go 一一对照

jemallocGo备注
arena(无对应,Go 只有全局 mheap)见下面的关键差异
binmcentral都是”按档位组织的全局池 + 锁”
runmspan都是”服务单一档位的连续页”
regionsize class 槽位都是”run/span 内的等大小单元”
tcachemcache都是”无锁本地缓存”,但绑定对象不同
decay-based purgescavenger都是”延迟地、按时间衰减地把空闲页还给 OS”

看到这张表你会有个疑问:Go 为什么没有 arena 这一层?

关键差异一:Go 不需要 arena,因为它有 P

jemalloc 加 arena 这一层,是为了解决”所有线程抢一把全局锁”的问题。它把锁拆成 N 份,让线程分散。

Go 也有一把全局锁的问题,但它用了另一招:每个 P 一个 mcache。绝大多数分配在 mcache 里就结束了,根本走不到那把锁。

为什么 Go 能这么干而 jemalloc 不能?因为 Go 有 P 这个”天然稳定、数量固定”的执行上下文(子主题四)。P 是 Go 调度器自己造出来的概念,它既是执行资源,又是缓存的天然宿主。

jemalloc 作为 C 库,它看到的只有”线程”,而线程是可以随时创建销毁、数量不可控的——所以它才需要”arena 按核数固定、线程哈希绑定”这一层间接,来人为制造一个”稳定的归属关系”。

换句话说:Go 用调度器的确定性,换掉了 jemalloc 的一层架构。 这是”语言 + 运行时 + 分配器协同设计”相对于”库”的结构性优势。

关键差异二:Go 的分配器知道类型,jemalloc 不知道

这是最容易被忽略、但影响最深远的一条。

Go 分配对象时,编译期就生成了类型信息:这个类型含不含指针、哪些偏移处是指针(指针位图)。分配器据此把 span 分成**含指针(scan)和不含指针(noscan)**两类。

后果是什么?

noscan 的 span,GC 完全不用扫。 一个 [10MB]byte 的缓冲区,GC 扫它的时候连一个字都不用看——因为类型说了”这里面没有指针”。这对有大量 []byte、字符串、大数组的服务是巨大的红利。

jemalloc 呢?它是 C 库,malloc(1024) 拿到的就是一个地址,它不知道你打算往里面放什么。所以它既不能分 scan/noscan,也不能帮你做任何 GC 相关的事。

这就是”有 GC 的语言自研分配器”的真正红利:分配器和 GC 是协同设计的。 分配路径上多收集的一点点信息(类型、指针布局),能在回收路径上省下巨大的成本。

Go 里所有看起来”多余”的设计(分配时查类型、span 分 scan/noscan、GC 位图和分配位图分离、写屏障按 span 粒度开关),都是这条主线的体现:把信息前置,把成本后移,让两个系统共享同一套元数据。

关键差异三:purge 策略,两种解法一个目标

jemalloc 的 decay-based purging:空闲内存按”时间衰减曲线”逐步归还——刚空出来的内存保留着(马上可能要用),空得越久还的比例越高(指数衰减)。

Go 的 scavenger:保留一个缓冲额度(由堆目标决定)+ 滞后时间(空了一段时间的页才还)。

两者细节不同,但思想完全一致:

不要问”这块内存空了吗”,要问”这块内存空了多久,以及我预计还要不要”。

这是所有现代分配器的共识。“空闲”是一个瞬时态,不能直接触发昂贵的系统调用;必须给它加上时间维度,才能做出正确的决策。

关键差异四:size class 的档位粒度

jemalloc 的 size class 分得更细(它把尺寸分成几个组,不同组用不同的步进策略,档位数量比 Go 多),并且明确区分 small / large / huge 三类。

代价是更多的元数据和缓存开销,收益是更低的对象级浪费率。Go 选了更少的档位——又一次权衡,又一次”够用就好”的务实选择。

顺便回答一个常见问题:“能不能把 Go 的默认分配器换成 jemalloc?”

技术上可以(用 cgo 链接,或者用 LD_PRELOAD 之类的方式),但几乎总是亏的:你会失去 scan/noscan 的优化、失去分配器与 GC 的协同、失去每 P 无锁缓存(jemalloc 的 tcache 是绑线程的,而 goroutine 在线程间飘)、还要付 cgo 调用开销。Go 的分配器不是”凑合能用”,它是为 Go 的运行时量身定做的。


子主题十四:业界对照补强(二)——Java 的 bump allocator 为什么快得离谱却不能给 Go 用

现在来看另一个极端。

bump allocator:分配就是一条加法

Java(以及 .NET、V8 的新生代)在新生代用的分配方式叫 bump allocator(指针碰撞):

堆里维护一个 top 指针,指向"空闲区的起点"。
分配 size 字节 = 
    检查 top + size 是否越过边界(一次比较)
    obj = top; top += size; return obj;   (两条指令)

没了。就这么简单。

对比一下 Go 的小对象路径:查 size class 表 → 取 mcache 对应槽位的 span → 检查 allocCache → 找最低位 0 → 更新 freeindex → 可能还要更新 span 的一些统计字段……几十条指令打底。

bump allocator 是 3 条指令。它比 Go 的”极速快路径”还要快一个数量级。

那问题来了:Go 为什么不用?

因为 bump allocator 有个致命的隐藏账单

往回看那个算法:分配就是 top += size。

现在问你:怎么 free?

你没法 free。因为指针只往后走,你在中间 free 掉一块,那块空洞就永远用不上了——top 已经越过它了,永远不会再回来。

所以 bump allocator 的唯一回收方式是:把活着的对象复制到别处,然后 top 归零,重新开始。

这就是复制式 GC(copying GC / evacuation)。而它的成本是 O(存活对象)——你要把每个活着的对象读一遍、拷一遍、修一遍所有指向它的指针。

这账单什么时候能接受?恰好是分代新生代

现在把两件事放在一起:

事实一(分代假说 / weak generational hypothesis):绝大多数对象”朝生夕死”——在新生代里,一次 Minor GC 之后,90% 以上的对象已经死了。

事实二:复制式 GC 的成本是 O(存活对象)。

两个事实一碰撞:存活对象只占 10%,所以复制成本极低,几乎等于”什么都不用做”。

于是形成了一个完美闭环:

大部分对象会死
  → 存活者很少
  → 复制成本低
  → 可以用 bump + 复制(分配 3 条指令)
  → 分配极快,回收也快

bump allocator 不是”无条件地更快”,它是”把成本从分配期挪到了回收期,然后赌一把:赌大部分对象会死”。

赌赢了,它是世界上最快的分配器。赌输了——比如你的程序里对象普遍长寿——那复制成本就会爆炸。这是一个建立在统计假设上的设计。

Go 为什么赌不起这一把

Go 用不了 bump,有两个硬理由:

理由一:Go 的 GC 不移动对象。

Go 的堆是非移动的(non-moving)。为什么?

  • cgo 需要稳定地址:C 代码持有的 Go 指针不能随便变(涉及复杂的 cgo 指针传递规则);
  • 内部指针(interior pointer)语义:Go 允许指向结构体字段、指向 slice 元素的指针。移动对象意味着要修正所有内部指针,而分配器/GC 并不总能知道”这个指针指向哪个对象的哪个偏移”——这需要精确的对象起始地址映射,成本高且容易出错;
  • 实现复杂度与暂停时间:移动对象需要维护转发指针(forwarding pointer)、需要读屏障或在并发场景下的额外同步。Go 从一开始就追求极低的暂停时间,非移动设计让并发标记-清扫更容易做对。

不移动 → 不能用复制式回收 → 不能用 bump allocator(因为没人去”整理”那些空洞)。

理由二:Go 没有分代。

没有分代,就没有”这块区域里大部分对象会死”这个前提。整个堆一起看,存活比例可能很高,复制成本不可控。

(Go 为什么不做分代?这是 04 章的主题,这里只留一句话:Go 用并发标记 + 逃逸分析 + 值类型语义绕开了分代的大部分收益,代价是写屏障的常驻开销。)

于是 Go 必须选”能单独回收”的结构

不能移动、不能复制,那就意味着:每个对象必须能被独立地释放,且释放后它的空间必须能立刻被另一个同样大小的对象复用。

这个需求,正好就是 free list / size class + span 位图。

  • 对象按大小分档,同档位对象互相替代 → 释放一个,空位立刻能被下一个同档请求用上;
  • 位图管理,释放就是清一个 bit → O(1),不需要合并;
  • 不移动,地址永远稳定。

看清楚这个因果链:Go 的 GC 设计(非移动、非分代、并发)决定了它的分配器设计(size class + span + 位图)。不是”Go 选了一个次优的分配器”,是”Go 在它的 GC 约束下,只能选这个”。

这叫作设计的一致性:一个系统的各个部分是被同一组约束逼出来的,你不能单独把某一部分换成”理论上更好”的那个。

反向问一句:Java 能不能用 size class?

技术上当然能(有些 JVM 的 TLAB 之外、老年代的分配其实就是 free list 式的)。但在新生代:用一个 3 条指令的方案能解决的事,为什么要换成一个几十条指令的方案?

而且 Java 的新生代还有一层优化叫 TLAB(Thread Local Allocation Buffer):每个线程从 Eden 里批发一大块,之后在自己的 TLAB 里 bump。这样连”多线程抢 top 指针”的问题都解决了——线程本地缓存 + bump,两条加速策略叠在一起。

这给 Go 的启示是什么?Go 的 mcache 和 Java 的 TLAB 是同一个思想的两个实例:都是为了”让热路径不碰全局共享状态”。区别只在于缓存里装的东西不同——Java 缓存的是”一段可以 bump 的连续空间”,Go 缓存的是”一个可以扫位图的 span”。

同样的招式,不同的内力。

三种分配哲学对照表

bump allocator(Java 新生代)size class + span(Go)通用 malloc(glibc)
分配成本几条指令(极快)几十条指令(很快)较高(搜索 + 锁)
能否单独释放不能,只能整区回收能,清一个 bit能,但需合并逻辑
是否需要移动对象必须(复制式 GC)不需要不需要
外部碎片无(整体整理)无(档位统一)有(需合并)
内部碎片无(精确分配)有(档位向上取整)有(对齐 + 块头)
依赖的假设大部分对象会死对象大小分布集中无(通用)
代价复制成本 + 地址会变档位浪费 + 元数据锁竞争 + 碎片

看最后三行。没有银弹,只有假设。 每个分配器都是在对”程序会怎么分配内存”下注:

  • Java 赌”对象会死”;
  • Go 赌”对象大小会集中、且需要稳定地址”;
  • glibc 什么都不赌,所以什么都不占便宜。

最后一个类比:停车场

  • bump allocator 是机场大巴的上下客区:车一辆接一辆往前排,人上下完就走,位置不留。等到整条道排满了,调度员喊一嗓子”还在这儿的车往前开到新道上去,其余的清场”——然后整条道清零重来。效率极高,但你没法在中间抽走一辆车。
  • size class + span 是立体停车库:车位按大小分了区(小型车位、SUV 车位),你的车走了,那个位子立刻能被下一辆同样大小的车停进去。随时进出,但车位比车大一点的时候会浪费,而且你得维护一张”哪些位子空着”的表。
  • 通用 malloc 是路边随便停:什么车都能停,但你得自己找位置、还得等别人挪车(合并),高峰时段堵成一团。

机场大巴适合”大进大出、一波带走”;立体车库适合”细水长流、随时进出”。你的程序是哪种,决定了你该用哪种。


业界对照

tcmalloc / jemalloc: 都是”线程本地缓存 + size class”的通用分配器。Go 的分配器就是 tcmalloc 思想的直系后裔。区别在于 Go 把”线程本地”改成了”P 本地”,以适应自己的调度模型。jemalloc 则额外擅长处理”多线程频繁分配释放导致的碎片和虚假共享”。

(jemalloc 的 arena / bin / run / region / tcache 骨架,以及它和 Go 逐层对照后的四个关键差异,见子主题十三。)

malloc(glibc ptmalloc): 通用分配器,为通用负载优化,对高并发小对象分配不如 tcmalloc/jemalloc 极致,锁竞争更明显。这也是为什么高性能 C++ 服务常会替换成 jemalloc/tcmalloc。

Java: 不使用”size class + 线程缓存”的 C 风格分配,而是bump allocator(指针碰撞)——堆里放一个指针,分配就是”指针往后挪一下”,快得惊人。但它成立的前提是”对象都在一块连续的、可整体回收的区域(新生代)“,所以 Java 的 Eden 区分配极快,代价是 GC 要更频繁地回收。Go 没有分代(04 章会讲为什么),所以走的是 size class 这条路。

(为什么 bump allocator 是”赌一把”的设计、为什么 Go 赌不起、以及三种分配哲学的完整对照,见子主题十四。)

Rust: 默认用系统分配器,可替换为 jemalloc/mimalloc。Rust 的 Box 分配就是普通 malloc,性能取决于链接的分配器,语言本身不内置专用分配器。


版本演进小结

Go 分配器的演进,主线是”减少锁竞争、提高缓存命中、减少内存浪费”:

  • 早期:mcache 跟着 M(线程)走。
  • 改为跟着 P 走:大幅减少 goroutine 换 M 导致的缓存失效。
  • 持续优化 size class 的档位划分、span 的布局、位图的管理方式。
  • 引入了 tiny allocator,优化极小对象。
  • 持续优化归还内存的策略,让 RSS 和真实使用量更贴近:后台 scavenger 从粗放到精细,从”尽量不还”到”按目标堆大小算着还”;归还手段从 munmap 转向以 madvise 为主(地址空间骨架不动,只掏空内容);在 MADV_FREE 与 MADV_DONTNEED 之间摇摆后选择了对可观测性更友好的一侧。
  • 引入软内存上限(GOMEMLIMIT 一类的机制),让 GC 的堆目标和 scavenger 的归还额度协同起来,解决容器环境下的 OOM 问题。
  • 持续优化 span 的位图管理(预取式的位缓存、两份位图指针互换),把小对象分配压到几十条指令。

这里有一条贯穿始终的设计哲学值得你记住:Go 的内存系统,一直在”分配速度”和”内存占用”之间做精细的权衡,而且几乎每一次优化,都是针对”真实世界负载的实测痛点”,而不是理论上的完美。 这和”照着教科书造一个最优分配器”是完全不同的两件事。


本章思考题

【思考题】

  1. 为什么小对象的”单位字节分配成本”通常比大对象高?请从分配路径的角度解释。

  2. 为什么缓存(mcache)要挂在 P 上,而不是 M(线程)或 goroutine 上?这背后的通用原则是什么?

  3. 为什么 Go 程序 RSS 涨上去后不容易降下来?这是内存泄漏吗?什么情况下它才会真正把内存还给操作系统?

  4. 为什么 tiny allocator 只允许塞”不含指针”的极小对象?如果塞了指针,会发生什么?

  5. size class 的档位是”小处密、大处疏”的(小尺寸段近似等差,大尺寸段近似等比)。如果改成纯等差步长 8 字节,或者改成纯等比 ×1.5,分别会出什么问题?设计者真正锁定的约束条件是什么?

  6. 一个 span 为什么要同时维护 allocBits 和 gcmarkBits 两份位图,而不是一份?GC 清扫阶段结束之后,怎样用 O(1) 的代价完成”账本切换”?

  7. tiny allocator 里的对象为什么无法单独回收?这会带来什么不那么直观的后果?

  8. make([]byte, 1<<30)(1GB)返回得非常快,但紧接着把这块内存写一遍却慢得多。请解释这个反差,并说明”分配了 1GB”和”占用了 1GB”为什么是两件不同的事。

  9. 你的 Go 服务 RSS 4GB,而 pprof 的 inuse_space 只有 500MB。请问这两者都没说谎吗?请给出区分”真泄漏”和”分配器持币观望”的判据,并说明 MemStats 里哪几个字段能给出结论、它们之间是什么包含关系。

  10. Java 新生代的 bump allocator 分配只要”一次比较 + 一次加法”,比 Go 的 size class 快路径还快得多。为什么 Go 不用?这里面被”赌”上的假设是什么?

【参考答案】

  1. 小对象走的是”size class → mcache → mcentral → mheap”这条精细路径,虽然单次无锁、很快,但频繁的分配要频繁地管理位图、维护空闲列表、搬运 span,单位字节的”管理开销”被摊得很薄反而显得高。大对象直接从 mheap 要一个专用 span,一次 mmap 拿一整块,管理开销占比极低,且分配频率低。所以”单位字节成本”上,大对象反而更划算——这也是为什么大数组、大缓冲区不必过度焦虑,真正拖慢程序的是海量小对象的频繁分配。

  2. 因为缓存的命中率取决于”绑定对象的稳定性”。goroutine 和 M 都会频繁切换(goroutine 在 M 上飘,M 也会换 P),缓存跟着它们走会频繁失效;而 P 数量固定(约等于核数)、相对稳定,缓存跟着 P 走命中率最高。通用原则是:把缓存放在最稳定、最不频繁变化的执行上下文上。

  3. 不是泄漏。Go 分配器”持币观望”——对象回收后,内存逐层回退到 mcache/mcentral/mheap,但默认不立刻 munmap 还给操作系统,而是留着复用(因为 mmap/munmap 很贵)。只有当 mheap 的空闲页攒够一定量、且操作系统报告内存压力时,Go 才会主动把一部分页 munmap 回去。所以 RSS 高位徘徊是正常现象,判断泄漏应该看”堆使用量(heap inuse)是否持续增长”,而不是看 RSS。

  4. 因为 GC 扫对象时,需要知道对象里哪些位置是指针、哪些是数据。tiny allocator 把多个对象塞进同一个槽位,如果混入指针,GC 就无法判断”槽里这 4 字节到底是指针还是 int”,扫描就会出错(要么漏了指针导致对象被错误回收,要么把普通数据当成指针)。所以 tiny allocator 严格要求”不含指针”,含指针的极小对象必须走普通路径。这一条也反过来约束了写代码:极小对象里藏指针,是拿不到 tiny allocator 的优化的。

  5. 纯等差(步长 8):从 8 字节到 32KB 要四千多档。后果有三:mcache 里每个 P 要为每档留槽位,几十个核一乘就是可观的元数据开销;mcentral 也有四千多份,锁、链表、初始化成本全都是代价;最致命的是档位过多导致单个 span 填充率下降——同一时刻对某一档的需求量很小,一个 span 里可能只用掉几个槽位但不能给别人用,浪费从”对象级”转移到了”span 级”。纯等比(×1.5):相对浪费率恒定、档位数只有十来档,看着很美,但两个硬伤——一是档位全是 18、27、40 这类不对齐的数,破坏 8 字节对齐(而对齐在很多地方是正确性前提,不是优化),二是小尺寸段浪费率爆炸(请求 9 字节给 12 字节,浪费 25%),而小对象恰恰是数量最庞大的一群,等于在最需要省的地方省得最少;反过来在大尺寸段一跳就是上万字节,绝对浪费过大。设计者真正锁定的约束是”最坏相对浪费率有一个上界”(百分之十几这个量级),然后反推每一段能用多大的步长:小尺寸段步长必须小(因为同样的绝对步长在小尺寸上换算成的相对浪费率大得吓人),大尺寸段可以放宽。所以档位表不是算出来的数列,是”浪费率上界”这条曲线在对数坐标上的采样。另外别忘了它是二维优化——每个档位还要挑一个让 span 尾部剩余尽量小的页数组合。

  6. 因为分配和 GC 标记是并发进行的:goroutine 在 GC 标记期间还在不停地分配和释放,GC 如果直接改 allocBits(借出登记簿),新分配的对象和标记结果就会混在一起,账对不上。所以 GC 必须拿一本新账(gcmarkBits)单独记,清扫时再和 allocBits 对照:分配了但没被标记的 → 没人引用 → 清掉 allocBits 对应位;分配了且被标记的 → 保留。切换的 O(1) 技巧是”交换指针”而不是拷贝:清扫完成后,“这一轮的标记结果”恰好就等于”下一轮开始时谁是被占用的”,于是直接把 gcmarkBits 和 allocBits 两个指针互换,旧的 allocBits 清零后当下轮的 gcmarkBits 用。一次赋值取代了对整张位图的遍历清零。这个技巧之所以存在,是因为数据结构(两份同构位图)从一开始就是为这个操作设计的——数据结构的形状决定算法的上界。(顺带:分配快路径还用了预取——把 64 个位一次性取到 allocCache 里,之后几十次分配只在寄存器里做”找最低位 0”,根本不碰 allocBits 的内存。)

  7. 因为位图里的一个 bit 对应的是槽位,不是 tiny 对象。三四个 tiny 对象挤在同一个 8/16 字节槽位里,共享同一个 bit,分配器根本没有地方记录”槽内的第 2 个对象已经死了”。所以回收粒度是槽位,不是对象:只要槽里还有任何一个 tiny 对象活着,整个槽的 bit 就不能清,其余早就没人引用的 tiny 对象只能跟着赖着。 后果是你的 tiny 对象实际生命周期 = 它所在槽位里”活得最久的那个对象”的生命周期。这条和”无指针”限制是咬合的:如果允许 tiny 槽里放指针,这个指针就会把一个本该死掉的对象额外钉住一段不确定的时间,形成隐蔽的逻辑泄漏。另外还有两层:GC 的指针位图是按 8 字节字记录的,槽里混了指针就会把邻居的纯数据当指针去追(误保活甚至崩溃);而写屏障是按”span 是否含指针”开关的,如果槽位标成 noscan 却藏了指针,写入不触发屏障,GC 会漏掉指针从而错误回收活对象——这是正确性错误,不是性能问题。

  8. 因为 Go 和操作系统都是”懒”的:分配 1GB 时,分配器只向内核要了虚拟地址空间(在页表里登记这段地址归你),内核一页物理内存都没给。等你真正写入时,CPU 访问未映射的页触发缺页异常,陷入内核,内核找空闲物理页、挂页表、清零、返回——1GB 就是十几万次缺页。所以 1GB 的分配成本被推迟并摊到了第一次写它的那一刻。“分配了 1GB”和”占用了 1GB”是两件事:RSS 是在写入时涨的,不是在分配时涨的;你 make 一个巨大 slice 只用前 1KB,RSS 只涨一点点。同理,Go 保证零值,但分配器在 span 上记了”是否需要清零”的标记——从 OS 刚要来的新页内核保证是零页,不需要 memset;只有复用的脏页才需要清零。这三件事(新页、不清零、惰性映射)叠在一起,才让大分配快得反常。

  9. 都没说谎。RSS 是”操作系统视角的占用”,inuse_space / HeapAlloc 是”你真正活着的对象”,差额是分配器回收后没还给 OS、留着复用的空闲内存。包含关系是:HeapSys ≈ HeapInuse + HeapIdle,HeapInuse ≥ HeapAlloc(差值是 span 内空槽位和内部碎片),HeapReleased ≤ HeapIdle(已把物理页还给 OS 的部分)。近似式:RSS ≈ HeapAlloc + 内部碎片 + (HeapIdle − HeapReleased)。判据分五步:① 看 HeapAlloc 在每次 GC 之后的谷值连成的线——平稳则无泄漏,单调上升则真泄漏(全局 map 只增不减、slice 不截断、goroutine 泄漏、未关闭的 ticker、无淘汰的缓存);这是唯一可靠的泄漏判据,看峰值会被 GC 时机污染。② HeapAlloc 稳但 RSS 高、且 HeapIdle − HeapReleased 巨大 → 持币观望,不是泄漏,解决方向是 GOMEMLIMIT / GOGC / 大请求后主动触发归还。③ HeapInuse − HeapAlloc 长期偏大 → 内部碎片重,对象大小卡在档位边界上方(比如 33 字节落 48 字节档,浪费 30%),调 struct 字段顺序压 padding 让它掉回 32 字节档,是少数”改几个字节就省几百 MB”的优化。④ HeapReleased 长期为 0 而 HeapIdle 巨大 → 归还没生效。⑤ HeapSys 涨而堆字段稳 → 涨的是非堆内存(goroutine 栈、runtime 元数据、cgo),头号嫌疑是 goroutine 泄漏。补充:采集要定时打时间序列而不是单点快照;alloc_space 是累计分配量,衡量分配压力和 GC 频率,不能用来判断泄漏。

  10. bump allocator 的隐藏账单是它不能单独 free:指针只往后走,中间 free 出来的空洞永远用不上,所以唯一回收方式是”把活着的复制到别处、top 归零”的复制式 GC,成本 O(存活对象)。它之所以成立,是因为赌上了分代假说(绝大多数对象朝生夕死):新生代里一次 Minor GC 后 90%+ 的对象已死 → 存活者极少 → 复制成本几乎为零。它不是无条件更快,而是”把成本从分配期挪到回收期,然后赌大部分对象会死”。 Go 赌不起,两个硬理由:① Go 的 GC 不移动对象——cgo 需要稳定地址、Go 允许指向字段和 slice 元素的内部指针(移动对象要修正所有内部指针,代价高且易错)、非移动设计让并发标记-清扫更容易做到极低暂停;不移动就不能做复制式整理,也就不能用 bump。② Go 没有分代,就没有”这块区域里大部分对象会死”的前提,全堆一起看复制成本不可控。于是 Go 只能选”能单独回收”的结构:按大小分档让同档对象互相替代,位图让释放变成清一个 bit(而且因为同尺寸,连”空闲块合并”这段 malloc 最恶心的逻辑都省了)。因果链是:GC 的非移动、非分代、并发设计 → 决定了分配器必须是 size class + span + 位图。不是 Go 选了次优方案,是 Go 在它的约束下只能选这个。(补充:Java 的 TLAB 和 Go 的 mcache 是同一个思想的两个实例——都是让热路径不碰全局共享状态,只是 Java 缓存的是”可 bump 的连续空间”,Go 缓存的是”可扫位图的 span”。)