内存模型与栈堆布局
# 01.内存模型与栈堆布局
卷三第一篇——从进程地址空间出发,看清 Go 的栈、堆、全局区怎么布局。Go 没有"传统堆",它的堆是 TCMalloc 思想的实现:
mspan→mcache→mcentral→mheap四级缓存。Go 的栈不是固定 8MB 的分段栈,而是 2KB 起步的连续栈——栈会复制、会扩容、会缩容。理解这套机制,是排查 Go 程序 OOM、栈溢出、GC 延迟的根本前提。关键词:连续栈、栈扩容与缩容、mspan/mcache/mcentral/mheap、分级对象分配、逃逸分析。
# 目录介绍
- 1. 案例引入
- 2. 架构概览
- 3. Go 的连续栈机制
- 4. 分段栈的历史教训
- 5. 堆的四级分配器
- 6. 对象大小三级分类
- 7. 逃逸分析决定分配位置
- 8. GC 与内存布局的联动
- 9. pprof 内存诊断实战
- 10. 综合案例串讲
# 1. 案例引入
# 1.1 一段崩在哪
看一段在生产环境跑了三个月的 Go 微服务——处理实时推送的 goroutine 池,某天凌晨流量峰值时,K8s 的 OOM Killer 把它杀掉了。运维查监控,RSS 在 5 分钟内从 200MB 飙到 2GB,然后进程消失:
// push_service.go —— 消息推送引擎
package main
import (
"encoding/json"
"sync"
)
type PushMessage struct {
UserID int64 `json:"user_id"`
Payload json.RawMessage `json:"payload"`
Retries int `json:"retries"`
}
// 全局缓存——保存最近 1000 条消息用于重试
var recentMessages = make([]*PushMessage, 0, 1000)
var mu sync.Mutex
func handlePush(msg *PushMessage) error {
// 记录最近消息
mu.Lock()
if len(recentMessages) >= 1000 {
recentMessages = recentMessages[1:] // 丢掉最旧的
}
recentMessages = append(recentMessages, msg)
mu.Unlock()
// 实际推送逻辑
return pushToClient(msg)
}
// 每秒调用——清理过期消息
func cleanupExpired() {
mu.Lock()
defer mu.Unlock()
// 过滤掉 Retries > 3 的消息
filtered := make([]*PushMessage, 0, len(recentMessages))
for _, m := range recentMessages {
if m.Retries <= 3 {
filtered = append(filtered, m)
}
}
recentMessages = filtered
}
func main() {
// 100 个 goroutine 处理推送
for i := 0; i < 100; i++ {
go func() {
for msg := range pushCh {
handlePush(msg)
}
}()
}
// ...
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
现象:
- 平时 RSS 稳定在 200MB,goroutine 数稳定在 100~200
- 流量峰值:RSS 5 分钟涨到 2GB,goroutine 数飙涨到 5000+
- K8s 配置了 2.5GB 内存 limit → OOM Kill
- 但
recentMessages的容量明明是 1000——最多几十 KB 才对
第一反应:是不是 goroutine 泄漏了?pprof 看一眼 goroutine 栈:
$ curl http://localhost:6060/debug/pprof/goroutine?debug=2 | head -40
goroutine 4872 [runnable]:
main.handlePush.func1()
/app/push_service.go:42 +0x...
...
# 4872 个 goroutine 全部卡在 handlePush 内部的某行
2
3
4
5
6
goroutine 确实泄漏了——但更诡异的是 RSS 的增长远超 goroutine 栈本身的开销(每个 goroutine 初始栈只有 2KB)。5000 个 goroutine × 2KB = 10MB——但 RSS 涨了 1.8GB!多出来的内存在哪?
# 1.2 顺藤摸到根因
追查:
假设 1:是不是闭包捕获了外部大变量?——
handlePush闭包引用了msg,这是一个指针,不占额外内存。假设 2:是不是
json.RawMessage的底层切片在逃逸到堆上?——用go build -gcflags="-m"看逃逸分析:
./push_service.go:15:6: moved to heap: msg
./push_service.go:42:3: func literal escapes to heap
2
两个关键发现:
msg(*PushMessage)本身逃逸到了堆——因为存进了recentMessages切片,生命周期超出了handlePush的栈帧- goroutine 闭包也逃逸到了堆——因为被
go关键字启动
- 假设 3:
recentMessages在堆上,msg也在堆上,容量 1000——但为什么 RSS 涨到 2GB?—— 看pprof heap:
$ go tool pprof http://localhost:6060/debug/pprof/heap
(pprof) top
Showing nodes accounting for 1.65GB, 92% of 1.80GB total
flat flat% sum% cum cum%
1.20GB 66.67% 66.67% 1.20GB 66.67% json.RawMessage
0.30GB 16.67% 83.33% 0.30GB 16.67% encoding/json.Unmarshal
0.15GB 8.33% 91.67% 0.15GB 8.33% bytes.makeSlice
2
3
4
5
6
7
json.RawMessage 占了 1.2GB——但它只是 []byte 别名!回头看业务:某几个特别大的推送消息包含 2MB 的 JSON payload——存进 recentMessages 后,虽然切片容量是 1000 个指针,但这 1000 个指针各自指向的 PushMessage 中 Payload 字段是 2MB 的 []byte 底层数组。1000 × 2MB ≈ 2GB。
更深层的问题:recentMessages = recentMessages[1:] 虽然让切片头移了一位,但底层数组(容量 1000 的 *PushMessage 数组)并没有缩小。这是 Go 切片的"容量不缩"特性——内存被锁死在这 1000 个指针槽位上,直到 cleanupExpired() 用 filtered 替换了整个切片引用(此时旧底层数组才可能被 GC)。
但问题在于:GC 扫描到 recentMessages 中已经"丢出窗口"的前面元素时,它们还占着底层数组的槽位,这些槽位里的指针仍指向 2MB 的大 RawMessage——GC 必须保留它们,因为切片的底层数组仍然引用着它们。真正释放发生在旧底层数组完全不可达时——也就是 filtered 赋值给 recentMessages 之后的那次 GC。
这个事故藏着至少 8 个原理点:
① goroutine 的栈初始是多大?什么情况下会扩容? → 第 3 章
② Go 的"连续栈"机制——栈扩容为什么拷贝整个栈? → 第 3.3
③ 为什么 Go 1.3 把分段栈砍掉换连续栈? → 第 4 章
④ 堆分配的四级结构 mspan→mcache→mcentral→mheap 怎么协作? → 第 5 章
⑤ Go 怎么决定一个对象放栈上还是堆上?(逃逸分析) → 第 7 章
⑥ 为什么"切片的容量不缩"会导致不预期的内存占用? → 第 9 章
⑦ GC 什么时候才把空闲内存还给 OS?为什么 RSS 不降? → 第 8.3
⑧ pprof 怎么看 goroutine 栈快照和 heap profile? → 第 9 章
2
3
4
5
6
7
8
# 1.3 我们要回答什么
这个案例就是本篇的主线案例。我们从 Go 的栈机制出发(2KB 起步、连续栈复制),深入到 TCMalloc 风格的四级堆分配器,最后用逃逸分析解释"什么在栈上、什么在堆上"。第 10 章回到推送服务,用 pprof + 逃逸分析 + 容量显式截断彻底根治。
本篇路线:
架构总图 (第 2 章) ── Go vs C 的地址空间差异
↓
连续栈 (第 3-4 章) ── 解开"goroutine 栈为什么只需要 2KB"
↓
四级分配器 (第 5-6 章) ── 解开"Go 的堆为什么不像 C 的 malloc"
↓
逃逸分析 (第 7 章) ── 解开"编译器怎么替你做栈/堆决策"
↓
GC 联动 (第 8 章) ── 解开"GC 为什么有时不归还内存"
↓
pprof 实战 (第 9 章) ── 武器库
↓
综合案例 (第 10 章) ── 彻底剖开 + 修复
2
3
4
5
6
7
8
9
10
11
12
13
📌 本篇定位:这是 Go"内存"话题的总入口。第 02-08 篇讲的对象分配、GC 三色标记、写屏障、内存逃逸优化,本质都是"在这套内存模型上发生的事"。读完本篇,后续再看任何 Go 内存话题,都能回答:"它分配在栈还是堆?经过了几层分配器?GC 怎么看到它的?"
# 2. 架构概览
# 2.1 Go 地址空间全景
Go 编译出的二进制是静态链接的 ELF——没有依赖 libc 的 malloc,没有依赖 ld-linux.so 做动态链接。但也因此,Go 的进程地址空间布局和传统 C 程序有明显差异:
高地址 (0xFFFF_FFFF_FFFF_FFFF)
┌─────────────────────────────────────────────────┐
│ 内核空间 │
├─────────────────────────────────────────────────┤
│ ↓↓↓ goroutine 栈区 ↓↓↓ │ ← 每 G 2KB 起步
│ G1 栈 [8MB rw-] G2 栈 [2KB rw-] │ 连续栈,可复制扩容
│ G3 栈 [32KB rw-] G4 栈 [2KB rw-] │
│ 守护页 4KB ---p(每个栈底) │
├─────────────────────────────────────────────────┤
│ │
│ Go 堆(mheap 管理的大片匿名 mmap) │
│ ┌───────────────────────────────────────┐ │
│ │ mheap → mcentral → mcache → mspan │ │
│ │ (四级缓存,无 libc malloc) │ │
│ └───────────────────────────────────────┘ │
│ │
├─────────────────────────────────────────────────┤
│ 全局数据段 │
│ noptrdata 无指针全局/静态(不参与 GC 扫描) │
│ data 有指针已初始化全局 │
│ bss 未初始化全局(零页 COW) │
├─────────────────────────────────────────────────┤
│ 代码段 │
│ text 函数机器码 │
│ rodata 只读常量、字符串字面量、itab │
├─────────────────────────────────────────────────┤
│ 保留区(0x0 起,捕获 nil 解引用) │
└─────────────────────────────────────────────────┘
低地址 (0x0000_0000_0000_0000)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
Go 与 C 进程地址空间的核心差异:
| 特性 | C 进程(Linux + glibc) | Go 进程 |
|---|---|---|
| 堆管理者 | glibc ptmalloc2(brk + mmap) | Go runtime mheap(仅 mmap) |
| 栈管理 | OS 分配,每线程 8MB 固定 | Runtime 分配,每 goroutine 2KB 起步,连续栈可复制 |
| 线程模型 | pthread 1:1 内核线程 | GMP 调度:N 个 G 跑在 M 个 P 上 |
| 栈大小上限 | ulimit -s(默认 8MB) | 1GB(64位),但可扩容到更大 |
| 全局数据 GC | 无 GC(手动管理) | 全局 data/bss 中的指针也参与 GC 扫描 |
noptrdata 段 | 无 | 有——无指针全局变量不参与 GC 扫描 |
# 2.2 与 C 内存模型的差异
疑惑:为什么 Go 不直接用 malloc/free?
论证:
malloc 不知道对象的"指针信息"——C 的
malloc(100)返回的字节块,glibc 不知道里面哪些偏移是指针。Go 需要 GC 扫描对象的指针字段来追踪存活对象——它必须在分配时记住"这个对象的指针位图(gc mask)"。所以 Go 不能用通用的 malloc,需要自己的分配器。Go 的每个 goroutine 都需要一个小栈——如果给每个 goroutine 分配 8MB 的 OS 线程栈,100 万个 goroutine 需要 8TB——不现实。Go runtime 自己管理栈,初始 2KB。
无锁优先——glibc 的 malloc 在多线程下有 arena 级别的锁竞争。Go 的 mcache 是每 P 一个,在同一个 P 上执行的 goroutine 用 mcache 分配完全无锁。
对齐和元数据嵌入——Go 的每个堆对象前有对象头(包含 GC 位、大小信息),而 C 的 malloc chunk 只有
prev_size+size+ flags——没有 GC 信息。
结论:Go 不用 malloc/free 不是因为"可以不用",而是因为 Go 需要的信息更多(指针位图、GC 色标、大小级),而 glibc 不提供这些。Go 的 runtime 自己从 OS 申请大块内存,再用四级缓存分发给 goroutine。
# 3. Go 的连续栈机制
# 3.1 栈的初始值与结构
每个 goroutine 创建时,Go runtime 分配 2KB 的栈(Go 1.4+,之前是 4KB 或 8KB):
// runtime/stack.go (简化)
const (
_StackMin = 2048 // 初始栈大小 2KB
_StackSmall = 128 // 小栈阈值
)
// runtime/proc.go
func newproc1(fn *funcval, ...) *g {
// ...
if newg.stack.lo == 0 {
newg.stack = stackalloc(_FixedStack) // 2KB 栈
}
// ...
}
2
3
4
5
6
7
8
9
10
11
12
13
14
每个 goroutine 栈在虚拟地址空间中的布局:
高地址
┌───────────────────────┐ ← stack.hi
│ │
│ 有效栈空间 │ rw-
│ (2KB 初始,随 │
│ 扩容而增长) │
│ │
│ ...调用帧... │
│ │
├───────────────────────┤ ← stack.lo + _StackGuard
│ StackGuard │ ← 预留区,用于检测是否需要扩容
│ (928 字节) │
├───────────────────────┤ ← stack.lo + _StackSmall
│ StackSmall │
│ (128 字节) │
├───────────────────────┤ ← stack.lo
│ 守护页 │ ---p (不可访问)
└───────────────────────┘ ← 上一页的末尾
低地址
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
关键字段(runtime/runtime2.go):
type stack struct {
lo uintptr // 栈低地址
hi uintptr // 栈高地址
}
2
3
4
stack.lo:栈底(低地址)stack.hi:栈顶(高地址)- Go 的栈从高向低生长——
call时 SP 减小
# 3.2 栈分裂检查
Go 在每个函数的序言中插入栈分裂检查(stack split check):
func doWork(n int) int {
// 编译器插入的栈检查序言:
// CMP SP, g->stackguard0
// JHI 函数体
// CALL runtime.morestack_noctxt
var buf [256]byte
// ...
}
2
3
4
5
6
7
8
对应汇编(amd64):
TEXT main.doWork(SB), NOSPLIT|NOFRAME, $0-8
// 栈检查
MOVQ (TLS), CX ; CX = 当前 G 的地址
CMPQ SP, 16(CX) ; SP vs G.stackguard0
JHI ok ; 如果 SP > guard → 栈够,跳过
CALL runtime.morestack_noctxt(SB)
ok:
SUBQ $280, SP ; 分配栈帧
; ... 函数体 ...
2
3
4
5
6
7
8
9
关键:CMP SP, G.stackguard0——比较当前 SP 和栈警戒线。当 SP 降到警戒线以下,说明"再压栈就要溢出了",触发 morestack。
# 3.3 栈扩容全流程
runtime.morestack → runtime.newstack 的完整流程:
函数序言检测到 SP < stackguard0
│
▼
runtime.morestack()
│
├── 1. 保存当前调用上下文(PC、SP、BP 等)
│ 通过写入 G.sched 字段
│
▼
runtime.newstack()
│
├── 2. 计算新栈大小
│ newSize = oldSize * 2
│ 但不超过 maxstacksize(1GB)
│ 2KB → 4KB → 8KB → 16KB → ... → 1GB
│
├── 3. 分配新栈空间
│ stackalloc(newSize)
│ → 从 mcache.stackcache 拿(如果有缓存)
│ → 否则从 mheap 分配
│
├── 4. 拷贝旧栈到新栈
│ copy(newStack, oldStack, usedBytes)
│ 调整所有指针:新栈基址变了,
│ 旧栈上的所有指针都要加上偏移量
│
├── 5. 调整 G 的栈描述
│ G.stack.lo = newLo
│ G.stack.hi = newHi
│ G.stackguard0 = newLo + _StackGuard
│
├── 6. 释放旧栈
│ stackfree(oldStack)
│
└── 7. 跳转到 G.sched 保存的 PC 继续执行
→ gogo(&G.sched)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
栈复制的关键——指针调整:
// runtime/stack.go
func copystack(gp *g, newsize uintptr) {
// ...
// 1. 调整 gp 中引用栈上指针的字段
adjustinfo := adjustinfo{...}
// 2. 扫描旧栈上的所有指针
// 对于每个指针 p:
// if 旧栈 ≤ p < 旧栈+used → p = p + delta
// 这就把旧栈上的局部变量指针全部指向新栈对应位置
// 3. 调整 deferred 调用链
// 4. 调整 panic 链
// 5. 调整 G.sched 中保存的寄存器(SP、BP 等)
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
栈扩容的连续性保证——所有指向旧栈的指针都被更新,因为 Go runtime 知道栈上的每个对象的类型(通过栈帧的 funcdata 信息),可以精确找到每一个指针并调整。这就是为什么 Go 的连续栈比 C 的分段栈安全——不存在"跨段指针悬空"的问题(Go runtime 负责修正所有指针)。
# 3.4 栈缩容与回收
栈不只扩容——GC 期间也会检查是否需要缩容:
// runtime/mgcmark.go
func shrinkstack(gp *g) {
used := gp.stack.hi - gp.sched.sp // 实际用到的栈空间
// 缩容条件:使用了不到 1/4 的栈,且栈大于 2KB
if used <= gp.stack.hi - gp.stack.lo / 4 &&
gp.stack.hi - gp.stack.lo > _FixedStack {
newSize := gp.stack.hi - gp.stack.lo / 2 // 缩一半
if newSize < _FixedStack {
newSize = _FixedStack // 最小 2KB
}
copystack(gp, newSize)
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
缩容时机:GC 的 STW(Stop The World)阶段扫描所有 goroutine 栈时,对"用得很浅的大栈"触发缩容。这就意味着一个 goroutine 在递归处理后,栈能自动缩回去——不会像 C 线程栈那样"8MB 一旦分配就在那了"。
# 4. 分段栈的历史教训
# 4.1 1.3 前的分段栈模型
Go 1.3 之前,goroutine 栈是分段栈(segmented stack):每次栈不够用时,分配一个新段(segment),通过链表连接:
G1 栈:
[段 1: 2KB] → [段 2: 4KB] → [段 3: 8KB] → ...
栈底 通过指针链接 通过指针链接
2
3
// Go 1.2 时代的分段栈结构 (已废弃)
type Stktop struct {
stackguard uintptr
stackbase uintptr
next *Stktop // 下一个栈段
// ...
}
2
3
4
5
6
7
每次函数调用时检查 SP < stackguard → 分配新段 → 切换到新段执行 → 函数返回时再切回旧段。
# 4.2 热点分裂问题
分段栈有一个致命问题——热点分裂(hot split):
func hotLoop() {
for i := 0; i < 1000000; i++ {
doWork(i) // doWork 正好在段边界的"悬崖"上
}
}
2
3
4
5
如果 doWork 的调用恰好发生在栈段边界的最后几个字节——每次调用都要触发分段:
段1(满) → doWork ← 段1 栈顶,差 8 字节触发分段
→ 分配段2 → doWork → 返回 → 释放段2 (或保留)
→ 下次循环 → 又差 8 字节触发分段 → 再分配段2
→ ... 100 万次 → 100 万次分段分配和释放
2
3
4
每次分段分配约 ~100ns(mcache 缓存命中)到 ~1μs(需要 OS mmap)。100 万次就是 100ms 到 1s 的纯栈管理开销——而这只是一个 goroutine 里一个循环的代价。
更糟糕的是:如果每次分段后不释放旧段,栈越用越大;如果释放,下次循环又分配——CPU 和内存双杀。
# 4.3 为什么连续栈是正确解
疑惑:为什么不加缓存?比如"刚释放的段保留 1 秒"?
论证:
加缓存只是推迟问题——"热点分裂"的真正原因是"栈段边界不可控"。你没法预知哪个函数的调用帧恰好落在边界的最后几个字节。
连续栈的"复制 + 指针调整"虽然单次代价更高(~1μs 复制 16KB),但触发频率极低——栈扩容是 2× 增长,从 2KB 到 1GB 只需要 20 次扩容。热点分裂在一次循环中就能发生 100 万次。
连续栈的指针调整在 Go 中可行,因为 runtime 知道每个栈帧的类型信息(通过 funcdata)。C 语言做不到这一点——C 的栈帧没有类型元数据,运行时不知道
[rbp-8]是指针还是整数,无法安全复制栈。反向验证——Go 1.4 切换到连续栈后,所有"热点分裂"导致的性能抖动 bug 全部消失。这个决策的证据不是理论推导,是 Go 核心团队在生产系统中测量到的实际性能数据。
结论:连续栈的"复制"是一次性代价,分段栈的"分裂"是重复性惩罚。在 goroutine 生命周期中,栈扩容总共发生十几次(2KB→1GB 维度),而热点分裂可以在毫秒内触发上万次。连续栈是 Go 内存模型里最正确的设计决策之一。
# 5. 堆的四级分配器
# 5.1 mspan 页管理单元
Go 的堆不是"字节级细粒度"分配的——它以 页(page,8KB) 为单位从 OS 申请,然后在页内切成固定大小的对象:
mspan (内存跨度) 结构:
┌──────────────────────────────────────────────────┐
│ mspan 元数据: │
│ startAddr uintptr ← 这段内存的起始地址 │
│ npages uintptr ← 页数(1+) │
│ spanclass spanClass ← 大小级 + noscan 标志 │
│ allocBits *gcBits ← 分配位图(每 bit 一个槽)│
│ freeIndex uintptr ← 空闲槽索引 │
│ allocCount uint16 ← 已分配对象数 │
│ nelems uint16 ← 对象槽总数 │
│ next/prev *mspan ← mcentral 双向链表 │
│ │
├──────────────────────────────────────────────────┤
│ │
│ [slot 0][slot 1][slot 2] ... [slot N-1] │
│ 每个 slot 大小 = spanclass 指定的 size │
│ │
└──────────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
mspan 有 68 个大小级(size class):
| 级 | 对象大小 | 每页槽数 | 浪费率 |
|---|---|---|---|
| 0 | 8 B | 1024 | 0% |
| 1 | 16 B | 512 | 0% |
| 2 | 24 B | 341 | 0.3% |
| 3 | 32 B | 256 | 0% |
| ... | ... | ... | ... |
| 66 | 28672 B | 0(跨页) | — |
| 67 | 32768 B | 0(跨页) | — |
// runtime/sizeclasses.go (Go 源码摘录)
var class_to_size = [_NumSizeClasses]uint16{
0, 8, 16, 24, 32, 48, 64, 80, 96, 112, 128, ...
}
var class_to_allocnpages = [_NumSizeClasses]uint8{
0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, ...
}
2
3
4
5
6
7
每个 mspan 只管理一种大小级的对象——这就是"按大小级分类"的核心。需要 24 字节对象的 goroutine 不会去抢 32 字节的槽。
# 5.2 mcache 每 P 缓存
mcache 是每个 P 一个的本地缓存——在同一个 P 上运行的 goroutine 访问 mcache 完全无锁:
// runtime/mcache.go
type mcache struct {
nextSample uintptr // 触发 heap profiling 的采样计数
// 内存分配缓存
alloc [numSpanClasses]*mspan // 每个 spanClass 一个 mspan
// 微对象分配器 (tiny allocator)
tiny uintptr // 当前 tiny 块的起始地址
tinyoffset uintptr // 当前 tiny 块的已用偏移
tinyAllocs uintptr // tiny 分配计数
// 栈缓存
stackcache [_NumStackOrders]stackfreelist
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
分配流程(小对象 ≤ 32KB):
goroutine 调用 new(T) 或 make([]int, 10)
│
▼
获取当前 P 的 mcache
│
├── mcache.alloc[spanClass] 有可用 mspan?
│ │
│ ├── 有 → mspan.freeIndex 指向的空闲槽
│ │ └─ 更新 allocBits + freeIndex
│ │ → 返回槽的地址 (O(1),无锁)
│ │
│ └── 没有 → 去 mcentral 拿一个 mspan
2
3
4
5
6
7
8
9
10
11
12
为什么 mcache 是每 P 一个而不是每 G 一个——goroutine 可能在 P 之间迁移(当它被抢占或系统调用返回时)。每 P 一个 mcache 保证了:同一时刻只有一个 goroutine 在访问这个 mcache——无锁。
# 5.3 mcentral 全局供给
当 mcache 的某个 spanClass 用完了,去 mcentral 拿:
// runtime/mcentral.go
type mcentral struct {
spanclass spanClass
partial [2]spanSet // 有可分配槽的非满 mspan 集合
full [2]spanSet // 全满的 mspan 集合(惰性清扫)
}
2
3
4
5
6
7
mcentral 的关键:它只负责一个 spanClass——68 个大小级 × 2 (scannable/noscan) = 136 个 mcentral:
mcache 用完了某个 spanClass 的 mspan:
│
▼
mcentral[spanClass].cacheSpan()
│
├── 1. 从 partial 集合拿一个非满 mspan → 返回
│
├── 2. partial 为空 → 从 full 集合拿一个
│ → 可能已清扫(有可分配槽)→ 移到 partial
│ → 可能全部在使用 → 跳过
│
└── 3. 都没有 → 去 mheap 申请新的 mspan
2
3
4
5
6
7
8
9
10
11
12
mcentral 有堆锁——但竞争很小,因为 mcache 已经缓存了大部分请求。只有当 P 的本地缓存全部用完时才会走到 mcentral。
# 5.4 mheap OS 接口层
当 mcentral 也没有可用 mspan 时,到 mheap 从 OS 申请新页:
// runtime/mheap.go
type mheap struct {
lock mutex // 全局堆锁
// 页分配器
pages pageAlloc // 位图式页分配器
// 按大小级缓存的 mspan(GC 清扫后放这里复用)
central [numSpanClasses]struct {
mcentral mcentral
pad [cpu.CacheLinePadSize - unsafe.Sizeof(mcentral{})%cpu.CacheLinePadSize]byte
}
// 大对象分配统计
largeAlloc uint64
largeAllocCount uint64
// arena 区域(Go 1.22+ 的 page alloc 重构后简化了)
arenas [1 << arenaL1Bits]*[1 << arenaL2Bits]*heapArena
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
mheap 从 OS 申请内存的方式——调用 mmap:
// runtime/mem_linux.go
func sysAlloc(n uintptr, sysStat *sysMemStat) unsafe.Pointer {
p, err := mmap(nil, n,
_PROT_READ|_PROT_WRITE,
_MAP_ANON|_MAP_PRIVATE, -1, 0)
// ...
}
2
3
4
5
6
7
Go 不和 C 的 brk/sbrk 交互——所有堆内存都通过 mmap 申请(匿名映射)。这避免了和 glibc malloc 的冲突。
大对象 (>32KB) 直接走 mheap:不经过 mcache → mcentral 链条,直接在 mheap 层面分配 page span,并且有自己的 mspan 记录。
# 6. 对象大小三级分类
# 6.1 Tiny 微型分配器
Tiny allocator 是 mcache 内嵌的一个特殊优化——给极小的、无指针的对象用的:
// 分配 ≤ 16 字节的无指针对象
// 示例:小字符串头(StringHeader: 16B)、小的 bool 包装
var flag = true // 可能走 tiny allocator
// runtime/malloc.go
func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
if size <= maxTinySize && noscan {
off := c.tinyoffset
if off+size <= maxTinySize && c.tiny != 0 {
// 塞进当前 tiny 块的剩余空间
x = unsafe.Pointer(c.tiny + off)
c.tinyoffset += size
return x
}
// 当前 tiny 块不够 → 从 mcache 申请新的 16 字节块
// ...
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Tiny allocator 的精妙:多个小对象挤在同一个 16 字节的内存块里。例如:
tiny 块 (16 字节):
┌────┬────┬────┬────┬────┬────┬────┬────┬────┬────┬────┬────┬────┬────┬────┬────┐
│ B1 │ B2 │ B3 │ 剩余 │
└────┴────┴────┴────┴────┴────┴────┴────┴────┴────┴────┴────┴────┴────┴────┴────┘
3B 3B 1B 9B free
2
3
4
5
B1 是一个 bool (1B) + int16 (2B) 的小结构体,B2 是另一个小结构体——本来需要 3 次 malloc,tiny allocator 让它们共享同一个 16 字节块。
# 6.2 Small 按级匹配
Small 对象(≤ 32KB 且 > 16B) 走标准 mcache → mcentral 路径:
// 分配 64 字节对象:→ spanClass = size_to_class[64] = 5
// mcache.alloc[5] 有 mspan → 直接从这个 mspan 拿空闲槽
var buf = make([]byte, 64)
2
3
完整的小对象分配路径:
new([64]byte)
→ size=64, class=5, spanClass=(5, scan)
→ mcache.alloc[spanClass]
├─ 有空闲槽 → O(1) 拿 → 返回
├─ 无空闲槽 → mcentral.cacheSpan()
│ ├─ partial 有 → 拿 → 给 mcache
│ ├─ full 有已清扫的 → 拿 → 给 mcache
│ └─ 全部满 → mheap.alloc(npages)
│ ├─ page allocator 找 N 页连续空间
│ ├─ 找到 → 初始化 mspan → 给 mcentral → 给 mcache
│ ├─ 找不到 → sysAlloc(mmap 新 arena)
│ └─ 全失败 → OOM panic
└─ O(1) 从 mspan 拿空闲槽 → 返回
2
3
4
5
6
7
8
9
10
11
12
13
# 6.3 Large 直接走 OS
大对象(>32KB) 跳过 mcache/mcentral,直接到 mheap:
// 分配 64KB:→ large allocation path
var bigBuf = make([]byte, 64*1024)
// runtime/malloc.go
func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
if size > maxSmallSize { // 32768
// large allocation path
span = mheap_.alloc(npages, spanClass)
span.freeindex = 1
span.allocCount = 1
x = unsafe.Pointer(span.base())
}
}
2
3
4
5
6
7
8
9
10
11
12
13
大对象的 mspan 只包含一个对象——它的 nelems = 1。释放时直接整体归还 mheap,由 mheap 的 page allocator 回收页。
# 7. 逃逸分析决定分配位置
# 7.1 编译器逃逸判定规则
Go 编译器对每个 new(T) 或 &T{...} 做逃逸分析(escape analysis)——决定对象放栈上还是堆上:
$ go build -gcflags="-m" push_service.go
./push_service.go:15:6: moved to heap: msg # msg 逃逸到堆
./push_service.go:18:3: func literal escapes to heap # goroutine 闭包逃逸
./push_service.go:30:15: ... argument escapes to heap
2
3
4
编译器判定逃逸的核心规则:
return &local→ 逃逸(返回了局部变量的地址,调用方还要用它)- 存进全局变量 → 逃逸(全局变量生命周期是整个程序)
- 存进
interface{}→ 逃逸(编译器不知道接口背后是什么类型,保守处理) go func() { use(local) }→ 逃逸(闭包的执行时间不确定,局部变量必须活着)- 存进切片/映射/通道 → 可能逃逸(如果切片本身在堆上)
- 超出栈帧生命周期 → 逃逸(被其他 goroutine 引用)
不逃逸的条件:编译器能在编译期证明对象的生命周期 ≤ 创建它的函数的栈帧生命周期。
# 7.2 常见逃逸场景
// ✅ 不逃逸:局部使用,编译器栈上分配
func noEscape() int {
x := new(int) // 编译器:x 只在这个函数里用
*x = 42 // → 栈上分配!不需要 GC 扫描
return *x
}
// ❌ 逃逸:返回了指针
func escape1() *int {
x := new(int) // x 要返回给调用者
*x = 42 // → 堆上分配
return x
}
// ❌ 逃逸:存进全局 map
var cache = make(map[string]*Data)
func escape2(key string, val Data) {
cache[key] = &val // &val 逃逸 → val 堆上分配
}
// ❌ 逃逸:传给 interface{}
func escape3(x int) {
fmt.Println(x) // x 被装箱成 interface{} → 逃逸
}
// ❌ 逃逸:goroutine 闭包捕获
func escape4() {
x := 42
go func() {
fmt.Println(x) // x 被闭包捕获 → 逃逸
}()
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
# 7.3 逃逸的汇编证据
验证"不逃逸时栈上分配":
// test_escape.go
func sum(a, b int) *int {
result := new(int)
*result = a + b
return result
}
func noEscape(a, b int) int {
result := new(int)
*result = a + b
return *result
}
2
3
4
5
6
7
8
9
10
11
12
sum(逃逸版)vs noEscape(非逃逸版)的汇编对比:
; sum: 堆分配版本
sum:
CALL runtime.newobject(SB) ; ← 调用 runtime 分配器 → 堆
MOVQ AX, (AX) ; *result = a+b
RET
; noEscape: 栈分配版本
noEscape:
MOVQ AX, (SP) ; 直接在栈上写结果
RET ; ← 没有调用 newobject!
2
3
4
5
6
7
8
9
10
逃逸分析的意义:Go 的栈分配比堆分配快 10~100 倍。栈分配 = 减 SP 一条指令;堆分配 = mcache 查找 + 可能 mcentral + 可能 GC。逃逸分析决定了一个对象的"人生成本"。
# 8. GC 与内存布局的联动
# 8.1 写屏障与栈扫描
Go GC 使用三色标记 + 写屏障(混合写屏障,Go 1.8+):
- 栈上的对象在 GC 标记阶段需要暂停 goroutine、扫描其栈帧——找栈上所有"指向堆对象"的指针
- 写屏障拦截所有"向堆对象写入指针"的操作——防止 GC 漏标
goroutine 栈帧中的指针 → GC root → 标记堆对象 → 递归标记
│
▼
栈上的指针为什么重要?
→ 如果栈上有个 *T 指向堆对象,
这个堆对象必须被标记为存活
→ GC 必须扫描每个 goroutine 的栈
2
3
4
5
6
7
栈是 GC 的根集合之一——连续栈让 GC 能线性扫描整个栈(不像分段栈需要遍历链表)。
# 8.2 对象头的 GC 位
Go 堆对象的布局:
┌─────────┬───────────────────────────────────┐
│ 对象头 │ 用户数据 │
│ (8-16B) │ (size 字节) │
├─────────┴───────────────────────────────────┤
│ 对象头包含: │
│ - 指向 _type 的指针(类型信息,包含 gc mask) │
│ - GC 色标(white/grey/black,占用 2 bits) │
│ - 是否为 noscan(无指针,跳过 GC 扫描) │
└─────────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
noscan 标志的关键价值——如果对象声明为无指针类型(如 [64]byte、不含指针的结构体),它的 mspan 被标记为 noscan。GC 扫描时直接跳过这个 mspan 的所有对象——大幅减少扫描时间。
// 有指针 → scan
type Node struct {
next *Node // 有指针!
data [64]byte
}
// 无指针 → noscan
type DataBlock struct {
id int64
buf [256]byte
flags uint32
}
2
3
4
5
6
7
8
9
10
11
12
# 8.3 内存返还 OS 的时机
疑惑:Go 进程的 RSS 为什么通常不降?
论证:
Go runtime 在 GC 后不会立即把空闲内存 munmap 还给 OS,而是保留给后续分配:
// runtime/mheap.go
func (h *mheap) scavenge(nbytes uintptr) {
// scavenger(清扫器)在后台定期运行
// 把超过 5 分钟未使用的空闲页归还给 OS(_madvise_dontneed)
// → OS 可以回收这些页的物理内存 → RSS 降
}
2
3
4
5
6
Go 还内存给 OS 的条件:
- 页在 mheap 的 free 集合中(不隶属于任何 mspan)
- 页已经空闲超过 5 分钟(
scavenge的周期) - 通过
madvise(MADV_DONTNEED)告诉内核可以回收物理页——虚拟地址保留但物理页释放
为什么不立即还——Go runtime 内部有大量的"短期空闲"内存(goroutine 创建销毁、临时缓冲区等)。5 分钟延迟让大部分临时对象自然死亡、复用,避免频繁 mmap/munmap。
手动触发:
import "runtime/debug"
debug.FreeOSMemory() // 强制 GC + 立即 scavenge
2
排除"Go 内存泄漏假象":pprof heap -inuse_space 显示的是Go 认为在用的内存——如果这里不大但 RSS 很大,说明是 Go runtime "囤着"没还给 OS。用 debug.FreeOSMemory() 后 RSS 降了 → 不是泄漏,是 scavenger 没触发。
# 9. pprof 内存诊断实战
# 9.1 heap profile 解读
$ go tool pprof http://localhost:6060/debug/pprof/heap
(pprof) top
Showing nodes accounting for 1.65GB, 92% of 1.80GB total
flat flat% sum% cum cum%
1.20GB 66.67% 66.67% 1.20GB 66.67% json.RawMessage
2
3
4
5
- flat:这个函数直接分配的内存(不包括它调用的子函数)
- cum:这个函数及其子函数累计分配的内存
flat=1.2GB cum=1.2GB→json.RawMessage本身分配了 1.2GB,且它的子函数没有更多分配
(pprof) list handlePush
ROUTINE ======================== main.handlePush
1.20GB 1.20GB (flat, cum) 66.67% of Total
. . 30:func handlePush(msg *PushMessage) error {
. . 31: mu.Lock()
. . 32: if len(recentMessages) >= 1000 {
. . 33: recentMessages = recentMessages[1:]
. . 34: }
1.20GB 1.20GB 35: recentMessages = append(recentMessages, msg)
2
3
4
5
6
7
8
9
第 35 行:append 触发了底层数组的分配/扩容——堆上分配。
# 9.2 goroutine 栈快照
$ go tool pprof http://localhost:6060/debug/pprof/goroutine
(pprof) top
Showing nodes accounting for 4872, 99.5% of 4896 total
flat flat% sum% cum cum%
4872 99.51% 99.51% 4872 99.51% runtime.gopark
2
3
4
5
4872 个 goroutine 卡在 gopark(等待状态)——不是"泄漏"就是"死锁在 channel/锁上"。
看具体卡在哪:
$ curl http://localhost:6060/debug/pprof/goroutine?debug=2 | grep -A2 "handlePush"
goroutine 4872 [chan receive]:
main.handlePush.func1()
/app/push_service.go:42 +0x8f
2
3
4
第 42 行是什么?——for msg := range pushCh。生产者的 pushCh 没有被关闭且没有新数据,消费者 goroutine 永久阻塞。
# 9.3 典型泄漏模式
模式 1:切片容量不缩(第 1 章案例):
// ❌ slice = slice[1:] → 底层数组不变
recentMessages = recentMessages[1:]
// ✅ 显式截断底层数组
if len(recentMessages) >= 1000 {
copy(recentMessages, recentMessages[1:])
recentMessages = recentMessages[:len(recentMessages)-1]
}
2
3
4
5
6
7
8
模式 2:闭包捕获大变量:
// ❌ 闭包引用整个大结构体
var bigBuf [1 << 20]byte
go func() {
time.Sleep(time.Hour)
use(bigBuf[0]) // bigBuf 逃逸,1MB
}()
// ✅ 只捕获需要的部分
v := bigBuf[0]
go func() {
time.Sleep(time.Hour)
use(v) // 只捕获 1 字节
}()
2
3
4
5
6
7
8
9
10
11
12
13
模式 3:Goroutine 泄漏——永远阻塞的 channel:
// ❌ channel 没关闭 → goroutine 永远阻塞
ch := make(chan struct{})
go func() { <-ch }() // 这个 goroutine 永远不会退出
// ✅ 用 context 控制生命周期
go func() {
select {
case <-ctx.Done():
return
case <-ch:
}
}()
2
3
4
5
6
7
8
9
10
11
12
# 10. 综合案例串讲
# 10.1 案例真相揭晓
回到第 1 章推送服务的八个疑问,逐条作答:
| 疑问 | 答案 |
|---|---|
| ① goroutine 栈初始多大?何时扩容? | 第 3 章:2KB 起步,序言 SP 检测触发 morestack → 2× 扩容 |
| ② 连续栈怎么复制? | 第 3.3:分配新栈 → 拷贝数据 → 调整所有指针 → 释放旧栈 |
| ③ 为什么砍分段栈? | 第 4 章:热点分裂——循环中反复在段边界触发分配 |
| ④ 四级分配器怎么协作? | 第 5 章:mcache(P) → mcentral(有锁) → mheap(全局) → OS mmap |
| ⑤ 怎么决定栈/堆? | 第 7 章:逃逸分析——指针被 return/存全局/进闭包 → 堆 |
| ⑥ 切片容量为什么不缩? | 第 9.3:data[1:] 不释放底层数组 → 旧指针仍可达 → GC 不回收 |
| ⑦ 内存什么时候还 OS? | 第 8.3:scavenger 后台进程,空闲 5 分钟后 madvise(DONTNEED) |
| ⑧ pprof 怎么看泄漏? | 第 9 章:heap profile 看分配热点,goroutine profile 看阻塞点 |
案例完整根因链条:
推送消息 JSON payload 2MB
→ json.Unmarshal 在堆上分配 RawMessage ([]byte 底层 2MB)
→ PushMessage.Payload 持有这个切片引用
→ PushMessage 指针存入 recentMessages(容量 1000 的切片)
→ 1000 个 2MB 消息 ≈ 2GB
→ recentMessages = recentMessages[1:] 只是移动了切片头
→ 底层数组的前面槽位中的旧 PushMessage 指针仍然存在
→ GC 扫描到底层数组中所有槽位的指针 → 标记为存活 → 内存不释放
→ 直到 cleanupExpired() 用 filtered 替换整个切片引用
→ 旧底层数组才变得完全不可达 → GC 回收 → 但 scatter 要 5 分钟后才还 OS
→ 这 5 分钟内 RSS 2GB → OOM Kill
2
3
4
5
6
7
8
9
10
11
修复方案:
// 方案 A:显式截断(治本)
if len(recentMessages) >= 1000 {
n := copy(recentMessages, recentMessages[1:])
recentMessages = recentMessages[:n]
}
recentMessages = append(recentMessages, msg)
// 方案 B:用固定长度环形缓冲区(更可预测)
type RingBuffer struct {
buf []*PushMessage
head int
count int
}
// 方案 C:限制 Payload 大小
if len(payload) > 256*1024 {
payload = payload[:256*1024] // 截断到 256KB
}
// 方案 D:定期 FreeOSMemory
go func() {
for range time.Tick(2 * time.Minute) {
runtime.GC()
debug.FreeOSMemory()
}
}()
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# 10.2 一个 Go 对象的完整旅程
var msg = &PushMessage{UserID: 42, Payload: payload}
───────────────────────────────────────────────────────────
│
├─ 编译期:逃逸分析
│ &PushMessage{...} 被存进 recentMessages(全局变量)
│ → 必须逃逸到堆
│
├─ 编译器生成代码:调用 runtime.newobject
│ object size = sizeof(PushMessage) = 32 字节
│ → size class 3 (32 字节),noscan = false (有 Payload 字段)
│
├─ 运行时分配:
│ P.mcache.alloc[spanClass]
│ ├─ mcache 中该 spanClass 的 mspan 有空闲槽 → O(1) 分配
│ ├─ 无 → mcentral.cacheSpan()
│ ├─ 无 → mheap.alloc(1 page)
│ │ ├─ page allocator 找空页
│ │ └─ 切成 256 个 32B 槽 → 初始化 mspan → 返回
│ └─ 返回槽地址 0xc00019a000
│
├─ 对象头写入:
│ [0xc00019a000-8]: 指向 *_type(PushMessage) 的指针
│ GC 色标初始化为 white
│
├─ 用户代码填充字段:
│ msg.UserID = 42
│ msg.Payload = payload (payload 本身也在堆上)
│ → 写屏障触发:堆对象写入指针 → GC 记录
│
├─ 存入 recentMessages:
│ recentMessages[999] = msg (又是一个堆对象写入)
│ → 写屏障触发
│
├─ GC 扫描:
│ 根: recentMessages → 遍历底层数组的每个槽
│ → 发现 msg → 标记 grey → 扫描 msg 字段
│ → msg.Payload 也是堆指针 → 标记 → 递归
│ → 全部可达对象标记 black
│ → 剩下的 white 对象 → sweep → 归还 mspan 到 mcentral
│
├─ 对象释放:
│ cleanupExpired() 后 filtered 替换 recentMessages
│ → 旧底层的 msg 不可达
│ → 下一次 GC 标记 msg 为 white → sweep
│ → mspan.allocCount -= 1
│ → allocCount == 0 → 整个 mspan 归还 mheap
│ → mheap 中空闲 5 分钟后 scavenge → madvise(DONTNEED) → RSS 降
│
└─ OOM Kill 的物理路径:
scavenger 未触发 → 物理页仍在 RSS
→ K8s limit 2.5GB → 内核 OOM Killer → SIGKILL
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
# 10.3 设计哲学回扣
哲学 1:用复制代替分裂——连续栈的设计勇气
C/C++ 的线程栈是"固定大小、不可复制"的——因为编译器不知道栈上的类型信息。Go 在编译时把每个函数的栈帧类型信息嵌入二进制(funcdata),让 runtime 能在运行时精确调整所有栈上指针。这个决定让 Go 的 goroutine 栈从 2KB 起,按需扩容到 1GB——用"每次几微秒的复制"换来了"100 万 goroutine 只要 2GB 虚拟空间"。这是 Go 最激进也最正确的设计选择之一。
哲学 2:用分级缓存消除锁竞争——TCMalloc 的 Go 改造
四级分配器(mcache→mcentral→mheap→OS)把 99% 的分配操作限定在 per-P 的无锁缓存内。没有这层设计,所有 goroutine 每次 new(T) 都要争抢全局堆锁——Go 的并发模型将名存实亡。每个层级有精确的边界:mcache 管分配、mcentral 管调度、mheap 管 OS 交互——职责分离保证了锁的粒度不同。
哲学 3:编译器替你"算分配位置"——逃逸分析的编译器天赋
Go 编译器在编译期就决定了"这个对象在栈上还是堆上"。它不需要程序员标注 alloc/free 或 new/delete——通过静态分析直接给出最优解。逃逸分析是 Go "看起来慢(GC),其实快(栈分配多)"的关键——大量的短生命周期对象根本进入不了 GC 的视线。
哲学 4:延迟归还——在"OS 视角"和"Runtime 视角"之间做缓冲
Go runtime 持有空闲内存 5 分钟才归还 OS。这不是 bug,是工程权衡:goroutine 的创建/销毁、临时缓冲区的分配/释放都是极高频的。如果每次 free 都 munmap,mmap/munmap 的系统调用会吃掉大量 CPU。5 分钟的"囤积周期"给了 Go 自主复用的窗口。
# 10.4 速查表
栈相关:
| 项 | 值 |
|---|---|
| 初始栈大小 | 2KB(Go 1.4+) |
| 扩容倍数 | 2×(2KB→4KB→8KB→...→1GB) |
| 缩容阈值 | 使用量 < 1/4 容量 |
| 缩容时机 | GC STW 阶段 |
| 最小栈 | 2KB(不可缩到以下) |
| 最大栈 | 1GB(64 位) |
| 栈守护页 | 1 页(4KB),栈底 ---p |
堆相关:
| 层级 | 锁粒度 | 用途 |
|---|---|---|
| mcache | 无锁(per-P) | goroutine 最常用的分配入口 |
| mcentral | 有锁(per-spanClass) | mcache 的补给站 |
| mheap | 全局锁 | OS mmap 页面分配 |
| OS (mmap) | 系统调用 | 真正的虚拟内存申请 |
对象大小级:
| 分类 | 大小 | 分配路径 |
|---|---|---|
| Tiny | ≤ 16B,无指针 | mcache.tiny 组合分配 |
| Small | ≤ 32KB | mcache → mcentral → mheap |
| Large | > 32KB | mheap 直接分配 |
诊断命令:
# 逃逸分析
go build -gcflags="-m" . # 查看逃逸报告
go build -gcflags="-m -m" . # 更详细的逃逸分析
# pprof
go tool pprof http://localhost:6060/debug/pprof/heap # 堆 profile
go tool pprof http://localhost:6060/debug/pprof/goroutine # goroutine 栈快照
go tool pprof -alloc_space http://localhost:6060/debug/pprof/heap # 累计分配
# 运行时调试
GODEBUG=gctrace=1 ./app # GC 日志
GODEBUG=allocfreetrace=1 ./app # 分配/释放跟踪
GODEBUG=schedtrace=1000 ./app # 调度器跟踪
# 环境变量
GOGC=100 # GC 触发比率(默认 100%)
GOMEMLIMIT=2GiB # 软内存限制(Go 1.19+)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
下一篇:我们已经知道了"Go 的对象怎么在栈和堆上分配",下一步进入 02.Go 对象内部布局——把
struct的对齐、接口的iface/eface结构、slice/map/channel的内存表示剖到字节级别。