栈、堆
栈
帧
栈是程序运行的基础。每当一个函数被调用时,一块连续的内存就会在栈顶被分配出来,这块 内存被称为帧(frame)。
调用过程
栈是自顶向下增长的,一个程序的调用栈最底部,除去入口帧(entry frame),就是main函数对应的帧,而随着main函数一层层调用,栈会一层层扩展;调用结束,栈又会一层层回溯,把内存释放回去。
在调用的过程中,一个新的帧会分配足够的空间存储寄存器的上下文。在函数里使用到的通用寄存器会在栈保存一个副本,当这个函数调用结束,通过副本,可以恢复出原本的寄存器的上下文,就像什么都没有经历一样。此外,函数所需要使用到的局部变量,也都会在帧分配的时候被预留出来。
怎么确定需要多大的帧呢?
这要归功于编译器。在编译并优化代码的时候,一个函数就是一个最小的编译单元。
在这个函数里,编译器得知道要用到哪些寄存器、栈上要放哪些局部变量,而这些都要在编译时确定。所以编译器就需要明确每个局部变量的大小,以便于预留空间。
结论
在编译时,一切无法确定大小或者大小可以改变的数据,都无法安全地放在栈上,最好放在堆上。
放栈上的存在问题
栈溢出
栈上的内存分配是非常高效的。只需要改动栈指针(stack pointer),就可以预留相应的空间;把栈指针改动回来,预留的空间又会被释放掉。预留和释放只是动动寄存器,不涉及额外计算、不涉及系统调用,因而效率很高。 所以理论上说,只要可能,我们应该把变量分配到栈上,这样可以达到更好的运行速度。 那为什么在实际工作中,我们又要避免把大量的数据分配在栈上呢?
这主要是考虑到调用栈的大小,避免栈溢出(stack overflow)。一旦当前程序的调用栈超出了系统允许的最大栈空间,无法创建新的帧,来运行下一个要执行的函数,就会发生栈溢出,这时程序会被系统终止,产生崩溃信息。
堆
栈虽然使用起来很高效,但它的局限也显而易见。当我们需要动态大小的内存时,只能使用堆,比如可变长度的数组、列表、哈希表、字典,它们都分配在堆上。
预留空间
堆上分配内存时,一般都会预留一些空间。
let mut arr = Vec::new();
arr.push(1);
arr.push(2);
这个列表实际预留的大小是 4,并不等于其长度 2。这是因为堆上内存分配会使用 libc 提供的 malloc() 函数,其 内部会请求操作系统的系统调用,来分配内存。系统调用的代价是昂贵的,所以我们要避免频繁地 malloc()。
对上面的代码来说,如果我们需要多少就分配多少,那列表每次新增值,都要新分配一大块的内存,先拷贝已有数据,再把新的值添加进去,最后释放旧的内存,这样效率很低。所以在堆内存分配时,预留的空间大小 4 会大于需要的实际大小 2 。
生命周期
除了动态大小的内存需要被分配到堆上外,动态生命周期的内存也需要分配到堆上。
上文中我们讲到,栈上的内存在函数调用结束之后,所使用的帧被回收,相关变量对应的内存也都被回收待用。所以栈上内存的生命周期是不受开发者控制的,并且局限在当前调用栈。
而堆上分配出来的每一块内存需要显式地释放,这就使堆上内存有更加灵活的生命周期,可以在不同的调用栈之间共享数据
放堆上的问题
内存泄漏
如果手工管理堆内存的话,堆上内存分配后忘记释放,就会造成内存泄漏。
一旦有内存泄漏,程序运行得越久,就越吃内存,最终会因为占满内存而被操作系统终止运行。如果堆上内存被多个线程的调用栈引用,该内存的改动要特别小 心,需要加锁以独占访问,来避免潜在的问题。比如说,一个线程在遍历列表,而另一个线程在释放列表中的某一项,就可能访问野指针,导致堆越界(heap out of bounds)。而堆越界是第一大内存安全问题。
使用已释放内存
如果堆上内存被释放,但栈上指向堆上内存的相应指针没有被清空,就有可能发生**使用已释放内存(use after free)**的情况,程序轻则崩溃,重则隐含安全隐患。根据微软安全反应中心(MSRC)的研究,这是第二大内存安全问题。
练习
- 如果有一个数据结构需要在多个线程中访问,可以把它放在栈上吗?为什么? 不能, 每个线程都会拥有自己的栈,栈上数据无法进行跨栈访问.
- 可以使用指针引用栈上的某个变量吗?如果可以,在什么情况下可以这么做? 可以,只要指针的生命周期小于或者等于栈上的引用源就行,如果生命周期大于引用源就会出现野指针的情况.在rust中会报错从而无法编译.
总结
栈
栈上存放的数据是静态的,静态大小,静态生命周期(不受控制,函数调用完就被回收;
存在问题:栈溢出
堆
堆上存放的数据是动态的,动态大小,动态生命周期(分配出的每一块内存需要显式地释放,灵活的生命周期。
存在问题:内存泄漏,使用已释放内存