用餐厅和盘子理解栈和堆

最近在学 Rust。其实之前就学过一段时间,但学到所有权这里就卡住了,概念一多加上当时在专升本,后面就放弃了🥲。趁着国庆假期有整块时间,决定重新捡起来,这次争取把它啃下来。

本文的内容来源于 Rust 官方教程 What Is Ownership?,在学习所有权(Rust 独有的机制)之前,官方先花了一段篇幅介绍栈(Stack)和堆(Heap)。很多从 Java、Python、Go 这类有 GC 的语言转过来的同学(包括我自己)平时很少关心数据到底放在哪里,所以这一段读起来有点抽象,我让 AI 翻译了一下并且加上了一些内容,方便我理解,我感觉写的挺好的,就分享一下。

栈 Stack

栈的规则只有一条,叫后进先出(LIFO):最后放上去的盘子,最先被拿走。

复制代码
   放入(push)     取出(pop)
       ↓             ↑
    ┌───────┐
    │ 盘子 3 │  ← 栈顶:只能在这里操作
    ├───────┤
    │ 盘子 2 │
    ├───────┤
    │ 盘子 1 │  ← 最先放的,最后才拿走
    └───────┘

栈有一个硬性要求:每个数据的大小必须在编译时就确定,而且不能变。

就像每个盘子尺寸都一样,才能整整齐齐摞起来。i32(4 字节)、bool(1 字节)、[u8; 10](10 字节)这类类型都可以放在栈上。

栈为什么快? 因为根本不用找位置,新数据永远放在栈顶。分配内存只是把栈顶指针挪一下,几乎不花时间。

堆 Heap

如果数据大小在编译时不知道,或者运行中会变化(比如用户输入的字符串、一个会不断 push 的 Vec),就没法放在栈上,只能放到堆里。

堆就像一家餐厅:

  1. 你进门告诉服务员:"我们 4 个人。"(申请 4 个单位的空间)
  2. 服务员在店里找一张够大的空桌。(分配器搜索空闲内存)
  3. 把桌子标记为"已占用",并告诉你:"你们坐 7 号桌。"(返回一个指针,也就是内存地址)
  4. 朋友晚到了,只要问"7 号桌在哪"就能找到你们。(通过指针访问数据)
复制代码
        栈(Stack)                         堆(Heap)
   ┌──────────────────┐            ┌─────────────────────────┐
   │ x: i32 = 5       │            │ ░░░░  [已占用]  ░░░░░░░  │
   ├──────────────────┤            │                         │
   │ s: String        │            │   ┌───┬───┬───┬───┬───┐ │
   │  ├ ptr ──────────┼───────────►│   │ h │ e │ l │ l │ o │ │
   │  ├ len = 5       │            │   └───┴───┴───┴───┴───┘ │
   │  └ cap = 5       │            │ ░░░  [空闲]  ░░░░░░░░░░  │
   └──────────────────┘            └─────────────────────────┘
     大小固定,排列整齐               大小可变,位置随意

关键点:指针本身大小是固定的 (64 位系统上是 8 字节),所以指针可以放在栈上,它指向的真实数据放在堆上。String 就是这样:栈上存"指针 + 长度 + 容量"三个字段,字符内容存在堆上。

栈和堆的区别

先用一张表把两者放在一起对比:

栈 堆
数据大小 编译时确定,固定不变 编译时未知,运行时可变
分配方式 直接放在栈顶 先找一块够大的空位,再做记录
访问方式 直接读取 先读指针,再跳到对应地址
内存布局 连续、紧凑 分散在内存各处
释放时机 函数返回时自动弹出 需要有人负责回收
速度 快 相对慢
类比 一摞盘子 餐厅找座位

其中最直观的差别是速度,堆比栈慢主要体现在两个环节。

1. 分配:栈不用找位置,堆要找

往栈上放数据,就像往盘子堆上再放一个,位置永远是栈顶,挪一下栈顶指针就完事了。

往堆上放数据,就像服务员要满店找一张够大的空桌,找到后还要登记"这张桌有人了",步骤多得多。

2. 访问:栈直接拿,堆要跳一下

访问栈上的数据可以直接读;访问堆上的数据要先读指针,再跳到指针指向的地址,多了一步。更重要的是,堆上的数据可能散落在内存各处。

还是用餐厅打比方,一个服务员负责点单:

复制代码
高效(类似栈):A桌 → A桌 → A桌 → B桌 → B桌 → B桌
低效(类似堆):A桌 → B桌 → A桌 → B桌 → A桌 → B桌
                  ↑ 来回跑,大部分时间花在路上

CPU 也一样:数据在内存里挨得越近,CPU 缓存命中率越高,处理就越快;在内存里跳来跳去就会变慢。

至于"释放时机"这一行的差别,正是后面要讲的重点:栈上的数据随函数调用自动清理,而堆上的数据需要有人管,这也是所有权要解决的问题。

函数调用的实现

调用函数时,参数和局部变量会被压入栈;函数返回时,这些数据被弹出,自动清理,不用操心。

rust 复制代码
fn main() {
    let a = 1;
    foo(a);
}

fn foo(x: i32) {
    let y = 2;
}
复制代码
 main开始        调用foo           foo结束          main结束
┌────────┐     ┌────────┐       ┌────────┐
│        │     │ y = 2  │       │        │
│        │     │ x = 1  │ foo   │        │       (空)
│ a = 1  │     │ a = 1  │ main  │ a = 1  │
└────────┘     └────────┘       └────────┘

栈上的数据根本不需要人管理,因为它的生命周期和函数调用严格绑定。

麻烦的是堆的分配

堆上的数据不会随函数结束自动消失。这就带来了几个问题,继续用餐厅类比:

问题 餐厅类比 后果
谁在用这块数据? 7 号桌现在到底是谁的? 搞不清就容易出错
重复数据 同一拨人被安排了三张桌子 浪费内存
不清理 客人走了桌子一直没收 内存泄漏,最后没座位可用
清理两次 / 清理后还用 桌子收了,又有人回来坐 double free / 悬垂指针,程序崩溃或出现安全漏洞

各语言处理这些问题的方式不同:

  • C/C++ :靠程序员手动 malloc/free,容易出错。
  • Java/Go/Python:用垃圾回收器(GC)定期巡视、回收,省心但有运行时开销,还有需要单独学习垃圾回收机制😭。
  • Rust :用所有权 规则在编译期就保证安全。每块堆内存都有唯一的"主人"(owner),主人离开作用域时内存自动释放,没有 GC 开销,也不会忘记释放。
rust 复制代码
{
    let s = String::from("hello"); // 在堆上分配,s 是主人
    // 使用 s ...
}   // s 离开作用域 → Rust 自动调用 drop → 堆内存被释放
相关推荐
Ivanqhz2 小时前
激活函数在 Transformer 中的作用及各种变体简述
java·linux·数据库·人工智能·深度学习
沙漠之主3 小时前
C++编程教学设计资料:从入门到实战的完整课程方案
java·前端·c++
FPGA小徐3 小时前
PA1 基础设施(2)深度解析:NEMU 中 ITRACE、MTRACE、FTRACE、IRINGBUF、ETRACE、DIFFTEST 全解析
java·服务器·网络
YOLO数据集集合3 小时前
EvoAgent:面向PR研发治理的自进化Multi-Agent Harness系统
java·开发语言·目标检测·agent·自进化
知行EDI3 小时前
知行之桥 MaBang 端口使用指南——Create Order 订单创建篇
java·服务器·数据库
笑鸿的学习笔记3 小时前
C++笔记之大块顺序写
java·c++·笔记
打工仔折腾 AI4 小时前
从 Demo 到生产级 Agent:8 个关键设计机制与 Python 实现拆解
java·jvm·人工智能·后端·python·langchain·ai agent 实战
lvwangshu4 小时前
登录日志 题解
数据结构·线段树·题解·技巧·性质题·思维好题·细节题
CQU_JIAKE4 小时前
10.2【A】
数据结构