编程进阶网 编程进阶网
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接

杨充

专注编程 · 终身学习者
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接
  • README
  • 性能优化实践

  • 程序编程原理

    • README
    • 序卷方法论

    • 数据的本质

    • 运行时模型

    • 并发的设计

    • 内存的真相

      • README
      • 1.虚拟内存与地址空间
      • 2.内存模型技术设计
      • 3.堆和栈内存的设计
        • 0.从一个深夜事故说起
          • 0.1 一个递归引发的雪崩
          • 0.2 三个被忽视的本质问题
        • 01.核心设计思想
          • 1.1 为何分堆和栈
          • 1.2 栈设计思想
          • 1.3 堆设计思想
        • 2.设计哲学探索
          • 2.1 堆设计哲学
          • 2.2 栈设计哲学
          • 2.3 栈和堆原理
        • 3.栈的完整机制
          • 3.1 硬件支持
          • 3.2 栈帧结构
          • 3.3 栈内存案例
        • 4.堆的完整机制
          • 4.1 堆分层架构
          • 4.2 malloc设计
          • 推演 1:为什么需要 "chunk header"
          • 推演 2:为什么需要空闲链表
          • 推演 3:为什么一条链表不够
          • 推演 4:为什么需要 fast bin(犹豫不合并)
          • 推演 5:为什么需要 unsorted bin(延迟分类)
          • 完整设计总览
          • 4.3 堆内存案例
        • 5.多线程场景
          • 5.1 栈是线程安全
          • 5.2 堆需处理并发
        • 6.堆和栈性能
          • 6.1 性能指标
          • 6.2 量化案例
          • 6.3 结果分析
          • 6.4 深层原因
          • 6.5 核心结论
        • 7.跨语言堆栈策略对照
          • 7.1 C/C++:程序员全权负责
          • 7.2 Java:基本类型栈 + 对象堆
          • 7.3 Go:编译器决定堆栈
          • 7.4 Rust:所有权决定生命周期
          • 7.5 跨语言对比表
        • 8.经典陷阱与反模式
          • 8.1 栈溢出:递归与大数组
          • 8.2 悬空指针:返回栈地址
          • 8.3 use-after-free与double-free
          • 8.4 内存泄漏:堆的反面
          • 8.5 false sharing:堆栈都中招
        • 9.一句话总结
      • 4.内存对齐与缓存局部性
      • 5.内存回收机制设计
      • 6.多种引用技术设计
      • 7.内存泄漏与诊断原理
      • 8.数据拷贝设计原理
    • 交互和系统

  • 稳定性与可靠性

  • 工程化与运维

  • 方案设计思想

  • 专栏
  • 程序编程原理
  • 内存的真相
杨充
2025-11-28
目录

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
1
2
3
4
5
6
7
8
9

# 目录介绍

  • 0.从一个深夜事故说起
    • 0.1 一个递归引发的雪崩
    • 0.2 三个被忽视的本质问题
  • 1.核心设计思想
    • 1.1 为何分堆和栈
    • 1.2 栈设计思想
    • 1.3 堆设计思想
  • 2.设计哲学探索
    • 2.1 堆设计哲学
    • 2.2 栈设计哲学
    • 2.3 栈和堆原理
  • 3.栈的完整机制
    • 3.1 硬件支持
    • 3.2 栈帧结构
    • 3.3 栈内存案例
  • 4.堆的完整机制
    • 4.1 堆分层架构
    • 4.2 malloc设计
    • 4.3 堆内存案例
  • 5.多线程场景
    • 5.1 栈是线程安全
    • 5.2 堆需处理并发
  • 6.堆和栈性能
    • 6.1 性能指标
    • 6.2 量化案例
    • 6.3 结果分析
    • 6.4 深层原因
    • 6.5 核心结论
  • 7.跨语言堆栈策略对照
    • 7.1 C/C++:程序员全权负责
    • 7.2 Java:基本类型栈 + 对象堆
    • 7.3 Go:编译器决定堆栈
    • 7.4 Rust:所有权决定生命周期
    • 7.5 跨语言对比表
  • 8.经典陷阱与反模式
    • 8.1 栈溢出:递归与大数组
    • 8.2 悬空指针:返回栈地址
    • 8.3 use-after-free 与 double-free
    • 8.4 内存泄漏:堆的反面
    • 8.5 false sharing:堆栈都中招
  • 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 不优化
}
1
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
1
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 嵌套
1
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 全花在分配上
1
2
3
4
5
6
7
8
9

用栈解决所有问题(极端方案 2):

Node* createList() {
    Node head;       // 栈上分配
    head.next = ...;
    return &head;    // 返回栈地址 → 函数返回栈帧销毁 → 悬空指针
}
// 跨函数生命周期的数据无处安放
1
2
3
4
5
6

两种方案都不可行——它们各自在另一种生命周期模式上彻底崩盘。栈为"确定性"而生,堆为"灵活性"而生,这不是设计偏好,而是被数据生命周期的物理属性逼出来的。

更深一层:CPU 函数调用机制本身就要求栈

冯·诺依曼架构的函数调用满足三个铁律:

  1. 嵌套:A 调 B,B 调 C,必然 C 先返回,再 B,再 A(LIFO)
  2. 要保存返回地址:CALL 指令需要把 RIP 推到某处
  3. 要保存现场:被调用函数要保留调用者的寄存器

这三件事拼起来,几乎在数学上等价于一个栈结构。所以 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%
1
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%+)
1
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
1
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
1
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
1
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 控制
1
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               — 成对压入/弹出寄存器
1
2
3
4
5
6
7
8
9
10
11
12

栈是唯一有 CPU 硬件直接支持的内存区域。 堆没有专门的硬件指令。

推演过程:为什么 CPU 抽出一个寄存器专门服务栈?

问题 1:如果不设专用寄存器,每次压栈要怎么做?
  → 读“顶位置"变量” → 加 8 → 写回“顶位置"变量” → 实际写入 = 4 次访存
问题 2:函数调用频率有多高?
  → 现代程序每秒几亿次调用 → 这是热点中的热点
问题 3:优化热点 → 为高频操作设专用硬件
  → RSP 作为专用寄存器不出入内存
  → PUSH/POP 独立为原子指令
  → CALL/RET 隱含压栈/弹栈
1
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 指向这里
│  (下一次调用的空间)    │
低地址
1
2
3
4
5
6
7
8
9
10
11
12
13
14

为什么栈帧要设计成这样?逐层推演:

为什么需要返回地址?

A() 调用 B()
B() 执行完​需要跳回 A() 中 B 调用后的下一条指令
  → 这个"下一条指令"的地址必须在 B 执行期间被保存着
  → 不能只依靠寄存器(因为 B 可能会嵌套调用 C,C 会覆盖同一个寄存器)
  → 必须压栈
1
2
3
4
5

为什么需要保存 RBP?

B() 执行期间会修改 RBP(指向 B 自己的栈帧底部)
B() 返回后 A() 需要恢复自己的 RBP
  → 压栈保存 → 返回时弹出恢复
1
2
3

为什么局部变量要从高地址向低地址排列?

栈本身向低地址增长(x86 传统)
RSP 从高地址走向低地址
  → 新入栈的局部变量肯定在 RSP 附近的低地址
  → RBP 以上是调用者的栈帧,RBP 以下是本函数的
  → 用 RBP-N 访问局部变量(N 恒为正数,常量偏移)
1
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;
}
1
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 移回去,数据还在但逻辑上已无效)
1
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

关键洞察:

  1. 分配 = RSP 减一个常数(一条 SUB RSP, N 指令),不需要搜索空闲块
  2. 释放 = RSP 加回去(一条 ADD RSP, N),不需要合并、不需要标记
  3. 栈上的"旧数据"不会被清零——只是 RSP 移过去了,下次调用会覆盖。这就是为什么未初始化的局部变量是"垃圾值"
  4. 函数返回后访问局部变量的地址是未定义行为——数据可能还在,但随时会被下一次函数调用覆盖

# 4.堆的完整机制

# 4.1 堆分层架构

应用程序
  │  malloc(32)
  ▼
┌──────────────────────────────────────────────┐
│ 用户态分配器 (glibc malloc / tcmalloc / jemalloc) │
│                                              │
│  维护空闲链表/空闲树,从已有的大块中切小块      │
│  大多数分配在这一层就完成了,不需要系统调用       │
└──────────────┬───────────────────────────────┘
               │ 空间不够时
               ▼
┌──────────────────────────────────────────────┐
│ 系统调用                                      │
│  brk/sbrk  → 扩展进程数据段(连续)            │
│  mmap      → 映射新的虚拟内存区域(不连续)      │
└──────────────┬───────────────────────────────┘
               │
               ▼
┌──────────────────────────────────────────────┐
│ 内核虚拟内存管理                               │
│  分配虚拟页,加入进程的 VMA 链表                │
│  此时还没有分配物理内存!                        │
└──────────────┬───────────────────────────────┘
               │ 首次访问时触发 Page Fault
               ▼
┌──────────────────────────────────────────────┐
│ 物理页帧分配 (Buddy System + Slab)            │
│  从物理内存中分配 4KB 页帧                     │
│  建立页表映射:虚拟地址 → 物理地址              │
└──────────────────────────────────────────────┘
1
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:它之前在哪条空闲链里?    → 需要能查到链接信息
1
2
3

这些信息什么时候存?只能在“块本身"上。于是每块都必须携带一个 header,这是被“任意释放”这个需求逼出来的。

# 推演 2:为什么需要空闲链表

如果没有空闲链表,每次 malloc 都要从堆的头开始扫描“哪些块是空的",这是 O(n),堆越大越慢。

解法:把空闲块串成链表,每次只扫空闲部分,不扫已用部分。

# 推演 3:为什么一条链表不够

考虑这个照片:现在空闲链表里有 1000 个块,大小分别是 16B、32B、100KB。你 malloc(50)。怎么找?

方案 A:顺序扫描      → 遇到千个 16B/32B 都被跳过,才找到合适的 → 慢
方案 B:按大小分档  → 小块走小 bin,中块走中 bin,大块走大 bin
1
2

这就是分级 bin 思想的根源。

# 推演 4:为什么需要 fast bin(犹豫不合并)

在实际程序中有个现象:小块分配/释放频率极高,且生命周期极短。例如:JSON 解析器的临时 buffer、STL 容器的 node。

问题:如果每次 free(16B) 都要检查邻居是否可合并,合并后还要从原链表取出、插入到新链表……
代价:一次 free 要 ~30ns
但这块 16B 可能 1ms 后又要被重新 malloc,又要 ~30ns
总代价:~60ns
同一块在两秒内各 malloc/free 1 万次 → 60μs
1
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。
1
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 最小数据(对齐)
1
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 默认阈值)              │
│   直接向内核要整页                             │
│   释放时直接还给内核                           │
└───────────────────────────────────────────┘
1
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);
}
1
2
3
4
5

Step 1: malloc(48) 进入 glibc

请求 48 字节 → 加上 16 字节 header → 实际需要 64 字节
64 字节对齐后仍是 64 字节
→ 属于 Fast Bin 范围(≤ 160 字节)
1
2
3

Step 2: 查找 Fast Bin

fast_bin[index(64)] → 链表头
  │
  有空闲 chunk?
  ├── 有 → 取出链表头(一次 CAS),返回 chunk + 16 的地址
  │        时间:~10ns
  │
  └── 没有 → 查找 Small Bin
1
2
3
4
5
6
7

Step 3: 查找 Small Bin(如果 Fast Bin 为空)

small_bin[index(64)] → 双向链表
  │
  有空闲 chunk?
  ├── 有 → 取出链表尾(FIFO),返回
  │
  └── 没有 → 查找 Unsorted Bin
1
2
3
4
5
6

Step 4: 查找 Unsorted Bin(如果 Small Bin 为空)

遍历 Unsorted Bin:
  对每个 chunk:
    如果大小恰好匹配 → 取出返回
    否则 → 放入对应的 Small/Large Bin(顺便整理)
  
  遍历完仍没找到 → 从 Top Chunk 切割
1
2
3
4
5
6

Step 5: Top Chunk 切割(如果所有 Bin 都没有)

Top Chunk = 堆顶的一大块连续空闲内存

  ┌────────────────────────────┐
  │        Top Chunk           │
  │    (假设还有 4MB)           │
  └────────────────────────────┘
  
  切出 64 字节:
  ┌─────────┬─────────────────┐
  │ 64B     │  剩余 Top Chunk  │
  │(返回给用户)│                │
  └─────────┴─────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12

Step 6: Top Chunk 也不够(极端情况)

调用 sbrk() 或 mmap() 向内核申请新的内存页
内核分配虚拟页 → 首次访问时 Page Fault → 分配物理页
1
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(一次链表头插入)
1
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; }
    }
}
1
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

互不干扰,零竞争,不需要锁
1
2
3
4
5

推演:为什么可以这样

Q1: 所有线程在同一个虚拟地址空间里,为什么能独享一块栈?
→ 虚拟地址空间是 2^48 (现代 64 位 Linux)= 极大
→ OS 在创建线程时,给每个线程划一块 8MB 虚拟地址不同的区间作为栈
→ 不同线程的 RSP 寄存器从不同区间起始下降
→ 只要没人主动告诉别人“我的栈在哪",其他线程就访问不到

Q2: 那为什么间​偶​听说“跨线程访问栈”这种事?
→ 只有一种情形:程序员主动把栈地址传出去(返回局部变量地址、取 & 传给其他线程)
→ 此时不是“栈不线程安全",而是你主动破坏了隔离

Q3: 那调用 setjmp/longjmp、继​​装​​​​​​​​​​​​​​​​​​​续会怎么样?
→ setjmp/longjmp 在同一线程里跳栈帧,不跨线程
→ 但你如果 longjmp 到一个已经返回了的栈帧→UB(未定义行为)
1
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 就会玩不转
1
2
3
4
5

“每线程独立栈”是多线程计算能走到今天的基础设计——不是优化,是使多线程可能的初始条件。

这个设计的代价:栈空间限制

多线程独立栈不是免费的:

场景:Java 应用 创建 1 万个线程
  → 每线程 1MB 栈 × 1万 = 10GB 虚拟内存
  → 32 位系统上连虚拟地址都不够
  → 这是为什么 32 位 JVM 创建 5000 线程就会 OOM
  → 也是 Project Loom 要发明虚拟线程的根本动机之一
1
2
3
4
5

这是个经典的设计权衡:不共享栈达到了线程安全和高性能,但限制了可创建的线程数量。所以现代语言又发明了“动态可伸缩栈”(Go goroutine 2KB 起步可增长)、“不带栈的协程”(状态机化后存在堆)来变相突破这个限制。

# 5.2 堆需处理并发

核心问题:堆是全局共享的,多个线程同时在里面分配/释放。怎么避免争抢?这是计算机设计里“争用资源”问题的经典案例,三代分配器各有不同的解法。

最原始的方案:全局锁

原始 dlmalloc 设计:
  所有 malloc/free 都要拿到唯一一把全局锁
  优点:简单
  缺点:4 核 上 4 个线程同时 malloc → 3 个被阻塞
  多核 CPU 退化为单核表现
1
2
3
4
5

第一代优化:Arena(多锁池化)

glibc malloc 设计:
  主线程 → main_arena(只有一个,用 brk 扩展)
  其他线程 → thread_arena(用 mmap 创建,数量 = min(8*核数, 线程数))
  
  多个线程可能共享 arena → arena 内部有 mutex
  但 arena 数量较多,竞争被分散
1
2
3
4
5
6

推演:这个设计还是不够

Q: 为什么不能每线程一个 arena?
A: 内存浪费!每个 arena 预留几 MB 起步,1万线程 × 1MB = 10GB,浪费别谈

Q: 那怎么进一步减锁?
A: 只给线程留一小部分高频使用的块(thread-local cache)
   “在货"时全部在本地 → 零锁
   只有“补货"时才上锁
1
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)               │
  └──────────────────────────────┘
1
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;
};
1
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)
}
1
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
1
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) → 复杂路径
  }
}
1
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
1
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
1
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       │  ∞         │
└─────────────────────┴──────────────┴──────────────┴────────────┘
1
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 基础差距
1
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
1
2
3
4
5

堆的访问模式——碎片化空间局部性:

迭代1: pkt地址 = 0x5555_A000  → L1 miss, 加载
迭代1: free后该地址的缓存行可能被后续逻辑驱逐
迭代2: pkt地址 = 0x5555_A000  → 可能L1 hit(fast bin快速复用同一块)
                                 也可能L1 miss(被驱逐了)
1
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倍时间
1
2
3
4
5
6

3.原子操作开销

堆分配器(glibc malloc)在多线程环境下需要保护共享数据结构:

lock cmpxchg (无竞争): ~10 cycles
  → 需要锁缓存行,通知MESI协议

lock cmpxchg (有竞争): ~50-200 cycles  
  → 缓存行弹跳(cache-line bouncing)
  → 核间通信延迟 ~40-80ns

栈:完全不需要任何同步原语(每线程独立栈)
1
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
1
2
3
4
5
6
7
8

# 6.5 核心结论

性能差距的本质 = 指令数(30x) × 缓存效率(2-5x) × 同步开销(1-10x)

栈:约束(LIFO)→ 极简指令 → 极致缓存 → 零同步 → 极致性能
堆:自由(任意序)→ 复杂管理 → 缓存不友好 → 需要同步 → 性能代价

这不是实现好坏的问题,而是"自由度"的物理代价。
1
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
}
1
2
3
4
5

哲学:相信程序员,用裸指针换控制力。

代价:内存安全靠人,因此 C/C++ 程序中超过 70% 的安全漏洞与内存错误相关(微软 2019 年统计)。

# 7.2 Java:基本类型栈 + 对象堆

void f() {
    int a = 10;              // 栈:基本类型 + 局部变量
    String s = "hello";      // s 引用在栈,对象在堆
    Object obj = new Object();  // 引用栈、对象堆
}
1
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 编译器自动改为堆分配
}
1
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
1
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
1
2
3
4
5

哲学:所有权(Ownership)静态决定一切。

所有权三铁律:
  1. 每个值有且仅有一个所有者
  2. 所有者离开作用域时值被 drop
  3. 借用必须比所有者活得短
1
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
1
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
}
1
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 不支持)
1
2
3
4
5
6
7
8
9
10
11

衍生陷阱:栈上大数组

void f() {
    char buf[8 * 1024 * 1024];  // 8MB 栈数组 → 直接撞穿默认栈
}
1
2
3

修复:超过 KB 级别的数组永远走堆,或写到全局/static。

# 8.2 悬空指针:返回栈地址

int* createInt() {
    int x = 42;
    return &x;     // 函数返回,x 的栈帧已销毁
}                  // 返回的指针指向"已不存在"的栈空间

int* p = createInt();
printf("%d\n", *p);  // UB:可能 42,可能任何值,可能段错误
1
2
3
4
5
6
7

根因:栈帧的释放是 add rsp, N —— 不擦除数据,只是逻辑上无效。下一次函数调用会覆盖这块内存。

对照 Go 的处理:

func createInt() *int {
    x := 42
    return &x   // ✓ Go 编译器逃逸分析:x 改为堆分配
}
1
2
3
4

Rust 的处理:

fn create_int() -> &i32 {
    let x = 42;
    return &x;   // ✗ 编译期错误:returns a reference to data owned by the current function
}
1
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:把同一块释放两次
                      // 后果:链表损坏 → 后续分配崩溃 → 严重时被攻击者利用
1
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
1
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);
}
1
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
}
1
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
};
1
2
3
4
5
真实测量(2 线程对比单线程):
  无 false sharing:吞吐 1.95×(接近线性扩展)
  有 false sharing:吞吐 0.4×  (比单线程还慢!)
1
2
3

修复:

struct Counter {
    long a;
    char pad[56];  // 填充到 64B
    long b;
};
1
2
3
4
5

或用 C++17 提供的 alignas(std::hardware_destructive_interference_size)、Java 8+ 的 @Contended 注解。

这个陷阱栈和堆都会中招——只要两个变量落在同一缓存行 + 多核高频写。Disruptor 等高性能框架的所有热点字段都做了缓存行对齐。


# 9.一句话总结

堆和栈不是两种内存,而是两种生命周期模型——一种被 LIFO 嵌套约束,换来极致性能;另一种被任意时序解放,付出复杂代价。半个世纪的语言演进,都在把"堆的代价"一步步推回给编译期。

核心收获:

  1. 栈快不是因为聪明,是因为受限:LIFO + 编译期定大小 + 每线程独立 + 栈顶反复使用,这五个约束相乘,才让栈快出 30-100 倍。
  2. 堆的复杂度不是实现差,是自由度的必然代价:要任意分配 → 要搜索;要任意释放 → 要合并;要共享 → 要同步。每一个都是被现实逼出来的。
  3. 现代语言在做一件事:把"决定栈还是堆"这个责任,从程序员(C)→ 运行时 GC(Java)→ 编译期逃逸分析(Go)→ 编译期所有权(Rust),一路前推。
  4. 三大经典陷阱:栈溢出(递归 + 大数组)、悬空指针(返回栈地址)、内存泄漏(漏 free)—— 70% 的 C/C++ 安全漏洞都在这条线上。
  5. 下一篇看 GC 怎么解决堆的释放问题:→ 33.内存回收机制设计

延伸阅读:

  • ← 31.内存模型技术设计:堆栈是结构,内存模型是契约——共享数据的可见性规则
  • → 33.内存回收机制设计:堆释放的自动化——GC 如何决定"谁还活着"
  • → 34.多种引用技术设计:强、软、弱、虚——引用强度如何影响 GC 决策
  • ↘ 11.线程前世今生探索:每线程独立栈是多线程的基石
  • ↘ 16.并发Bug源头由来:false sharing 等堆栈共有的陷阱
上次更新: 2026/06/07, 10:26:12
2.内存模型技术设计
4.内存对齐与缓存局部性

← 2.内存模型技术设计 4.内存对齐与缓存局部性→

最近更新
01
信号崩溃快速排查
06-15
02
CoreDump破案
06-15
03
perf火焰图实战
06-15
更多文章>
Theme by Vdoing | Copyright © 2019-2026 杨充 | MIT License | 桂ICP备2024034950号 | 桂公网安备45142202000030
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式