每日一R「22」内存:堆与栈

105 阅读5分钟

我报名参加金石计划1期挑战——瓜分10万奖池,这是我的第01篇文章,点击查看活动详情

课程开始之前,考虑如下的代码:

let s = "hello, world".to_string();
  • "hello, world"是一个字面常量(string literal),在编译阶段被存储到可执行文件的 .RODATA 段(GCC)或者 .RDATA 段(VC++);程序加载时,获得一个固定的内存地址。
  • to_string方法会在堆上分配一块空间,并把 hello, world 逐字节拷贝过去。
  • 赋值语句(或者说变量绑定)将堆上数据赋值给 s 时,s 作为栈上的变量,它需要知道堆上数据的内存地址、堆上已分配空间的容量、数据实际占用的长度。所以,s 拥有三个内存字(word),分别用来存储这三个信息。64位系统上,一个 word 为8字节;32位系统商,一个 word 为4字节。

堆和栈上分别存储什么样的数据?或者说开发时,如何确定哪些数据或内容存放在堆上,哪些存放在栈上?

01-栈

栈是程序运行的基础。在计算机内存中,栈是自顶向下(高地址向低地址)扩张的,堆是自底向上扩张的。

当函数被调用时,会在栈顶分配一块连续的空间,称为栈帧(frame)。栈帧中保存有函数调用时寄存器等上下文的值,并在函数调用结束时用这些值来恢复寄存器等上下文的内容。

如何确定栈帧大小,或者说在函数调用时,需要在栈中分配多少连续的空间?这在编译时可以确定。编译时,函数是最小的编译单元。编译器知道,在函数执行时需要哪些寄存器、会定义哪些局部变量。寄存器的大小是固定的,局部变量大小也必须是编译时可知的,以便编译器可以预留空间。

由此可以得出以下结论:

在编译时,一切无法确定大小或者大小可以改变的数据,都无法安全地放在栈上,最好放在堆上.

根据上面的结论,我们可以知道哪些数据存放在堆上,哪些数据存放在栈上。在文章开头的例子中,我们也能明白为什么 Rust 中的 String 类型的变量会使用三个内存字的胖指针指向堆上的一块空间。

数据存放在栈上的优点与不足:

  • 栈的内存分配是非常高效的。分配与回收仅需要改动指针即可,不涉及额外计算、不涉及系统调用,仅修改寄存器。
  • 局限性(感觉不能称之为缺点)是过大的栈内存分配会导致栈溢出,特别注意递归调用问题。
  • 另外一个局限性就是,栈帧的大小是固定的,不能处理动态增长的内存。

02-堆

当需要动态大小的内存时,只能使用堆,例如可变长度的数组、列表、哈希表、字典等。

在堆上分配内存时往往分配的空间(容量)会大于实际需要,换句话说会预先多分配一部分内存,主要是因为堆内存分配会调用系统调用,代价昂贵,要避免频繁调用。

除了动态大小的内存需要分配在堆上,动态声明周期的内存也需要分配在堆上。存储在栈中的局部变量会随着栈帧的回收而被释放,所以栈上变量的声明周期是不受开发者控制的,且局限在当前调用方法内部。堆上分配的内存需要显式地释放,使得其具有更灵活性的生命周期,也可以在多个栈帧(或者说多个方法)之间共享。

数据存放在堆上的优点和不足:

  • 堆内存的申请和释放比较灵活,可由开发人员自由掌控。
  • 如果堆内存的释放完全依赖使用者,则极容易出现内存泄漏;甚至不恰当地使用或释放堆内存,还会发生使用已释放内存(use after free)、堆越界等内存安全问题。

如何解决堆内存管理问题,不同的语言选择了不同的方案。

  • Java 使用 GC 来处理堆内存的回收。GC 是一种追踪式垃圾回收方法,来自动管理堆内存(主要是回收)。
  • Object C 和 Swift 使用自动引用计数来管理堆内存。

GC 在内存分配和回收时无需额外的操作,因此效率较高。而 Arc 在分配和回收时需要处理引用计数值,因此效率较 GC 低。但是,GC 会存在 STW 问题。

我们使用 Android 手机偶尔感觉卡顿,而 iOS 手机却运行丝滑,大多是这个原因。

GC 分配和释放内存的效率和吞吐量要比 ARC 高,但因为偶尔的高延迟,导致被感知的性能比较差,所以会给人一种 GC 不如 ARC 性能好的感觉。

03-思考题

  1. 如果有一个数据结构需要在多个线程中访问,可以把它放在栈上吗?为什么?

不可以。栈是线程独占的内存空间,无法在线程之间共享。

可以使用指针引用栈上的某个变量吗?如果可以,在什么情况下可以这么做?

可以。在栈上创建一个指针,指向栈上的变量。例如:

let s = 10;
let s1 = &s;       // s1 为 s 的指针

创建的指针仅可在当前方法中可用,不可传递到其他方法中。否则,会发生使用已释放内存。

本节课程链接《01|内存:值放堆上还是放栈上,这是一个问题