try ai
科普
编辑
分享
反馈
  • 堆分配:动态内存管理指南

堆分配:动态内存管理指南

SciencePedia玻尔百科
核心要点
  • 内存管理涉及到一个关键的权衡:一边是快速、简单且大小受限的栈,另一边是灵活、强大但更慢、更复杂的堆。
  • 对于必须“逃逸”其创建作用域的数据(例如在闭包中),堆是必不可少的,因为其生命周期必须在函数返回后依然持续。
  • 堆管理的一个主要挑战是外部碎片,即空闲内存被分割成许多小的、无法使用的块,而各种分配器策略正是为了缓解这个问题。
  • 现代编译器使用逃逸分析来显著提高性能,它能识别出那些不会离开其函数作用域的对象,并将它们分配在栈上而不是堆上。

引言

在软件世界里,内存管理是一位无名英雄,一个基础层,它使得从简单的应用程序到庞大的云基础设施的一切成为可能。在这门学科的核心,存在一个根本性的选择:数据存储在哪里。虽然快速有序的栈处理着可预测的函数调用流,但现代编程的真正威力来自于动态创建和管理数据的能力。这需要一个远比栈灵活但同时也更复杂的内存空间,即所谓的堆。然而,驾驭堆的力量也带来了巨大的挑战,包括性能开销、内存泄漏以及碎片化这个棘手的问题。

本文深入探讨了堆分配的复杂世界,清晰地阐述了其核心概念和深远影响。在第一章 ​​原理与机制​​ 中,我们将剖析栈与堆之间的根本区别,探究为何某些数据必须“逃逸”到堆上,并审视内存分配器用以对抗混乱的精妙策略。随后,在 ​​应用与跨学科联系​​ 中,我们将看到这些原理不仅适用于我们的代码内部,还贯穿于不同领域,从操作系统设计、编译器优化到现实世界资源的管理,揭示出堆分配作为一种管理稀缺性和不确定性的通用模式。

原理与机制

想象一下,你计算机的内存是一个巨大的工作空间。为了完成任务,你需要地方来放置工具和材料。系统给了你两个主要区域:一个是个人的、 meticulously organized 的工作台,以及一个连接到巨大共享仓库的通道。这个工作台就是​​栈​​,而仓库就是​​堆​​。理解这两者之间根深蒂固的差异,以及它们之间美妙的协作,是理解现代软件如何真正工作的关键。

内存的两个世界:纪律严明的栈与桀骜不驯的堆

​​栈​​是简洁与效率的奇迹。当你的程序调用一个函数时,一块新的内存区域,称为​​栈帧​​,会被放置在栈顶。这个栈帧保存着函数的局部变量、参数以及函数完成时要返回的地址。当函数结束时,它的栈帧就被简单地弹出,瞬间消失。这个过程快如闪电,仅通过上下移动一个指针来管理。这种严格的后进先出(LIFO)纪律是栈最大的优点。它非常适合构成计算主干的可预测的、临时的数据。

但这种刚性也是它的局限。栈是有限的,并且通常很小。如果一个函数需要创建一个巨大的数组,而其大小直到程序运行时才知道,那该怎么办?把它放在栈上是一场赌博。在像操作系统内核这样的安全关键环境中,这种赌博是不可接受的。​​栈溢出​​是一种灾难性的失败。内核工程师必须进行仔细的最坏情况分析,计算嵌套函数调用和系统中断可能产生的最大栈使用量,以定义一个硬性的安全阈值。任何超过此阈值的分配都必须去往别处,以防灾难发生。

这个“别处”就是​​堆​​。与栈整齐堆叠的栈帧不同,堆是一片广阔、非结构化的内存区域,可用于满足更复杂的需求。当你需要一块内存时,你向系统的“内存管理器”——即堆分配器——请求。你可以请求任何大小,并且可以持有它任意長的时间,远在请求它的函数消失之后。这种灵活性是堆的超能力。它使得真正动态的数据结构成为可能,从文本文档到视频游戏中庞大的对象网络。但这种能力伴随着高昂的代价:复杂性和开销。栈是自动管理的,而堆则需要一个复杂的“图书管理员”——分配器——来跟踪每一个借出和归还的块。这个过程天生就比较慢,并且为一系列新的、有趣的问题打开了大门。

栈与堆之间的选择并不总是关乎安全;它也是一个微妙的性能权衡。一次大的栈分配可能会迫使操作系统一次性准备许多内存页,导致初始页错误集中爆发。而一次堆分配,则可能被更懒惰地处理,仅在内存页被触及时才产生页错误。最优选择可能取决于操作系统虚拟内存系统的复杂细节,甚至程序本身的概率行为。

生命周期问题:为何某些数据必须“逃逸”出栈

堆存在的最深刻原因之一,与其说与数据的大小有关,不如说与其​​生命周期​​有关。变量的生命周期是其可被有效访问的时间段。对于一个栈变量,其生命周期与其所在函数的执行不可分割地绑定在一起。当函数返回时,该变量就被销毁了。

但如果一个函数需要创建某个生命周期超越其自身的东西呢?这是现代编程的基石。考虑一个创建并返回另一个函数的函数,这个概念被称为​​闭包​​。

想象一个类 Algol 的函数 MakeAccum(base),它创建了一个嵌套函数 Step(delta)。Step 将其输入 delta 加到一个在 MakeAccum 中初始化的变量 acc 上。然后 MakeAccum 函数返回 Step。

loading

这里就存在一个悖论。我们调用 MakeAccum,其包含变量 acc 的栈帧被创建。它返回 myAccumulator 函数后,其栈帧被销毁。但 myAccumulator 为了正常工作,仍然需要访问 acc!如果 acc 存在于栈上,那么 myAccumulator 内部对它的引用现在会变成一个“悬垂指针”,指向垃圾内存。这就是经典的“向上函数参数问题”(upward funarg problem)。

对于拥有此类特性的语言,编译器实现的优雅解决方案是识别出 acc 必须“逃逸”出 MakeAccum 的作用域。然后编译器会将包含 acc 的环境分配在持久的堆上,而不是临时的栈上。闭包 myAccumulator 于是携带一个指向这个堆分配状态的安全指针,只要 myAccumulator 本身是可达的,这个状态就会一直存在。同样的基本原理也适用于更奇特的结构,如一等续体(first-class continuations),它实际上是将“整个计算的其余部分”捕获到一个闭包中,如果这个闭包逃逸了,它的环境也必须保存在堆上。

图书管理员的困境:碎片化的混乱

一旦我们决定使用堆,就必须面对其固有的混乱。我们的分配器,这位勤勉的图书管理员,面临着一项艰巨的任务。这不仅仅是找到空闲空间;更重要的是要随着时间的推移保持空间的可用性。这就引出了堆管理的两个噩梦:碎片化和泄漏。

当内存被分配后,即使程序不再使用它,也从未被释放,这时就会发生​​内存泄漏​​。这可能由简单的编程错误引起。例如,一个处理数据流的解析器可能会为它遇到的每个新元素分配一个上下文对象,并打算在元素关闭时释放它。如果数据流被突然截断,关闭事件永远不会到达,相应的上下文对象也永远不会被释放。它们变成了孤儿——程序无法访问,但仍在消耗内存,导致系统资源缓慢而无情地流失。

更隐蔽的是​​碎片化​​。想象一个简单的 first-fit 分配器,它从内存的起始位置扫描,并使用它找到的第一个足够大的空闲块。现在,考虑一个对抗性的程序,它交替分配小块(a)和大块(b),直到堆被填满:[a][b][a][b]...。然后,它释放所有的小块 a。内存映射变成了 [free][b][free][b]...。现在有大量的总内存是空闲的,但它被粉碎成许多小的、不连续的片段。你现在能做的最大单次分配的大小是 a,即使空闲空间的总和是其数百倍。这就是​​外部碎片​​:空间是空闲的,但它没有用。

这种情况可能以微妙的方式发生。一个程序可能分配一个巨大的 512 MiB 块,然后用数百个微小的 2 MiB 块填充其后的空间。稍后,这些小块以棋盘格模式被释放,最后,最初的巨大块也被释放。你最终得到一个 512 MiB 的空闲块,但它被困在堆的开头,被一个微小的已分配块隔离开。其余的空闲内存是一片由小的 2 MiB 岛屿组成的无用群岛。堆已经变成了一片无法使用的空闲空间荒地。

维持秩序的策略:分配器设计一瞥

我们的图书管理员如何对抗这种混乱?几十年来,计算机科学家们设计出了各种巧妙的策略,每种策略都有其自身的权衡。内存分配器的设计是在速度、内存浪费和确定性之间取得平衡的大师课。在实时操作系统(RTOS)中,这些权衡尤为关键,因为在RTOS中,一次分配可能需要在严格的时间限制内(例如101010微秒)完成,以防止系统故障。

一个简单的方法是​​空闲链表​​(free list),这是一个包含所有空闲块的链表。它很容易理解,但寻找一个合适的块可能需要扫描一个长长的链表,使其性能不可预测——这对RTOS来说是致命的。

一个更优雅的方法是​​伙伴系统​​(buddy system)。堆最初是一个大小为2的幂的单个块。要分配时,一个块被递归地对半分割,直到找到一个大小类别合适的“伙伴”。其神奇之处在于,找到一个块,以及至关重要的,在释放时将其与它的空闲伙伴合并,都非常快速且可预测。缺点呢?​​内部碎片​​。一个65字节的请求可能会由一个128字节的块来满足,浪费了将近一半的已分配空间。

现代系统通常使用混合的、高度优化的方法。​​分离适配​​(Segregated fit)分配器为不同的大小类别维护着几十个独立的空闲链表(例如,一个用于16字节块的链表,一个用于24字节块的链表,等等)。对特定大小的请求可以通过直接访问相应的箱子(bin)在几乎恒定的时间内得到满足。这大大减少了内部碎片并提供了极高的速度。像 ​​TLSF (两级分离适配)​​ 这样的算法使用巧妙的位图索引在恒定时间内找到最近的非空箱子,从而实现了即使是最苛刻的实时系统所要求的有界延迟和低碎片率。对于极其常见的对象大小,​​slab分配器​​可以像一个高度专业化的缓存一样工作,保持一个预先初始化的对象池以供即时使用。这些算法是系统性能的无名英雄。

伟大的逃逸:编译器如何“智取”堆

我们已经看到,堆既是必要的,也是危险的。分配、释放和垃圾回收的性能成本可能相当可观。因此,最强大的优化是完全避免使用堆。这是“圣杯”,而现代的即时(JIT)和预先(AOT)编译器通常可以通过一种名为​​逃逸分析​​(Escape Analysis)的卓越技术来实现它。

编译器扮演侦探的角色,分析程序的数据流。它为每个创建的对象提出了一个简单的问题:对该对象的引用是否会“逃逸”出创建它的函数的作用域?逃逸可能通过三种方式发生:引用被函数返回,它被存储在全局变量或另一个堆对象中,或者它被传递给另一个行为未知的函数。

如果编译器能够证明一个对象不会逃逸——即它在一个函数的范围内出生、生存和死亡——那么它根本不需要放在堆上!编译器可以执行一种称为​​标量替换​​(scalar replacement)的优化,将对象分解为其组成字段,并将它们作为简单的局部变量存储在快速、高效的栈上。堆分配被完全省略了。

考虑一个函数,它创建一个小的 Pair 对象,对其字段求和,然后返回总和。Pair 对象本身从未离开该函数。逃逸分析证明了它的局部局限性,编译器可以用简单的寄存器操作替换堆分配,从而产生零分配开销。

这种分析甚至可以是过程间的(interprocedural)。如果一个对象被传递给另一个函数,简单的分析将不得不假设它逃逸了。但是,如果编译器拥有关于被调用函数的信息——也许是通过一个声明其“非捕获”的注解,或者通过将该函数体直接​​内联​​(inlining)到调用点——它就可以看到该引用没有被存储或返回,从而仍然可以证明其未逃逸。

这不仅仅是一个学术上的好奇心;它对现实世界的性能有着巨大的影响。在带有垃圾回收(GC)的托管语言中,每次堆分配都会增加“GC压力”。当分配了足够多的内存后,GC必须运行,暂停应用程序以查找并回收死对象。通过将分配从堆移到栈,逃逸分析直接减少了GC的工作负载。

在一个现实场景中,启用内联使得逃逸分析能够证明一个热点调用位置(每秒600万次调用)的对象可以分配在栈上。这个简单的改变可以使你的应用程序因垃圾回收而暂停的总时间每秒减少超过11毫秒。在另一个案例中,如果一个程序的工作负载包含75%的短生命周期对象,而逃逸分析能够成功地将其中60%移到栈上,那么堆分配的总数——以及昂贵的GC周期的频率——将惊人地下降45%(0.75×0.60=0.450.75 \times 0.60 = 0.450.75×0.60=0.45)。

这就是系统设计内在的美和统一性。一个来自编译器理论的深刻概念——证明关于程序数据流的事实——直接转化为一个更快、更流畅的应用程序。从简单的栈到复杂的堆,再回到栈的旅程,揭示了在正确性、灵活性和性能之间持续存在的、创造性的张力,这是一场由语言设计者、编译器作者和操作系统工程师共同编排的舞蹈,使我们的软件成为可能。

应用与跨学科联系

在经历了堆分配错综复杂的机制——malloc和free的基本规则、指针的幽灵之舞以及碎片化挥之不去的幽灵——之后,我们可能会倾向于将这些知识归档为纯粹的实现细节,只留给硬核系统程序员去关心。但这就像学会了国际象棋的规则,却从未欣赏过特级大师的棋局之美。堆管理的原则不仅仅是在计算机中移动字节;它们是一种资源管理的基本模式,在无数的科学和工程领域中回响。这是一门将有限的整体分割以满足一连串不可预测需求的艺术。一旦你学会看透它,你会发现它无处不在。

操作系统与云:连续空间的守护者

堆分配最直接、最宏大的应用当然是在操作系统本身。操作系统是终极的资源管理器,而内存是其最宝贵的连续领地。想一想现代的云 hypervisor,这种软件运行着支撑互联网的虚拟机(VM)。当客户请求一台具有一定量 RAM 的新 VM 时,hypervisor 的行为就像一个堆分配器。它的总物理 RAM 就是“堆”,而对 VM 的请求就是从中分配一个大的、连续块的请求。如果 hypervisor 的 RAM 变得碎片化——在运行的 VM 之间布满了小的、未使用的间隙——它可能无法找到一个足够大的单一连续块来容纳新的 VM,即使可用内存总量是足够的。这是巨型规模的外部碎片,它对云提供商具有真实的财务影响。当一个 VM 关闭时,它的内存被“释放”,一个智能的 hypervisor 会将这个新释放的块与任何相邻的空闲区域合并,为下一个客户创造一个更大、更有用的空间。

同样的戏剧也在你的程序内部以小得多的规模上演。考虑一个常见的任务:读取目录内容。许多标准库函数,如类 Unix 系统中的 scandir 调用,为此提供了一种便捷的方式。你调用函数,它返回一个整潔的数组,包含所有符合你条件的目录条目。但是这个数组以及其中所有文件名的内存从哪里来?当然是堆。对于每个匹配的文件,该函数都会进行一次小的分配。如果你扫描一个有数千个匹配项的目录,你就在不知不觉中触发了数千次堆分配,可能消耗数兆字节的内存。另一种方法,使用像 readdir 这样的函数,以流式方式一次处理一个文件。无论目录大小如何,它都使用少量固定的内存,不执行任何按条目的堆分配。这两种函数之间的选择是一个直接的权衡:一个以可能巨大且尖峰的堆使用为代价提供便利,而另一个则要求程序员付出更多的手动工作以换取内存效率。这是一个塑造系统性能的日常决策的缩影。

操作系统的生命是对这些请求的持续、动态的模拟。任务诞生,请求内存,运行一段时间,然后消亡,将其内存归还给系统。模拟这个过程揭示了堆混乱、不断变化的景象,以及为下一个排队的请求保持其整洁高效所面临的深远挑战。

编译器:对抗内存分配的隐形盟友

如果管理堆是如此充满危险,那么如果我们能简单地避免它,岂不是妙哉?这就是我们沉默的伙伴——编译器——登场的地方。现代编译器非常聪明,它们最强大的技巧之一就是​​逃逸分析​​。编译器仔细审查我们的代码,并为我们创建的每个新对象提出一个简单的问题:“这个对象的存在能否逃逸出当前的函数调用?”

如果一个对象在单个函数执行的范围内被创建、使用并变得不可达,它的生命周期就被清晰地界定。编译器可以证明它没有逃逸,并执行一个漂亮的优化:它将对象分配在栈上而不是堆上。栈分配速度极快——只需移动一个指针——并且释放是免费的,在函数返回时自动发生。

但“逃逸”意味着什么呢?想象一个移动应用的函数创建了一个新按钮。如果该按钮立即被添加到应用的主用户界面中,而主用户界面是一个长生命周期的全局结构,那么该按钮的引用就已经逃逸了。它需要在创建它的函数返回后很长时间内继续存在。编译器看到这一点,别无选择,只能生成将按钮分配在堆上的代码。

并发为这个问题增加了另一个维度。如果一个函数创建一个对象并将其传递给一个可能比该函数生命周期更长的后台线程,那么该对象就逃逸了。将其放在栈上将是灾难性的;后台线程将持有一个指向已释放内存的悬垂指针。同样,编译器必须保守地选择堆分配。有趣的是,如果编译器能证明函数在返回前等待后台线程完成(例如,通过调用 thread.join()),它也许能推断出该对象的生命周期实际上是受限的,并安全地使用栈。

这种智能的顶峰体现在现代分布式系统中。一个函数可能会创建一个数据传输对象(DTO),并根据某些条件,要么用它执行一个简短的本地日志记录任务,要么将其序列化并通过网络发送到另一个服务。在后一种情况下,对象的数据必须持久存在,因此它实际上逃逸了。一个足够先进的编译器可以分析这些不同的控制流路径。它可以生成专门的代码,对于仅限本地的路径,在栈上执行“虚拟”分配(甚至可能将对象分解到寄存器中,这种技术称为标量替换),而仅为对象通过网络发送的路径生成真正的堆分配。这就是编译器作为我们出色的、即时的资源管理器在为我们服务。

为极限而工程:性能与可预测性

尽管编译器尽其所能,但在高性能计算和专业领域,工程师必须亲自动手。在这里,堆分配的成本不仅是速度问题,更是原则问题。

考虑解析一个配置文件,其中可能包含不同类型的值:字符串、整数和布尔值。一种天真的方法可能将所有内容都转换为字符串,但这效率低下。它迫使程序每次需要整数值时都要重新解析字符串,并且通过为每个值进行堆分配而增加了内存使用量。一种远为优越的设计,在高性能库中很常见,是使用标签联合体(tagged union)。这是一种巧妙的结构,可以在同一内存位置容纳任何一种可能的类型,并带有一个小“标签”来指示当前类型。对于像整数和布尔值这样的小数据类型,根本不需要堆分配。对于字符串,一种称为“小字符串优化”的技术甚至可以将短字符串直接存储在结构内部,再次避免了对堆分配器的调用。这种细致的、关注分配的设计最大限度地减少了碎片,改善了缓存局部性,并带来了巨大的性能提升。

然而,在某些领域,即使是快速分配也不够好。在硬实时系统中——比如飞机的飞行控制器或医疗设备的安全监视器——主要关注的不是平均速度,而是​​可预测性​​。通用的堆分配器无法提供这样的保证。在最坏的情况下,一个内存请求可能会触发对碎片化空闲链表的复杂搜索,甚至是一次垃圾回收周期,导致不可接受的、不可预测的长时间暂停。对于这些系统,操作的延迟必须有一个可证明的、恒定的上限。

解决方案?在关键代码路径上避免使用通用的堆分配器。一种常见的策略是在初始化期间预先分配系统可能需要的所有对象的固定大小池。当一个函数需要一个新对象时,它只是从这个“空闲列表”中取一个。当它用完后,再将其归还到池中。这些操作简单、快如闪电,最重要的是,花费恒定且可预测的时间。这是一个深刻的教训:当保证至关重要时,你就构建一个规则完全由你控制的专业系统。

一个统一的原则:分配世界资源

最后也是最美的领悟是,堆分配不仅仅关乎计算机内存。它是一个普遍问题的抽象解决方案。

想想用于5G无线通信的无线电频谱。可用的频率形成一个连续的频段——一种一维资源,就像内存一样。当一个移动运营商需要开通一个具有特定带宽的新数据信道时,它就是在发出一个分配请求。频谱监管机构的系统,就像一个分配器,必须找到一个足够大的空闲频率块来满足请求。不同的分配策略,如“最佳适配”(best-fit,找到最紧凑的槽以最小化剩余浪费),对于如何有效利用有限的频谱有着实际的影响。当信道关闭时,频率块被“释放”,并与任何相邻的空闲频段合并,使其可供未来用户使用。

这个类比甚至可以更加具体。想象你是大型货船的装卸主管,船舱就是你的堆。各种尺寸的集装箱到达,你必须放置它们。你可能会选择一种“最差适配”(worst-fit)策略:对于一个小集装箱,你把它放在你最大的空闲区域。为什么?这似乎违反直觉,但它保留了较小的间隙,为其他小集装箱保留了它们,同时为未来可能需要装载的、意想不到的巨大货物留下了尽可能大的连续空间。分配策略的选择是一种策略的选择,是对未来请求性质的一种押注。

从我们计算机中的硅片,到我们船只的钢铁,再到我们周围的电波,挑战都是一样的:如何在面对不确定的未来时管理有限的、连续的资源。堆分配的原则提供了一套强大而优雅的工具和策略来解决这个根本问题,揭示了工程世界逻辑中深刻而令人满意的统一性。

procedure MakeAccum(base); var acc; acc := base; procedure Step(delta); acc := acc + delta; return acc; end; return Step; // Return the nested procedure end; // Somewhere else in the code... let myAccumulator = MakeAccum(10); myAccumulator(5); // Should return 15 myAccumulator(2); // Should return 17