大家好,我是公号「左诗右码」作者 Alex。
在面试中,当你被问到“Go 是如何分配内存的?”时,如果你的回答仅仅停留在“堆和栈”,那是远远不够的。
Go 的内存分配器是基于 Google 的 TCMalloc (Thread-Caching Malloc) 算法演进而来的,它的核心设计理念就是:多级缓存,降低锁竞争。
今天,我们就来扒一扒这个复杂但极其精妙的内存分配机制。
1. 请简述 Go 是如何分配内存的?(Go 内存分配器架构)
Go 的内存分配器主要由三个核心组件构成:mcache、mcentral 和 mheap。
你可以把它们想象成三级“仓库”:
-
mcache (线程缓存) :
- 位置:绑定在每个 P (Processor) 上。
-
特点:无锁访问。因为每个 P 同一时间只会运行一个 Goroutine,所以 G 在申请内存时,优先去当前 P 的
mcache里拿,速度极快。 -
存储:存的是各种大小规格的
mspan(内存块)。
-
mcentral (中心缓存) :
- 位置:全局共享。
-
特点:需要加锁。当
mcache里的内存用完时,P 会去mcentral申请一批新的mspan。 - 作用:作为中间商,平衡各个 P 之间的内存需求。
-
mheap (堆) :
- 位置:全局共享。
- 特点:大锁。这是 Go 内存管理的最高层级。
-
作用:如果
mcentral也没货了,就会向mheap申请。mheap负责直接向操作系统(OS)申请大块的虚拟内存(Arena),然后切割成mspan下发给mcentral。
一句话总结分配流程:
Goroutine 需要内存 -> 查本地无锁 mcache -> 不够就加锁查全局 mcentral -> 还不够就查 mheap -> 最终向 OS 申请。
2. 大对象、小对象与微对象的分配策略
Go 会根据你申请的内存大小,采用不同的分配策略:
2.1 微对象 (Tiny Objects, < 16B)
比如 bool, int8 等。
Go 会把它们拼凑在一个 16 字节的内存块(Tiny Block)中分配。这就像是“拼车”,极大减少了内存碎片的产生。
2.2 小对象 (Small Objects, 16B ~ 32KB)
这是最常见的场景(绝大多数的结构体、切片都在这个范围)。
Go 会计算出它属于哪个具体的跨度类 (Size Class),然后直接去 mcache 对应的 mspan 链表中找空闲的格子。
2.3 大对象 (Large Objects, > 32KB)
如果对象非常大,它会直接绕过 mcache 和 mcentral,直接向 mheap 申请分配。因为大对象在本地缓存里放不下,搬来搬去开销太大。
3. 为什么小对象多了会造成 GC 压力?
这是一个非常经典的高级面试题。
在实际业务中,如果我们频繁地申请和丢弃大量的小对象(比如在 for 循环里不断拼接字符串、解析巨大的 JSON 产生无数小结构体),会导致非常严重的后果:
-
扫描成本剧增:
Go 的垃圾回收(GC)是并发标记清除。GC 标记阶段需要遍历堆上的所有存活对象。小对象数量越多,对象之间的引用关系图(对象图)就越庞大、越复杂,GC 扫描的时间就越长。 -
STW 延迟变长:
虽然 Go 优化了 STW 时间,但在 GC 开启和结束的瞬间仍有短暂的 STW。对象图过于复杂会拖慢整个 GC 周期,导致 CPU 大量时间被用在runtime.gcBgMarkWorker上,业务处理能力下降。 -
内存碎片与分配开销:
即便有 TCMalloc 的 Size Class 优化,极高频的申请依然会击穿mcache,导致频繁向mcentral甚至mheap申请,增加了锁竞争和系统调用。
优化建议:
面对大量小对象,我们通常的解决办法是对象池化。利用上一篇讲过的 sync.Pool 将小对象复用起来,让它们“长生不老”,从而逃过 GC 的频繁扫描和回收。
总结
-
分配架构:
mcache(P 本地无锁) ->mcentral(全局共享有锁) ->mheap(堆顶层)。 - 分配策略:微对象拼车,小对象查 mcache,大对象直达 mheap。
-
小对象危机:数量庞大的小对象会极大增加 GC 标记阶段的扫描负担,拖慢系统性能,需用
sync.Pool破局。
了解了对象在堆上是如何分配的,下一个问题来了:Go 是怎么知道一个变量应该分配在堆上,还是分配在栈上的呢?
下一篇,我们来聊聊神秘的逃逸分析。