在上一篇文章中,我们深入探索了malloc的底层实现,看到了通用内存分配器如何在碎片与效率之间寻找平衡。然而,malloc仅仅管理着内存世界的一半版图——堆。理解内存分配的全貌,我们还需要认识另一个同样重要但哲学截然不同的领域:栈。

本文我们将跳出具体的分配器实现,从更高维度思考:为什么程序需要同时存在堆和栈这两种内存模型?它们的本质区别是什么?又是什么深层的设计哲学决定了它们各自的特性和限制?

总结概括

【基本管理规则】 栈是内存连续的,栈上的内存由系统自动管理,其空间较小,栈上对象的生命周期严格受到作用域的限制。【优点】 优点在于栈内存的分配和释放基于移动栈指针实现,速度较快不需要担心内存泄露;并且由于永远遵循先进后出的规则回收,所以内存永远是连续的,不会产生内存碎片。【缺点】 但是,为了保证栈的高效,栈布局必须在编译期确定,因此栈对象的灵活性较差,不允许动态分配。 【示例】 比如函数调用的返回地址、函数参数、局部变量等都会存储在栈上,在函数调用时移动栈指针开辟函数栈帧,函数结束时通过移动栈指针来释放内存。

【基本管理规则】 堆是无序内存,需要由用户手动管理或是通过GC来管理,堆空间更大且生命周期更灵活。【优点从堆本身定义说起】 优点从其名字可以看出来,其之所以叫做堆(heap)这个名字,是因为heap在英文中有“堆积、杂乱(一堆)”的意思,按照这个角度来看,更准确的翻译是”堆杂物的内存“,和数据结构的堆(优先级队列)没啥关系,其更可能是形容的是“用户的管理方式”——程序员在获取到heap内存后,相比于栈那样必须连续存储、分配释放顺序固定,heap允许你”杂乱无章“地将自己的东西存储在这块内存上——生命周期不受约束、内存布局不受约束,即在时间和空间上都是自由的,适合存储动态对象【缺点】 这种自由是有代价的,其内存分配涉及到系统调用,分配和释放成本较高;此外由于生命周期不受控制,所以可能发生内存泄露的问题,并且还带了内存碎片

从时间和空间的角度来看,栈 = 时间空间均受限,栈上分配的内存大小固定、生命周期固定;堆 = 空间时间均自由,堆上的内存大小由运行时自由决定,生命周期由程序员手动管理

一些问题

为什么选择了“栈”这个结构?

栈并不是单一原因产生的,而是函数调用机制结构化编程需求共同演化的结果。数调用天然具有后进先出的特性,而结构化编程中的作用域嵌套和局部变量生命周期也符合LIFO模式。ALGOL(一种早期的编程语言)将这两种需求相统一,提出了“栈”这种管理内存的概念

从结构化编程来说为了解决代码难以维护的问题,而引入了作用域的概念。通过划分出作用域,不让全局变量满天飞,而是让变量局部化——进入每个作用域时,为其开辟一块内存,退出作用域时,再自动将该内存释放掉,让变量的生命周期可控。并且,这些作用域是可以相互嵌套的,所以选择了栈。

从函数调用机制的角度来说,函数同样调用天然符合嵌套调用结构,而嵌套结构的返回地址和局部信息符合后进先出的特点,所以选择了栈。程序执行的过程实际上就是在调用一个个的函数,而函数调用的本质是保存返回点+跳转,函数返回的本质是跳回到返回点,这和栈没有必然的关系,完全可以只用寄存器来保存返回的地址。真正的问题在于函数嵌套调用的场景,必须保证函数返回的地址以及局部信息不会被覆盖,需要将旧地址和局部信息放到一块内存区域,同时不受到后调用函数的影响,保证一个函数向栈中存的数据和拿出来的数据前后是一致的,这就要求后存的数据必须先拿,先存的数据必须后拿,即FILO,这才是为什么需要使用栈。而call指令只是利用了这个结构,将push+jmp相融合

再向深一层说,为什么函数的调用结构天然符合嵌套的特点?

这是由冯诺依曼体系结构决定的,冯诺依曼机器一次只能执行一个控制流(PC指针唯一),且是同步执行流(顺序执行+阻塞等待)。比如A->B->C->D,当当前正在执行A的逻辑时,如果顺序执行到B,那么必须跳转到B的逻辑;当当前正在执行D的逻辑时,C B A函数都必须暂停,D返回时必须先回到D自身的调用点,回到调用点后继续执行C的逻辑。同理,在执行C的逻辑时,B A函数都已经暂停,C返回时必须先回到C自身的调用点,回到调用点后继续执行B的逻辑…..所以,函数的调用天然符合嵌套的结构

从拓扑结构的视角来看,A->B->C的函数调用过程,被调用者必须在调用者继续执行之前完成,也就是说,C没返回前,B也不会继续;B返回前,A也不会继续,其拓扑结构是一棵树,执行顺序是DFS,而DFS的实现结构就是栈

为什么需要堆?

换个说法,为什么需要有一块内存区域,通过”堆“的方式进行管理?

栈是为了满足作用域嵌套、避免全局变量乱飞而提出的,其可以让局部变量自动创建和销毁,使其生命周期可控。 而这个保证是通过栈指针RSP的移动、函数栈帧的创建和销毁提供的(用函数调用流程解释怎么提供的保证)。具体体现为汇编指令中的RSP -= size开辟栈帧,栈帧回退时的RSP += size。而为了保证这些操作足够高效,所以这些汇编指令中的偏移值都需要在编译期完全确定。而要求这些偏移值在编译期确定下来,就导致无法在运行时分配内存,因为会直接影响栈帧布局。

逻辑链条:生命周期可控->通过栈帧机制保证->为了高效,编译指令中的偏移值必须在编译时确定->内存开辟的大小必须在编译时确定

void f(int n) {
    int a[n]; // C++ 标准不允许(VLA)
}

那如果要开辟多大内存根本没法在编译期确定呢,不就导致无法在编译器确定偏移值了吗?比如网络请求响应、用户输入了多少数据、文件有多少数据,会根据运行时信息决定的需要的内存块的大小,该怎么办?所以,我们需要一块内存区域,允许将内存的分配推移到运行时,但这也意味着,没法再依赖于LIFO对内存进行生命周期管理,需要由程序员手动管理。我们将这块内存区域称作为堆。

从时间和空间的角度来看,栈 = 时间空间均受限,栈上分配的内存大小固定、生命周期固定;堆 = 空间时间均自由,堆上的内存大小由运行时自由决定,生命周期由程序员手动管理

之所以叫做heap这个名字,更可能是形容的是“用户的管理方式”——程序员在获取到heap区域的内存后,相比于栈那样必须连续存储、分配释放顺序固定,heap允许你”杂乱无章“地将自己的东西存储在这块内存上——生命周期不受约束、内存布局不受约束。按照这个角度来看,更准确的翻译是”堆杂物的内存“,和数据结构的堆(优先级队列)没啥关系

为什么栈由高地址到低地址增长,堆由低地址到高地址增长?

从堆栈设计的角度:

需要综合考虑二者,二者都是动态增长的,相向动态增长是最好的选择,可以尽可能地降低二者的冲突,充分利用内存。如果二者都是向着同一个方向增长,比如都向着高地址增长,出现的问题:如何确定栈的起始位置/栈底位置?

这个位置决定了栈的大小和堆的大小的堆内存的划分分配。但是二者的大小并不是绝对的,在有些情况下,可能堆用的空间更大,有时就是栈用的空间更大。所以,会出现这种情况——一个程序因为栈溢出而崩溃,但是程序却有大量的堆空间没有利用。

如果要支持动态调整栈底的位置,则涉及到移动整个栈的数据,太过麻烦。

从硬件的角度:现代CPU指令集大部分都定义好了栈顶指针push pop的指令,默认规定了栈是从高地址向着低地址增长的

栈是否可以动态增长?

当栈的空间被耗尽时,会触发缺页异常,通过异常陷入内核态后,异常会被内核的expand_stack() 函数处理,进而调用 acct_stack_growth() 来检查是否还有合适的地方用于栈的增长。如果当前栈的大小小于8M(通常情况下),那么栈会被扩容,否则会发生栈溢出(stack overflow),进程收到段错误(segmentation fault)信号。

动态栈增长是唯一一种访问未映射内存区域依然合法的情形,其他情况下都会直接触发页错误,进而导致段错误

为什么栈内存分配速度快,堆内存分配速度慢?

栈:

分配机制的角度:栈空间的分配极为简单,内核通过rbp指向栈底,rsp指向栈顶,栈分配内存的本质就是移动rsp栈顶指针预留空间,而释放就是rsp栈顶指针回退,O(1)时间复杂度完成。

系统调用的角度:这个过程中几乎不涉及系统调用,程序启动时,内核给进程分配一块固定大小的栈空间,后续完全是在用户态移动rsp指针(除非触发栈增长或是缺页异常)

硬件层面的角度:栈空间是连续的,缓存命中率高;栈对应的物理页是少量连续的,TLB局部性好

堆:

分配机制的角度:堆内存是任意顺序分配的,所以malloc首先需要找到合适的空闲块,如果块过大还需要split,涉及到一系列数据结构的增删查改操作

系统调用的角度:当堆空间不够用时,如果申请的是小于128K的小块内存——使用do_brk()系统调用来调账堆中brk指针的指针来增加或者回收堆内存;如果申请的是大于128K的大块内存——则会调用mmap在虚拟地址空间中的文件映射与匿名映射区中,创建出一块VMA内存区域(这里是匿名映射)。涉及到了用户态和内核态的切换、页表的修改、TLB flush,成本较高

维护堆空间:内核需要处理内存碎片,合并相邻空闲块等

多线程同步问题:对于栈来说,每个线程独立栈,不需要加锁;对于堆来说,堆是全局共享的,需要加锁

硬件层面:堆并不是连续的,是通过双向链表管理起来的,缓存命中率不高;此外,堆可能对于大量的离散页,TLB miss更频繁

总结

通过对堆栈的深入探讨,我们看到:栈的本质是“编译期可预测的自动管理”,堆的本质是“运行时期动态的手动管理”。这种根本性的差异,决定了它们在性能、安全性、灵活性上的不同权衡。这直接引出了我们下一篇文章的核心话题:C++的值语义与引用语义。