内存

我们的程序无时无刻不在跟内存打交道。在下面这个把 “hello world!” 赋值给 s 的简单语句中,就跟只读数据段(RODATA)、堆、栈分别有深度交互:

1
let s = "hello world".to_string();

首先,“hello world” 作为一个字符串常量(string literal),在编译时被存入可执行文件的 .RODATA 段(GCC)或者 .RDATA 段(VC++),然后在程序加载时,获得一个固定的内存地址。

当执行 “hello world”.to_string() 时,在堆上,一块新的内存被分配出来,并把 “hello world” 逐个字节拷贝过去。

当我们把堆上的数据赋值给 s 时,s 作为分配在栈上的一个变量,它需要知道堆上内存的地址,另外由于堆上的数据大小不确定且可以增长,我们还需要知道它的长度以及它现在有多大。

最终,为了表述这个字符串,我们使用了三个 word:第一个表示指针、第二个表示字符串的当前长度(11)、第三个表示这片内存的总容量(11)。在 64 位系统下,三个 word 是 24 个字节。

栈是程序运行的基础,当一个函数被调用时,一块连续的内存空间被分配出来,这快内存叫做帧(frame)

栈是自顶向下增长的,一个程序的调用栈最底部,除去入口帧(entry frame),就是 main()函数对应的帧,随着main函数一层层调用,栈会一层层扩展,调用结束,栈会一层层回溯,把内存释放回去

在调用过程中,一个新的帧会分配足够的内存空间给存储寄存器的上下文.在函数里使用到的通用寄存器会在栈保存一个副本,当这个函数调用结束后,通过副本,可以恢复原本寄存器的上下文,就像什么都没有经历过一样.

此外,函数所需要使用的局部变量,也都会在帧分配的时候预留出来.

如何确定究竟需要多大的帧?

在编译并优化代码的时候,一个函数就是一个最小的编译单元.

在这个函数里,编译器得知道要用到哪些寄存器,栈上要放哪些局部变量,而这些都要在编译时确定.所以编译器需要明确每个局部变量的大小,以便于预留空间

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

例:

1
2
3
4
5
fn say_name(name: String) {}

// 调用
say_name("Lindsey".to_string());
say_name("Rosie".to_string());

字符串的数据结构,在编译时不确定大小,运行时执行到具体的代码才知道大小

所以,我们无法把字符串本身放在栈上,只能将其先放在堆上,然后在栈上分配对应的指针,引用堆上的内存

放在栈上的问题

栈上的内存配分是非常高效的,只需要改动栈指针,就可以预留相应的空间;把栈指针改回来,预留的空间又会被释放掉.预留和释放只是动寄存器,不涉及额外计算,不涉及系统调用,因此效率很高

所以理论上,只要可能,我们应该把变量分配到栈上,这样可以达到更好的运行速度.

但是在实际工作中,我们要尽量避免把大量的数据分配到栈上.主要是因为要考虑到调用栈的大小,避免栈溢出,一旦当前程序的调用栈超出了系统允许的最大的栈空间,无法创建新的帧,就会发生栈溢出,这时候会被系统终止,产生崩溃信息

过大的栈内存分配是导致栈溢出的原因之一,更广为人值的原因是递归函数没有妥善终止.一个递归函数会不断调用自己,每次调用都会产生一个新的帧,如果递归函数无法终止,最终会导致栈溢出.

当我们需要动态大小的内存时,只能使用堆

堆上分配空间时,一般都会预留一些空间,这是最佳实践

1
2
3
let mut arr = Vec::new();
arr.push(1);
arr.push(2);

这个列表实际预留的大小是4,而不等于其长度2.这是因为堆上内存分配会使用libc提供的malloc()函数,其内部会请求操作系统的系统调用,来分配内存.系统调用的代价是昂贵的,因此我们要避免频繁的malloc()

对于上面的代码来说,如果我们需要多少就分配多少,那么列表每一次新增值,都会重新分配一块内存,先拷贝已有数据,再把新的值添加进去,然后释放内存,这样效率很低,所以在堆内存分配时,预留的空间大小会大于实际需要的大小

除了动态大小的内存需要被分配到堆上以外,动态生命周期的内存也需要分配到堆上

栈上内存在函数调用结束以后,所使用的帧被回收,相关变量对应的内存也被回收,所以栈上内存的生命周期是不受开发者控制的,并且局限于当前调用栈

而堆上分配的每一块内存都需要显示的释放,这就是使得堆上内存有更加灵活的生命周期,可以在不同的调用栈之间共享数据

放在堆上的问题

如果手工管理堆内存的话,堆上的内存忘记释放,那么就会导致内存泄漏,一旦发生内存泄漏,那么程序运行的越久,可使用内存就会越少,最后导致占满系统内存而被操作系统终止

如果堆上内存被多个线程调用,该内存的改动要特别小心,需要加锁来明确独占访问,来避免潜在的问题.比如说,一个线程在遍历列表,而另外一个线程在释放列表里的某一项,就可能访问野指针,导致堆越界.堆越界是内存第一安全问题

如果堆上内存被释放,但栈上指向堆内存的相应指针没有被清空,就有可能发生使用已释放内存的清空,轻则程序崩溃,重则隐含安全隐患.这是第二大安全问题

GC和ARC 如何解决内存问题

GC

追踪式垃圾回收,自动管理堆内存,通过定期标记找出不在被引用的对象,然后将其清理掉,来自动管理内存,减轻开发者负担

ARC

在编译时,为每个函数插入retain/release语句来自动维护堆上对象的引用计数,当引用计数为零时,release语句就释放对象

对比

从效率上来说,GC在内存分配和释放上无需额外操作,而ARC添加了大量的额外代码,所以GC的效率更高,吞吐量更大

但是,GC释放内存的时机是不确定的,释放时引发的Stop The World,也会导致代码执行的延迟,所以一般携带GC的编程语言,不适合做嵌入式系统

总结

对于存入栈上的值,他的大小在编译时期就要被确定.栈上存储的变量生命周期在当前调用栈的作用域内,无法跨调用栈引用

堆可以存入大小未知或动态伸缩的数据类型.堆上存储的变量,其生命周期从分配后开始,一直到释放时才结束,因此堆上的变量允许在多个调用栈之间引用. 但也导致堆变量的管理非常复杂,手工管理会导致很多内存安全性问题,而自动管理,无论是GC还是ARC,都会有性能损耗和其他问题

一句话:栈上存放的数据是静态的,固定大小,固定生命周期;堆上存放的数据是动态的,不固定大小,不固定生命周期的