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 查找足够大的连续虚拟页区间:
- 先利用页堆中已经空闲的连续页。
- 需要时清扫尚未处理的 Span,发现新空闲页。
- 仍不够时向操作系统保留并映射新的堆 Arena。
- 受地址空间、容器限制、系统提交策略或实际可用内存约束而无法扩展时,最终 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 几乎没有内部指针,扫描成本可能低于由海量小指针对象组成的结构;但它会提高堆占用、内存峰值和清零成本。