ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

编程底层概念回顾:虚拟内存、栈、栈帧、堆

编程底层概念回顾:虚拟内存、栈、栈帧、堆 不得不承认在 Java、Python、Scala 等拥有内存自动回收机制的语言上工作久了关于堆、栈的知识就淡忘了。本文是了解 C 智能指针、对象创建与析构的前期储备知识想要透彻了解这些语言机制必须要对栈、栈帧、堆有清晰的认知。了解文本内容后可延伸阅读《C 智能指针示例、原理、适用场景全方位解读》 、《C 共享指针“循环依赖”问题深度剖析》 和《C 对象和嵌套对象的创建与销毁》 三篇关联文章。1. 虚拟内存操作系统为了防止多进程运行时造成的内存地址冲突引入了虚拟内存地址为每个进程提供了一个独立的虚拟内存空间使得进程以为自己独占全部内存资源。在现代操作系统Linux / Windows / macOS中每个进程都有独立的虚拟地址空间地址范围通常是32 位机器4GB常见 3GB 用户态 1GB 内核态64 位机器理论 16EB但常见实现为 48 位有效地址 → 256TB其中用户态常见 128TB这里有一个关键点每个进程看到的都是自己独占全部的 3GB 或 128TB 虚拟地址空间也就独享机器的全部内存而不是切分出的一小块每个进程都有自己的一套“虚拟宇宙”这正是虚拟内存的设计初衷至于实际是占用的物理内存是怎样的这是操作系统要搞定的事情我们不再往下深究。下图引用自《Linux内存管理1——虚拟内存空间》分别展示的是 32 位和64 位机器的虚拟内存空间模型它们的结构基本上是一样的只是大小不同上图我们着重关注一下堆和栈的位置你可以看到栈在“高地址”区段堆在“低地址”区段它们中间有可再分配的区域所以它们是“往中间扩展”的对应到栈就是“向下”扩展对应到堆就是“向上”扩展。虚拟内存是一个很复杂的话题我们不能展开太多这样会会让我们失去关注的焦点我们真正要关注的是上图中出现的“堆”和“栈”这是程序员特别是 C/C 程序员经常会提及的这和我们要介绍的 delete 操作以及 C 的内存管理是息息相关的。2. 堆和栈在介绍“堆”和“栈”之前要说明的是在 C 标准中是没有“堆”和“栈”这两个概念的C 中有的是“动态存储期”和“自动存储期”这样的概念C 标准只会说“自动存储期对象在作用域结束时销毁”并不会说“自动对象必须放在栈里”但大多讲解语言内存管理的文章往往都是直接说成在“堆”和“栈”上会发生什么什么为了方便理解我觉得这样解释没有问题只是要清楚地知道“堆”和“栈”不是语言规范更像是实现语言标准例如“动态存储期”和“自动存储期”的具体实现细节。下面是展开说说栈是用于管理函数调用和自动变量的一块连续内存区域。栈中的对象会在作用域结束时 → 自动调用析构函数并在离开作用域后由栈自动释放内存简单地移动栈指针就完成了堆是由程序员手动申请和释放的一块动态内存区域再具体地说就是通过 new 和 delete 申请与释放空间都是在堆上。不会自动析构也不会自动释放空间必须由程序员手动控制下图非常地直观展示了 C/C 程序代码中各种变量与堆和栈的对应关系引用自 《进程的虚拟内存布局是怎样的》你会看到函数中普通的局部变量非 new 初始化的变量是自动驻留在栈里的你从来不会关心如何释放它们因为在离开了当前作用域栈帧后它会被栈自动清空这里还要再深入一层解释针对一个函数它在栈上到底是怎样一种形式存在的为什么在其执行结束后它的局部变量能自动释放我们要看进一步了解“栈”和“栈帧”。3. 栈和栈帧深入了解栈的话就会遇到“栈帧”的概念栈帧是栈的内部结构单元“栈”和“栈帧”是有非常具象化的映射物的它们分别对应程序运行时的“调用栈”和栈中的一个“方法”从这个角度理解“栈”和“栈帧”一切会变得清晰合理起来回想一下我们在 IDE 中的看到的 Debug 视图从视图是可以直接“映射”到“栈”结构上的可以说是对“栈”最直观的表现形式了在操作系统给每个进程划分的虚拟内存布局里那个叫“栈Stack”的内存段就是函数“调用栈Call Stack”栈内部的组成单元 “栈帧 (Stack Frame)” 对应调用栈中的一个“方法”它们只是抽象级别和上下文不同实质上指的是同一件事借助调用栈我们就能比较“形象”的理解栈和栈帧的工作方式下图是对栈和栈帧更“真实”的一层描述程序运行时操作系统会为进程分配一块连续的内存区域作为调用栈Call Stack它遵循 “后进先出LIFO” 的规则。函数的执行过程本质上就是 “栈帧Stack Frame” 在调用栈上 “压入push” 和 “弹出pop” 的过程。当你调用一个函数时系统会在调用栈上为这个函数分配一块独立的内存区域这块区域就叫栈帧—— 它是函数在栈上的 “专属内存空间”包含了这个函数运行所需的所有数据栈帧内容作用函数返回地址记录函数执行完后CPU 要回到调用者函数的哪一行继续执行调用者栈帧的基址保存调用者函数的栈帧基地址x86 架构用于恢复调用者的栈帧局部变量函数内定义的所有栈上局部变量int a、char b、指针 ptr 等函数参数传递给当前函数的参数如果参数是值传递会拷贝到当前栈帧临时变量函数执行过程中产生的临时数据比如表达式计算的中间结果寄存器备份保存调用者函数使用的寄存器值避免当前函数修改后影响调用者然后我们着重了解一下栈帧中的数据也就是方法中的参数和局部变量是怎么释放的为什么不会出现内存泄漏问题解释前先了解两个概念ESP栈顶指针寄存器EBP栈底指针寄存器它们组合起来就像一个“游标卡尺”精确标记出当前的“栈帧”位置当函数执行结束遇到return或函数体结束时CPU 会把当前栈帧中保存的 “调用者”的栈底地址写回 EBP 寄存器这相当于一只脚已经放回到上一次踩的位置上然后栈顶指针 ESP “后撤移动”当前栈帧的总大小参数 局部变量 返回地址等的总字节数就完成了当前栈帧的“弹出”也就是函数的“返回”CPU 根本不需要追一清理栈帧中的数据因为栈顶指针 ESP 移动后函数的栈帧区域就被标记为 “未使用”后续新的栈帧会直接覆盖这块内存无需主动 “擦除” 数据。所以栈释放资源是极其快速和高效的这和堆形成了鲜明的对比在堆上释放资源需要查找空闲块、合并碎片、维护链表等很多繁琐的工作。关于栈的运作方式让我想起了另一个经典问题在这里解释是再合适不过了真得是能“从根上解释清楚”的我们知道在 C 里函数体的局部变量如果只是定义而不显式地初始化的话C 是不会对其默认初始化的这和全局变量不同看下面的例子voidf(){intx;// 未初始化值是垃圾}在运行这个函数时变量 x 是会被分配内存空间的当然是在“栈”上但是由于 C 不会对其进行默认初始化所以我们常说它的值是未定义的“垃圾”值让人好奇的就是它为什么会有值它的值到底是怎么来的看完前面的解释再来看这个问题就非常“通透”了它的值就是上一次函数调用上一个栈帧分配的内存空间中未被清理的数据就是上一个栈帧“弹出”了但CPU不会清理数据这一次函数执行又压回一个栈帧占用的前一个函数栈帧用过的内存空间栈的区段未被初始化的 x 值就是上一次遗留下来完全不可测的值就是这样了。4. 堆堆也是操作系统给每个进程划分的虚拟内存布局里的一个内存段但和栈很不同栈有具象的结构和自动化的运行机制所以理解起来有些麻烦但用起来几乎是完全透明的而堆正好相反它没有具象的结构也没有自动化的运行机制所以没有过多需要了解的知识但用起来却是全要靠程序员手动维护栈自动管理、后进先出、小而快、生命周期随函数堆手动管理、无固定顺序、大而灵活、生命周期由程序员控制。对 C / C 程序员而言和堆打交道的抓手就是“new” 和 “delete这两个操作符了当然还有负责指向堆空间具体位置的“指针”这些是编程层面的话题我们放到另一篇文章中单独讨论。5. A* a new A() 背后的故事当我最初从 Java 转到 C 时有一个非常简单直白但却始终没有意识到的问题为什么 C 代码里到处在使用指针这确实是个很蠢的问题直到我意识到在 C 里通过 new 操作符创建对象时只会返回对象的“地址”而指针是唯一的接收“载体”尽管还有智能指针、匿名指针这样的变体但本质上还是指针就像 Java 中 new 出来的对象只返回引用一样都是唯一“访问一个分配在堆上的对象”的方式/途径。如果你想知道一个普通的A* a new A()操作发生的全部故事就必须清楚地了解堆和栈的工作细节我们就掰开了仔细说一下。#includeiostreamusingnamespacestd;classA{public:A(){coutA 的构造函数执行endl;}// 构造函数~A(){coutA 的析构函数执行endl;}// 析构函数};voidf(){A*anewA();}intmain(){f();cout函数 f 执行完毕endl;return0;}// 离开作用域a 被自动销毁进入函数 f()创建栈帧先执行变量声明A* a在栈上创建“指针变量 a”C 语言规则要求变量的内存分配声明必须先于对它的任何赋值 / 初始化操作分配栈内存空间在 f() 的栈帧中为指针变量 a 分配内存大小为 sizeof(A)64 位系统是 8 字节*赋值记录地址将 A 对象地址赋给指针变量 a备注这里有一个非常重要的细节栈上为 a 开辟的 8 字节空间是存目标对象的地址但程序又如何知道栈上的这个位置是变量a呢a 本身的这个“名字”和“它关联这个栈上的地址”又存在哪里呢实际情况是“变量 a”只是程序员和编译器约定的一个“符号”栈上不会保存一个内容是a的字符串和关联的内存地址变量名 a 只存在于编译阶段在运行阶段根本没有所谓的“变量名”程序完全靠 “内存地址偏移” 而非 “名字” 来定位栈上的变量这是一个非常重要的细节。执行 new A()在堆上创建 A 对象分配堆内存空间根据 sizeof(A) 计算的大小分配堆内存空间调用构造函数调用 A 的构造函数在分配的堆空间上实例化 A注意此时创建出的 A 对象只有地址还没有“名字”函数 f() 返回销毁栈帧销毁变量销毁栈帧中的所有局部变量包括“指针变量 a”注意销毁的是“指针变量 a”栈上的 8 字节内存不是指针所指的位于堆上的 A 对象栈帧弹出调用栈回到 main() 函数的下一行继续执行最终结果内存泄漏位于堆上的 A 对象没有显式地执行销毁动作也失去了唯一可追踪的地址再没有再被释放的机会了。如果手动添加delete a;voidf(){A*anewA();deletea;// 手动释放}则在 delete a 地会多出两步关键操作调用 A 对象的析构函数清理资源释放堆内存最终结果与上一版没有 delete a 操作的状况不同的是 位于堆上的 A 对象被释放不会发生“内存泄漏”了但是在函数返回前指针变量 a 尚未销毁但它所指的地址已不再有效此时 a 称为了“野指针”虽然函数很快将返回a 会被销毁但规范做法是要将 a 置空防止杜绝野指针情况的发生voidf(){A*anewA();deletea;// 手动释放anullptr;// 指针置空}最后再补充一个极易误解的地方“对象在栈帧销毁弹出时会自动析构吗”这个问题记好不要搞混只有在栈上创建的对象在栈帧销毁弹出时才会自动调用对象的析构函数voidf(){A a;// 直接在栈上创建A对象无new}// 离开作用域栈对象a自动析构在堆上创建的对象栈帧销毁弹出时只会销毁指针变量不会自动调用对象的析构函数我们上面举的例子就是这种情况不要把这两种情形搞混参考资料https://blog.csdn.net/K346K346/article/details/45592329https://www.51cto.com/article/770253.htmlhttps://blog.csdn.net/weixin_42482191/article/details/130593563
返回列表