C++应该更多使用堆还是栈?
问题取自知乎:C++可以通过new创建对象,也可以通过Type o(...)创建对象,前者在传递对象给函数时只需传递指针,不存在很大开销,后者可通过move操作传递对象,工程中应当更多使用哪个呢?
先复习基础知识和明确问题:
- 这里讨论的是 native C/C++ 程序。
- 栈指的是函数过程的调用栈 (call stack)。在 x86 机器上,栈由操作系统分配,并向低地址方向增长。栈的大小由编译器/链接器指定,信息保存在可执行文件中 (executable file)。
- 堆 (heap) 是一个由操作系统或 CRT (C Runtime Library) 运行时库管理的内存分配空间,有特定的内部数据结构,及其分配策略算法——解决内存的(重新)分配、回收与碎片问题。堆提供一组 API 给用户,用户调用这些 API 按需分配、释放内存。常见的堆内存向高地址方向增长。C/C++ 中也把堆分配叫做动态分配。
实际工程中使用栈还是堆,是多角度、多判定标准综合考虑的。只有聚焦具体的应用场景,结论才有意义。以下是常见的判定标准:
- 对于局部变量,它的存储在栈中分配,如果它是一个 array, struct, union, class 统称复合类型,那么它的每个成员都在栈中分配。它们的 size 是编译时确定的,所以在栈中构造,编译时即可预留空间(通过减小 esp/rsp 寄存器的值),内存分配的效率要比在堆中高,因为堆要在运行时执行分配算法。但前提是,由于 C/C++ 有指针这样的间接类型,要保证这些复合类型内部的各成员,以及成员的成员,它们构造时都没有使用堆。比如,一个内含
std::string
成员,或者char* p
成员,并在构造函数中p = new char[BUF_SIZE]
,这些即使在栈中构造对象,其实还是会对其成员执行堆分配算法,编译器确定的只是静态部分的 size。 - 对于静态变量(包括全局变量、编译单元级的 static、名字空间和类内的 static 和局部 static)。静态变量在编译时就预留了空间(信息放在目标和可执行文件中),分配的效率比在堆中高。但和局部变量类似,要看它们内部成员有没有动态分配的。如果非局部的静态对象内部有动态分配的成员,CRT 会在进入 main() 函数前执行堆分配算法。因为静态对象的生存期是到程序结束,所以它们内部的动态分配成员需要手工释放(如用 copy-and-swap 惯用法释放
std::vector
或std::basic_string
[1]),以避免内存泄露检测例程给出误判[2]。 - 由编译器/链接器决定的栈的大小是固定的,对于 MSVC 默认是 1MB,称为 reserved size of stack allocation in virtual memory,可用
cl /F
[3] 或link /STACK
[4] 选项调节。多线程情况下,会给每个线程分配各自的栈空间[5]。相比于堆,1MB 的栈是一个拮据的空间。所以使用栈中的局部对象时,要评估它是否是大 size 对象,以及这个函数是否反复重入 (reentrant function)。如果你利用栈实现一些算法——典型地是递归算法,例如递归下降分析器 (recursive descent parser) 或深度优先搜索 (depth-first search)[6],当问题规模很大时就会栈溢出 (stack overflow),系统会保护性地检测到这种错误(1MB 保留栈的最后一个 4KB 页是用于检测栈溢出的 guard page[5],所以实际可用的栈只有 1MB - 4KB)。将递归算法变换为非递归算法,考验程序员功力。简单问题容易用循环替代,复杂问题呢?有一个傻瓜的思路,用自定义的栈结构代替调用栈,而在堆中分配自定义的栈结构(比如用std::deque
)。 - 堆虽然比栈空间充裕,而且能动态增长,但也是有限的。即使 64bit 机器的地址空间足够满足大多数需要,但是堆还有执行分配算法和缓冲区拷贝等效率因素。在一些特殊需求下,还要利用系统提供的其它内存管理设施,如文件映射对象 (file mapping object)[7]。想一下十六进制编辑器 WinHex 如何做到秒速打开 GB 级别的文件的,肯定不是一下把文件都读入内存缓冲区中。还有,有时需要有某些特殊性质的内存区域,比如能在进程间共享。以及有时那些区域根本不是内存,只是一段内存空间,映射到其它设备存储上。
- C++ 中的
operator new/new[]
、std::vector
等,其实都是调用接口(一种约定的形式),它们实际上是在堆、栈,还是特殊的内存空间中分配存储,都是可以自定义的[1][8]。std::shared_ptr
是比operator new
更良好的形式,而std::vector
或std::basic_string
(当元素是 char/wchar_t 时)是比operator new[]
更良好的形式[1]。 - 即使要分配的是普通的堆内存,为了特殊的效率需求,有时也要用自定义分配算法,代替默认的系统或 CRT 堆算法。你充分了解自己应用中要分配内存的使用细节和特点(比如,分配/释放的频率和时机;是很多碎小的对象,还是少数大个对象),因此即使采用简单的算法,也可能比考虑各种情况但选平均最优的默认堆算法效率高。最简单的情况是,无需写 allocator,在某个过程初始化时,预分配足够的堆内存(如调用
std::vector::reserve()
[1]),然后应用逻辑运行时,只是赋值、操作这些已分配内存。这种思想叫做分配池 (pooling)。
参考
- ^abcd《Effective STL》(2013),Scott Meyers 著。第10条 了解分配子 allocator 的约定和限制;第11条 理解自定义分配子的合理用法;第13条 vector 和 string 优先于动态分配的数组;第14条 使用 reserve 来避免不必要的重新分配;第17条 使用 swap 技巧除去多余的容量 https://book.douban.com/subject/24534868/
- ^Find memory leaks with the CRT library (_CrtDumpMemoryLeaks()) https://docs.microsoft.com/en-us/visualstudio/debugger/finding-memory-leaks-using-the-crt-library
- ^MSVC compiler option: /F (set stack size) https://docs.microsoft.com/en-us/cpp/build/reference/f-set-stack-size
- ^MSVC linker option: /STACK (stack allocations) (also editbin option) https://docs.microsoft.com/en-us/cpp/build/reference/stack-stack-allocations
- ^abThread Stack Size https://docs.microsoft.com/en-us/windows/win32/procthread/thread-stack-size
- ^C/C++ maximum stack size of program https://stackoverflow.com/questions/1825964/
- ^File Mapping https://docs.microsoft.com/en-us/windows/win32/memory/file-mapping
- ^《Effective C++》第3版 (2011),Scott Meyers 著。第8章 定制 new 和 delete https://book.douban.com/subject/5387403/
这个问题是一个非常好的问题!
它反应了堆与栈各有利弊。
栈,是不需要涉及内存分配的,你可以把它看成一个很长的连续内存,用来执行函数。自动以先进后出的方式使用。具体的进出在C++里你可以假设是不能操纵这个栈的,实际上它存在。
main函数是第一个进栈的函数(有人指出了这里不严谨,的确发生了其他的事情,为了理解方便可以假设这句话是“对的”,不会妨碍我们理解),中间执行了其他程序,就会有其他的函数入栈,执行完一个函数,这个函数就会从栈里弹出来。main函数是最后一个退出栈的函数(同理,这里也是不严谨的,比如静态对象的析构函数在main退出以后执行)。
但是栈的弊端也在这里,函数在不断的发生调用,栈是一个临时的执行产所,我们不能把数据持久的保存在栈上,因为函数执行完,那个数据就等于是不再可以使用了,生命周期只有函数执行开始和结束之间的那一会儿。
有些数据,比如我们保存的一个班级的学生数据在内存里,我们希望可以对这个数据集合进行不断的增删改查,会用不同的函数来执行这些操作。那就要求这些数据集合保存的数据要能根据我们的需要产生和释放,所以集合动态变化的那部分数据就只能放到另一个叫堆的内存空间里。
堆,堆就是用来弥补栈的数据不能持久保存的能力的,在堆上分配的内存,只有我们主动释放,才会被回收,否则就一直为我们使用。如果忘记释放,就等于缩小了可以使用内存的大小(又叫内存泄露,一个日夜执行的服务端程序,某个函数执行一次泄露一个字节,随着客户端大量频繁的调用这个服务,可能都会导致服务器内存爆掉)。堆的好处就是可以持久的保存数据。
由于堆可以手动的释放和申请,所以像vector,string这种动态可变大小的容器,他们存储的数据都是放在堆上面的。但是vector<int> a;这里的a是一个栈对象,这一点也不矛盾。意思就是我知道a只是临时的函数内对象,但是我也希望在执行函数的过程中,a具有扩容的能力。这是很自然的事情。
假如这里的a是在main函数内定义的,由于main函数的执行伴随着整个程序,所以a的使用就更能够达到被各个函数用于不断增删改查的目的。
现在回答的你问题,指针由于只需要传递地址,所以效率高(指针变量就像一个整形变量一样,存储了一个整数,只不过这个整数是一个地址而已,而不需要拷贝整个指针指向的对象)。这是C语言的逻辑。C语言都是这么做的。通过传递指针来做绝大多数的事情。但是弊端就是指针很难被管理,很容易忘记释放,或者重复释放,甚至不太知道哪里才是释放的时机。C++ 在一开始就是为了解决这个问题才产生的。所以,如果你用C++语言还传递指针,这是不建议的,违背了C++的初衷。在C++里建议的方式是传递引用。
如果是传递不需要被修改的对象,C++里更好的方式是传递const T&,也就是常量引用。
const引用参数有四个好处:1 避免了堆内存分配;2 避免了栈对象拷贝;3 函数内不小心修改了该对象编译器报错;4 代码直观易于理解。
所以,《Effective C++ 》有一条指出应该最大限度的使用const引用参数。
既然是传递引用,就应该保证引用参数还在,不能这边函数要使用一个引用对象,在外面其他地方因为超出作用域已经被释放了。这就要求你要知道你的参数的生命周期(也就是业务需求),这一点实际上很容易做到,就像上文说的在main函数中定义的用来存储班级学生信息的容器对象,就可以不断的传递自身的引用给各个操作它的函数。
你说的move同样是一个很好的问题。
考虑到从函数直接返回一个vector,比如 vector<int> fun(),代码看起来像这样: a = f();
而不是这样:
vector a;
f(a);//这里得到a,看起来就不直观。
既然要能够从函数返回vector,那么我们也知道vector里面保存了动态内存数据,这时候的vector是一个非平凡的类,也就是值拷贝返回这种对象会导致其管理的动态内存有多个对象同时在指向的问题(加上拷贝得到的对象也在指向)。由谁释放就是一个大问题。所以C++诞生了复制控制。
赋值控制也好,复制控制也罢,都是一个意思,就是解决vector这种类型的对象的拷贝问题。
还是刚才的那个从函数返回vector的问题,赋值控制解决了内存不会混乱的问题,C++沿用了C语言的值语义(赋值总是拷贝,如果是vector这样的容器就连动态内存也一起拷贝),但是总是把一个对象的动态内存拷贝一份过来,另一个对象可能立刻又要释放,这个对象也涉及分配一次内存,实在是代价太高。本来动态内存的分配就已经需要成本了,现在一个赋值语句,居然要释放一次,再开辟一次内存,还要拷贝一次,这个代价太高了。
所以我们希望像函数返回vector这种场景的效率能更高一些,这就产生了移动语义。
移动语义就是偷梁换柱,将两个对象的动态内存互换:
a = f();
f函数里面的那个vector使用移动语义把自己的动态内存交给a之后就析构了。这样就避免了一次内存释放和内存开辟。岂不是很完美。
所以你说的move这种情形,在C++里并不常见,只有在函数返回一个vector这种对象的时候才会发生。其余场景都被引用参数完成了。
C++中使用指针的场景:借用对象而不负责对象的生命周期管理的时候是比较适合用指针的(这要求团队要有代码规范)。具体参考(请留意文章中的注释部分,是亮点):
C++ 原始指针、shared_ptr、unique_ptr分别在什么场景下使用但是也有一些库,比如Rapidjson,为了快,统统使用移动语义。这种库要求你赋值之后,对象的动态内存部分就被转移走了。这种场景下很少发生不必要的动态内存的开辟和释放。而且就算发生了动态内存的开辟和释放,这个库自己也在一开始就开辟了很长一段内存给自己使用,类似于内存池。用完了再来一段。最大限度的避免了内存碎片。
这种库的作者可以驾驭这种内存分配 方式,对于普通开发者实在没有必要这么干,我们会用就足矣。
但是,很多新人不好好打基础,大学没毕业就要实现一个内存池。这就是不务正业(对,瞅啥呢,说的就是你)。
就像vector的移动函数,我们知道有这么回事,知道会用,就足以。绝大多数情况下,至少我工作7年以来,从来没有在工作中需要写移动函数的。因为各种库非常完善,STL里面的容器早就写好了,你用就行了。工作中极少自己写一个容器,都是基于STL的模板容器开发(直接使用,而不创造新的)。
所以,结论就是,指针几乎不会在C++里作为参数传递(除非有编码规范)。参数传递时move也不会发生,因为被引用参数取代。函数返回vector这种对象需要move,但是标准库早就替你写好了,所以你也不必担心。