try ai
科普
编辑
分享
反馈
  • 堆管理

堆管理

SciencePedia玻尔百科
关键要点
  • 堆管理是在性能与内存浪费之间取得平衡的关键过程,内存浪费主要由内部和外部碎片引起。
  • 首次适配、最佳适配和下次适配等分配策略决定了如何使用空闲内存,这些策略对堆的长期健康状况有着违反直觉的影响。
  • 自动垃圾回收为程序员简化了内存管理,其中标记-整理等技术为外部碎片问题提供了明确的解决方案。
  • 堆管理的原则不仅限于计算机内存,还适用于电信和云计算等领域中任何可分割资源的分配问题。

引言

管理计算机内存是软件工程中的一个基础挑战。虽然部分内存在栈上进行可预测的分配,但程序生命周期中的大部分活动都涉及对不同大小和生命周期的内存的动态请求。这便是堆(heap)的领域,一个灵活但混乱的资源池。本文要解决的核心问题是如何高效地管理这个堆,在保证性能和稳定性的同时,最大限度地减少空间浪费(碎片)。如果没有稳健的堆管理,应用程序可能会运行缓慢,意外耗尽内存,甚至容易受到安全攻击。

为了揭开这一关键主题的神秘面纱,本文分为两个主要部分。在“原理与机制”中,我们将剖析支撑堆管理器的基本算法和数据结构,探讨空闲链表、合并等概念,以及不同分配策略之间的权衡。然后,在“应用与跨学科联系”中,我们将拓宽视野,看看这些相同的原则如何指导着从操作系统、云计算到连接我们设备的无线电波等不同领域的资源分配。我们的旅程将从探索为混乱的堆带来秩序的核心机制开始。

原理与机制

想象一下,你负责管理一条很长的纸带,它代表你计算机的内存。你的工作是把纸带的片段分发给任何提出请求的人,并在他们用完后收回。一开始很简单,你从末端分发一片,再一片,又一片。但很快,人们开始归还纸片。有人从中间还回一小片,另一个人从靠近开头的地方还回一大片。过了一段时间,你那曾经整洁的纸带变得一团糟。空闲空间不再是一整条长带,而是由一堆大小不一、互不相连的零碎纸片组成。

这正是​​堆管理​​的根本挑战。而这种混乱的状态有一个名字:​​外部碎片​​(external fragmentation)。

内存游戏:一块拼布被

让我们把这个概念说得更精确一些。假设你的堆中散布着总共 FFF 字节的空闲内存。此时来了一个新的内存块请求,但你拥有的最大单个连续空闲块的大小为 LLL。如果 LLL 小于请求的大小,那么即使你可能有大量的总空闲空间,分配也会失败!这就是碎片的悖论。我们可以为其定义一个简单的度量:外部碎片率为 ϕ=1−L/F\phi = 1 - L/Fϕ=1−L/F。如果你所有的空闲空间都在一个块中,那么 L=FL=FL=F,碎片率为 000。但如果你的空闲空间碎裂成一百万个小块,LLL 相对于 FFF 会变得非常小,碎片率会趋近于 111,这意味着你几乎所有的空闲内存都无法使用。你的堆已经变成了一块由已分配块和空闲块组成的拼布被,而找到一块足够大的补丁来满足新请求,就是这个游戏的名字。

那么,分配器——负责管理堆的系统,例如C语言中的标准malloc函数——是如何玩这个游戏的呢?它需要一个策略,而这个策略始于追踪这些碎片。

记录追踪:空闲链表与合并的魔力

管理空闲补丁最常见的方法是将它们链接成一个​​空闲链表​​(free list)。这正如其名:一个链表,其中每个节点都是一个空闲的内存块。但是我们应该在哪里存储这个链表的“next”指针呢?巧妙的技巧是把它们直接存储在空闲块内部。每个空闲块开头的一小部分被保留用于记账,形成一个​​头部​​(header)。

一个真正优雅的实现方式是使用所谓的​​边界标签​​(boundary tags)。每个块——无论是空闲的还是已分配的——在开头都有一个小头部,在结尾有一个小脚部。头部和脚部都存储了块的大小和一个分配位(如果在使用中则为'1',如果空闲则为'0')。

这可能看起来多余,但它实现了一个至关重要的操作,称为​​合并​​(coalescing)。当你释放一个块时,你应该检查它的邻居是否也是空闲的。如果是,你应该将它们合并成一个更大的单一空闲块。这是对抗碎片化的主要武器。有了边界标签,这个操作效率极高。当你释放地址为 ppp 的块时,你知道它的大小,所以你可以找到紧随其后的块并检查其头部。并且,由于 ppp 之前那个块的脚部,你也可以找到它的头部并检查其状态,所有这些操作都可以在常数时间内完成!

这就引出了一个设计选择:我们应该何时进行合并?是每次释放一个块时都立即进行吗?这会使 free 操作更复杂,但能保持堆的整洁。还是我们采取“懒惰”的方法,即 free 只是将块的状态翻转为'0',只有当一次分配失败时我们才对整个堆进行一次完全的合并遍 पास?。前者可能更可预测,而后者可以使 free 操作快如闪电,但代价是可能(尽管罕见)出现一次昂贵的分配。

选择区块:分配策略的艺术

假设我们有了空闲链表。一个请求 100100100 字节的请求到来了。我们的链表里有大小为 120120120、150150150 和 300300300 的块。我们该选择哪一个?这就是分配策略的问题。

  • ​​首次适配(First-Fit)​​:这是最简单的策略。从头开始扫描空闲链表,使用你找到的第一个足够大的块。
  • ​​最佳适配(Best-Fit)​​:这听起来更优。扫描整个空闲链表,选择最贴合请求的块——即剩余空间最小的那个。其直觉是这样可以避免用一个大块来满足一个小请求,从而造成浪费。
  • ​​下次适配(Next-Fit)​​:这是首次适配的一个微妙但强大的变体。你不是总从链表头部开始扫描,而是从上一次分配完成的地方开始,将链表视为一个环。

哪种最好?“最佳适配”听起来应该是赢家,但现实却出奇地违反直觉。最佳适配在寻求最紧密匹配的过程中,常常会留下微小、无法使用的内存碎片。随着时间的推移,堆可能会被这些碎片污染,导致比首次适配更严重的外部碎片!

那么首次适配和下次适配呢?把堆想象成一个社区。首次适配就像一个总是在社区入口处寻找土地的开发商。这个区域很快就会被高度开发并变得支离破碎,剩下一些微小的地块。然而,下次适配就像一个从上次建造的地方继续搜索的开发商。这会将“磨损”分散到整个社区,倾向于留下更大、更有用的连续土地块。这只是算法上的一个简单改变,却导致了堆完全不同的宏观行为。

浪费的两个方面:内部与外部

我们一直关注外部碎片——即已分配块之间的浪费。但是还有另一种更隐蔽的浪费。

想象一下,你的程序堆本身需要增长。它向操作系统(OS)请求更多内存。为了避免过于频繁地进行这种昂贵的操作,操作系统可能会给你的程序一个比它立即需要的更大的块。例如,如果堆的限制是 l0l_0l0​,而你需要更多空间,一个常见的策略是将其几何级数地扩展到一个新的限制 l1=l0×rl_1 = l_0 \times rl1​=l0​×r,其中 rrr 是某个增长因子。如果你最终的堆使用量是 HHH,但操作系统分配的段是 lk>Hl_k > Hlk​>H,那么空间 (lk−H)(l_k - H)(lk​−H) 就被分配给了你的进程但未被使用。这就是​​内部碎片​​——在已分配区域内部的浪费。这里有一个优美的权衡:更大的增长因子 rrr 意味着对操作系统的昂贵调用更少,但可能浪费更多的内存。事实上,对于给定的最大堆大小 Hmax⁡H_{\max}Hmax​ 和最大扩展次数 NNN,我们可以计算出最优增长因子 r⋆=(Hmax⁡/l0)1/Nr^{\star} = (H_{\max}/l_0)^{1/N}r⋆=(Hmax​/l0​)1/N,以最小化这种最坏情况下的内部浪费。

这种权衡出现在很多地方。例如,许多现代分配器处理非常大的分配请求时,不是从主堆中分配,而是通过像 mmap 这样的机制直接向操作系统请求一个专用的内存映射。然而,操作系统提供的内存是固定​​页面大小​​(例如,444 KiB)的倍数。如果你请求 258258258 KiB,操作系统可能会给你 656565 个页面,即 260260260 KiB。额外的 222 KiB 是另一种形式的内部碎片。选择一个阈值 θ\thetaθ 来切换堆分配和 mmap 是一个微妙的平衡行为。低阈值可以减少堆中的外部碎片,但会因页面取整而增加内部碎片。高阈值则相反。没有唯一的“正确”答案;这是一个经典的工程妥协。

自动管家:垃圾回收

到目前为止,我们都假设程序员是一个完美的公民,会一丝不苟地释放他们分配的每一个内存块。在现实世界中,这是一项繁琐且容易出错的任务。如果我们能将这个过程自动化呢?这就是​​垃圾回收(GC)​​的承诺。

最常见的GC形式是​​追踪式垃圾回收器​​。其原理简单而深刻:一个对象只有在你能够访问到它时才有用。GC从一组“根”(roots)——存储在全局变量或当前函数调用栈中的指针——开始,遍历每一个对象引用,标记它能到达的每一个对象。遍历之后,任何未被标记的对象都是不可达的,因此是可以回收的垃圾。

但这种自动化带来了一个微妙的陷阱。GC是一个逻辑学家,而不是一个读心者。它遵循的是指针,而不是意图。考虑一个视频游戏中的粒子系统。当一个粒子飞出屏幕时,它就不再需要了。从语义上讲,它是垃圾。但如果代码中的一个bug忘记将它从活动粒子的主列表中移除呢?从GC的角度来看,这个粒子仍然可以从那个列表访问到,所以它永远不会被回收。游戏的内存使用量将线性、无休止地增长,直到崩溃。这就是​​逻辑内存泄漏​​:内存不再需要,但由于一个过时的引用而被无意中保持“存活”。

那么,对抗顽固的外部碎片问题的终极武器是什么呢?它是一种强大的GC形式,称为​​标记-整理​​(mark-and-compact)。在标记完所有存活的对象之后,收集器不是简单地释放死掉的对象,而是将所有存活的对象并排滑动到堆的一端。这个操作,就像整理硬盘碎片一样,消除了所有的间隙。所有的空闲空间被合并成堆另一端的一个单一、连续的块。外部碎片被完全消灭。这是一个昂贵的操作,但对于饱受碎片化困扰的系统来说,这是一个决定性的解决方案。

玩火:安全性与稳健性

管理堆不仅仅关乎性能;它还关乎稳定性和安全性。一个简单的bug可能带来灾难性的后果。考虑以下操作序列:free(B); free(C); free(B);。这是一个​​重复释放​​(double-free),也是灾难的根源。如果分配器使用简单的“后进先出”空闲链表,第一个 free(B) 会使链表变为 B→∅B \rightarrow \varnothingB→∅。free(C) 使其变为 C→B→∅C \rightarrow B \rightarrow \varnothingC→B→∅。现在,第二个 free(B) 盲目地将 BBB 重新插入到头部。它将 BBB 的next指针设置为当前的头 CCC。但 CCC 的next指针仍然指向B。你制造了一个循环:空闲链表现在是 B→C→B→…B \rightarrow C \rightarrow B \rightarrow \dotsB→C→B→…。下一次分配器试图扫描这个链表时,它将进入一个无限循环,导致程序挂起。更糟糕的是,聪明的攻击者可以利用这类堆损坏漏洞来控制一个程序。

我们如何防御这种情况呢?我们可以添加一个简单的安全检查。让我们在每个块的头部添加一个单字节的​​墓碑标签​​(tombstone tag)。当一个块被释放时,我们设置它的墓碑。现在 free 函数会首先检查这个标志;如果它已经被设置,就知道有人在尝试重复释放,并且可以引发一个错误,而不是损坏堆。但这种安全并非没有代价。由于内存对齐规则,那个额外的字节可能会迫使我们的头部增长。例如,一个17字节的头部可能需要被填充到24字节以满足8字节的对齐要求。这种增加的开销降低了堆的总有效负载容量。我们再次看到了一个根本性的权衡:在这种情况下,是在安全性与空间效率之间的权衡。

现代竞技场:多核世界中的堆

我们讨论过的原则——空闲链表、合并、分配策略——都诞生于单核处理器的时代。今天的计算机有许多核心,都在并行执行。如果所有这些核心都需要分配内存,它们都会试图访问同一个全局堆、同一个全局空闲链表,以及同一个全局锁来防止竞争条件。这会造成一个巨大的性能瓶颈。

现代的解决方案是为每个核心提供其自己的私有堆,或称为​​区域​​(arena)。大多数时候,在某个核心上运行的线程可以从其本地区域分配和释放内存,完全不需要加锁,这速度极快。这种方式完美地划分了问题。但如果一个核心的区域用完了某种大小的块会发生什么?分配会失败吗?不会。分配器可以尝试从另一个核心的区域​​窃取​​一个空闲块。这种复杂的、分层的设计证明了堆管理基本原则的持久力量,它们被调整和重新构想以满足现代并行计算的需求。从一张简单的纸带到这些复杂的多核区域的旅程,完美地诠释了计算机科学核心处的优雅与智慧。

应用与跨学科联系

在探索了堆管理的原理和机制之后,你可能会留下这样的印象:这是一个小众、技术性的话题,是操作系统和编程语言架构师才关心的问题。但事实远非如此。管理有限、可分割的资源是工程学乃至自然界中最普遍的问题之一。我们讨论过的思想——寻找合适的空间、碎片化不可避免的混乱,以及周期性整理的需求——都出现在最意想不到的地方。

在本章中,我们将踏上一段旅程,去看看这些基本思想在实践中的应用。我们会发现,堆不仅仅是计算机内存的一个区域,它还是任何需要分配和回收空间(无论是有形的还是无形的)的系统的强大抽象。这是一个关于一组优美、合乎逻辑的原则如何将广阔的不同领域统一起来的故事。

数字景观

让我们从熟悉的领域开始:现代计算机。堆管理的概念不仅仅是抽象理论;它们是我们数字体验得以建立的基石。

想象一下你计算机的硬盘或固态硬盘。实际上,它就是一个巨大的存储块堆。当你保存一个文件时,操作系统扮演了内存分配器的角色,寻找一个连续的“空闲块”磁盘空间来存储你的文件数据。当你删除一个文件时,你就在“释放”那个块。随着时间的推移,由于不同大小的文件被创建和删除,磁盘上的空闲空间被分割成无数的小块——这正是外部碎片的一幅完美图景。你甚至可能亲眼见过这种情况,如果你运行过“磁盘碎片整理程序”工具。那个过程就是整理(compaction)!它煞费苦心地将已分配的块(你的文件)移动到一起,将所有的小洞合并成一个大的、可用的空闲空间。

同样的戏剧在计算机主存(RAM)这个更快、更动态的舞台上演。每个应用程序、每个浏览器标签、你运行的每个进程都需要内存来运作。当一个程序启动时,操作系统的分配器必须在堆中为程序的代码、数据及其自身的栈找到空间。一个复杂的应用程序可能不会请求一个巨大的块,而是在其生命周期中请求许多较小的块。由首次适配或最佳适配等策略管理的复杂分配和释放之舞,直接决定了整个系统的性能和稳定性。你是否曾被告知你的计算机无法打开一个新程序,即使理论上你有足够的“总”内存?你刚刚目睹了严重碎片的后果,即没有一个单一的空闲块大到足以满足新程序的需求。内存可用性与调度新任务能力之间的这种密切联系是操作系统设计的基石。

即使在单个运行的程序内部,堆也是默默无闻的功臣。考虑一个模拟复杂系统的程序,比如天气或股票市场。这样的程序可能会使用“事件驱动”模型,其中“事件”是表示在特定时间发生某事的数据对象。这些事件被创建,被安排在未来执行,并在处理后被销毁。它们的生命周期不像函数调用那样整齐地嵌套,所以它们不能存在于栈上。它们必须在堆上分配。决定模拟未来的事件队列,保存的不是事件本身,而是指向它们的指针——在广阔堆景观中的地址。堆管理器在创建和销毁这些无数、短暂的对象时的效率,对于模拟器展望未来的能力至关重要。

现代世界中看不见的堆

一个科学原理真正的力量和美,在于它能超越其最初的背景。堆不仅仅是为内存字节而生;它适用于任何可以被细分的连续资源。

想想你周围的空气,充满了连接我们移动设备的无形电波。对于一家5G网络提供商来说,它从政府那里许可的频谱是一种资源——一种宝贵的、一维的、连续的资源。这就是他们的“堆”。当你打电话或观看视频时,网络的控制系统就像一个分配器。它必须找到一个可用的、连续的频率块——一个“信道”——并在你连接期间分配给你。当你断开连接时,你的信道被“释放”,并可以与任何相邻的空闲信道合并。网络提供商的目标是在其有限的频谱中容纳尽可能多的用户。为此,它可能会使用最佳适配策略为新连接找到最紧密的信道,从而为未来的高带宽请求留下尽可能大的连续频谱块。在这里,“频谱碎片”是一个可能限制网络容量的现实世界问题。

现在,让我们从单一资源扩展到全球资源。考虑一个庞大的云存储提供商,其“堆”不是单个内存块或频谱,而是由世界各地数据中心成千上万个物理存储节点组成。当一家公司想要上传一个庞大的数据库时,一个全局分配器必须决定将其放在哪里。这就成了一个“多堆”问题。分配器不只是寻找一个空闲空间;它在所有节点中搜索最佳空间。“最佳”可能是指浪费空间(“闲置空间 (slack)”)最小的那个,或者是在一个地理位置上能最小化延迟的那个。这个选择是一个复杂的优化问题,通常带有复杂的约束,比如数据对齐,它规定一个块必须从一个能被特定数字整除的地址开始。在这里,我们看到了堆管理的熟悉原则——寻找匹配、最小化碎片——被放大到分布式系统的全球层面。

机器中的幽灵:优化与隐藏成本

到目前为止,我们都把堆看作是一个既定的、管理动态资源的必要工具。但如果对堆最明智的使用是完全避免它呢?在这里,我们得以窥探现代编译器的思想,并看到它一些最深奥的技巧。

在科学计算或大规模数据处理等高性能领域,堆分配的开销可能是一个显著的瓶颈。考虑对一个大到无法装入内存的数据集进行排序的任务。一种“外部排序”算法通过在内存中对较小的块进行排序并将它们写入磁盘,然后合并这些已排序的块来工作。这个合并步骤需要在内存中有几个大的、连续的I/O缓冲区。如果堆因程序中的其他活动而变得碎片化,对这些大缓冲区的请求可能会失败,使整个过程陷入停顿。一个聪明的程序员或系统有几种选择。一种是更灵活,减少一次合并的块数,以需要更少的连续内存。一种更激进的方法是完全绕过应用程序的通用堆,直接从操作系统请求一个大的、原始的虚拟内存块。这就像为你的任务请求一个私有的、未碎片化的堆。

然而,最优雅的解决方案是当系统足够聪明,能为我们做出这个选择时。当程序员写下new Object()时,这看起来像是一个直接在堆上分配的命令。但是一个足够聪明的编译器,使用一种叫做*逃逸分析*(escape analysis)的技术,可以证明在某些情况下,堆分配是不必要的。它分析对象的“生命”。如果该对象只在当前函数内使用,并且从不“逃逸”——通过被返回、存储在全局变量中或传递给另一个线程——那么它的生命周期是简单且可预测的。然后编译器可以执行一个神奇的优化:它将对象放在快速、简单的函数调用栈上,而不是堆上,甚至可能将其分解并将其字段存储在CPU寄存器中,从而完全消除该对象。然而,如果编译器看到哪怕有一条路径该对象可能逃逸,它就会安全地默认在堆上分配。这种路径敏感的分析是一个系统通过推理程序行为来自动生成最高效代码的优美范例。

最后,我们必须认识到,一个混乱、碎片化的堆的代价不仅仅是分配失败的风险。它还会带来隐藏的性能税。对于具有自动内存管理或垃圾回收(GC)的系统,收集器必须遍历活动对象的图来识别和回收死掉的对象。一个碎片化的堆通常意味着逻辑上连接的对象在物理上相距甚远。这种糟糕的局部性可能会对CPU的缓存造成严重破坏,导致更多的缓存未命中。每一次未命中都是一个微小的停顿,是CPU等待数据从较慢的主存中到达的时刻。在数十亿次操作中,这些微小的停顿累积起来,会使整个程序变慢。因此,运行一个整理周期不仅仅是为了创造一个大的空闲块;它也是为了改善局部性,并降低GC过程本身的时间和能源成本。它是为了保持数字机器良好润滑并以最高效率运行。

从我们可以触摸到的磁盘到我们看不见的编译器,堆管理的故事证明了几个简单、优雅的思想的力量,它们解决了一个本质上和想在拥挤的架子上再放一件东西一样古老的问题。这是一个关于秩序与混乱、创造空间与清理的根本模式,它定义了计算的世界。