Note

标记清扫产生内存碎片后,Go 如何分配大对象?

区分内部碎片与外部碎片,解释 Go 的 Size Class、Span、页分配和大对象路径,以及大块内存申请失败时的工程处理。

省流

这个问题要先区分 GC 和内存分配器。标记清扫只负责判断对象是否存活,真正决定空闲空间如何复用的是分配器。非压缩式 GC 不移动存活对象,所以理论上会产生外部碎片:空闲总量足够,但找不到足够大的连续区间。

在 Go 中,32 KiB 及以下的小对象按 Size Class 从每个 P 的 mcache、全局 mcentral 和 mspan 分配;更大的对象绕过前两层,直接向 mheap 申请连续页。完全空闲的 Span 会回到页堆,空闲页可以重新组合使用;页堆找不到足够大的区间时会向操作系统扩展堆。Go GC 不压缩,所以 runtime.GC() 或 debug.FreeOSMemory() 只能促进发现、清扫和归还空闲页,不能移动存活对象来拼出连续空间。

工程上如果经常申请超大 Slice,应该先确认是否真需要一整块连续地址空间,优先改成分块、流式处理、限流或复用有上限的缓冲区;再通过 heap profile 和 runtime/metrics 判断究竟是对象仍被引用、页碎片、内存上限,还是进程外内存导致的 OOM。

先区分两种碎片

类型含义例子
内部碎片分到的块比对象实际需要的大,块内部有浪费申请 25 B,被归入 32 B Size Class
外部碎片空闲空间散落在不同位置,总量足够但没有足够大的连续块多个存活对象夹在空闲页之间

压缩式 GC 主要缓解外部碎片;Size Class 则以少量内部浪费换取快速分配和同尺寸空间的高复用率。

通用的解决方式

1. 压缩或复制

移动式 GC 可以把存活对象复制或压缩到一侧,再更新所有引用,得到连续空闲区。这能缓解外部碎片,但需要搬迁对象、修正指针,并可能带来额外暂停或读写屏障。

Java 的不同收集器会采用分代、区域化和压缩等不同策略;不能简单地说所有“标记清除”实现都会永久保留碎片。

2. 空闲块合并与分级分配

分配器可以把相邻空闲页合并成更大的区间,并为不同尺寸维护独立空闲链表、位图或树。这样不移动存活对象,也能复用相邻的空闲空间。

3. 扩展堆或分配独立大对象区

如果现有堆没有合适区间,运行时可向操作系统申请新的虚拟地址区间。大对象通常走独立于小对象缓存的页级路径,避免被固定大小槽位限制。

注意“虚拟地址连续”不要求物理内存页连续。用户态的大数组需要连续的虚拟地址,但操作系统可以把这些地址映射到分散的物理页。

Go 分配器如何降低碎片影响?

Go 分配器按页和 Span 管理堆。当前运行时源码给出的核心层次是:

小对象(<= 32 KiB)
  -> Size Class
  -> 当前 P 的 mcache
  -> 对应 Class 的 mcentral
  -> mheap 分配一组页形成 mspan
  -> 必要时向 OS 扩展堆

大对象(> 32 KiB)
  -> 直接从 mheap 申请足够页数
  -> 必要时向 OS 扩展堆

同一个用于小对象的 Span 只服务一个 Size Class。某个对象被回收后,它的槽位可以直接给相同 Class 的新对象使用;只有当 Span 中所有对象都死亡时,整组页才会回到 mheap,供其他尺寸或大对象重新使用。

因此可能出现这种现象:很多 Span 都只剩一个存活小对象,HeapInuse - HeapAlloc 很大,但这些 Span 不能整体还给页堆。这属于由对象分布造成的保留和内部浪费。Go 不会移动那个存活对象来腾空 Span。

真要申请一个很大的对象时会发生什么?

以 make([]byte, n) 为例,若它进入堆且超过小对象阈值,运行时会按页数向 mheap 查找足够大的连续虚拟页区间:

  1. 先利用页堆中已经空闲的连续页。
  2. 需要时清扫尚未处理的 Span,发现新空闲页。
  3. 仍不够时向操作系统保留并映射新的堆 Arena。
  4. 受地址空间、容器限制、系统提交策略或实际可用内存约束而无法扩展时,最终 OOM。

Go 的小对象碎片不必然阻止大对象分配,因为大对象走页级路径,而且 64 位进程通常有很大的虚拟地址空间。但如果存活对象把页区间切得很散、内存限制过紧或确实没有足够资源,非移动 GC 无法通过压缩自救。

Go 运行时发生不可恢复的堆内存不足时通常直接终止进程,不能把它当成普通 panic 可靠捕获后继续服务。

runtime.GC 和 debug.FreeOSMemory 有用吗?

  • runtime.GC() 强制完成一轮垃圾回收,可以发现已经不可达但尚未回收的对象。
  • debug.FreeOSMemory() 会先 GC,并更积极地尝试把不用的物理页交还操作系统。
  • 二者都不会压缩 Go 堆,也不能回收仍被引用的对象。

如果问题来自“垃圾尚未被本轮 GC 发现”,它们可能暂时有用;如果问题来自存活对象导致的碎片或确实需要超大连续虚拟空间,它们不能从根本上解决。

工程上应该怎么办?

避免必须连续的超大分配

优先使用分块读取、流式编解码、分页处理或分段缓冲。例如处理一个 4 GiB 文件时,不要默认先 make([]byte, 4<<30) 再一次性读入。

给输入和并发设置上限

单个请求分配 200 MiB,50 个并发请求就是 10 GiB。限制请求体、批次大小和同时执行的大任务数量,通常比 GC 参数更关键。

谨慎复用大缓冲区

池化能减少重复分配,但不要把偶发的超大 Slice 长期放入 sync.Pool 或全局池。应设置可复用容量上限,超过阈值就丢弃,否则少量峰值请求会长期抬高进程内存。

用指标判断是哪类问题

  • HeapAlloc 高:仍有大量堆对象存活或尚待回收。
  • HeapInuse - HeapAlloc 高:Span 内空闲槽位、Size Class 舍入或对象分布造成的浪费较大。
  • HeapIdle - HeapReleased 高:运行时持有大量空闲页,但还未归还给 OS。
  • RSS 高而 Go 堆不高:继续检查 Goroutine 栈、mmap、cgo、线程栈和内核缓冲区。

结合 heap profile 的 inuse_space、alloc_space 和 runtime/metrics 看趋势,不要只凭一次 RSS 判断“GC 没回收”。

扩展问题

Go 为什么不用压缩 GC?

非移动设计让对象地址稳定,运行时与底层系统交互更简单,也避免搬迁整个存活堆的成本;代价是无法靠移动对象消除外部碎片。Go 通过 Size Class、Span、页分配和较大的虚拟地址空间降低影响,这是吞吐、延迟和实现复杂度之间的取舍。

内存明明空闲,为什么 RSS 没下降?

“已能被 Go 再利用”和“已归还操作系统”是两件事。清扫后的槽位或页可能留在运行时堆中等待后续分配;后台 Scavenger 才会逐步把合适的物理页归还 OS。

大对象会让 GC 更慢吗?

不只看字节数,还看对象是否含大量指针。一个很大的 []byte 几乎没有内部指针,扫描成本可能低于由海量小指针对象组成的结构;但它会提高堆占用、内存峰值和清零成本。

参考资料