自动内存管理
为了克服手动内存管理的问题并为程序员提供更愉快的处理方式,已经提出了不同的自动内存管理方法。 有趣的是,与第二古老的高级编程语言 LISP 一样古老,大约在 1958 年提出(仅比 FORTRAN 晚几年),在该领域可以提供很多东西。 由于在很大程度上基于列表处理的主要函数式语言中 - 手动内存管理会非常不舒服。 函数式编程范式将程序视为对组合函数的评估,并强烈避免数据修改(突变)和副作用。 分配和释放内存是高度可变的并且有明显的副作用。 在函数式代码中以这种方式处理内存会使它充满命令式气味,而 LISP 被设计为一种高度声明性的语言。 正如 LISP 语言的创造者所说,“必须明确删除列表会使一切变得非常丑陋。” 因此,必须开发更复杂的东西。 LISP 的第一个版本有一个内置的 eralist(擦除列表)功能,但在引入自动内存管理后它被删除了。
总的来说,LISP 是一种非常具有创新性的语言,它的设计帮助发明了许多重要的计算机科学思想,自动内存管理就是其中之一。 事实上,人工智能的联合创始人之一和 LISP 的发明者约翰麦卡锡也是第一个垃圾收集算法之父。 许多当时的想法仍然有效,并在今天的语言中使用。 可以肯定地说,自动内存管理是在 LISP 中诞生的。 McCarthy 于 1958 年撰写的第一篇论文介绍了 Mark and Sweep 算法,我们将在后面的章节中深入研究该算法,因为它仍在 .NET 环境和许多其他地方使用。
LISP,由于其表达力和简洁性,以清单 1-6 所示的简单形式表示我们的示例程序
清单 1-6。 显示自动内存管理的示例 LISP 程序
(defun printReport(data)
(write-line (format nil "Report: ~a" data))
)
(prog
((ptr 25))
(printReport ptr)
)
多亏了自动内存管理,所有的代码混乱都消失了,我们可以清楚地看到程序业务目标的高级描述——打印“一份报告”。
John McCarthy 在关于 LISP 设计的论文“符号表达式的递归函数及其机器计算,第一部分”中有一个有趣的轶事。 他简洁地描述了这种机制,但将其简单命名为“回收”。 后来,他对这部分做了注释:
我们已经将这个过程称为“垃圾收集”,但我想我不敢在论文中使用它 - 否则电子语法研究实验室的女士们不会让我
除了它的名字,这个想法已经存在并准备实施。 目前,自动内存管理机制和垃圾收集名称可以互换使用。 我们可以将其定义为一种机制,它可以免除程序员手动管理内存的责任,以便对象一旦创建,就可以在不再需要时自动销毁(以及回收之后的内存)。
我想在本书中传达的主要信息之一是,即使内存管理是全自动的,它也可能会导致问题。 作为一个小小的确认,值得引用一个关于第一个 LISP 垃圾收集实现的有趣事实。 正如麦卡锡在《编程语言史 I》一书中回忆的那样,在麻省理工学院工业联络研讨会上首次公开演示 LISP 时,由于疏忽,Flexowriter(当时的电动打字机)开始打印大量页面 错误消息以以下内容开头:
THE GARBAGE COLLECTOR HAS BEEN CALLED. SOME INTERESTING STATISTICS ARE AS FOLLOWS (已调用垃圾收集器。 一些有趣的统计数据如下)
因此,在观众大笑的情况下,演示不得不取消。 没有人知道这是由于垃圾收集器的滥用造成的,只有 John 自己知道。 虽然这是人为错误而不是算法错误,但我们仍然可以说垃圾收集器从一开始就制造麻烦
Allocator, Mutator, and Collector (分配器、修改器和收集器)
本章我们要熟悉的修改器等概念是自动内存管理学术研究中的重要术语。 由于定义清晰,我们可以在以后的学术和技术论文中毫不含糊地区分它们。 例如,可以说特定算法的“Mutator 开销”。 在考虑各种垃圾收集设计时,经常会讨论 Collector 对 Mutator 的影响,反之亦然。 让我们仔细看看这些术语。
The Mutator(修改器)
在与内存管理相关的几个基本概念中,最基本同时也是非常重要的概念是一个称为 Mutator 的抽象概念。 在最简单的版本中,我们可以将 Mutator 定义为负责执行应用程序代码的实体。 它的名字来源于 Mutator 改变(改变)内存的状态——对象被分配或修改,它们之间的引用被改变。 换句话说,Mutator 是应用程序中与内存有关的所有更改的驱动器。 这个名字是 Edger Dijkstra 于 1978 年在论文“On-the-Fly Garbage Collection: An Exercise in Cooperation”中创造的(在同一篇论文中),我们可以在其中找到关于这个主题的详细阐述。 一个有趣的事实是,Dijkstra 在这篇相当古老的论文中提出的命题仍在使用,例如,在 2015 年被 Go 语言使用,并取得了良好的效果。
我喜欢 Mutator 抽象,因为它在特定框架或运行时内提供了一个漂亮而干净的事物分类。 我们可以将 Mutator 定义为任何有可能修改内存的东西,要么通过修改现有对象,要么通过创建新对象。 虽然并不严格,但另外,我们可以将其扩展到所有可以读取内存的地方(因为读取是程序执行的关键操作)。 这使我们得出一个重要的观察结果。 为了完全可操作,Mutator 需要为正在运行的应用程序提供三种操作:
- New(amount) - 分配给定数量的内存,然后将由新创建的对象使用。 请注意,在这个抽象级别,我们不考虑对象的类型信息,这些信息可能在运行时可用,也可能不可用。 我们只是提供需要分配的内存大小。
- Write(address, value) - 在给定地址下写入指定值。 在这里,我们还抽象了我们是否正在考虑对象字段(在面向对象编程中)、全局变量或任何其他类型的数据组织。
- Read(address) - 从指定地址读取一个值。
在最简单的世界中,不存在任何垃圾收集算法,这三个操作的实现很简单(在清单 1-7 中用类似 C 的伪代码编写)
清单 1-7。 没有自动内存管理的三种主要 Mutator 方法实现
Mutator.New(amount)
{
return Allocator.Allocate(amount);
}
Mutator.Write(address, value)
{
*address = value;
}
Mutator.Read(address) : value
{
return *address;
}
但在自动化垃圾回收的世界里,这三个操作是 Mutator 配合垃圾回收器(Collector)和分配机制(Allocator)的地方。 这种合作看起来如何以及它在多大程度上扰乱了上述实现的简单性是最重要的设计问题之一。 我们将在本书中遇到的最常见的增强是添加所谓的屏障——它要么是读屏障,要么是写屏障。 屏障是一种在特定操作之前或之后增加附加操作的方法。 屏障让我们与垃圾收集器机制同步(直接或间接,同步或异步),以通知程序的执行和内存使用情况。 清单 1-7 中的三个方法是每个垃圾收集器可能希望插入的注入点。在描述不同的垃圾收集算法时,我们将在后续章节中返回一些最常见的可能变体。
在开发人员的日常现实中,最常见的 Mutator 抽象实现是众所周知的线程。 它完美地符合定义——它是一个运行代码并改变对象和引用对象之间的图形的单一单元。 这对我们来说非常直观,因为绝大多数最流行的运行时都使用这个实现。 在许多其他功能中,线程通过一些附加层与操作系统通信以允许操作 New、Write 和 Read。
就操作系统线程而言,Mutator 不必作为线程来实现。 流行的例子可以是带有进程的 Erlang 生态系统——它们作为超轻量级的协程被管理在运行时本身中。 它们可以被视为所谓的“绿色线程”,但在 Erlang VM 的术语中,最好将它们称为“绿色进程”,因为运行时强制执行的分离比类线程实体之间的分离要强得多。 这意味着它们是在运行时级别而非操作系统级别管理的实体。 Mutator 的另一个常见实现可以基于所谓的纤程,即在 Linux 和 Windows 中实现的轻量级执行单元。
The Allocator(分配器)
Mutator 必须能够使用 New 操作,我们在上一点中讨论过。 当谈到这些方法的内部结构时,迟早必须提到另一个非常重要的概念——分配器。 简单来说,Allocator 是一个负责管理动态内存分配和释放的实体。 正如我们之前提到的,在像 ALGOL 或 FORTRAN 这样的古老语言中,没有分配器,因为根本没有动态内存分配。
分配器必须提供两个主要操作:
- Allocator.Allocate(amount) - 分配指定数量的内存。 如果类型信息可用于 Allocator,则可以通过能够为特定类型的对象分配内存的方法明显扩展这一点。 正如我们所见,这是由 Mutator.New 操作内部使用的。
- Allocator.Deallocate(address) - 释放给定地址下的内存以供将来分配使用。 请注意,在自动内存管理的情况下,此方法是内部方法,不会暴露给 Mutator(因此,没有用户代码可以显式调用它)。
这个想法可能看起来非常简单,更不用说 - 微不足道了。 但正如我们将看到的,它并不像人们预期的那么容易。 分配器设计有很多不同的方面。 事实上,一如既往,一切都是关于权衡,主要是在性能、实现复杂性(直接导致可维护性)和其他方面。 我们将深入研究两种最流行的分配器:顺序分配器和自由列表分配器。 但由于它是一个实现细节,因此最好在第 4 章的 .NET 特定上下文中了解它们。
The Collector (收集器)
虽然我们将 Mutator 定义为负责执行应用程序代码的实体,但我们可以类似地将 Collector 定义为运行垃圾收集(自动内存回收)代码的实体。 换句话说,我们可以将 Collector 视为一段软件(代码)或执行它的线程,或两者兼而有之。 这取决于上下文。
Collector 如何知道哪些对象不再需要并且可以被释放? 这是一个不可能的问题,因为它实际上应该猜测未来——某个特定的对象是否会再被使用? 这取决于将要执行的代码,这可能进一步取决于独立因素,例如用户操作、外部数据等。 一个理想的 Collector 会知道对象的活跃度——活跃的对象是那些将被需要的对象。 相反 - 死(或垃圾)对象不会被使用并且可以被销毁。 显然,因此Collector通常被称为Garbage Collector或简称GC。
Mutator、Allocator 和 Collector 合作会产生一个有趣的结果。 请再次注意,由于没有公开的公共 Allocator.Deallocate 方法,Mutator 不可能显式释放获得的内存。 Mutators 只能要求分配越来越多的内存,因为它会有无限的来源。 这确实意味着垃圾收集机制实际上是对具有无限内存的计算机的模拟。 该模拟如何工作以及它的效率如何成为实现细节。
人们可以想到一种特殊的垃圾收集器,它根本不释放分配的内存。 它被称为 Null 或零垃圾收集器。 它只能在具有无限内存的计算机上正常工作,不幸的是,这还不存在。 但是 Null 垃圾收集器并非没有任何实际用途。 例如,它可以用于可以接受无限内存增长的非常短寿命的程序。 也许它们会在无服务器、运行时间短的单一功能的世界中变得越来越流行。 第 15 章介绍了此类 .NET 零垃圾收集器的示例草案。
因为知道一个对象的活跃度是不可能的,Collector 是基于对象的一个不太严格的属性——它是否可以被任何 Mutator 访问。 对象的可达性意味着对象之间存在一系列引用(从任何 Mutator 的可访问内存开始),最终指向该对象(见图 1-12)。 可达性显然并不意味着对象的活跃度,但它是我们可以拥有的最佳近似值。 如果一个对象不能从任何 Mutator 到达,它就不能再使用,所以它是死的(垃圾)并且可以安全回收。 相反的显然不是事实。 可达对象可以永远保持可达(由一些复杂的引用图保存),但由于执行条件可能永远不会被访问,因此它是死的。 事实上,大多数托管内存泄漏存在于活性和可达性之间。
图 1-12。 可达性——对象 C 和 F 是不可到达的,因为没有从根(Mutator 的位置)到它们的路径
Mutator 在可达性方面的起点称为根。 它们究竟是什么取决于特定的 Mutator 实现。 但在大多数常见情况下,Mutator 只是一个线程(由基于操作系统的本机线程表示),根可以是:
- 局部变量和子程序参数 - 放置在堆栈上或存储在寄存器中。
- 静态分配的对象(例如,全局变量)——放置在堆上。
- 存储在 Collector 自身内部的其他内部数据结构。
了解了三个主要构建块 - Mutator、Allocator 和 Collector - 我们现在可以继续熟悉大量不同的自动内存管理方法。 虽然提供一个全面的列表并详细描述所有这些内容很诱人,但本书可以涵盖的内容要多得多。 相反,我们将了解我们在当今语言中可以遇到的一些主要的、最流行的方法。
引用计数
两种最流行的自动内存管理方法之一称为引用计数。 它背后的想法非常简单。 它基于计算对对象的引用数。 每个对象都有自己的引用计数器。 当一个对象被分配给一个变量或一个字段时 - 对它的引用数量正在增加。 同时,先前指示该变量的对象的引用计数器减少。
引用计数方法中对象的活跃度是通过引用引用对象的对象数量来跟踪的。 如果计数器降为零,则没有人在引用一个对象,因此它可以被释放。 但是,如果计数器没有降到零怎么办? 这并没有说明对象的活跃度——它只是说明有人在保留对它的引用,而不是它将使用它。 因此,引用计数是另一种不太严格的猜测对象活动性的方法。
回到我们清单 1-7 中的简单 Mutator 示例,在引用计数的情况下,它可以如清单 1-8 所示描述。
清单 1-8。 描述简单引用计数算法的伪代码
Mutator.New(amount)
{
obj = Allocator.Allocate(amount);
obj.counter = 0;
return obj;
}
Mutator.Write(address, value)
{
if (address != NULL)
ReferenceCountingCollector.DecreaseCounter(address);
*address = value;
if (value != NULL)
value.counter++;
}
ReferenceCountingCollector.DecreaseCounter(address)
{
*address.counter--;
if (*address.counter == 0)
Allocator.Deallocate(address)
}
图 1-13 和清单 1-9 中的一个简单程序说明了引用计数行为。 根据 Mutator 的方法重写了三行简单的代码,以显示引用如何变化。
清单 1-9。 说明引用计数的示例伪代码
o1 = new SomeObject();
o2 = new SomeObject();
o2 = o1;
// 变成:
addr1 = Mutator.New(SizeOf(SomeObject)) // addr1.counter = 0
Mutator.Write(&o1, addr1) // addr1.counter = 1
addr2 = Mutator.New(SizeOf(SomeObject)) // addr2.counter = 0
Mutator.Write(&o2, addr2) // addr2.counter = 1
Mutator.Write(&o2, &o1) // addr1.counter = 0; addr2.counter = 2
图 1-13。 清单 1-8 的引用计数说明
正如我们在清单 1-9 中看到的,Mutator.Write 操作增加了很大的开销。 它必须检查和修改计数器数据,并在计数器降为零时采取释放操作。 这在多线程(多个 Mutator 并行工作)环境中变得更加复杂。 在这种情况下,这些操作应该是线程安全的,因此同步会增加它自己的额外开销。 突变体。 写入是一个非常常见的操作(由任何赋值引入),因此其中的开销会为整个程序执行带来显着的开销。 此外,从实现的角度来看,将对象的计数器存储在何处并不明显。 这可以是专用空间或某种尽可能靠近对象本身的标题。 在这两种情况下,它都不会改变这样一个事实,即每次分配都会产生额外的内存写入,这是非常不受欢迎的。 这也可能导致 CPU 缓存使用效率低下,但这是我们将在下一章中了解更多的主题。
如果我们回到前面提到的可达性属性,可以说引用计数是通过局部引用来近似活跃度,而不是跟踪引用对象图的全局状态。 特别是,在没有任何额外改进的情况下,它可能会被循环引用误认为。 在双链表等流行的数据结构中可以找到这种结构(见图 1-14)。 在这种情况下,引用计数器永远不会降为零,因为具有 value1 的数据结构和具有 value2 的数据结构相互指向对方。
图 1-14。 引用计数循环引用问题
然而,创建循环引用在语言层面上可能会变得困难,这是一个双赢的局面。 在这种情况下,可以使用引用计数算法而不必太担心由此问题导致的内存泄漏。
引用计数流行的一个非常大的优势和来源是它不需要任何运行时支持这一事实。 它可以以外部库的形式作为某些特定类型的附加机制来实现。 这意味着我们可以保留原始的 Mutator.New 和 Mutator.Write 完好无损,只引入此类逻辑的更高级别对应物,例如具有适当重载的运算符和构造函数的类。 例如,最流行的 C++ 实现就是这种情况。
引入了所谓的智能指针(也称为智能指针),它以更复杂的方式管理它们所指向的对象的生命周期。 从实现的角度来看,C++ 中的智能指针实际上只是通过适当的运算符重载表现得像普通指针的模板类。 对于 C++,我们可以使用两种:
- unique_ptr 实现唯一的所有权语义(例如指针是一个对象的唯一所有者,一旦 unique_ptr 超出范围或另一个对象被分配给它就会被销毁)
- 实现引用计数语义的shared_ptr
继续使用清单 1-5 中的示例代码,使用智能指针,我们可能会生成清单 1-10 中所示的 C++ 代码。
清单 1-10。 示例 C++ 程序显示使用智能指针进行自动内存管理
#include <iostream>
#include <memory>
void printReport(std::shared_ptr<int> data)
{
std::cout << "Report: " << *data << "\n";
}
int main()
{
try
{
std::shared_ptr<int> ptr(new int());
*ptr = 25;
printReport(ptr);
return 0;
}
catch (std::bad_alloc& ba)
{
std::cout << "ERROR: Out of memory\n";
return 1;
}
}
如果我们在 printReport 函数内部调用 data.use_count() 方法,它会得到值 2,因为在这个函数内部,两个不同的共享指针指向同一个对象。 另一方面,在退出 try 块范围后,使用计数将为 0,因为不再有智能指针指向我们的对象。
请注意,清单 1-10 中的代码不符合 C++ 良好实践。 传递智能指针只是为了读取底层数据应该通过常量引用 (const&) 而不是通过值来完成,但这不会增加引用计数; 因此它对我们的解释目的没有用。
我们看到此类代码有很大的进一步改进,因为:
- 我们不必使用删除运算符手动销毁对象。
- 异常处理得到简化,因为在 printReport() 函数抛出任何异常的情况下,智能指针刚好超出 try 区域范围(以及所有封闭范围),因此它将自动销毁。 这要归功于前面提到的 RAII(Resource Acquisition Is Initialization)原则,它根据对象所代表的指针的变量范围来关心对象的生命周期。
共享指针和唯一指针也可以用作类中的字段,这使它们成为非常强大和有用的工具。
问题是 C++ 中的智能指针是在标准库级别引入的,而不是语言本身。 其他库正在引入他们自己的实现,有时要让所有这些库都能很好地相互交流是有问题的。 Qt 有它的 QtSharedPointer,wxWidgets 有它的 wxSharedPtr< T > 等等。 如果没有编译器和语言的支持,它就必须是那样的。 这就是为什么自动内存管理在 .NET 等面向组件的编程中如此重要的原因。 当 .NET 诞生时,将内存管理的责任从开发人员转移到运行时本身是主要的、关键的设计决策之一。 如何创建、管理和回收对象的通用平台意味着每个组件将以相同的方式重用它,并且除了运行时本身之外,组件之间没有耦合。
关于 C++,有趣的是 Bjorne 允许在 C++ 标准中使用更复杂的 GC——它并未被禁止,只是尚未实现。 此外,由于 C++ 的灵活性,尽管使用内存池系统或 Boehm–Demers–Weiser 收集器,也可以将垃圾收集用作扩展库——我们将很快介绍它。
其他语言可以将智能指针(结合引用计数)直接引入到它们的设计中,而 Rust 正是这种情况——一种由 Mozilla 创建的现代低级编程语言。 它通过将智能指针(实际上有几种不同的智能指针)的概念合并到语言中,在编译级别强制执行数据安全。 它强烈使用所有权语义和 RAII 原则,允许在编译时检查是否没有像取消引用悬挂指针这样的违规行为。 引用计数的另一个值得注意的用法是 Swift 语言中内置的自动引用计数。
引用计数的优缺点简单总结如下: 优点:
- 确定性释放时刻——我们知道当对象的引用计数器降为零时就会发生释放。 因此,只要不再需要,内存就会被回收。
- 更少的内存限制——由于内存回收的速度与对象不再使用的速度一样快,因此等待收集的对象不会消耗内存。
- 无需运行时的任何支持即可实现
缺点:
- 清单 1-8 中这种天真的实现在 Mutator 上引入了非常大的开销
- 引用计数器上的多线程操作需要经过深思熟虑的同步,这可能会带来额外的开销。
- 没有任何额外的增强,循环引用无法回收。
对诸如延迟引用计数或合并引用计数之类的原始引用计数算法进行了改进,它们以牺牲某些优点(主要是立即回收内存)为代价消除了其中的一些问题。 然而,在这里描述它们远远超出了本书的范围。
Tracking Collector(追踪收集器)
查找对象的可达性很难,因为它是对象的全局属性(它取决于整个程序的整个对象图),并且释放对象的简单显式调用是非常局部的。 在这个本地上下文中,我们不知道全局上下文——其他对象现在正在使用这个对象吗? 引用计数试图通过只查看带有一些附加信息的局部上下文来克服这个问题——对一个对象的引用次数。 但这显然会导致循环引用问题,并且会产生我们之前看到的其他缺点。
跟踪垃圾收集器基于对对象生命周期全局上下文的了解,可以更好地决定是否是删除对象(回收内存)的好时机。 事实上,这是一种非常流行的方法,几乎可以肯定,当有人谈到垃圾收集器时,他可能指的是跟踪垃圾收集器。 我们可以在 .NET 等运行时、不同的 JVM 实现等中遇到它。
核心概念是Tracking Garbage Collector通过从Mutator的根开始并递归地跟踪整个对象的程序图来找到对象的真正可达性。 这显然不是一项简单的任务,因为进程内存可能占用数 GB,并且跟踪如此大量数据中的所有对象间引用可能很困难,尤其是当 Mutator 一直在运行并更改所有这些引用时。 Tracing Garbage Collector 最典型的方法包括两个主要步骤:
- 标记 - 在此步骤中,收集器通过查找对象的可达性来确定可以收集内存中的哪些对象。
- 收集 - 在此步骤中,收集器回收被发现不再可达的对象的内存。
这个简单的两阶段逻辑的实现可以扩展,就像 .NET 中的情况一样,可以描述为 Mark-Plan-Sweep-Compact。 我们将在下一章中详细了解这些内部工作原理。 现在,让我们以更一般的方式看一下标记和收集步骤,因为它们也会引发有趣的问题。
Mark Phase(标记阶段)
在标记步骤中,收集器通过查找对象的可达性来确定应收集内存中的哪些对象。 从 Mutator 的根开始,Collector 遍历整个对象图并标记访问过的对象。 那些在标记阶段结束时没有被标记的对象是不可达的。 由于对象的标记,循环引用没有问题。 如果在图的遍历期间我们将返回到先前访问过的对象,我们将中断进一步的遍历,因为该对象已被标记。
图 1-15 显示了这种算法的几个起始步骤。 从根开始,我们通过对象间引用在对象的图形内部移动。 这是一个实现细节,我们是以深度优先还是广度优先的方式访问这个图。 图 1-15 显示了深度优先方法,显示了每个对象的三种可能状态:
- 尚未访问的对象,标记为白框。
- 记住访问过的对象,标记为浅灰色框。
- 对象已访问(标记为可达),标记为
深灰色框。
图 1-15 中所示的第一个步骤可以描述如下(每个步骤描述相应的子图):
- 最初所有的对象都还没有被访问过
- 增加一个待访问的对象A,作为第一个根。
- 由于对象 A 具有指向对象 B 和 D 的指针(作为字段),因此添加它们以供访问。 对象 A 本身在这个阶段被标记为可达。
- 正在访问“to visit”集合中的下一个对象——对象 B。因为它没有任何传出引用,所以它被简单地标记为可达。
- 正在访问“to visit”集合中的下一个对象——对象 D。它包含对对象 E 的单个引用,因此它被记住被访问过。 对象 D 本身被标记为可达。
- 对象 E 对对象 G 的传出引用被记住是被访问过的。 对象 E 本身被标记为可达。
- 正在访问“to visit”集合中的最后一个对象——对象 G。它不包含对它的引用,只是简单地标记为可达。 在这个阶段,没有更多的对象可以访问,所以我们已经确定对象 C 和 F 不可访问(死)
图 1-15。 Mark阶段的几个第一步
显然,在正常的 Mutator 工作期间遍历这样的图是很困难的,因为图会由于正常的程序执行而不断变化——创建新对象、变量、对象的字段分配等等。 因此,在某些垃圾收集器实现中,所有 Mutator 在 Mark 阶段的持续时间内都被简单地停止。 这允许对图进行安全且一致的遍历。 当然,一旦线程恢复运行,Collector 持有的基于对象图的知识就会过时。 但这对于不可达对象来说不是问题——如果它们之前不可达,它们就永远不会再次可达。 然而,有许多垃圾收集器实现,其中标记阶段以并发方式完成,因此标记过程可以与 Mutator 的代码一起运行。 JVM 中的 CMS(并发标记扫描)、JVM 中的 G1 和 .NET 本身等流行算法就是这种情况。 .NET 中如何准确地实现这种并发标记将在第 11 章中详细描述。
标记阶段有一个不明显的问题。 为了跟踪可达性,Collector 应该能够知道根并知道在堆上的什么位置放置了对其他对象的引用。 如果运行时支持这样的信息,这是一个微不足道的问题。 但它也可以用不同的方式克服。
Conservative Garbage Collector(保守垃圾收集器)
这种类型的收集器可以看作是穷人的解决方案。 当运行时或编译器不通过提供准确的类型信息(对象在内存中的布局)直接支持集合时可以使用它,并且 Collector 在对指针进行操作时无法获得 Mutator 的支持。 如果所谓的保守收集器想要找出哪些对象是可达的,它会扫描整个堆栈、静态数据区域和寄存器。 由于没有任何帮助,它不知道什么是指针或不是指针,它只是试图猜测。 它通过检查几件事情(并且都取决于特定的 Collector 实现)来做到这一点,但最重要的一项检查是将给定单词解释为地址(指针)是否指向有效的、由 Allocator 堆区域管理的区域? 如果这样做,Collector 保守地(因此得名)假定它确实是一个指针。 并且它把它当作一个引用来遵循,就像上面描述的通用标记相位图遍历一样。
显然,Collector 可能会猜测错误,这会导致一些不准确 - 随机位可能看起来像具有正确地址的有效指针。 这将导致保留内存是垃圾。 这不是一个很常见的问题,因为内存中的大多数数值都相当小(计数器、财务数据、索引),所以唯一的问题可能是密集的二进制数据,如位图、浮点数或某些 IP 地址块。 有一些微妙的算法改进可以帮助克服这个问题,但我们不会在这里触及它。 此外,保守的报告意味着您无法在内存中移动对象。 这是因为您必须更新指向移动对象的指针,如果您不确定看起来像指针的东西是否确实是指针,这显然是不可能的。
那么谁可能首先需要这样的收集器呢? 它的主要优点是它可以在没有运行时支持的情况下工作——事实上它只是扫描内存,因此不需要运行时支持(引用跟踪)。 因此,例如,在尚未开发 GC 的完整类型信息时开发新的运行时时,这是一种方便的方法。 在不阻塞工作的情况下,可以进行系统其余部分的开发。 当提供正确的类型信息已经实现时,您可以简单地关闭保守跟踪。 Microsoft 在开发其运行时的某些版本时使用了这种方法。
但是,Conservative Collector需要Allocator的支持才能克服未知对象的内存布局问题。 例如,它可以按照将对象分组为大小相等的对象段的方式来安排对象的分配。 此类区域的保守扫描是可能的,因为对象的边界被定义为特定段对象大小的简单乘法。
在许多语言中,Allocator 可以在语言(库)级别上被替换,这导致了 Conservative Garbage Collection 作为库的流行。 最常用的 C 和 C++ API 不可知实现之一是 Boehm–Demers–Weiser GC(简称 Boehm GC)
例如,它在 Mono(开源 CLR 实现)中使用到 2.8 版(2010 年),它引入了所谓的 SGen 垃圾收集器 - 某种混合方法仍然保守地扫描堆栈和注册但支持扫描堆 通过运行时类型信息。
让我们简要总结一下关于保守垃圾收集的要点:
优点:
- 对于不支持从头开始进行垃圾收集的环境更容易 - 例如,早期运行时阶段或非托管语言。
缺点:
- 不准确——所有随机看起来像有效指针的东西都会阻止内存被回收——尽管这不是常见的情况,可以通过改进算法和附加标志来克服。
- 在一个简单的方法中,对象不能被移动(压缩)——因为 Collector 不确定什么是指针(并且它不能只更新它假定为指针的值)。
Precise Garbage Collector(精确的垃圾收集器)
在所谓的精确垃圾收集器情况下,这要简单得多,因为编译器和/或运行时会为收集器提供有关对象内存布局的完整信息。 它还可以支持堆栈爬取(枚举堆栈上的所有对象根)。 在这种情况下,猜测是没有意义的。 从定义明确的根开始,它只是一个对象一个对象地扫描内存对象。 给定指向对象开头的内存地址(或所谓的指向对象内部的内部指针和解释此类引用的适当知识),Collector 只知道传出引用(指针)的放置位置,因此它可以递归地跟随 他们在图形遍历期间。
.NET 使用 Precise Garbage Collector,因此我们将在接下来的章节中看到更多它的内部结构。 事实上,从第 7 章到第 10 章的整个章节都致力于此目的。
收集阶段
Tracking Garbage Collector 找到可达对象后,它可以从所有其他死对象中回收内存。 由于许多不同的方面,收集器的收集阶段可以设计成许多不同的方式。 不可能在一个简短的段落中描述所有可能的组合和变体。 但是可以而且应该区分两种主要方法,各种实现都集中在这两种方法上。
Sweep(打扫)
在这种方法中,死对象被简单地标记为以后可以重用的空闲空间。 这可以是非常快的操作,因为(在示例性实现中)只有存储块的单个位标记必须被改变。 这种情况如图 1-16 所示,其中不再使用的对象 C 和 F(如图 1-15 中的示例所示)只需将它们标记为可用空间即可成为可用空间。
图 1-16。 扫描集合 - 天真的实现
然后,在简单的实现中,在分配期间扫描内存的间隙大小不小于要创建的对象的大小。
但是重要的实现可能需要构建数据结构来存储有关空闲内存块的信息,以便更快地检索,通常采用所谓的空闲列表的形式(如图 1-17 所示)。 此外,这些空闲列表必须足够智能以合并相邻的空闲内存块。 进一步的优化可能会导致存储一组用于不同大小的内存间隙的空闲列表。 在实现细节方面,也有不同的方式来扫描这样的列表。 两种最流行的方法是最佳拟合和首次拟合方法。 在 first-fit 方法中,只要找到合适的空闲内存块,我们就会停止空闲列表扫描。 在最佳匹配方法中,我们总是扫描所有空闲列表条目,试图找到所需大小的最佳匹配项。 前者更快但可能导致更大的碎片,后者恰恰相反。
图 1-17。 Sweep collection——free-list实现
虽然速度非常快,但 Sweep 方法有一个主要缺点 - 它最终会导致更大或更小的内存碎片。 随着对象的创建和销毁,堆上会出现越来越小或越来越大的空闲间隙。 这可能会导致这样一种情况,尽管总的来说有足够的空闲内存供新对象使用,但因为它没有单一的、连续的空闲空间。 在一般描述堆分配时,我们已经在图 1-11 中看到了这种情况。
Compact(紧凑)
在这种方法中,以降低性能为代价消除了碎片,因为它需要在内存中移动对象。 移动对象的方式可以减少删除对象后产生的间隙。 这里可以进一步区分两种主要的不同方法。
以一种更简单的方式,从实现的角度来看,每次收集发生时,复制压缩所有活动的(可访问的)对象都被复制到不同的内存区域(见图 1-18)。 压缩是一个接一个地复制每个活动对象的简单结果,省略那些不再需要的对象。 显然,这会导致高内存流量,因为必须来回复制所有活动对象。 它还会带来更大的内存开销,因为我们必须维护比正常情况下多两倍的内存。
图 1-18。 压缩集合 - 复制实现
由于这些弱点,该算法似乎没有实际应用。 但是,它可以有效地使用。 我们必须记住只将它用于某些小的内存区域,而不是用于整个进程内存。 当复制压缩用于较小的内存区域时,在某些 JVM 的实现中正是这种情况。
在更复杂的场景中,可以实现 In-Place Compacting。 对象相互移动以消除它们之间的间隙(见图 1-19)。 这是最直观的解决方案,也正是我们移动乐高积木的方式。 从实施的角度来看,这不是微不足道的,但仍然可行。 在这里可以发现的主要问题是 - 如何在不相互覆盖且不使用任何临时缓冲区的情况下相对移动对象?
图 1-19。 压缩集合 - 就地实施
正如我们将在第 9 章中看到的那样,.NET 正是使用这种方法和用于优化的非常聪明的数据结构,因此我们将在那里找到该问题的答案。
可以问一个问题:哪个垃圾收集器更好? 它是 HotSpot Java 1.8 还是 .NET 4.6? 或者也许 Python 或 Ruby 有更好的 GC? “更好的 GC”究竟意味着什么? 比较垃圾收集算法的第一个也是最重要的规则是每个比较都是从头开始非常模糊的。 这是因为 GC 本身极难分离和比较。 它们与运行时环境如此融合,几乎不可能单独测试它们。 因此,很难进行任何真正客观的比较。 如果我们想比较不同 GC 的性能——我们可以使用吞吐量、延迟和暂停时间等指标(我们将在第 3 章中看到这些概念之间的区别)。 但是所有这些措施都将在整个运行时的上下文中进行,而不仅仅是单独的 GC。 可以引入框架或运行时机制(例如,分配模式、内部对象池、额外的编译或任何其他隐藏的内部机制),因此 GC 对整体性能的贡献的明显开销可以忽略不计。 此外,每个 GC 中都有许多微调,使其在特定类型的工作负载中表现更好。 有些可以优化以在交互式环境中快速响应,有些可以处理庞大的数据集。 其他人可能会尝试动态更改其特性以适应当前的工作负载。 此外,由于使用的硬件配置(针对特定处理器架构、CPU 核心数或内存架构进行了优化),不同的 GC 可能会有不同的行为。
当然,我们可以比较GC使用的算法和提供的功能。 垃圾收集器的分类方式还有很多。 正如我们已经看到的,我们将 CG 定义为保守的(Mono 直到 2.8)或精确的(.NET)或者甚至是它们的混合(Mono 2.8+)。 一个实现 Sweep 集合,另一个实现 Compact 集合,还有一个实现两者。 另一个重要的区别是 GC 如何对内存进行分区。 我们将在第 5 章详细了解如何将堆划分为更小的部分。它可能在某些部分使用引用计数,或者根本不使用引用计数。 分配器是如何实现的? 是并行GC还是并发GC? (第 11 章)。 由于可能存在如此多的功能差异,因此很难说哪种组合“更好”——根本就没有一种完美的解决方案。
简单总结下Tracking Garbage Collector的优缺点:
优点:
- 从开发人员的角度来看完全透明——内存只是抽象为无限,无需担心释放不再需要的对象的内存。
- 循环引用没有问题。
- Mutators 没有大的开销。
缺点:
- 更复杂的实现。
- 非确定性释放对象——它们将在一段时间无法访问后被释放
- 停止标记阶段所需的世界 - 但仅限于非并发风格。
- 更大的内存约束——由于对象在不需要后没有那么快地回收,因此可能会引入更多的内存压力(一段时间内更多的垃圾生命)。
主要是因为第一个优势,tracking GC 在不同的运行时和环境中如此流行。
小历史
在学习了扎实的基础理论知识之后,现在让我们简要了解一下不同编程语言背景下自动内存管理的历史。
LISP 是最长寿的语言之一,有许多出现和消失的方言,其中两种最流行的是 - Common LISP 和 Scheme。 然而,毫无疑问,现在最流行的是被称为 Clojure 的方言,它可以编译成 Java 虚拟机、公共语言运行时 (.NET) 和 JavaScript。 这使得它非常灵活和强大,当然这也是如今垃圾收集和 LISP 相遇的化身。
但不仅像 LISP 这样的函数式语言在流行时有时还具有自动内存管理功能。 任何与语言相关的历史都不应忽视另一种极具影响力的语言——Simula的影响。 它被称为第一个完全面向对象的语言,引入了对象和类、继承、多态性和 OOP 的其他基本支柱的概念。 所有语言,从 Smalltalk 开始,然后从 C++,通过 Java 和 C#,再到 Python 或 Ruby,都以某种方式受到了这种语言的启发。 重要的是,Simula 67 具有自动内存管理功能,它首先是引用计数和跟踪垃圾收集器的组合,但在语言开发过程中被 LISP 语言启发的压缩垃圾收集器所取代。 与其祖先 Smalltalk 一起,垃圾收集已成为语言设计者的流行选择。 软件复杂性的增加促使语言设计者引入或多或少复杂的方法来帮助程序员进行内存管理。
Web 的普及和 1990 年代互联网时代的开始,将软件开发推向了更高层次的编程需求。 C 和 C++ 为王的时代已经过去了。 他们对系统的低级控制在 Web 应用程序编程和服务器端应用程序的大规模增长的背景下没有任何价值。 随着 Internet 的极速发展,它增加了 Web 应用程序的复杂性以及更快地生成更多代码的需求。
如果不提及语言和 Java 平台,没人能讲述自动内存管理的历史。 被 Sun Microsystem 公司规划为“更好的 C++”,垃圾收集机制是新平台应该满足的首要和基本假设之一。 从 1990 年代开始,该项目作为内部 Oak 语言开始,它包含标记和扫描机制。 第一个公开可用的 Java 1.0a 于 1994 年发布。随着 Java 的流行,人们对垃圾收集机制的存在的认识不断提高。 从那时起,自动内存管理几乎成了所有高级语言设计者的“不二之选”。
当 Java 诞生时,还创造了另外两种主流语言——Python 和 Ruby。 出于前面提到的相同原因,这两种语言都配备了自动内存管理。 2.0 版之前的 Python 只有引用计数,但随后还采用了更复杂的方式来处理循环引用。 Ruby 提供了一种基于标记清除方法的更简单的机制。
在我们简短的历史故事中,我们不能忽略与 Java 同年出现的 JavaScript。 尽管与 Java 这个名字的相似性更多的是一种营销策略,而不是真正的相似性,但 JavaScript 也被认为是一种高级脚本语言。 没有手动内存管理的空间。 目的是允许在高级别操作 HTML 内容,而无需考虑内存使用等方面。 JavaScript 运行时环境负责这些任务。 在更长时间运行的 JavaScript 使用情况下——单页应用程序和 node.js 后端服务——JavaScript 引擎中自动垃圾收集的重要性变得越来越重要。 例如,node.js 使用的非常流行的 V8 JavaScript 引擎正在使用标记、清除和压缩方法及其自身的额外优化。
因此可以注意到,即使具有自动内存管理的语言已经存在了 50 年,它们真正流行的增长发生在 1990 年代。 在这里,我们可以了解对我们来说最重要和最有趣的环境的历史 - .NET Framework 的历史。
更重要的是,微软在那个时候开发了自己的 JavaScript 实现,称为 JScript。 JScript 是我们故事的重要组成部分,因为它为用于创建 .NET 的解决方案奠定了基础。 当然,我们最感兴趣的还是内存管理这个话题。 实际上,这一切都始于四个人在几个周末编写的 JScript。 其中一位是 Patrick Dussud,我们可以毫无疑问地将其称为 .NET 垃圾收集器之父。 他写了一个简单的 保守 GC 作为概念证明。
在开始 CLR 工作之前,Patrick Dussud 从事 JVM 方面的工作。 是的,Microsoft 曾一度认真考虑过自己的 JVM 实现,而不是创建我们现在称为 .NET 运行时的东西。 因此,在 JVM 的启发下,基于已经实现的 JScript 版本,他编写了另一个版本,又是一个 Conservative GC。 但是这个在未来部分形成了 CLR 的团队很快发现 JVM 引入了令人不安的限制。 首先,对新创建环境的期望是对 COM 和非托管代码的强大支持。 其中一个目标是创建一个环境,在这个环境中,使用新的 /CLR 标志重新编译 C++ 程序应该可以在新环境下运行它。 而且,标准化很麻烦,他们可能只是害怕由此带来的局限性。 他们甚至考虑过发布带有垃圾收集扩展的 C++ 运行时。
之后,在咨询了一位朋友(Symbolics 公司的 David Moon,处理分代垃圾收集器)后,Patrick 做出了明智的决定,从头开始编写“尽可能最好的 GC”,并在 Common LISP 中实现了一个原型。 为什么选择这种语言? 这是他多年来一直在使用的语言,而且效果很好。 此外,他还拥有使用当时“最好的调试工具”进行 LISP 的经验。 在编写完 LISP 版本后,他编写了一个转换器,将代码转译为 C++。 这就是用于 JVM 实验性实现的实验性垃圾收集器的创建方式。 当 CLR 的工作开始时,这个实验代码的一部分被用在一个用 C++ 从头开始编写的项目中。 因此CLR的GC代码完全由LISP转换而来只是传说。
已经学习了理论基础和一些历史,现在是时候了解本书将介绍的众多规则中的第一个了。
概括
我们在本章中涵盖了非常广泛的材料。 人们可以很容易地用几本单独的书来讨论上述主题。 从比特和字节等基本概念开始,我们学习了计算机体系结构的主要类型——哈佛和冯诺依曼。 我们已经学习了构建计算机的基础知识,包括注册表、地址和单词等定义。 学习静态或动态分配、指针、堆栈或堆等概念后,我们继续讨论最重要的概念——自动内存管理,也称为垃圾收集。 顺便说一句,我们也遇到了手动内存管理的不便以及自动化它的原因。 仅简要讨论了 .NET 实施概念的基础,例如跟踪垃圾回收及其阶段标记、清除和压缩。 我们将在本书的相应章节中更仔细地研究它们。 我们谈论的一切也都包含了一些历史和更广泛的背景,使我们能够从更广泛的角度看待这个主题。
最后,我们在这里获得的知识将使我们能够更好地理解后续章节。 从一章到另一章,我们将越来越接近 .NET 环境的实际实施问题。 但是,如果不理解本章中呈现的更广泛的上下文,那么这将是一个不完整的外观。 我现在邀请您阅读第 2 章,我们将从理论基础转向低级计算机和内存设计的基础知识。
规则 1 - 自我教育
适用性:尽可能通用。
理由:本书中最通用的规则,它的适用范围比单独的内存管理要广泛得多。 这无外乎就是我们要始终立足于拓展知识,努力成为专业人士。 知识不是自己来的。 我们必须赢得它。 这是一个乏味、耗时且费力的过程。 这就是为什么我们必须不断地激励自己。 如此明显的事实是否值得单独制定一条规则? 我想是这样。 在日常生活中,我们很容易忘记它。 在我们看来,日常任务似乎可以教会我们一些东西。 当然,在某种程度上,他们确实如此。 但很明显,要走出舒适区,我们需要遵循几个步骤。 自觉地。 这意味着伸手去拿一本书,看一个网络教程,读一篇文章。 可能性有很多,在这里一一列举是没有意义的。 然而,它是如此基础,以至于它必须列在每个专业人士的规则列表中。 如果您不相信我的话,请对 http://manifesto.softwarecraftsmanship.org 上的软件工艺概念和清单感兴趣。 我也是机械同情概念的忠实拥护者,该概念由拉力赛车手 Jackie Stewart 提出:
你不必成为一名工程师才能成为赛车手,但你必须具备机械敬畏心。
Martin Thompson 随后将此概念引入 IT 世界。 这是什么意思? 显然,您不需要成为机械师才能成为赛车手。 但是,如果不深入了解汽车的工作原理、机械原理、发动机的工作原理、影响力是什么——很难成为一名优秀的赛车手。 她应该只是“感受汽车”,与它和谐相处。 她应该感受到机械同情。 这正是我们程序员的情况。 当然,我们可以只考虑 .NET 或 JVM 之类的框架,然后就此打住。 但到那时我们就会像周日的司机一样,从方向盘和几个踏板的角度看汽车。
如何申请:在这样的一般规则中,几乎没有一种简单的方法可以采用。 您可以阅读有关计算机或您选择的框架如何工作的书籍。 您可以使用许多在线培训服务。 您可以观看或参加会议和本地用户组。 您可以创建一个博客并撰写此类主题,因为没有比教更好的学习方法了。 可能性太多了,我什至不想一一列举。 只要牢记“自学”的座右铭,并努力在生活中贯彻这条规则!