3.堆和栈内存的设计
# 32.堆和栈内存的设计
📍 本篇位置:第 4 卷 · 内存与资源 · 第 2 篇 🎯 核心矛盾:栈的快速 vs 堆的灵活 —— 一个在编译期就定生死,一个随运行期自由膨胀 🧭 设计灵魂:栈 = LIFO + 自动释放,堆 = 自由分配 + 需要回收;现代语言的"逃逸分析"正是在消灭"不必在堆上"的分配 🌐 跨语言覆盖:C/C++(明确分清 stack/heap) · Java(基本类型栈 + 对象堆 + 逃逸分析) · Go(编译器自动决定堆栈) · Rust(默认栈 + Box 显式堆) · JavaScript(基本类型栈 + 对象堆) 🔗 延伸阅读:← 31.内存模型技术设计 · → 33.内存回收机制设计 · → 34.多种引用技术设计
flowchart LR
A[变量生命周期] --> B{编译期能否确定}
B -->|是| C1[栈上分配<br/>push/pop 极快]
B -->|否| C2[堆上分配<br/>malloc/new 较慢]
C1 --> D1[函数返回自动释放]
C2 --> D2[需要 GC 或手动释放]
D2 --> E[现代优化<br/>逃逸分析把堆降级到栈]
style C1 fill:#d4edda
style E fill:#d4edda
2
3
4
5
6
7
8
9
# 目录介绍
# 0.从一个深夜事故说起
# 0.1 一个递归引发的雪崩
2024 年某个工作日的凌晨 1 点,我们的对账服务突然告警——所有任务全部 StackOverflowError 崩溃。值班同学翻日志,发现同一行代码刷屏:
// 简化后的对账代码
public BigDecimal sumOrders(List<Order> orders) {
return sumRecursive(orders, 0);
}
private BigDecimal sumRecursive(List<Order> orders, int idx) {
if (idx == orders.size()) return BigDecimal.ZERO;
return orders.get(idx).getAmount()
.add(sumRecursive(orders, idx + 1)); // 尾递归,但 JVM 不优化
}
2
3
4
5
6
7
8
9
10
这段代码在 QA 环境跑了半年都没事——QA 数据每天最多 5000 笔订单。但凌晨那批历史数据补录有 48 万笔。 48 万次递归调用,把 1MB 的线程栈撑爆了。
第一个修复想法很自然:"把它改成循环不就行了?" 但事故复盘会上有人追问:
- 为什么
for循环能处理 4800 万行,递归连 5 万行都撑不住? - 为什么栈这么"小气",不能像堆一样自动扩容?
- 同样是"放变量",为什么这俩待遇差这么多?
这三个问题,正是本章要回答的核心。
# 0.2 三个被忽视的本质问题
flowchart TB
Q[一段会崩的递归代码] --> Q1[问题1<br/>栈为何有上限<br/>而堆没有?]
Q --> Q2[问题2<br/>同样是变量<br/>为何区别对待?]
Q --> Q3[问题3<br/>这种区分<br/>是被迫的还是主动的?]
Q1 --> A1[栈的物理本质<br/>= 一段连续虚拟地址<br/>= 触碰守护页就崩]
Q2 --> A2[生命周期分裂<br/>= 嵌套 vs 任意<br/>= LIFO 模型 vs 自由模型]
Q3 --> A3[计算机科学最经典取舍<br/>= 约束换性能<br/>= 自由换灵活]
style A1 fill:#fff3cd
style A2 fill:#fff3cd
style A3 fill:#d4edda
2
3
4
5
6
7
8
9
10
这三个问题层层递进——从现象(崩了)追到机制(为什么),再到哲学(为什么必然这样设计)。接下来每一节都在回答其中之一:
| 问题 | 答案章节 | 关键洞察 |
|---|---|---|
| 为什么必须分堆和栈? | §1.1 / §2.3 | 数据生命周期的两种模式天然不可统一 |
| 栈为什么这么快? | §3.1 / §3.2 / §6.4 | CPU 硬件直接支持 + 缓存极致命中 |
| 堆为什么这么贵? | §4.1 / §4.2 / §6.4 | 自由度的代价:搜索 + 元数据 + 同步 |
| 怎么减少堆分配? | §7.3 / §7.4 | 逃逸分析、所有权——"把堆降级回栈" |
| 哪些坑必须避开? | §8.x | 五个真实生产事故复盘 |
# 01.核心设计思想
# 1.1 为何分堆和栈
计算机内存只是一个巨大的字节数组,本身不区分"堆"和"栈"。分区是软件层面的设计决策——但这个决策不是某个委员会拍脑袋决定的,而是被两种本质不同的数据生命周期"逼"出来的。
第一性追问:从生命周期开始看
程序里的所有数据,按生命周期可以分成两类:
| 需求特征 | 典型场景 | 生命周期 | 大小 |
|---|---|---|---|
| 确定的、短暂的、有层级的 | 函数局部变量、返回地址 | 函数进入→函数返回 | 编译时已知 |
| 不确定的、跨越函数的 | 动态创建的对象、用户输入的数据 | 程序员/GC 决定 | 运行时才知 |
这两种生命周期有一个关键差异:前者是嵌套的(nested),后者是任意的(arbitrary)。
嵌套生命周期(函数调用): 任意生命周期(对象创建):
main() ┌─────────────┐ obj A ────────┐
│ caller() │ obj B ──┐ │
│ ┌─────────┐ │ obj C ──┼─┐ │
│ │ callee()│ │ │ │ │
│ └─────────┘ │ ▼ ▼ ▼
└─────────────┘ 生命周期可重叠/交叉
完美的 LIFO 嵌套
2
3
4
5
6
7
8
9
反向假设:如果只有一种内存区域
用堆解决所有问题(极端方案 1):
void func() {
int* x = malloc(sizeof(int)); // 局部变量也得 malloc
*x = 42;
// ... 用完 ...
free(x);
}
// 100 行代码里 50 个局部变量 → 100 次 malloc/free
// 每次调用都触发 ~50ns × 100 = 5μs 纯分配开销
// 函数调用频次 1000 万/秒 → CPU 全花在分配上
2
3
4
5
6
7
8
9
用栈解决所有问题(极端方案 2):
Node* createList() {
Node head; // 栈上分配
head.next = ...;
return &head; // 返回栈地址 → 函数返回栈帧销毁 → 悬空指针
}
// 跨函数生命周期的数据无处安放
2
3
4
5
6
两种方案都不可行——它们各自在另一种生命周期模式上彻底崩盘。栈为"确定性"而生,堆为"灵活性"而生,这不是设计偏好,而是被数据生命周期的物理属性逼出来的。
更深一层:CPU 函数调用机制本身就要求栈
冯·诺依曼架构的函数调用满足三个铁律:
- 嵌套:A 调 B,B 调 C,必然 C 先返回,再 B,再 A(LIFO)
- 要保存返回地址:CALL 指令需要把 RIP 推到某处
- 要保存现场:被调用函数要保留调用者的寄存器
这三件事拼起来,几乎在数学上等价于一个栈结构。所以 CPU 直接给栈配了硬件指令(PUSH/POP/CALL/RET)和专用寄存器(RSP)——栈不是软件加的概念,它从 CPU 指令集层面就被钦定了。
而程序创建的数据对象不遵循 LIFO(一个对象可以被任意函数持有、任意时刻销毁),需要自由分配/释放,这就是堆。
# 1.2 栈设计思想
核心命题:栈是"用约束换极致性能"的典型代表——它故意放弃了大量自由度,换来其他内存模型望尘莫及的速度。
推演过程:约束如何变成性能
约束 1:只能在顶部操作(LIFO)
↓
分配位置不需要搜索 → 永远在 RSP 指向的位置
↓
分配 = sub rsp, N (1 条指令)
释放 = add rsp, N (1 条指令)
约束 2:大小在编译期确定
↓
不需要运行时元数据(不需要记"这块多大")
↓
没有 header 开销,没有空闲表
约束 3:连续分配
↓
天然无碎片(碎片只能出现在"中间块被释放"时,而 LIFO 永远不会)
约束 4:每线程独立
↓
天然线程安全,不需要任何同步原语
约束 5:栈顶反复使用同一片小区域
↓
时间局部性 + 空间局部性 → L1 命中率 ~99%
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
关键洞察:约束的乘法效应
这五个约束不是相加而是相乘——它们互相强化:
- 因为 LIFO,所以无碎片;因为无碎片,所以无元数据;因为无元数据,所以更小更快更命中缓存
- 因为每线程独立,所以无锁;因为无锁,所以无 cache-line 弹跳;因为无弹跳,所以多核扩展性满分
这就是为什么栈分配能比堆分配快 50 倍以上——不是某一项快,而是每一项都快,且互相加成。
栈是一种被故意限制了能力的内存管理方式。正因为限制了使用方式(只能从顶部操作),才能做到极致简单和极致快。这是计算机系统设计中"少即是多"哲学最纯粹的体现。
# 1.3 堆设计思想
核心命题:堆与栈是镜像关系——栈是"放弃自由换性能",堆是"放弃性能换自由"。两者都是最优解,只是优化目标不同。
推演过程:自由如何变成代价
自由 1:任意时刻分配任意大小
↓
必须有一张"哪里有空、空多大"的地图(空闲表/树)
↓
分配 = 在地图里搜索合适的块 → O(log n)~O(n)
自由 2:任意时刻释放任意块
↓
释放点不再连续 → 出现"中间空、两边占用"的碎片
↓
必须做合并:检查左右邻居是否也空闲 → 复杂的边界判断
自由 3:每块大小可任意
↓
必须为每块附带元数据(size、是否已用、前后指针)
↓
malloc(1) 实际占用 32 字节 = 16B header + 16B 最小数据
自由 4:跨线程共享
↓
多线程同时 malloc 必须协调
↓
需要锁/CAS/per-CPU 缓存等复杂机制
自由 5:分配顺序 ≠ 访问顺序
↓
对象在地址空间里散落
↓
缓存命中率 ~50-70%(远低于栈的 95%+)
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
核心代价对照表:
核心自由:任意时刻分配任意大小,任意时刻释放任意块
付出的代价:
分配 = 搜索空闲块 + 分割 O(1)~O(n)
释放 = 合并相邻空闲块 O(1)~O(log n)
碎片 内部碎片 + 外部碎片
元数据开销 每块需要记录大小、状态等
缓存不友好 分配顺序 ≠ 访问顺序
并发开销 锁/CAS/arena
2
3
4
5
6
7
8
9
关键洞察:堆的复杂性是"被迫的"
你可能会问:"既然堆这么贵,能不能优化掉?"——答案是不能,因为这些代价不是实现质量问题,而是"自由度"在物理世界的必然代价:
- 想任意分配 → 必须搜索 → 必须有元数据
- 想任意释放 → 必须合并 → 必须维护邻居关系
- 想跨线程共享 → 必须同步 → 必须付出 CAS/锁开销
这正是为什么六十年来世界上所有的优秀分配器(dlmalloc/tcmalloc/jemalloc/mimalloc)做的事情高度相似——它们都在同一个根本性约束下挣扎:用各种缓存、分级、线程本地化的技巧把自由度的代价摊薄,但永远无法降到栈的水平。
# 2.设计哲学探索
# 2.1 堆设计哲学
上一节从"发生了什么"(机制)的角度看堆,这一节要从"为什么必须这样"(哲学)的角度重新看它。
五个哲学支柱:
graph TD
subgraph "堆内存设计哲学"
B1[动态分配<br/>运行期才知道要多大]
B2[全局访问<br/>跨函数/跨线程可见]
B3[显式释放<br/>生命周期不受调用栈约束]
B4[对象生命周期<br/>可以跨越任意函数调用]
B5[内存共享<br/>多个指针可指同一块]
end
B1 --> D[内存分配器]
B2 --> D
B3 --> D
B4 --> D
B5 --> D
D --> F[灵活管理<br/>代价:复杂性 + 开销]
style D fill:#fff3cd
style F fill:#ffe4b5
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
哲学之间的依赖不是独立的。你要"动态分配",就必然要"运行期决定生命周期";你要"跨函数生命周期",就必然要"全局访问";你要"多指针共享",就必然要"引用计数/GC"来判定何时释放。五个设计点是一个闭环的逻辑系统,抽掉任何一个堆都不成立。
关键洞察:堆本质上是"占用生命周期与调用栈生命周期解耦"。这句话每个字都重要:
- 栈:生命周期 ≡ 调用栈帧生命周期(紧帊定)
- 堆:生命周期 独立于 调用栈(以 GC 或手动释放控制)
一旦解耦,就必须有个东西来接管释放决策——要么是程序员(C/C++,代价是心智开销 + 内存安全 bug),要么是运行时(Java/Go,代价是 GC 暂停 + CPU 占用),要么是编译器(Rust,代价是学习曲线)。谁都逃不过这三选一。
# 2.2 栈设计哲学
栈的哲学与堆正好镜像对称。栈不是"设计出来的",而是"函数调用这个现象本身带出来的"。
五个哲学支柱:
graph TD
subgraph "栈内存设计哲学"
A1[LIFO后进先出<br/>完美匹配函数调用嵌套]
A2[自动管理<br/>函数返回即释放,不需总结]
A3[快速分配释放<br/>一条指令都不需要]
A4[局部性原理<br/>顶部反复访问=缓存友好]
A5[线程隔离<br/>每线程独立栈空间]
end
A1 --> C[栈指针移动]
A2 --> C
A3 --> C
A4 --> C
A5 --> C
C --> E[高效执行<br/>代价:限制 + 大小上限]
style C fill:#d4edda
style E fill:#cfe2ff
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
关键洞察:栈是"扪平函数调用抽象"的副产品
你可以把栈理解为一个状态机的“快照贴":
- 每个函数是一个状态机的一次"运行实例"
- 实例需要保存状态(局部变量 + 返回地址)
- 调用肨主要那个位置“还原”,所以调用肨状态还在等他
这是一个天生的 LIFO 结构。栈不是"为了快"才 LIFO,而是"函数本来就 LIFO"才使得栈能这么快。如果未来出现一种不遵循 LIFO 的调用模型,堆栈设计就会被推倍——事实上协程就是这样,遵循"挂起/恢复"而非 LIFO 调用,所以协程要么用动态栈抽象(stackful)、要么用状态机抽象(stackless),都不是传统意义上的栈。
哲学总结:栈是“嵌套调用"这个抽象在内存上的映射,是函数哲学的必然产物,不是某个工程师拍脑袋决定的。
# 2.3 栈和堆原理
栈 堆
──────────────────── ────────────────────
约束:LIFO 自由:任意顺序分配释放
↓ ↓
分配 = 移指针 分配 = 搜索+切割
释放 = 移回指针 释放 = 归还+合并
↓ ↓
O(1) 确定性延迟 O(1)~O(n) 不确定延迟
零碎片 有碎片
零元数据 16 字节/块元数据
缓存命中率极高 缓存命中率不确定
↓ ↓
CPU 提供硬件支持 无专用硬件
(RSP, PUSH, POP, CALL, RET) (纯软件管理)
↓ ↓
每线程独立,天然线程安全 共享,需要锁/分区
↓ ↓
大小固定(默认8MB) 可增长到虚拟地址空间上限
生命周期绑定函数调用 生命周期由程序员/GC 控制
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
设计哲学的本质:
栈和堆体现了系统设计中最经典的取舍——约束 vs 自由。
栈选择了最严格的约束(LIFO、编译时确定大小、绑定函数生命周期),换来了最极致的性能(一条指令分配、零碎片、硬件支持)。
堆选择了最大的自由度(任意时刻、任意大小、任意生命周期),付出了复杂性的代价(搜索算法、碎片管理、并发控制、GC)。
二者不可互相替代——栈处理不了动态大小和跨函数生命周期,堆达不到栈的性能。这就是为什么六十年来,每一种编程语言、每一个操作系统,都保留了堆和栈的二元设计。
# 3.栈的完整机制
# 3.1 硬件支持
为什么栈是唯一有 CPU 硬件直接支持的内存区域?这个问题推到起源就变成了“鸡生蛋还是蛋生鸡":不是 CPU 选择了栈,而是函数调用这个需求逼着 CPU 设计了栈指令。
CPU 专门为栈设计了寄存器和指令:
x86:
RSP (Stack Pointer) — 永远指向栈顶
RBP (Base Pointer) — 指向当前栈帧的底部(可选)
PUSH reg — RSP -= 8; [RSP] = reg (一条指令完成)
POP reg — reg = [RSP]; RSP += 8
CALL addr — PUSH RIP; JMP addr (保存返回地址+跳转)
RET — POP RIP (恢复返回地址+跳回)
ARM:
SP (Stack Pointer)
LR (Link Register) — 存返回地址,不需要压栈(一级调用时)
STP/LDP — 成对压入/弹出寄存器
2
3
4
5
6
7
8
9
10
11
12
栈是唯一有 CPU 硬件直接支持的内存区域。 堆没有专门的硬件指令。
推演过程:为什么 CPU 抽出一个寄存器专门服务栈?
问题 1:如果不设专用寄存器,每次压栈要怎么做?
→ 读“顶位置"变量” → 加 8 → 写回“顶位置"变量” → 实际写入 = 4 次访存
问题 2:函数调用频率有多高?
→ 现代程序每秒几亿次调用 → 这是热点中的热点
问题 3:优化热点 → 为高频操作设专用硬件
→ RSP 作为专用寄存器不出入内存
→ PUSH/POP 独立为原子指令
→ CALL/RET 隱含压栈/弹栈
2
3
4
5
6
7
8
这是计算机体系结构上“热点优化"思想的经典例子——你只要检查编译后任何一段代码的汇编,平均每 5-10 条指令就会出现一次与栈相关的操作,设专用硬件是唯一合理选择。
进一步推论:深圳静态预测
CPU 还给栈访问加了 store buffer、stack engine 等多重优化:
- Stack Engine:现代中高端 CPU(Intel Sandy Bridge 以后)会识别连续的 push/pop 指令,在后端合并为单个微操作
- Return Stack Buffer (RSB):CPU 为 CALL/RET 专门设计了预测堆栈,预测成功率 >99%
- Stack Probe:预测到栈需要扩展时提前触发页表加载
一个走栈的函数调用 CPU 有几十亿个晶体管伺候着。这些优化堆一个也享受不了。
# 3.2 栈帧结构
每次函数调用在栈上创建一个栈帧(Stack Frame),这个栈帧反映了一个函数“独立运行实例"需要的一切信息:
高地址
┌────────────────────┐
│ 调用者的栈帧 │
├────────────────────┤ ← 调用前的 RSP
│ 返回地址 (8 bytes) │ ← CALL 指令自动压入
├────────────────────┤
│ 保存的 RBP │ ← 可选,用于栈回溯
├────────────────────┤ ← RBP 指向这里
│ 局部变量 a (4 bytes) │ ← RBP - 4
│ 局部变量 b (4 bytes) │ ← RBP - 8
│ 局部变量 buf[32] │ ← RBP - 40
├────────────────────┤ ← RSP 指向这里
│ (下一次调用的空间) │
低地址
2
3
4
5
6
7
8
9
10
11
12
13
14
为什么栈帧要设计成这样?逐层推演:
为什么需要返回地址?
A() 调用 B()
B() 执行完需要跳回 A() 中 B 调用后的下一条指令
→ 这个"下一条指令"的地址必须在 B 执行期间被保存着
→ 不能只依靠寄存器(因为 B 可能会嵌套调用 C,C 会覆盖同一个寄存器)
→ 必须压栈
2
3
4
5
为什么需要保存 RBP?
B() 执行期间会修改 RBP(指向 B 自己的栈帧底部)
B() 返回后 A() 需要恢复自己的 RBP
→ 压栈保存 → 返回时弹出恢复
2
3
为什么局部变量要从高地址向低地址排列?
栈本身向低地址增长(x86 传统)
RSP 从高地址走向低地址
→ 新入栈的局部变量肯定在 RSP 附近的低地址
→ RBP 以上是调用者的栈帧,RBP 以下是本函数的
→ 用 RBP-N 访问局部变量(N 恒为正数,常量偏移)
2
3
4
5
为什么调用者保存 vs 被调用保存(caller-saved vs callee-saved)?
这是个结果跨 ABA 定的优化:部分寄存器“调用者负责保存"(如果调用者需要保证调用后还能用),部分“被调用负责保存"(被调用函数如果要用它,必须先保存再恢复)。这样设计避免了一些不必要的压栈操作——如果 B 根本不使用某个 callee-saved 寄存器,就不需要保存。
为什么栈帧要 16 字节对齐?
x86-64 SIMD 指令(SSE/AVX)要求 16/32 字节对齐读写。为了让任何函数都能随时使用 SIMD,ABI 规定函数入口处 RSP 必须 16 字节对齐。这个限制一发出,所有函数都必须遵从,即使函数本身不用 SIMD。
这些设计决策听上去都是细节,但拼起来就是一个严谨的 ABI(应用二进制接口)——不同语言、不同编译器产生的代码之所以能互调用,全指望栈帧布局是同一套。
# 3.3 栈内存案例
int add(int x, int y) {
int result = x + y;
return result;
}
int main() {
int a = 10;
int b = 20;
int c = add(a, b);
return 0;
}
2
3
4
5
6
7
8
9
10
11
x86-64 Linux(System V ABI) 下的执行过程:
Step 1: main() 开始执行
RSP = 0x7FFF_0100
栈状态:
0x7FFF_0100 │ │ ← RSP
Step 2: int a = 10; int b = 20;
编译器将 a, b 分配在栈上(或直接用寄存器)
实际上 x86-64 前6个整数参数用寄存器传递
a → 栈上 [RSP-4]
b → 栈上 [RSP-8]
RSP -= 16 (对齐)
0x7FFF_00F0 │ b = 20 │ ← RSP
0x7FFF_00F4 │ a = 10 │
0x7FFF_0100 │ ... │
Step 3: 调用 add(a, b)
// 参数通过寄存器传递(x86-64 ABI)
MOV EDI, 10 // 第一个参数 → EDI
MOV ESI, 20 // 第二个参数 → ESI
CALL add // RSP -= 8, 压入返回地址, 跳转到 add
0x7FFF_00E8 │ 返回地址 │ ← RSP (CALL 自动压入)
0x7FFF_00F0 │ b = 20 │
0x7FFF_00F4 │ a = 10 │
Step 4: add() 函数体
// 序言(prologue)
PUSH RBP // 保存调用者的 RBP
MOV RBP, RSP // 建立新栈帧
// int result = x + y
LEA EAX, [EDI+ESI] // result = x + y(直接在寄存器算)
// 尾声(epilogue)
POP RBP // 恢复调用者的 RBP
RET // POP RIP, 跳回 main
Step 5: 返回 main()
RET 执行后:
- RSP 恢复到 0x7FFF_00F0
- 返回值在 EAX 中 (= 30)
- add 的栈帧瞬间"消失"(RSP 移回去,数据还在但逻辑上已无效)
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
关键洞察:
- 分配 = RSP 减一个常数(一条
SUB RSP, N指令),不需要搜索空闲块 - 释放 = RSP 加回去(一条
ADD RSP, N),不需要合并、不需要标记 - 栈上的"旧数据"不会被清零——只是 RSP 移过去了,下次调用会覆盖。这就是为什么未初始化的局部变量是"垃圾值"
- 函数返回后访问局部变量的地址是未定义行为——数据可能还在,但随时会被下一次函数调用覆盖
# 4.堆的完整机制
# 4.1 堆分层架构
应用程序
│ malloc(32)
▼
┌──────────────────────────────────────────────┐
│ 用户态分配器 (glibc malloc / tcmalloc / jemalloc) │
│ │
│ 维护空闲链表/空闲树,从已有的大块中切小块 │
│ 大多数分配在这一层就完成了,不需要系统调用 │
└──────────────┬───────────────────────────────┘
│ 空间不够时
▼
┌──────────────────────────────────────────────┐
│ 系统调用 │
│ brk/sbrk → 扩展进程数据段(连续) │
│ mmap → 映射新的虚拟内存区域(不连续) │
└──────────────┬───────────────────────────────┘
│
▼
┌──────────────────────────────────────────────┐
│ 内核虚拟内存管理 │
│ 分配虚拟页,加入进程的 VMA 链表 │
│ 此时还没有分配物理内存! │
└──────────────┬───────────────────────────────┘
│ 首次访问时触发 Page Fault
▼
┌──────────────────────────────────────────────┐
│ 物理页帧分配 (Buddy System + Slab) │
│ 从物理内存中分配 4KB 页帧 │
│ 建立页表映射:虚拟地址 → 物理地址 │
└──────────────────────────────────────────────┘
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
# 4.2 malloc设计
核心问题:为什么 glibc malloc 要设计这么多不同的 bin?fast bin、small bin、large bin、unsorted bin、top chunk——这里面每一次划分都是被“现实”逼出来的设计决策,而不是拍脑袋。我们逐步推演。
# 推演 1:为什么需要 "chunk header"
堆要支持任意顺序释放,那么 free(p) 来的时候,分配器需要知道:
问题 1:这块内存多大? → 必须在某处存了 size
问题 2:它是否与前/后邻居可合并? → 必须能访问邻居状态
问题 3:它之前在哪条空闲链里? → 需要能查到链接信息
2
3
这些信息什么时候存?只能在“块本身"上。于是每块都必须携带一个 header,这是被“任意释放”这个需求逼出来的。
# 推演 2:为什么需要空闲链表
如果没有空闲链表,每次 malloc 都要从堆的头开始扫描“哪些块是空的",这是 O(n),堆越大越慢。
解法:把空闲块串成链表,每次只扫空闲部分,不扫已用部分。
# 推演 3:为什么一条链表不够
考虑这个照片:现在空闲链表里有 1000 个块,大小分别是 16B、32B、100KB。你 malloc(50)。怎么找?
方案 A:顺序扫描 → 遇到千个 16B/32B 都被跳过,才找到合适的 → 慢
方案 B:按大小分档 → 小块走小 bin,中块走中 bin,大块走大 bin
2
这就是分级 bin 思想的根源。
# 推演 4:为什么需要 fast bin(犹豫不合并)
在实际程序中有个现象:小块分配/释放频率极高,且生命周期极短。例如:JSON 解析器的临时 buffer、STL 容器的 node。
问题:如果每次 free(16B) 都要检查邻居是否可合并,合并后还要从原链表取出、插入到新链表……
代价:一次 free 要 ~30ns
但这块 16B 可能 1ms 后又要被重新 malloc,又要 ~30ns
总代价:~60ns
同一块在两秒内各 malloc/free 1 万次 → 60μs
2
3
4
5
解法:fast bin 犹豫不合并。小块 free 后直接什在 fast bin 头(1 次 CAS,~5ns),下次 malloc 同一大小直接从表头取出(1 次 CAS)。用“碎片换速度"——不合并在某些场景下有碎片风险,但代价上接近栈。
# 推演 5:为什么需要 unsorted bin(延迟分类)
考虑这个场景:你刚刚 free 了一块 200B,马上又要 malloc(200B)。如果释放时立刻合并 + 分类插入到 large bin,下一次要用又要从 large bin 取出——举勿都白做了。
解法:unsorted bin 是个“中转站":
free(200B) → 丟进 unsorted bin。合并、分类都不做。
malloc(200B) → 先查 unsorted bin。4≊上才取出却不是 200B,那就顺手把它分类进对应的 bin。
2
用延迟到重复使用随手分类,本质是在吞后携包。
# 完整设计总览
现在再看这张表,每一行都不是拍脑裄决定的了:
核心数据结构:Chunk
每个 malloc 返回的块实际上长这样:
┌─────────────────────────┐
│ prev_size (8 bytes) │ ← 前一个 chunk 的大小(仅当前一个空闲时有效)
├─────────────────────────┤
│ size (8 bytes) │ ← 本 chunk 大小 + 3个标志位
│ [P: prev_in_use] │ ← 最低位:前一个 chunk 是否在使用
│ [M: is_mmapped] │ ← 第2位:是否由 mmap 分配
│ [A: non_main_arena] │ ← 第3位:是否属于非主 arena
├─────────────────────────┤ ← malloc 返回的指针指向这里
│ │
│ 用户数据 (N bytes) │
│ │
├─────────────────────────┤
│ (下一个 chunk 的 header) │
└─────────────────────────┘
最小 chunk = 32 bytes (64位系统)
16 bytes header + 16 bytes 最小数据(对齐)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
你 malloc(1) 实际占用 32 字节。16 字节 header + 16 字节最小对齐。这就是内部碎片。
分配策略:按大小分级
glibc malloc 把块分为几类,用不同策略管理:
┌───────────────────────────────────────────┐
│ Fast Bins (16~160 bytes, 10 个 bin) │
│ 单链表,LIFO │
│ 不合并相邻空闲块(牺牲碎片换速度) │
│ 分配释放最快:O(1) │
├───────────────────────────────────────────┤
│ Small Bins (< 1024 bytes, 62 个 bin) │
│ 双向链表,FIFO │
│ 每个 bin 中所有 chunk 大小相同 │
│ 精确匹配,O(1) │
├───────────────────────────────────────────┤
│ Large Bins (≥ 1024 bytes, 63 个 bin) │
│ 双向链表,按大小排序 │
│ 最佳适配搜索,O(log n) │
├───────────────────────────────────────────┤
│ Unsorted Bin (1 个) │
│ 刚释放的 chunk 先放这里 │
│ 下次分配时顺便整理到对应 bin │
├───────────────────────────────────────────┤
│ mmap 直接分配 (≥ 128KB 默认阈值) │
│ 直接向内核要整页 │
│ 释放时直接还给内核 │
└───────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
为什么不同 bin 用不同数据结构?
| bin | 数据结构 | 原因 |
|---|---|---|
| Fast bin | 单链表 LIFO | 小块分配释放最频,LIFO 能让划过去释放的块马上被重复分配→缓存热度高 |
| Small bin | 双链 FIFO | 中块要合并,双链便于 O(1) 取出任意节点;FIFO 能让块“凉一凉"以能合并 |
| Large bin | 按大小排序的双链 | 大块大小变化多,需要“最佳适配"避免碎片 |
| mmap | 直接 OS | 大块释放时能直接还给内核,不会造成堆越来越肥 |
这些决策都能从“上面各类坌块出现频率与访问模式"推出来。这不是个设计于是探索出来的最优设计。
# 4.3 堆内存案例
堆内存案例:malloc(48) 的完整过程
void example() {
char* p = (char*)malloc(48);
strcpy(p, "hello");
free(p);
}
2
3
4
5
Step 1: malloc(48) 进入 glibc
请求 48 字节 → 加上 16 字节 header → 实际需要 64 字节
64 字节对齐后仍是 64 字节
→ 属于 Fast Bin 范围(≤ 160 字节)
2
3
Step 2: 查找 Fast Bin
fast_bin[index(64)] → 链表头
│
有空闲 chunk?
├── 有 → 取出链表头(一次 CAS),返回 chunk + 16 的地址
│ 时间:~10ns
│
└── 没有 → 查找 Small Bin
2
3
4
5
6
7
Step 3: 查找 Small Bin(如果 Fast Bin 为空)
small_bin[index(64)] → 双向链表
│
有空闲 chunk?
├── 有 → 取出链表尾(FIFO),返回
│
└── 没有 → 查找 Unsorted Bin
2
3
4
5
6
Step 4: 查找 Unsorted Bin(如果 Small Bin 为空)
遍历 Unsorted Bin:
对每个 chunk:
如果大小恰好匹配 → 取出返回
否则 → 放入对应的 Small/Large Bin(顺便整理)
遍历完仍没找到 → 从 Top Chunk 切割
2
3
4
5
6
Step 5: Top Chunk 切割(如果所有 Bin 都没有)
Top Chunk = 堆顶的一大块连续空闲内存
┌────────────────────────────┐
│ Top Chunk │
│ (假设还有 4MB) │
└────────────────────────────┘
切出 64 字节:
┌─────────┬─────────────────┐
│ 64B │ 剩余 Top Chunk │
│(返回给用户)│ │
└─────────┴─────────────────┘
2
3
4
5
6
7
8
9
10
11
12
Step 6: Top Chunk 也不够(极端情况)
调用 sbrk() 或 mmap() 向内核申请新的内存页
内核分配虚拟页 → 首次访问时 Page Fault → 分配物理页
2
Step 7: free(p)
free(p):
1. 指针 - 16 得到 chunk header
2. 读取 size = 64 → 属于 Fast Bin
3. 将 chunk 挂到 fast_bin[index(64)] 链表头
4. 不清零数据,不合并相邻块(Fast Bin 特性)
时间:~5ns(一次链表头插入)
2
3
4
5
6
7
注意:free 后数据还在内存中,只是标记为空闲。这就是为什么 use-after-free 漏洞可以读到"旧数据"。
// 堆内存工作原理示例
public class HeapPrincipleExample {
public void demonstrateHeapOperation() {
// 对象创建过程
Person person = new Person("张三", 25);
// 数组创建
int[] numbers = new int[100];
// 集合创建
List<String> list = new ArrayList<>();
list.add("Hello");
list.add("World");
// 大对象创建
byte[] bigData = new byte[1024 * 1024]; // 1MB,可能直接进入老年代
// 对象引用传递
processPerson(person);
// 方法结束,栈中的引用消失,但堆中的对象可能仍然存在
}
private void processPerson(Person p) {
// p是对堆中Person对象的另一个引用
p.setAge(26); // 修改堆中的对象
// 创建新对象
Person newPerson = new Person("李四", 30);
// newPerson引用在方法结束时消失,但对象在堆中等待GC
}
static class Person {
private String name; // 引用类型字段
private int age; // 基本类型字段
public Person(String name, int age) {
this.name = name; // 字符串对象也在堆中
this.age = age;
}
// getter和setter方法
public void setAge(int age) { this.age = age; }
}
}
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
# 5.多线程场景
# 5.1 栈是线程安全
核心洞察:栈的线程安全不是“加了锁"赢来的,而是“压根不共享"造成的。这是计算机多线程设计里最经典的“以隔离代替同步"案例。
每线程独立栈的本质
线程1 的栈: 0x7FFF_0000 ~ 0x7FFE_0000 (默认 8MB)
线程2 的栈: 0x7FFD_0000 ~ 0x7FFC_0000
线程3 的栈: 0x7FFB_0000 ~ 0x7FFA_0000
互不干扰,零竞争,不需要锁
2
3
4
5
推演:为什么可以这样
Q1: 所有线程在同一个虚拟地址空间里,为什么能独享一块栈?
→ 虚拟地址空间是 2^48 (现代 64 位 Linux)= 极大
→ OS 在创建线程时,给每个线程划一块 8MB 虚拟地址不同的区间作为栈
→ 不同线程的 RSP 寄存器从不同区间起始下降
→ 只要没人主动告诉别人“我的栈在哪",其他线程就访问不到
Q2: 那为什么间偶听说“跨线程访问栈”这种事?
→ 只有一种情形:程序员主动把栈地址传出去(返回局部变量地址、取 & 传给其他线程)
→ 此时不是“栈不线程安全",而是你主动破坏了隔离
Q3: 那调用 setjmp/longjmp、继装续会怎么样?
→ setjmp/longjmp 在同一线程里跳栈帧,不跨线程
→ 但你如果 longjmp 到一个已经返回了的栈帧→UB(未定义行为)
2
3
4
5
6
7
8
9
10
11
12
13
为什么这个设计这么重要
考虑一个反例:如果上边“每线程独立栈”这个设计不存在,所有线程共享一个栈,会发生什么?
线程 A: PUSH x → RSP -= 8
线程 B: PUSH y → RSP -= 8 (并发执行)
线程 A: 使用刚压的 x → 发现成了 y!
→ 函数调用机制彻底崩溃。需要压栈上锁。每次函数调用都上锁!
→ 多线程程序在 1990s 就会玩不转
2
3
4
5
“每线程独立栈”是多线程计算能走到今天的基础设计——不是优化,是使多线程可能的初始条件。
这个设计的代价:栈空间限制
多线程独立栈不是免费的:
场景:Java 应用 创建 1 万个线程
→ 每线程 1MB 栈 × 1万 = 10GB 虚拟内存
→ 32 位系统上连虚拟地址都不够
→ 这是为什么 32 位 JVM 创建 5000 线程就会 OOM
→ 也是 Project Loom 要发明虚拟线程的根本动机之一
2
3
4
5
这是个经典的设计权衡:不共享栈达到了线程安全和高性能,但限制了可创建的线程数量。所以现代语言又发明了“动态可伸缩栈”(Go goroutine 2KB 起步可增长)、“不带栈的协程”(状态机化后存在堆)来变相突破这个限制。
# 5.2 堆需处理并发
核心问题:堆是全局共享的,多个线程同时在里面分配/释放。怎么避免争抢?这是计算机设计里“争用资源”问题的经典案例,三代分配器各有不同的解法。
最原始的方案:全局锁
原始 dlmalloc 设计:
所有 malloc/free 都要拿到唯一一把全局锁
优点:简单
缺点:4 核 上 4 个线程同时 malloc → 3 个被阻塞
多核 CPU 退化为单核表现
2
3
4
5
第一代优化:Arena(多锁池化)
glibc malloc 设计:
主线程 → main_arena(只有一个,用 brk 扩展)
其他线程 → thread_arena(用 mmap 创建,数量 = min(8*核数, 线程数))
多个线程可能共享 arena → arena 内部有 mutex
但 arena 数量较多,竞争被分散
2
3
4
5
6
推演:这个设计还是不够
Q: 为什么不能每线程一个 arena?
A: 内存浪费!每个 arena 预留几 MB 起步,1万线程 × 1MB = 10GB,浪费别谈
Q: 那怎么进一步减锁?
A: 只给线程留一小部分高频使用的块(thread-local cache)
“在货"时全部在本地 → 零锁
只有“补货"时才上锁
2
3
4
5
6
7
第二代优化:Thread-Local Cache
tcmalloc / jemalloc 用这个思路(类似 per-CPU 思想):
┌──────────────┐ ┌──────────────┐
│ Thread 1 │ │ Thread 2 │
│ ┌──────────┐ │ │ ┌──────────┐ │
│ │Local Cache│ │ │ │Local Cache│ │
│ │ 16B: ●●● │ │ │ │ 16B: ●● │ │
│ │ 32B: ●● │ │ │ │ 32B: ●●●● │ │
│ │ 64B: ● │ │ │ │ 64B: ●● │ │
│ └──────────┘ │ │ └──────────┘ │
└──────┼───────┘ └──────┼───────┘
│ │
▼ 缓存不够时 ▼
┌──────────────────────────────┐
│ Central Free List │
│ (需要锁/CAS) │
└──────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
三代分配器并发设计对比
| 分配器 | 并发模型 | 4核 8线程 malloc(64) 吞吐 | 塑心学哲学 |
|---|---|---|---|
| dlmalloc | 全局锁 | ~5M ops/s | 简单换不住 |
| ptmalloc2 (glibc) | 多 Arena | ~30M ops/s | 锁池化减争抢 |
| tcmalloc / jemalloc | per-thread cache | ~150M ops/s | 高频不走锁 |
| mimalloc | per-thread + per-CPU | ~200M ops/s | 压柠到极限 |
核心洞察:这不是“锁怎么加快"问题,而是“怎么让大部分路径不走锁"问题。每一代进步,都是把“需要同步"的比例往下压。
这是在贵·馆守与郁守哲学在并发领域的体现:锁不是错,锁是必要代价;但“必要代价"出现频率越低越好。
# 6.堆和栈性能
# 6.1 性能指标
| 指标 | 栈 | 堆 | 差距倍数 |
|---|---|---|---|
| 分配耗时 | ~1ns(1条指令 sub rsp, N) | ~50-200ns(malloc路径) | 50-200x |
| 释放耗时 | ~1ns(1条指令 add rsp, N) | ~30-100ns(free路径) | 30-100x |
| L1缓存命中率 | ~95-99% | ~50-70% | 1.4-2x |
| 内存碎片 | 0%(连续增长/收缩) | 10-30%(碎片化) | ∞ |
| 线程安全开销 | 0(每线程独立栈) | 有锁/arena开销 | ∞ |
| 地址连续性 | 严格连续 | 随机分散 | - |
# 6.2 量化案例
量化案例:10万次对象创建/销毁
场景设计:分配一个64字节结构体,循环100,000次,对比栈分配 vs 堆分配的全链路开销。
struct Packet { // 64 bytes, 恰好一个缓存行
uint32_t seq;
uint32_t type;
uint64_t timestamp;
char payload[44];
uint32_t checksum;
};
2
3
4
5
6
7
1.栈分配路径的CPU指令级分析
void ProcessOnStack() {
for (int i = 0; i < 100000; ++i) {
Packet pkt; // sub rsp, 64
pkt.seq = i; // mov [rsp+0], eax
pkt.checksum = i ^ 1; // mov [rsp+60], eax
DoSomething(&pkt);
} // add rsp, 64 (隐含在函数epilogue)
}
2
3
4
5
6
7
8
CPU执行过程(每次迭代):
指令 周期数 说明
─────────────────────────────────────────────
sub rsp, 64 1 cycle RSP下移,分配完成
mov [rsp+0], eax 1 cycle L1命中,栈顶始终在缓存
mov [rsp+60], eax 1 cycle 同一缓存行,命中
call DoSomething ~ 业务逻辑
add rsp, 64 1 cycle RSP上移,释放完成
─────────────────────────────────────────────
分配+释放总计: ~2 cycles ≈ 0.7ns @3GHz
2
3
4
5
6
7
8
9
为什么L1几乎100%命中?
- 栈顶地址在一个极小范围内反复使用(同一个64B区域)
- CPU的L1 Cache是32-64KB,栈的活跃区域远小于此
- 循环中每次
pkt都在同一地址(RSP没变化),物理上是同一缓存行的覆写
2.堆分配路径的CPU指令级分析
void ProcessOnHeap() {
for (int i = 0; i < 100000; ++i) {
auto* pkt = new Packet(); // malloc(64) → 复杂路径
pkt->seq = i;
pkt->checksum = i ^ 1;
DoSomething(pkt);
delete pkt; // free(ptr) → 复杂路径
}
}
2
3
4
5
6
7
8
9
malloc(64) 内部执行路径(glibc,最优情况走Fast Bin):
步骤 周期数 说明
────────────────────────────────────────────────────────────────
1. 计算实际大小 2 cycles 64 + 16(header) = 80 → 对齐到80
2. 确定bin索引 3 cycles 80/16 = 5, 查fast_bin[5]
3. 读取fast_bin[5]头指针 ~4 cycles 该指针可能在L1/L2
4. CAS摘取链表头节点 ~10 cycles 原子操作 lock cmpxchg
5. 如fast bin为空→进small bin +20 cycles 更复杂的链表操作
6. 返回用户指针(跳过header) 1 cycle
────────────────────────────────────────────────────────────────
malloc最优路径: ~20 cycles ≈ 7ns @3GHz
malloc典型路径(含竞争): ~60 cycles ≈ 20ns
2
3
4
5
6
7
8
9
10
11
free(ptr) 内部执行路径:
步骤 周期数 说明
────────────────────────────────────────────────────────────────
1. 回退指针找chunk header 2 cycles ptr - 16
2. 读取chunk size 4 cycles 可能L2命中
3. 确定归属bin 2 cycles
4. CAS插入链表头 ~10 cycles 原子操作
5. 检查相邻chunk是否可合并 ~8 cycles 读前后chunk的header
────────────────────────────────────────────────────────────────
free最优路径: ~26 cycles ≈ 9ns @3GHz
2
3
4
5
6
7
8
9
# 6.3 结果分析
量化汇总(100,000次迭代)
┌─────────────────────┬──────────────┬──────────────┬────────────┐
│ 开销项 │ 栈分配 │ 堆分配 │ 倍数差 │
├─────────────────────┼──────────────┼──────────────┼────────────┤
│ 单次分配 │ ~0.3ns │ ~7-20ns │ 23-67x │
│ 单次释放 │ ~0.3ns │ ~9-26ns │ 30-87x │
│ 单次分配+释放 │ ~0.7ns │ ~16-46ns │ 23-66x │
├─────────────────────┼──────────────┼──────────────┼────────────┤
│ 10万次分配+释放 │ ~70μs │ ~1.6-4.6ms │ 23-66x │
│ L1 miss次数(估) │ ~0 │ ~10,000+ │ ∞ │
│ 原子操作次数 │ 0 │ 200,000 │ ∞ │
├─────────────────────┼──────────────┼──────────────┼────────────┤
│ 额外内存开销/次 │ 0 byte │ 16 byte │ ∞ │
│ 10万次内存浪费 │ 0 │ 1.6MB │ ∞ │
└─────────────────────┴──────────────┴──────────────┴────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
# 6.4 深层原因
1.指令数量差异
栈分配:1条指令 (sub rsp, N)
堆分配:~15-30条指令 (计算size → 查bin → CAS → 返回指针)
1条 vs 30条 = 30x 基础差距
2
3
4
2.缓存行为差异
栈的访问模式——极致时间局部性:
迭代1: pkt地址 = 0x7FFF_1000 → L1 miss(首次), 加载到L1
迭代2: pkt地址 = 0x7FFF_1000 → L1 hit (同一地址复用!)
迭代3: pkt地址 = 0x7FFF_1000 → L1 hit
...
10万次迭代:仅第1次miss,后续99,999次全部hit
2
3
4
5
堆的访问模式——碎片化空间局部性:
迭代1: pkt地址 = 0x5555_A000 → L1 miss, 加载
迭代1: free后该地址的缓存行可能被后续逻辑驱逐
迭代2: pkt地址 = 0x5555_A000 → 可能L1 hit(fast bin快速复用同一块)
也可能L1 miss(被驱逐了)
2
3
4
即使fast bin快速复用同一块内存,malloc/free本身要访问的元数据(bin头指针、chunk header)散布在不同缓存行,引入额外的cache miss。
cache miss的代价:
L1 hit: ~1ns (4 cycles @4GHz)
L2 hit: ~3-5ns (12-20 cycles)
L3 hit: ~10-15ns (40-60 cycles)
DRAM: ~60-100ns (240-400 cycles)
一次L3 miss = 一次栈分配+释放的 ~140倍时间
2
3
4
5
6
3.原子操作开销
堆分配器(glibc malloc)在多线程环境下需要保护共享数据结构:
lock cmpxchg (无竞争): ~10 cycles
→ 需要锁缓存行,通知MESI协议
lock cmpxchg (有竞争): ~50-200 cycles
→ 缓存行弹跳(cache-line bouncing)
→ 核间通信延迟 ~40-80ns
栈:完全不需要任何同步原语(每线程独立栈)
2
3
4
5
6
7
8
4.分支预测与流水线
栈分配:无分支,CPU流水线无stall
堆分配:多个分支判断
if (size < FAST_BIN_MAX) → 分支1
if (fast_bin[idx] != NULL) → 分支2
if (CAS成功) → 分支3
else → small_bin路径 → 分支4+
每次分支预测失败 = ~15-20 cycles penalty
2
3
4
5
6
7
8
# 6.5 核心结论
性能差距的本质 = 指令数(30x) × 缓存效率(2-5x) × 同步开销(1-10x)
栈:约束(LIFO)→ 极简指令 → 极致缓存 → 零同步 → 极致性能
堆:自由(任意序)→ 复杂管理 → 缓存不友好 → 需要同步 → 性能代价
这不是实现好坏的问题,而是"自由度"的物理代价。
2
3
4
5
6
设计启示:
- 小对象、生命周期确定 → 栈分配(如函数内的临时buffer)
- 大对象、生命周期跨作用域 → 堆分配(不可避免,但可用对象池/arena分配器优化)
- 高频分配场景 → 用
tcmalloc/jemalloc的thread-local cache把堆分配降到接近 ~10ns,缩小差距到10-15x
# 7.跨语言堆栈策略对照
每种语言的堆栈策略,本质上都在回答同一个问题:"分配决策由谁做?" ——程序员、运行时、还是编译器?这背后就是它们整体的设计哲学。
# 7.1 C/C++:程序员全权负责
void f() {
int a = 10; // 栈:编译期决定
int* p = new int(20); // 堆:程序员显式 new
delete p; // 程序员显式 delete
}
2
3
4
5
哲学:相信程序员,用裸指针换控制力。
代价:内存安全靠人,因此 C/C++ 程序中超过 70% 的安全漏洞与内存错误相关(微软 2019 年统计)。
# 7.2 Java:基本类型栈 + 对象堆
void f() {
int a = 10; // 栈:基本类型 + 局部变量
String s = "hello"; // s 引用在栈,对象在堆
Object obj = new Object(); // 引用栈、对象堆
}
2
3
4
5
哲学:所有对象都在堆上、由 GC 管理。基本类型在栈上是为了性能。
为了改善这个"对象一定在堆上"的过度严格,HotSpot 还引入了:
- 逃逸分析(Escape Analysis):JIT 编译器分析对象是否"逃出"方法范围。如果没逃出,就栈上分配 + 标量替换。
- Project Valhalla 在做的 value class:让用户级类型也能像基本类型一样栈分配。
# 7.3 Go:编译器决定堆栈
func f() {
a := 10 // 编译器分析:不逃逸 → 栈
p := new(int) // 编译器分析:是否逃逸 → 决定堆/栈
*p = 20
}
func g() *int {
a := 10 // 编译器分析:a 通过返回逃逸 → 堆
return &a // C/C++ 这是悬空指针;Go 编译器自动改为堆分配
}
2
3
4
5
6
7
8
9
10
哲学:程序员不操心,编译器算给你看。
甚至可以用 go build -gcflags="-m" 看到编译器的逃逸决定:
./main.go:10:9: &a escapes to heap
./main.go:10:9: moved to heap: a
2
代价:编译期分析能力有边界,过于复杂的代码会被保守地一律放堆上。
# 7.4 Rust:所有权决定生命周期
fn f() {
let a = 10; // 栈
let s = String::from("hello"); // String 数据在堆,元数据(指针,len,cap)在栈
let b = Box::new(20); // 显式堆分配
} // 离开作用域 → s/b 自动 drop,无需 GC
2
3
4
5
哲学:所有权(Ownership)静态决定一切。
所有权三铁律:
1. 每个值有且仅有一个所有者
2. 所有者离开作用域时值被 drop
3. 借用必须比所有者活得短
2
3
4
这三条规则编译期检查,违反者编译失败。结果:
- 没有 GC 暂停
- 没有悬空指针
- 没有 use-after-free
- 没有 double-free
Rust 用编译器之力,把"自动管理"和"零运行时成本"这对原本互斥的目标统一了——这是过去 50 年内存管理领域最重要的突破之一。
# 7.5 跨语言对比表
| 维度 | C/C++ | Java | Go | Rust | JavaScript |
|---|---|---|---|---|---|
| 分配决策者 | 程序员 | JVM(GC) + JIT(逃逸分析) | 编译器(逃逸分析) | 编译器(所有权) | V8 引擎 |
| 栈分配判断 | 局部非 new | 基本类型 + 不逃逸对象 | 不逃逸的所有变量 | 默认全部栈,除非 Box | 基本类型 |
| 堆释放方式 | 手动 free/delete | GC 标记清除 | GC 三色标记 | 离开作用域自动 drop | GC |
| 内存安全保障 | 程序员 | 运行时 | 运行时 | 编译期 | 运行时 |
| 典型代价 | 内存 bug | GC 暂停 | GC 暂停(更短) | 学习曲线 | GC 暂停 |
| 一句话哲学 | 信任程序员 | 信任运行时 | 编译器自动选 | 编译期就证明 | 全交给引擎 |
核心洞察:
flowchart LR
A[1972 C<br/>程序员负责] --> B[1995 Java<br/>运行时负责<br/>GC]
B --> C[2010 Go<br/>编译期+运行时<br/>逃逸分析+GC]
C --> D[2015 Rust<br/>纯编译期<br/>所有权]
style D fill:#d4edda
2
3
4
5
50 年的演进,在做一件事:把"内存管理"这个责任从程序员(运行时风险)一步步推到编译器(零运行时成本)。Rust 是这条线目前的终点,但远不是终点的终点。
# 8.经典陷阱与反模式
# 8.1 栈溢出:递归与大数组
回到本章开篇那段失败的对账代码:
private BigDecimal sumRecursive(List<Order> orders, int idx) {
if (idx == orders.size()) return BigDecimal.ZERO;
return orders.get(idx).getAmount()
.add(sumRecursive(orders, idx + 1)); // 每层栈帧 ~80B
}
2
3
4
5
根因复盘:
- JVM 默认线程栈 1MB
- 每层栈帧含:参数 + 返回地址 + RBP + 局部变量 + 对齐 ≈ 60-80B
- 1MB / 80B ≈ 1.3 万层
- 48 万订单 → 48 万层递归 → 雪崩
修复手段(按推荐度):
// 方案 1:改为循环(永远首选)
BigDecimal total = BigDecimal.ZERO;
for (Order o : orders) total = total.add(o.getAmount());
// 方案 2:手动栈代替递归栈(应对必须递归的算法)
Deque<Frame> stack = new ArrayDeque<>();
// 方案 3:增大线程栈(治标)
new Thread(null, runnable, "name", 8 * 1024 * 1024 /* 8MB */)
// 方案 4:尾递归优化(仅 Scala/Kotlin/Clojure 等支持,Java 不支持)
2
3
4
5
6
7
8
9
10
11
衍生陷阱:栈上大数组
void f() {
char buf[8 * 1024 * 1024]; // 8MB 栈数组 → 直接撞穿默认栈
}
2
3
修复:超过 KB 级别的数组永远走堆,或写到全局/static。
# 8.2 悬空指针:返回栈地址
int* createInt() {
int x = 42;
return &x; // 函数返回,x 的栈帧已销毁
} // 返回的指针指向"已不存在"的栈空间
int* p = createInt();
printf("%d\n", *p); // UB:可能 42,可能任何值,可能段错误
2
3
4
5
6
7
根因:栈帧的释放是 add rsp, N —— 不擦除数据,只是逻辑上无效。下一次函数调用会覆盖这块内存。
对照 Go 的处理:
func createInt() *int {
x := 42
return &x // ✓ Go 编译器逃逸分析:x 改为堆分配
}
2
3
4
Rust 的处理:
fn create_int() -> &i32 {
let x = 42;
return &x; // ✗ 编译期错误:returns a reference to data owned by the current function
}
2
3
4
三种语言对同一段代码的态度——C 让你撞墙、Go 帮你修、Rust 直接拒绝编译。
# 8.3 use-after-free与double-free
char* p = (char*)malloc(100);
free(p);
strcpy(p, "hello"); // use-after-free:p 仍指向已释放区
// 数据可能还在,但分配器可能已把这块给了别人
char* q = (char*)malloc(50);
free(q);
free(q); // double-free:把同一块释放两次
// 后果:链表损坏 → 后续分配崩溃 → 严重时被攻击者利用
2
3
4
5
6
7
8
9
真实事故:CVE-2017-9078(Dropbear SSH double-free 远程代码执行)、CVE-2020-25637(libvirt use-after-free 提权)—— 这两类漏洞在 C/C++ CVE 中长年占比最高。
防御:
// 1. 释放后立即置 NULL
free(p); p = NULL;
free(p); // free(NULL) 是合法的,无害
// 2. 用智能指针(C++)
std::unique_ptr<int> p = std::make_unique<int>(42);
// 离开作用域自动释放,且无法被复制 → 无法 double-free
// 3. 用 AddressSanitizer 检测
gcc -fsanitize=address main.c
2
3
4
5
6
7
8
9
10
# 8.4 内存泄漏:堆的反面
栈不会泄漏(自动释放),但堆会:
void f() {
char* p = (char*)malloc(1024);
if (some_error) return; // 忘了 free → 泄漏 1KB
free(p);
}
2
3
4
5
生产级真实事故(某金融服务):
- 错误处理路径漏写
free() - 单次泄漏 64KB
- QPS 1000,泄漏速率 64MB/s
- 17 分钟后 OOM Kill
防御:
// goto 错误处理模式
void f() {
char* p = NULL, *q = NULL;
p = malloc(1024); if (!p) goto fail;
q = malloc(2048); if (!q) goto fail;
// ... 业务 ...
fail:
free(p);
free(q);
}
// C++ RAII
void f() {
std::vector<char> buf(1024); // 离开作用域自动释放
}
// Rust 编译期保证
fn f() {
let buf = vec![0u8; 1024]; // 离开作用域 drop
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 8.5 false sharing:堆栈都中招
两个线程修改"看起来不相关"的两个变量,但它们落在同一个 64B 缓存行 → MESI 协议反复弹跳 → 性能暴跌:
struct Counter {
long a; // 线程 1 频繁写
long b; // 线程 2 频繁写
// a 和 b 在同一缓存行 → false sharing
};
2
3
4
5
真实测量(2 线程对比单线程):
无 false sharing:吞吐 1.95×(接近线性扩展)
有 false sharing:吞吐 0.4× (比单线程还慢!)
2
3
修复:
struct Counter {
long a;
char pad[56]; // 填充到 64B
long b;
};
2
3
4
5
或用 C++17 提供的 alignas(std::hardware_destructive_interference_size)、Java 8+ 的 @Contended 注解。
这个陷阱栈和堆都会中招——只要两个变量落在同一缓存行 + 多核高频写。Disruptor 等高性能框架的所有热点字段都做了缓存行对齐。
# 9.一句话总结
堆和栈不是两种内存,而是两种生命周期模型——一种被 LIFO 嵌套约束,换来极致性能;另一种被任意时序解放,付出复杂代价。半个世纪的语言演进,都在把"堆的代价"一步步推回给编译期。
核心收获:
- 栈快不是因为聪明,是因为受限:LIFO + 编译期定大小 + 每线程独立 + 栈顶反复使用,这五个约束相乘,才让栈快出 30-100 倍。
- 堆的复杂度不是实现差,是自由度的必然代价:要任意分配 → 要搜索;要任意释放 → 要合并;要共享 → 要同步。每一个都是被现实逼出来的。
- 现代语言在做一件事:把"决定栈还是堆"这个责任,从程序员(C)→ 运行时 GC(Java)→ 编译期逃逸分析(Go)→ 编译期所有权(Rust),一路前推。
- 三大经典陷阱:栈溢出(递归 + 大数组)、悬空指针(返回栈地址)、内存泄漏(漏 free)—— 70% 的 C/C++ 安全漏洞都在这条线上。
- 下一篇看 GC 怎么解决堆的释放问题:→ 33.内存回收机制设计
延伸阅读:
- ← 31.内存模型技术设计:堆栈是结构,内存模型是契约——共享数据的可见性规则
- → 33.内存回收机制设计:堆释放的自动化——GC 如何决定"谁还活着"
- → 34.多种引用技术设计:强、软、弱、虚——引用强度如何影响 GC 决策
- ↘ 11.线程前世今生探索:每线程独立栈是多线程的基石
- ↘ 16.并发Bug源头由来:false sharing 等堆栈共有的陷阱