
管理计算机内存是计算机科学中最基本的挑战之一。就像仓库管理员为新箱子寻找空间并清理旧箱子一样,操作系统必须为无数进程勤勉地分配和回收内存。这项任务充滿复杂性,需要巧妙的策略来对抗空间浪费并确保系统稳定性。算法的选择涉及一系列关键的权衡,这些权衡对性能和效率有着深远的影响。本文旨在探讨内存碎片和内存泄漏这两个核心问题,以及为解决它们而设计的算法方案。
本文将引导您深入了解错綜复杂的内存管理世界。首先,在“原理与机制”部分,我们将剖析基础算法,探讨固定分区与可变分区之间的经典困境、寻找空闲空间的启发式方法,以及优雅但复杂的自动垃圾回收世界。随后,“应用与跨学科联系”部分将揭示这些抽象概念如何成为驱动现代计算的无形力量,它们支撑着操作系统的宏大幻象,为程序员提供强大的工具,甚至在远离系统内存的领域也能找到相似之处。
想象一下,您的计算机内存是一个巨大而空旷的仓库。每当您运行一个程序或打开一个文件,操作系统——我们勤勉的仓库管理员——就必须在地板上找到一个空间来放置一个新的数据“箱子”。当程序结束时,箱子被移走,留下一个空位。这项看似简单的寻找位置和管理空闲空间的工作,是计算机科学中最基本的挑战之一。我们的系统所使用的策略,是由巧妙的算法、来之不易的妥协以及对空间与信息本质的深刻洞见交织而成的美丽织錦。
内存管理的核心在于一个根本性的选择,一种我们如何看待仓库场地的哲学分歧。我们是应该事先将其划分好,还是将其保留为一个巨大的开放空间?
一种方法是从一开始就 meticulous 地组织。我们可以将整个内存仓库划分为固定大小的储物格,比如每个正好8兆字节。当一个新进程到达时,我们看看它需要多少个储物格,然后分配给它。这就是固定分区分配。如果一个20 MiB的进程到达,我们的管理员会给它 个储物格。进程虽然能装下,但请注意其中的低效之处:我们为一个20 MiB的进程分配了 MiB的空间。最后一个储物格中剩下的4 MiB,其他任何人都无法使用。这种在一个已分配块内部浪费的空间被称为内部碎片。这就像把一本小小的平装书放进一个巨大的书架隔间里;隔间的其余部分都被浪费了。
另一种选择是“开放式场地平面”,即可变分区分配。在这里,我们为每个请求定制空间。如果一个进程请求11 MiB,我们就精确地划分出11 MiB。这似乎很完美——没有内部碎片!但这种看似完美的设计隐藏着一个更隐蔽的问题。想象一下进程来了又走。很快,我们的开放式场地就布满了大小各异的空闲地块。我们可能总共有25 MiB的空闲空间,但它被分成了不连续的洞,比如说12 MiB、8 MiB和5 MiB。现在,一个新的进程到达,需要两个段,一个11 MiB,一个9 MiB。尽管我们总共有超过20 MiB的所需空间,但我们无法满足这个请求。11 MiB的段可以占用12 MiB的洞,但没有任何剩余的洞足够容纳9 MiB的段。这就是外部碎片:我们有足够的总空间,但它不是一个连续的整体。内存就在那里,但却无法使用。
这就是内存管理的核心困境:在固定分区分配中简单但浪费的内部碎片,与可变分区分配中高效但混乱的外部碎片之间的权衡。
如果我们选择可变分区策略,我们的仓库管理员需要一个策略来决定为新请求使用哪个空闲洞。这不是一个微不足道的选择,因为它极大地影响了剩余空闲空间的结构。
最简单的策略是首次适应(First-Fit):从内存的开头开始扫描,并将新进程放入第一个足够大的洞中。这种方法快速简单,但它倾向于在内存的起始部分打碎大的空闲块,留下一串小的、通常无用的碎片。
一个巧妙的变体是下次适应(Next-Fit)。管理员不再总是从头开始搜索,而是记住它上次放置对象的位置,并从那里开始下一次搜索,如有必要则回绕。想象一个场景,内存中有大小分别为 {26, 6, 8, 7, 12} KB 的空闲洞,我们收到了对 5 KB、7 KB,然后是 24 KB 的请求。
这些“在线”算法必须在对未来请求一无所知的情况下做出决策。一个无所不知的“神谕”,使用离线最优策略,可以安排放置以最小化未来的碎片。例如,通过小心地将一个块放置在一个洞的末尾,它可能确保当一个相邻的块被释放时,两个产生的洞是相邻的并且可以合并,从而创建一个更大、更有用的块。像首次适应这样的简单启发式算法无法做到这一点,导致了“启发式差距”——衡量在线算法与一个完美的、不可能实现的“神谕”相比表现有多差的指标。
鉴于碎片的不可避免性,人们发明了更复杂的策略来控制它。
一个优雅的方法是伙伴系统(Buddy System)。它试图两全其美。内存被分解,但只分解成2的幂次大小(字节)。当一个请求到达时,它的大小被向上舍入到最接近的2的幂。这个系统的神奇之处在于释放。当一个块被释放时,分配器检查它的“伙伴”——即它最初被分割出来的相同大小的相邻块——是否也空闲。如果是,它们会立即合并。这使得合并空闲块变得异常快速。然而,代价是重新引入了内部碎片。为了满足一个17字节的请求,伙伴系统可能会分配一个32字节的块。一个恶意的请求序列,每个请求都略大于2的幂(例如,请求17字节得到一个32字节的块,或请求33字节得到一个64字节的块),可以使这种浪费最大化。在一个最小块为16字节的系统中,一个包含个仅为1字节请求的序列将总共浪费字节。
当可变分区系统中的外部碎片变得过于严重时,唯一剩下的选择就是暴力解决方案:压缩(compaction)。系统 literally 停止一切,将所有已分配的块 перемешивать到内存的一端,并创建一个单一的、巨大的、连续的空闲块。虽然计算出所有东西应该去哪里的算法复杂度相对较低,但物理上复制千兆字节数据的行为是昂贵的。此外,现代系统引入了令人抓狂的复杂性。一些内存区域可能是“固定的”(pinned),因为像GPU或网卡这样的硬件正在通过直接内存访问(DMA)直接访问它们。移动这样的块将是灾难性的。操作系统必须等待硬件完成,这会增加不可预测的延迟。压缩是一个强大的工具,但它是一个破坏性的、代价高昂的工具。[@problem-id:3628316]
到目前为止,我们一直假设程序员充当自己的内存管理器,显式地请求和释放内存。这是出了名的容易出错。一个被遗忘的 free() 调用就会造成内存泄漏,即内存被无限期地占用,最终耗尽系统资源。想象一个创建粒子效果的游戏引擎。一个 bug 可能会导致飞出屏幕的粒子永远不会被标记为可释放。即使每个粒子都很小,每秒产生数千个也意味着消耗的内存会线性地、不可阻挡地增长,直到应用程序崩溃。
为了解决这个问题,现代编程语言采用了垃圾回收(Garbage Collection, GC)。程序员只负责分配(new),而运行时系统会自动找出哪些内存不再被使用并回收它。基本原则是可达性:任何可以通过从一组已知的起始点(“根”,如全局变量和调用栈)开始跟踪指针链到达的对象都被认为是“活”的。其他任何东西都是垃圾。
经典的GC算法是标记-清除(Mark-Sweep)。首先,GC从根开始遍历对象图,将它找到的每个对象标记为活的。然后,它执行一次清除,从头到尾扫描整个堆。任何未被标记的对象都是垃圾,并被回收。这次清除的复杂性取决于堆的结构。如果所有对象都是相同大小的(同构堆),清除器可以简单地将被回收的槽链接成一个简单的列表,这是一个对每个对象都是高效的操作。但如果对象大小可变(异构堆),清除器必须合并相邻的空闲块,并将其维护在一个更复杂的数据结构中,比如平衡二叉搜索树,以便之后能高效分配。这为每次回收增加了一个对数成本,使得整个清除过程变慢。这展示了一种深刻的统一性:底层对象布局的选择直接影响了高层运行时系统的渐近复杂度。
一种完全不同的哲学是复制收集(Copying Collection)。收集器不是清理当前凌乱的空间,而是将堆分成两半:一个“源空间”(from-space)和一个“目标空间”(to-space)。它遍历源空间中的活对象,并将它们复制到空的目标空间中。一旦所有活对象都被撤离,整个源空间就会被一次性宣布为空闲。这就像整理你的房间:把你想要保留的几件东西搬到一个新房间,然后推平旧房间。这种优雅的方法会自动压缩内存,完全消除外部碎片。
复制GC的美妙之处在于,它的工作量与活数据()的数量成正比,而不是总堆大小()。如果大多数对象都很早夭折,收集会非常快。但这种效率是有代价的。首先,你牺牲了一半的内存。其次,你做的每一次分配都必须支付一笔“分摊税”来覆盖最终收集的成本。算法分析的一个优美结果表明,这个税是每次分配需要 单位的GC工作。这个公式揭示了一个基本的权衡:当你试图更密集地使用你的内存时(即接近其极限),分母会缩小,每次分配的GC成本会飙升至无穷大。为了保持GC的廉价,你必须购买更多的内存。
最终目标是实现一个不仅自动而且无感知的GC,没有长时间的“stop-the-world”暂停。这就引出了并发垃圾回收,即收集器与应用程序并行运行。这是一个充满微妙而危险的竞争条件的世界。想象GC正在将一个对象从地址复制到。如果它先复制字段,然后更新指针说“对象现在在”,一个并发的应用程序线程可能会在对象被复制之后但在指针翻转之前更新旧对象中的一个字段。那个更新就永远丢失了。
解决方案是一个并发设计的奇迹:先翻转指针。现在,所有的应用程序线程都会立即被导向到新的、未完成的对象。为了防止混乱,我们使用硬件级的原子操作,如比较并交换(compare-and-swap, CAS)。GC只有在中的目标字段仍处于其初始“空”状态时才复制一个字段。如果一个应用程序线程已经在那里写入了一个新值,CAS就会失败,GC就知道它的版本已经过时了,于是后退。这种硬件原子操作和软件屏障之间的复杂舞蹈,正是现代高性能运行时成为可能的原因。
最终,内存管理策略的选择是一个经济问题。你选择一个空闲列表系统,并支付碎片化()和元数据开销()的“税”吗?还是选择一个压缩式GC,并支付保留一部分内存作为储备()的“税”?存在一个盈亏平衡点,在这一点上这些成本是相等的。这个点可以用一个简单而优雅的表达式来捕捉:当空闲列表系统的元数据开销为时,两个系统是等效的。 没有单一的“最佳”算法。只有对这些权衡的深刻理解,才能让我们为正确的工作选择正确的策略,从而驾驭这个美丽而复杂的内存管理世界。
要真正领略内存管理的艺术与科学,我们必须超越算法本身,去观察它们在实际中的运作。就像一座宏伟时钟中隐藏的齿轮和弹簧,这些机制是驱动现代计算世界的无形力量。它们不仅仅是计算机科学家的学术好奇心;它们是操作系统构建其宏大幻象的基础,是程序员用来驯服复杂性的工具,甚至是在远超计算机内存的领域中也能找到回响的抽象原则。在这段旅程中,我们将看到这些思想如何支撑着从桌面多任务处理到我们所使用的编程语言灵魂的一切。
在许多方面,操作系统(OS)是一位幻术大师。它最伟大的戏法是让每个程序都相信自己独占了整台计算机,拥有一片广阔、私有且线性的内存空间。当然,现实是一场为争夺有限物理RAM而展开的混乱争夺,由几十甚至上百个进程共享。内存管理算法就是操作系统的魔杖和高帽。
最优雅的戏法之一是请求调页(demand paging)。想象一下运行五十个不同的程序,它们都依赖于同一个通用软件库。一种天真的方法会将该库的五十个独立副本加载到RAM中,这是一种巨大的浪费。相反,操作系统使用了一个巧妙的障眼法。它将包含库代码的同一个物理页映射到所有五十个进程的地址空间中。但诀窍在于:它甚至不会将库加载到RAM中,直到某个进程真正尝试使用它。第一次访问会触发一个“缺页中断”,这是一个微小的延迟,在此期间操作系统从磁盘获取代码。付出这一次性成本后,该页面就被所有进程共享。这种美丽的权衡——用微小的延迟换取巨大的内存节省——使得现代多任务处理成为可能。
这种“非到万不得已不做工”的原则通过一种称为写时复制(copy-on-write, COW)的技术得到进一步提炼。假设您想要创建一个正在运行的系统的快照,或者复制一个大型进程(在类UNIX系统中称为fork的常见操作)。暴力复制千兆字节的数据会慢得令人痛苦。相反,操作系统执行一次“惰性复制”。它创建一个新的页表,但将其指向与原始进程完全相同的物理内存帧。现在两个进程共享一切,但内存被标记为“写时复制”。只要它们只读取数据,就不会进行复制。一旦某个进程试图写入一个页面,操作系统就会介入,透明地分配一个新的帧,复制该单个页面的内容,并更新该进程的页表以指向其新的私有副本。这使得快照和进程创建几乎是瞬时的,复制的开销是增量支付的,并且只针对实际发生变化的数据 ([@problem-d:3668056])。
在云计算和虚拟化的世界里,赌注甚至更高。在这里,一个虚拟机监控程序(hypervisor)可能会在单个物理主机上运行多个虚拟机(VM),并且常常“超售”内存——向VM承诺的RAM总量超过了物理上可用的量。当VM的总需求超过主机的供应时会发生什么?虚拟机监控程序必须回收内存。一种天真的方法是主机级交换:虚拟机监控程序對其客户机的内部运作一无所知,它抓取页面并将其写入磁盘。但是客户机操作系统最了解自己的内存。它知道某些页面是宝贵的(程序的活动工作集),而其他页面是可牺牲的(干净的页缓存,其内容已在磁盘上)。一种更复杂的协作方法是在客户机内部使用一个“气球驱动程序”。虚拟机监控程序告诉气球“膨胀”,对客户机操作系统施加压力。客户机作为回应,会智能地首先丢弃其最不宝贵的页面——干净的缓存。这避免了灾难性的I/O放大,即非协作的虚拟机监控程序可能会浪费地将一个干净的页面写入其交换文件,之后又将其读回,而客户机本可以免费丢弃它。这是一个系统各层之间美妙的对话,全部由内存管理精心策划。
最后,操作系统的角色不仅仅是管理内存,而是协调软件和硬件之间的交响乐。现代CPU的性能严重依赖于其缓存。当操作系统在进程之间切换时,新进程常常发现缓存中充满了旧进程的数据,导致一场缓存未命中风暴。但一个聪明的操作系统可以做得更好。它可以使用一种称为页着色(page coloring)的技术,根据物理内存帧如何映射到CPU缓存来策略性地分配给进程。通过为不同进程分配不同的“颜色”(缓存的子集),操作系统可以最小化它们缓存足迹之间的重叠。当发生上下文切换时,新进程的工作集会映射到不同的缓存组,从而驱逐掉的前一个进程的缓存行要少得多。这是操作系统扮演硬件性能工程师角色的一个深刻例子,展示了内存分配与处理器架构之间深刻而交织的关系。
当操作系统管理着宏观大局时,程序员则在更精细的尺度上与内存搏斗,每当他们请求一块内存来存储一个对象或数据结构时。这就是 malloc 的世界,这个库函数是通往堆的门户。
这里的根本敌人是碎片。想象一个巨大的空停车场。起初,各种大小的汽车都能轻松停放。但随着汽车的来来往往,空闲空间被分割成小的、尴尬的位置。最终,你可能拥有足够的总空闲空间来停放一辆巴士,但没有一个单独的位置足够大。这就是外部碎片,它可能导致分配请求失败,即使总体上有足够的空闲内存。对于频繁分配和释放相同大小对象的应用程序,通用分配器恰恰会造成这种混乱。一个简单而强大的解决方案是内存池或slab分配器:一个专门的内存区域,预先分割成固定大小的槽。分配变得像从取票机取票一样快,释放也像归还票一样简单。这些对象之间没有碎片,因为每个槽的大小都是完美的。
当然,现实世界中的分配器更为复杂。它们是策略的集合,就像木匠的工具箱。例如,一种分离适配(segregated-fit)分配器,维护的不是一个而是许多空闲列表,每个列表对应不同大小类别的对象(例如,一个用于16字节块的列表,一个用于32字节块的列表,等等)。当一个请求到来时,可以从适当的列表中快速满足它,这融合了固定大小池的效率与处理各种大小的灵活性。这些分配器是实用工程的奇迹,平衡了速度、内存使用和碎片。
但权力越大,责任越大。动态分配赋予了程序员管理内存的灵活性,但也给他们带来了释放内存的责任。忘记这样做会导致内存泄漏——分配的内存丢失并且永远无法释放,慢慢消耗系统资源直到程序崩溃。在这里,分配器本身可以变成一名侦探。通过包装标准分配函数,我们可以构建一个调试器,它维护着一份秘密账本,记录了已分发的每一块内存。当一个块被释放时,它的条目被划掉。程序结束时,账本上任何剩余的条目都代表着泄漏的内存。这种instrumentation提供了一个宝贵的工具,将“泄漏”这个抽象问题轉化为一份具体的报告,说明哪些分配从未被核销 ([@problemid:3239024])。
分割、合并和管理内存块的逻辑是否为内存所独有?完全不是。 underlying 的算法是管理任何可替换和可分割资源的抽象原则。
考虑一个总容量为1024 Mbps的大型光纤链路。一个互联网服务提供商需要为不同客户分配带宽。一个180 Mbps的请求到达,接着是一个200 Mbps的请求。当一个客户的合同结束时,他们的带宽被返还到池中。这在结构上与内存分配问题完全相同。我们可以直接应用伙伴系统。总容量被视为一个大小为 的单个块。请求被向上舍入到最接近的2的幂,然后递归地分割块以满足它。当一个流终止时,它的块被释放,如果它的“伙伴”块也空闲,它们就会被合并成一个更大的块。这个最初为物理内存页设计的算法,同样漂亮地适用于管理网络带宽,展示了其抽象的力量。
这个原则也可以通过将其应用于一个截然不同的硬件架构来测试,比如图形处理单元(GPU)。GPU通过大规模并行实现其惊人的性能,以锁步方式执行数千个线程。假设我们想将slab分配概念应用于管理GPU上的一池固定大小对象。核心思想——预先 carving 的slab以消除碎片——仍然是合理的。然而,天真地移植一个CPU实现将是灾难性的。GPU上的主要性能关注点不是CPU缓存冲突,而是合并内存访问:确保一个组(一个“线程束”/“warp”)中的线程在一次事务中访问连续的内存位置。一个以CPU为中心的设计,比如给每个线程自己的对象缓存,将是不切实际的,并导致随机内存访问。这个原则必须被调整。一个GPU原生的设计会采用线程束同步(warp-synchronous)的方法:一个线程束中的一个线程原子地为整个线程束保留一批对象,然后每个线程计算其在这个连续批次中的偏移量。这保留了slab分配器的精神,同时尊重了GPU架构的物理现实,展示了一个美丽的想法必须如何重新构想才能在一个新环境中茁壮成长。
也许所有联系中最深刻的是内存管理与编程语言结构本身之间的联系。语言设计者就函数和作用域这样抽象的东西所做的选择,对运行时的内存模型产生了深刻且不可避免的后果。
对于许多语言来说,调用栈是一个优雅简洁的模型。当一个函数被调用时,一个包含其局部变量的新帧被推入栈中。当函数返回时,该帧被弹出。其生命周期完美嵌套,遵循严格的后进先出(LIFO)顺序。
但是,当我们引入像一等函数这样的特性时,情况会怎样呢?在这种特性中,一个函数可以作为另一个函数的值返回。这就产生了闭包:一个函数与它创建时的环境捆绑在一起。考虑一个函数 generator(),它定义了一个局部变量 x 并返回一个新函数 counter(),该函数递增并返回 x。generator() 函数返回了,它的栈帧应该被弹出和销毁。但是,可能还会存活的 counter 闭包仍然需要访问 x!如果该帧被销毁,counter 将持有一个指向已死内存的“悬垂指针”。
简单的LIFO栈模型被打破了。generator 的作用域帧的生命周期不再与其执行绑定;它与 counter 閉包的生命周期绑定。这迫使运行时系统发生根本性的改变。作用域帧不能再存在于简单的连续栈上。它们必须像其他对象一样在堆上分配。每个帧都持有一个指向其父级(其外围作用域)的指针,形成一个链表或树。当一个函数返回时,它的帧不会被销毁;它只是变得不活跃。只有当它不再可达时——即程序的活动部分和任何现有的闭包都不再持有对它的引用时——它才会被垃圾回收器或引用计数机制回收。
这一个语言特性就迫使整个内存模型从一个简单的栈 discipline 演变为一个由垃圾回收器管理的复杂的堆分配对象图。这向我们表明,内存管理不仅仅是性能调优的事后工作;它是一种编程语言表达能力的基本决定性特征。在非常真实的意义上,它是机器灵魂的一部分。