try ai
科普
编辑
分享
反馈
  • 堆碎片化

堆碎片化

SciencePedia玻尔百科
核心要点
  • 堆碎片化表现为两个截然不同的问题:外部碎片化,即空闲内存被分割成不连续的小块;以及内部碎片化,即已分配块内部存在空间浪费。
  • 内存分配策略(如首次适应或最佳适应)的选择对堆的健康状况有深远影响,但其有效性高度依赖于特定工作负载的分配和释放模式。
  • 对象大小与其生命周期之间的关系至关重要;生命周期长的大对象会永久性地分割堆,而生命周期短的大对象则可以提高内存的可用性。
  • 紧凑式垃圾回收为外部碎片化提供了明确的解决方案,而对碎片化的深刻理解对于系统编程、云计算中的性能优化乃至防止安全漏洞都至关重要。

引言

在软件开发领域,计算机内存通常被视为一种充裕且随时可用的资源。我们申请一块内存,使用它,然后丢弃它,很少去思考背后发生的复杂运作。然而,这种简单的表象掩盖了一个复杂而严峻的挑战:对有限、连续的地址空间进行高效管理。动态分配和释放内存块的过程不可避免地会导致一种被称为堆碎片化的无序状态,这是一种隐蔽的浪费形式,可能降低性能、导致意外故障,甚至产生安全漏洞。本文将揭开这一基本问题的神秘面紗,深入探讨其成因、影响及解决方案。

我们的探索始于“​​原理与机制​​”一章,其中我们将通过简单的类比来建立对外部碎片和内部碎片化的坚实理解。我们将探讨内存分配器用于放置数据的经典策略,分析它们之间的权衡,并发现时间维度——即数据的生命周期——如何扮演关键角色。最后,我们将审视作为对抗碎片化终极武器的内存整理技术。接着,“​​应用与跨学科联系​​”一章将拓宽我们的视野,揭示这个看似底层的问题如何对数据结构设计、高性能系统、GPU 编程、云计算乃至网络安全产生深远影响。通过理解碎片化,我们为构建更健壮、更高效的软件获得了至关重要的洞察。

原理与机制

想象一下,你是一家非常奇特的图书馆的管理员。书架上的书没有固定的位置;你只需把它们放在任何放得下的地方。当一本书被归还时,它会留下一个空隙。当一本新书到来时,你必须找到一个足够大的空隙来容纳它。起初,这很容易。但很快,你的书架上就布满了各种大小的书之间微小而无法使用的空隙。你有很多空的空间,但你却找不到任何地方可以放下一套新的百科全书。简而言之,这就是内存管理的挑战,而你所造成的混乱就叫做​​堆碎片化​​。

对于程序员来说,计算机的内存常常感觉像一片广阔无垠的空间。我们请求一块内存,系统就会交给我们。但在这表象背后,是一位孜孜不倦的管理者——​​内存分配器​​(memory allocator),它在用我们的数据玩着俄罗斯方塊的游戏。它的根本限制,也是我们所有麻烦的根源,在于内存是一维的、连续的地址线。为单个对象分配的内存块必须是单一、不间断的一块。这个简单的规则带来了深远的影响,迫使我们不仅要将内存视为一个数量,还要将其视为一种几何结构。这就是堆的世界,一个动态的空间,分配器必须在其中做出不可撤销的在线决策,就像经典​​在线装箱问题​​(Online Bin Packing)中的玩家:将到来的物品(我们的数据)放入可用的箱子(空闲内存块)中,而不知道接下来会到达什么物品。

孔洞的诞生:外部碎片与内部碎片

让我们观察一下碎片化是如何发生的。我们请求三块内存,A、B 和 C。分配器将它们整齐地排成一行:[ A | B | C | ...free space... ]。现在,我们告诉分配器我们用完了块 B。它被释放,留下一个孔洞:[ A | --- | C | ...free space... ]。这就是​​外部碎片化​​(external fragmentation):空闲内存被分割成多个不连续的块。

单个孔洞是无害的。但如果一个程序交替分配大小为 2 和 1 的块,然后释放所有大小为 1 的块呢?我们最终可能会得到一个像这样的内存布局:[ (size 2) | --- | (size 2) | --- | (size 2) | --- | ...]。堆变得像瑞士奶酪一样。我们可能有大量的总空闲内存,但它们都分散在微小、无用的片段中。一个大小为 3 的块请求将会失败,即使总空闲空间是 100!。

我们如何衡量这种“破碎程度”?一个直观的度量是查看总空闲内存 TTT,然后减去我们实际可以分配的最大单个块的大小 LLL。差值 E=T−LE = T - LE=T−L 代表了所有因碎片化而“丢失”的空闲内存。一个更优雅的方法可能是设计一个单一的“堆健康”得分。如果我们想要一个能够反映堆服务请求能力、被归一化且行为合理的得分,一条优美的推理路线会导出一个惊人简单的公式:堆的健康状况就是比率 LH\frac{L}{H}HL​,其中 LLL 是最大的空闲块,H 是堆的总容量。它简单地衡量了堆当前能够满足的最大连续请求占其总大小的比例。

但是,块之间浪费的空间只是故事的一半。在块内部也存在浪费的空间。这就是​​内部碎片化​​(internal fragmentation)。当你请求一块内存时,分配器可能会给你比你要求的稍多一些。为什么?首先,它需要随块存储一些管理信息,比如它的大小——一个“头部”。其次,出于性能原因,分配器通常会将每个块的大小向上取整到某个对齐值的倍数,比如 16 或 128 字节。如果你请求 3100 字节,分配器可能会添加一个 24 字节的头部,并将总数向上取整到 128 的下一个倍数,给你一个 3200 字节的块。那些你没有请求的额外 76 字节就被浪费了,它们位于你的分配块内部[@problem id:3657390]。

这种浪费问题出现在系统的每一层。操作系统可能使用​​分页​​(paging)来解决其自身的物理内存外部碎片化问题。它将内存划分为固定大小的页(例如 4096 字节),并且可以将任何页分配给任何进程,无论其物理位置如何。但这会产生其自身的内部碎片化:如果一个进程只需要 1000 字节,它仍然会得到一个完整的 4096 字节的页,浪费了剩下的部分。然后,在该页内部,进程自己的堆分配器又会继续制造它自己的内部和外部碎片混合体!。现代系统引入了又一个层次。为了在多线程程序中快速分配内存,每个线程可能会保留一个私有的预分配空闲对象缓存。这避免了竞争,但也意味着这些缓存的内存是“搁浅”的——它是空闲的,但对任何其他线程都不可见且不可用。这是另一种微妙的内部碎片化形式,是一种为了局部速度而牺牲全局内存效率的权衡。

布局的艺术:策略众生相

如果碎片化是疾病,那么分配策略就是处方。当一个内存请求到达时,分配器必须决定使用哪个空闲孔洞。有一整套策略,每种策略都有其自身的特点和后果。

  • ​​首次适应(First-Fit)​​:最简单的一种。从堆的开头开始扫描,取用第一个足够大的孔洞。它速度快,但倾向于在堆的开头造成一堆小的、无法使用的碎片,正如在交替分配的病态案例中所示。

  • ​​下次适应(Next-Fit)​​:试图做到更公平。它不是总是从头开始搜索,而是从上次分配结束的地方开始,使用一个​​巡回指针​​(roving pointer)。这将“磨损”更均匀地分布在整个堆上,但缺点是它可能会污染整个堆,使其充满小碎片,而不是将它们集中在一个区域。

  • ​​最佳适应(Best-Fit)​​:看起来很直观。找到最紧密贴合请求的孔洞,留下尽可能小的剩余部分。目标是为未来的大请求保留大块内存。

  • ​​最差适应(Worst-Fit)​​:反直觉的一种。选择最大的可用孔洞。其理由是留下一个希望足够大以至于仍然有用的剩余部分。

那么哪种最好呢?令人惊讶的答案是:视情况而定。在一个巧妙构建的场景中,堆的大小恰好能容纳 NNN 个对象,我们按顺序分配它们,那么任何时候都只有一个空闲块可用。在这种情况下,首次适应、最佳适应和最差适应在每一步都被迫做出完全相同的选择!如果我们接着释放每隔一个块,所有策略产生的碎片化结果都是相同的。这给我们一个深刻的教训:分配器的性能不是一个绝对属性,而是与特定的​​工作负载​​——即分配和释放的模式——紧密相关。

选择孔洞的策略只是谜题的一部分。另一部分是如何管理空闲孔洞列表本身。当一个块被释放时,我们应该将其添加到空闲列表的前端(​​后进先出 LIFO​​,Last-In-First-Out)还是后端(​​先进先出 FIFO​​,First-In-First-Out)?这揭示了一个经典的工程权衡。LIFO 通常非常快,因为程序表现出​​时间局部性​​(temporal locality):它们倾向于请求它们最近刚释放过的大小的内存。将最近释放的块放在前面意味着分配器几乎可以立即找到匹配项。然而,这会反复使用少数几个块,将它们分解成越来越小的碎片,从而增加碎片化。相比之下,FIFO 较慢——它必须搜索所有旧块——但它允许块“老化”,增加了它们在被重用前与邻居合并的机会,这可能导致整体碎片化程度降低。

时间维度:生命周期的舞蹈

我们对碎片化的描绘仍然过于静态,就像一张凌乱书架的单张照片。现实是一部电影。对象被创建(分配)、存活,然后消亡(被释放)。对象的​​生命周期​​为这个问题增加了一个至关重要的时间维度。最引人入胜的效应来自于对象大小与其生命周期之间的关联。

考虑两种情景。在情景 A 中,​​大对象生命周期长​​。想象一下分配几个巨大的核心数据结构,它们在程序的整个运行期间都存在。它们就像内存景观中巨大、不可移动的巨石。堆被这些巨石永久地分割,空闲空间被困在它们之间较小的“隔间”里。这对碎片化来说是灾难性的,因为永远无法形成大的连续空间。

现在考虑情景 B,其中​​大对象生命周期短​​。想象一个视频编辑器为一个单帧分配一个大缓冲区,处理它,然后立即释放它。这对堆来说太棒了!大块的内存不断地返回到空闲块池中。像最佳适应这样的智能策略可以“保留”这些大的、短暂可用的块,拒绝为小请求分割它们,并将它们留给下一个到来的大请求。这种动态补充,一种大空隙供需之间的美妙“时间契合”,导致碎片化显著降低。事实证明,碎片化不仅是布局的空间问题,更是一个时空问题,受分配和释放节奏的支配。

最终解决方案:移动它!

在与所有这些复杂的策略和权衡斗争之后,人们可能会想,是否有更简单、更强大的方法?如果当图书馆变得太乱时,我们可以神奇地打个响指,找到所有当前借出的书,并将它们整齐地堆放在书架的一端呢?

这正是​​紧凑式垃圾回收器​​(compacting garbage collector)所做的事情。它不是 meticulous地管理空闲列表,而是周期性地识别所有活动对象——程序仍然可以访问的对象——并将它们移动到堆起始处的一个连续块中。其他所有空间都默认为空闲。

效果是显著的。一个曾严重碎片化的堆,或许碎片化评分为 F=34F = \frac{3}{4}F=43​,瞬间被改变。在像 ​​Cheney 算法​​这样的复制式回收器运行后,所有活动数据都被打包在一起。空闲空间变成一个单一、巨大的连续块。碎片化评分降至 F=0F = 0F=0。完美。这是对抗外部碎片化的终极武器,也是为什么用 Java、C# 或 Python 等托管语言编写的程序基本上能免疫于这个问题的主要原因。

当然,即使是这种“完美”的解决方案也有其自身的成本和复杂性。复制所有东西所需的“stop-the-world”暂停对于实时应用可能是个问题。而在现代系统中,内存整理可能发生在多个层面。用户空间分配器可能会整理其堆内的对象,而就在这之前,操作系统可能决定整理该堆本身所在的物理内存页。如果没有协调,某些数据字节可能会被移动两次——一次由应用程序移动,一次由操作系统移动。这种“双重移动”是浪费的工作。设计真正高效的系统需要跨越这些层次进行思考,协调这些强大的机制以和谐共事。我们发现,与碎片化的斗争是在多条战线上进行的,从最简单的布局决策到系统级垃圾回收的宏大舞蹈。

应用与跨学科联系

我们已经遍历了内存分配错综复杂的机制,观察到一个看似简单的任务——为我们的数据找到空间——是如何充满复杂性的。我们看到了空闲块是如何被跟踪、分割和合并的。但要真正领会这只野兽的本质,我们必须离开理论的无菌室, venturing into the wild。堆碎片化不仅仅是一个计算机科学的好奇点;它是机器中的幽灵,一种微妙而普遍的力量,塑造着数字世界的性能、可靠性,甚至是安全性。它的指纹无处不在,从算法的优雅到云的庞大基础设施。

软件的核心:数据结构与算法

在最基础的层面上,程序员选择表示数据的方式决定了将出现的内存模式。这种选择通常是与碎片化幽灵的直接博弈。

考虑一个常见的任务:缓存函数结果以避免重新计算。两种流行的技术是制表法(tabulation)和记忆化(memoization)。使用制表法,我们可能会预先分配一个单一、巨大、连续的数组来保存所有可能的结果。这就像为派对预订整个宴会厅;所有空间都预先保留,形成一个干净的块。当我们完成时,整个大厅被释放,不留下任何凌乱的空隙。相比之下,记忆化更像是随着客人的到来预订 jednotlivé 桌子。我们为每个计算出的结果分配一小块内存。如果客人(结果)后来被随机移除,我们就会剩下一堆分散的空桌子。虽然空座位的总数可能很多,但我们再也无法容纳一个希望坐在一起的大团体。这是外部碎片化的完美例证:许多小的、不连续的空闲块, collectively large but individually useless for a large request。

当我们表示像树这样的层次结构数据时,也会出现同样的权衡。我们可以使用数组,其中节点在数组中的位置隱含地定义了其父子关系。对于一个密集、完美平衡的树来说,这是非常高效的。但如果树是稀疏且不平衡的,就像一个有许多缺失分支的家族树呢?数组表示法变成了一片广阔、大部分为空的空间。我们为每个可能的节点都预留了内存,而不仅仅是存在的那些。这产生了一种类似于内部碎片化的浪费形式,一个“可能存在”的内存幽灵。另一种选择——将每个节点作为带有指向其子节点指针的“踏脚石”单独分配——避免了这个问题,但又把我们带回了记忆化的问题:大量的小分配,随着时间的推移,可能将堆碎片化成无法使用的自由空间尘埃。

即使是单一、长生命周期的数据结构也会造成内存“疤痕”。考虑一个繁忙的网络服务器中的哈希表,它随着流量的增减而增长和缩小。每次增长时,它都会为其桶分配一个新的、更大的数组,并释放旧的、较小的数组。每次缩小时,它都会做相反的操作。如果这些操作反复发生,堆可能会 littered with free-blocks of specific sizes corresponding to the old table capacities。如果另一个长生命周期的分配恰好落在正确的位置,它就可以充当一个“别针”,阻止两个相邻的空闲块合并,永久地将一块内存困在一个碎片化的状态中。

动力室:系统编程与硬件交互

从单个数据结构放大来看,我们发现碎片化在操作系统及其与硬件交互的层面上扮演着更加关键的角色。在这里,后果超越了单纯的低效率,可能会使整个进程停滞。

想象你有一个巨大的文件,它使你计算机的内存相形见绌。要对其进行排序,你必须使用一种名为“外部排序”(external sorting)的技术,即从磁盘读取数据块,在 RAM 中对其进行排序,然后写回。为了实现极快的速度,从磁盘传输数据需要在内存中有一个大的、连续的“着陆区”——一个缓冲区。如果堆被碎片化成上千个小块,即使总空闲内存是你需要的十倍,分配这个缓冲区也变得不可能。排序算法因缺乏可用的工作空间而 grinding to a halt。这揭示了一个深刻的真理:总空闲内存通常是一个虚荣的指标。最大可用块的大小才是许多高性能任务真正重要的。

这个问题并不仅限于单个进程。在现代操作系统中,多个进程通常通过共享一个公共内存区域来协作。可以把它想象成一个公共地,不同的程序可以在那里为彼此留下数据。如果每个程序在不同时间从这个共享池中分配和释放大小可变的段,它们 collectively act to fragment their shared resource。一个进程的小型、临时分配可能会分割一个大的空闲块,无意中阻止了另一个进程稍后进行一次大的、关键的分配。这就是内存地址中的公地悲剧。

当我们考虑到像图形处理单元(GPU)这样的专用硬件时,挑战就加剧了。GPU 有自己专用的高速内存(VRAM),这是由 OS 驱动程序管理的宝贵资源。加载视频游戏的纹理、模型和其他资源是一个动态分配问题。此外,GPU 硬件通常 imposing strict alignment requirements; a texture might need to start at a memory address that is a multiple of, say, 222 MiB. This can force the allocator to leave small, unusable gaps of "dead space" to satisfy alignment. Over the course of gameplay, as assets are loaded and discarded, these alignment gaps and the usual fragmentation can accumulate. Eventually, the driver may find itself unable to load a crucial texture for the next scene, not because VRAM is full, but because there is no single contiguous and correctly aligned block large enough. The result can be visual glitches, poor performance, or even a crash. And why not just "clean up" the memory by moving everything? This process, called compaction, is incredibly difficult and risky for VRAM because the GPU might be reading from that memory at that very moment via Direct Memory Access (DMA), and moving its data would pull the rug out from under it ([@problemid:3657420])。

前沿:高风险计算与安全

在某些领域,碎片化超越了性能问题,成为可靠性和安全性的问题。

在实时或嵌入式系统中——汽车制动系统、工厂机器人或医疗设备的大脑——可预测性是王道。一个操作不仅必须正确;它还必须在严格的截止日期前完成。一个通用的分配器,可能需要遍历一个长长的、碎片化的空闲块列表,其执行时间是不可预测且可能无界的。寻找内存块时几毫秒的延迟可能是灾难性的。为了对抗这一点,这类系统的设计者通常放弃通用分配器,转而采用更 rigid but deterministic strategies, such as fixed-size memory pools. By restricting allocations to a few predefined sizes, they can guarantee that an allocation is a constant-time operation. They willingly trade some memory efficiency (a request may be served from a pool block that is slightly too large, creating internal fragmentation) to purchase something far more valuable: certainty,。

现在,让我们将这个问题放大到仓库的大小。在云数据中心,物理服务器的 RAM 是一个堆,hypervisor 从中分配整个虚拟机(VM)。这是 grand scale 上的“堆分配”。如果 hypervisor 不小心,不断创建和销毁不同大小的 VM 将会使服务器的物理 RAM 碎片化,留下许多小到无法容纳新 VM 的空隙。数据中心中每一 GB 未使用的 RAM 都是金钱和能源的浪费。将 VM 放置视为一个复杂的装箱和堆分配问题,允许云提供商设计策略以最小化这种碎片化,更密集地打包 VM 并最大化其硬件的效用。

最后,我们来到了一个最令人惊讶和微妙的应用:碎片化作为一种武器。如果攻击者知道应用程序分配器使用的可预测算法(例如,首次适应),他们就可以发动拒绝服务攻击,而无需利用传统的漏洞。通过发送精心设计的分配和释放请求序列,他们可以 deliberately poison the heap。想象一个序列,反复分配两个并排的块,然后释放第一个,留下一个小孔。通过保持第二个块被分配,它充当了一堵墙,阻止孔洞与其邻居合并。通过重复此过程,攻击者可以有条不紊地将服务器的可用内存切成微小、无用的空闲块的细尘。服务器仍然报告总共有大量空闲内存,但它再也无法满足任何对 reasonably large block 的请求。合法操作开始失败,性能骤降,服务 grinding to a halt。服务器不是被流量淹没;它死于对其内存 공급的千刀万剐。

从算法的逻辑到数据中心的经济学,从汽车的安全到服务器的安全,堆碎片化是一个深刻而统一的原则。它是对动态性的一种基本税收。理解它就是为了在不完美的基础上构建可靠高效的系统,获得了深刻的洞察,这是一场持续不断的、巧妙的斗争。