Go 内存管理(1):内存分配

5 阅读5分钟

Go 内存分配

系列阅读:内存分配 -> 内存分配源码1 -> 内存分配源码2
术语口径:口袋≈mcache、补给站≈mcentral、总仓≈mheap、货架≈mspan;尺码档即 size class;spanClass 是记在每架货架(mspan)上的档位/类型标签。大件与 tiny 不照搬同一条流水线,见下文「内存分配流程」。

这篇写给谁

  • 想先建立「堆上怎么要一块内存」的整体图景,分清小对象 / 大对象 / tiny 的边界。
  • 更偏直觉与分工,暂不深抠 runtime 源码。

Go 进程的内存区域

image.png

区域特点存放内容管理方式
栈区 (Stack)每个 Goroutine (G) 都有自己专属的栈(初始 2KB);栈是动态的,太挤了会换更大块「搬家」。函数里的局部变量、参数、返回地址。编译器自动分配与释放。
堆区 (Heap)即mheap、mspan 那一套。逃逸的对象、new / make 的大数据、全局共享对象。GC(垃圾回收器)定期清扫。
数据段 (Data Segment)分两块:.data(已赋初值)、.bss(未初始化,Go 默认刷成 0)。全局变量、静态变量。随进程加载布局建立。
只读区 (Text / ROData)整段只读,运行时改写会触发 Segment Fault。Text:编译后的机器指令;ROData:字符串常量(如 "Hello World")、const 等。
运行时元数据 (Runtime Metadata)通常不在用户堆里,而在 Off-heap(堆外内存)。mcache、mcentral、mheap、GC 位图 (Bitmap)、P 等。Runtime 手动申请与管理;GC 不扫描它们。

本文主要讲述堆相关的内存分配


概述

程序跑起来会不停地「要新地方」:new、字面量、slice 变长、逃逸到堆上的局部变量,最后都落到运行时的堆分配上。如果每次问内存都全局加锁、每次都找操作系统要一页,性能和延迟都会很难看。

所以 Go 的分配器做成了分层:能就近解决的就近解决(每个调度上下文自带一小份缓存),不够再去「中心」批量拿,再不够才从总堆划页、向系统要空间。这套思路早年借鉴过 tcmalloc,后来已经演化成自己的实现。

堆相关结构

image.png 可以把堆想象成一座高效仓库:

货架长什么样?

  • 货架 (mspan)内存管理的基本单位。一段连续多页,要么切成同一档的小格子装小对象,要么整段给一件大对象。挂在口袋、补给站、总仓之间流转的

货架内部如何切分?

  • 尺码表 (size class):仓库不按精确字节划分,而是统一划分为 67 种固定档位(如 S/M/L);每种补给线只发同一档位的货
    • 一整架货被切成等大的格子;例如 16B 档,每一格都是 16B,拿取快、碎片可控。

货物怎么放?

  • 口袋 (mcache):每个工人口袋里都有最常用的货,拿取无需排队。

    • 每个调度用的 P 自带一份。
    • Tiny 对象:< 16B 且无指针。
    • Small 对象:16B ~ 32KB。
  • 补给站 (mcentral):口袋空了才去对应尺码的补给站补货;同码补给站有很多,减少全厂抢一把锁。

  • 大宗区域 (mheap):超大件不占小件那条流水线,直接在总仓空地上按连续页划;也统管从操作系统要来的页。

    • Large 对象:> 32KB。

仓库怎么维持运转?

  • 页账本 (pageAlloc):总仓里按记「哪段地址空着、能连续划给新货架」。

  • 街区 / 户口簿 (heapArena):堆地址按大块切成很多「街区」,每个街区一本户口簿:这一页归哪个 span、配合 GC 标记等,方便分配器和回收器查。

  • 行政库房 (fixalloc):给 mspan、mcache 等的简单分配器,不放你的业务对象


内存分配流程

内存分配.堆分配流程总览.png

想象你是一个 Go 程序,现在执行了 obj := new(Object)(且它走的是典型小对象那条线):

  1. 「口袋」是否有货?(mcache) 你要领一个 16 字节的对象。你先翻自己的口袋(mcache)。 动作: 看看口袋里有没有对应「16 字节档位」的货架(mspan)。 为什么快: 因为口袋是绑在「当前干活的 P」上的,你拿东西不用跟全厂排队(常见路径无锁)。
  2. 「口袋」空了,去「补给站」批发(mcentral) 口袋里对应的 16 字节货架用完了。你得跑去中心补给站(mcentral)。 动作: 你说:「给我来一整个满载 16 字节槽位的货架(mspan)!」 代价: 补给站是公用的,这里要排队(加锁)。但你一次批发一整块货架回去,够你用好久,减少了下次排队的机会。
  3. 「补给站」也没货了,去「总仓」拆页(mheap) 补给站发现 16 字节的货架全卖光了。它转身找总仓(mheap)。 动作: 总仓翻开页账本(pageAlloc),找一段连续的空闲内存(比如 8KB 的一页),把它切成几十个 16 字节的小格子,拼成一个新的货架(mspan)给补给站。
  4. 「总仓」没地了,找「地主」圈地(OS) 如果总仓连 8KB 的空闲页都找不到了。 动作: 总仓向**操作系统(OS)申请新的连续地址空间(运行时再按规则切成街区、登记到页账本;有时一次会多要一些,摊薄跟系统打交道的次数)。 登记: 拿地之后,要在户口簿(heapArena)**上记下来:这一块地怎么切页、每个页对应谁管,方便分配和 GC 查。

还有两类常见例外:

1. 极小、且不含指针的分配(tiny)

特别小的、回收时不需要顺着里面去扫指针的那类,有时会先塞进一个 16 字节的微盒里,好几个极小对象拼成一盒,再占「一整格货架」。所以你看到源码里 tiny 相关逻辑时,要知道:它是在「拼单」,不一定一上来就对应「某一档尺码的一整格」那条典型故事。

2. 大对象

尺寸超过「小对象」上限的,不再进每个 P 的口袋、也不进按尺码分的补给站,而是直接在总仓按连续页划一整段,这一整段通常只装这一件大货。可以理解为:大件不走小件流水线,避免占满一整个货架却只放一样东西还要反复协调。