[译] 现代垃圾回收

关于 Go 语言最新的垃圾回收器(garbage collector),我最近阅读了许多篇赞扬它的文章,但是它们都让我将信将疑,其中的不少来自 Go 语言的官方团队博客。他们像是暗示着在垃圾回收领域已经发生了一个巨大的突破。

以下是这个垃圾回收器在 2015 年 8 月第一次被公之于众时的摘录:

Go 正在准备构建一个不仅属于 2015 年更属于 2025 年及未来的垃圾回收器。Go 1.5 的垃圾回收将会预示着 stop-the-world 不再会成为构建一个安全的编程语言的壁垒。届时应用可以被轻松高效的在硬件之间扩展。并且随着硬件越来越强大,软件的扩展性也会变得越来越强大,垃圾回收不再会成为其中的障碍。

Go 团队不仅声称他们已经解决了垃圾回收中的 stop-the-world 问题,并且还表示这将使你的编程体验会越发简易:

目前一个比较高层次抽象且解决垃圾回收性能问题的方案是添加更多的垃圾回收预配置。编程人员可以根据他们应用程序的具体情况,选择不同的预配置来启动应用。这个方案的缺点就是,随着时间的推移,预配置变得越来越多,你也渐渐进入了其中的选择综合症中不能自拔。Go 的解决方案完全与之相反,它仅仅提供一种预配置,即 GOGC 。

看到这些有关新运行时的消息,Go 语言的开发者们无疑都非常开心。但是这些话仅仅都只是博客世界中的摘录,让我们先冷静下来,仔细深入推敲它们。

现实是 Go 的新垃圾回收器并没有真正地使用任何新的概念或新的研究成果。Go 团队在声明中也承认,新的垃圾回收器中的并发 标记/删除 模型早在 1978 年就已被提出。这个新的垃圾回收器之所以吸引眼球,仅仅是因为它被设计来最小化垃圾回收中的暂停时间,但其实它付出了垃圾回收中其他所有重要方面的妥协代价。Go 团队似乎并没有告诉市场这些权衡所付出的代价。所以,我们可以将事实概括为:

我们使用了一个 10 多年前的算法,创造了一个属于未来 10 年的垃圾回收器。Go 的新垃圾回收器是一个并发的,三色的,标记/删除 回收器。该算法最早由 Dijkstra 在 1978 年提出。它与目前几乎所有的“企业级”垃圾回收解决方案都不同,仅单独使用它这一种方案,即可很好的适应现代硬件,以及符合现代软件所要求的低延迟。

所以,这 40 年来,在“企业级”垃圾器回收器领域中的研究,都是一无所获么?还是…

垃圾回收理论简述

当设计一个垃圾回收算法时,以下是你需要考虑的众多不同因素:

  • 程序吞吐量: 你的算法会拖慢程序多久?它通常使用有多少百分比的 CPU 时间被用于 垃圾回收 vs 真正的工作 来衡量。
  • 垃圾回收吞吐量: 在恒定的 CPU 时间内,回收器可以清理多少垃圾?
  • 堆使用量: 你的垃圾回收器还需使用多少额外的堆内存?
  • 暂停时间: 你的垃圾回收器一次会 stop-the-world 多久?
  • 暂停频率: 你的垃圾回收器多久会 stop-the-world 一次?
  • 暂停分布: 你的垃圾回收器会时而暂停很久而又时而短暂暂停么?或者还是比较恒定?
  • 内存分配效率: 分配新内存是高效的,低效的,还是不可预测的?
  • 内存紧凑度: 你的垃圾回收器会因为有较多内存碎片,而在还有足够内存的情况下,在申请内存时抛出内存不足错误吗?
  • 并发性: 你的垃圾回收器是否能很好地使用多核处理器?
  • 扩展性: 在堆变得越来越大时,你的垃圾回收器仍能很好的工作吗?
  • 可配置性: 你的垃圾回收器的配置会很复杂吗?
  • 热身时间: 你的垃圾回收器是否会根据环境情况自我调整?如果是,它需要多长时间来达到最佳状态?
  • 内存释放: 你的算法是否会将不再使用的内存释放回操作系统中?如果是,在何时?
  • 可移植性: 你的垃圾回收器是否能在不同的 CPU 架构中工作?
  • 兼容性: 你的垃圾回收器为哪个编程语言和编译器服务?它是否可为那些无需垃圾回收的语言(如 C++)服务?当改变垃圾回收算法时,是否需要重新编译整个程序以及依赖?

正如你所见,在设计一个垃圾回收器时,有许许多多需要考虑的因素。其中的一些甚至会影响围绕该平台的生态。

正因为要考虑的因素如此多且复杂,垃圾回收已经是计算机科学研究中的一个子领域。新的算法不断地被提出,然后在学术界和工程界中被实现。但是不幸的是,还没有单独一个算法,可以完美适用于所有情况。

处处是妥协

让我们说的更具体一些。

第一个垃圾回收算法是为单处理器机器中使用小规模堆内存的程序而设计的。CPU 和内存非常昂贵,并且用户对程序的性能没有特别高的要求,所以肉眼能够看见的暂停也是允许的。那时的算法设计主要以最小化使用 CPU 时间和堆内存容量来设计。这以为在你无法继续分配内存之前,垃圾回收都不会启动。当无法分配之时,垃圾回收器将会暂停程序,然后在堆中执行一个全量的 标记/删除 回收。

这类的垃圾回收器十分古老,但它们仍有一些可见的优点:它们的实现十分简单,在不回收时它们不会拖慢你的程序,并且不会占用额外的内存。在一些保守的回收器(如 Boehm )中,它们甚至不需要随着你的编译器和编程语言而改变!这使得它们非常适合于使用小量堆内存的桌面程序,例如 AAA 视频游戏。

让我们转向另一种情况,如果你正有一个 10 核处理器并使用着几百 GB 的堆内存。或许你的服务器正在处理金融市场中的交易,或者正在运行一个搜索引擎,因此暂停时间的短暂对你非常重要。在这样的情况下,你可能需要一个在后台进行的低暂停时间的但会降低程序速度的算法。

所以说不存在单独一个算法完美适合所有的情况。也没有一个编程语言运行时可以知道你正在执行的是一个批量操作还是一个延迟敏感的交互程序。这就是为什么“垃圾回收预配置”开始出现。并不是因为运行时工程师们愚钝,而是因为我们在计算机科学领域目前的能力所限。

分代假设

自从 1984 年后,人们开始了解到,大多数分配的内存在被分配后的很短时间内,就已经可以被回收。这个观察被称为分代假设,并且是整个编程语言界最强大的经验发现之一。即使经历了这十多年来工程界和编程语言的变化,它依然被证实是十分正确的。

这个发现对于垃圾回收算法而言意味深长,这意味着算法可以利用它。新的分代垃圾回收器相比旧的纯 标记/删除 类型的回收器有以下改进:

  • 垃圾回收吞吐量: 它可以以更快的速度回收更多的垃圾。
  • 内存分配效率: 分配内存不再需要搜索整个堆来寻找空处,所以分配内存变得十分高效。
  • 程序吞吐量: 分配的内存被放置得十分整齐紧凑,这优化了缓存的使用。虽然分代回收器会让程序在运行时执行一些额外的工作,但是由于其优化了缓存,这使得结果利大于弊。
  • 暂停时间: 大多数(并非全部)的暂停时间变得更短了。

当然,它也产生了以下缺点:

  • 兼容性: 实现一个分代垃圾回收器需要有移动内存中实体的能力,并且需要在程序向指针写入时做一个额外的工作。这意味着回收器必须和编译器紧密集成。所以现在并没有为 C++ 而作的分代垃圾回收器。
  • 堆使用量: 分代垃圾回收器需要再多个“空间(spaces)”中来回分配和复制。因此,这增加了堆的使用量。
  • 暂停分布: 所以此时许多的垃圾回收暂停已经是非常短暂的了,但有时仍需要对全堆进行较慢速的 标记/删除 。
  • 可配置性: 分代回收器发明了“新生代”和“老生代”的概念,这使得程序的性能对于“生代”的具体大小变得敏感。
  • 热身时间: 为了缓解可配置性问题,一些分代回收器动态地根据程序的运行情况调整“新生代”的大小。但是这样一来,暂停时间就会随着程序的运行时间而改变。

虽然有以上这些缺点,但是由于瑕不掩瑜,所以几乎所有的现代垃圾回收算法都是分代的。分代垃圾回收算法也可以与许多其他的特性相结合,如并发,并行,紧凑内存等等。

Go 的并发回收器

由于 Go 是一个具有类型系统且相对普通的命令式语言,它的内存访问模式可以与 C# 相比较。所以它的运行时使用的回收器与 .NET 类似,都是分代的。

事实上大多数 Go 程序也是有着像 HTTP 服务器那样的 请求/响应 模式,这意味着 Go 程序会展现出强烈的分代趋势。所以 Go 团队也正在探索未来的一个“面向请求的回收器”。但这个回收器也已被观察仅是一个具有两种可调节策略的分代回收器。在任何其他的 请求/响应 模型的运行时里,这个垃圾回收器都可以被模仿出来,只需保证“新生代”空间足够大,可以撑满所有的请求数据即可。

除此之外,Go 现在的垃圾回收器并不是分代的。其余的部分就是古老的 标记/删除 回收器。

这么做你可以得到一个好处,那就是你可以拥有非常非常低的暂停时间。但是,在几乎其他所有的方面,你都要付出代价:

  • 垃圾回收吞吐量: 随着堆的增长,垃圾回收所需的时间也随之增加。即当你的程序使用越来越多的内存的时候,你的内存会被释放得越来越慢,垃圾回收 vs 实际工作的比例也变得越来越高。唯一可以让以上说话作废的可能就是,让你的程序完全不并行,并且让垃圾回收同时在其他核中没有限制地运转。
  • 内存紧凑度: 由于有大容量的“新生代”空间,所以内存完全不紧凑。
  • 程序吞吐量: 由于每个循环垃圾回收都有许多的事情必须要做,必然导致它将使用更多的 CPU 时间。
  • 暂停分布: 任何的并发垃圾回收器都会遇到一个在 Java 世界中被称为“并发模式失败”的情况:你的业务线程制造垃圾的速度比回收器线程清理的速度更快。在这个情况下,回收器只能完全停止你的业务线程,来等待清理彻底完毕。因此,虽然 Go 团队声称他们的垃圾回收暂停非常短暂,但这也仅在回收器有足够 CPU 时间来保证跑得比业务程序快的情况下才是真实的。除此之外,Go 的编译器自身还缺少能够可靠地立即暂停业务程序的特性。所以,暂停时间是否真的短暂,取决于你正在跑什么样的业务代码(例如使用 base64 解压大块数据可以使暂停时间快速增涨)。
  • 堆使用量: 因为使用 标记/删除 清理堆会非常低效缓慢。所以 Go 需要大容量的“新生代”空闲空间来保证你不会遇到“并发模式失败”。所以,Go 默认会让你的堆使用量多出 100 %…

所以,Go 对于暂停时间的优化,代价几乎是让其余部分的代码都变得更慢了。

与 Java 相比较

HotSpot JVM 提供了许多垃圾回收算法,你可以在命令行里选择其中之一。这些算法中并没有一个的目标是为了得到像 Go 一样的超短暂暂停时间,因为它们都还有考虑其他方面的妥协因素。用户可以通过重启程序来切换垃圾回收算法。所以当用户为了不同的场景进行代码调优时,可以尝试不同的算法。

在任何的现代化计算机上,默认的算法都是高吞吐量回收器。它为执行大批量任务而设计,并且并没有在暂停时间方面有所特别优化。虽然这个默认选项让人们会认为 Java 的垃圾回收做的并不好,但是在黑盒之外,Java 仅仅是试图让你的程序可以跑到最快速,并且使用最少的内存,尽管暂停时间可能不乐观。

如果更多的暂停时间对你来说非常重要,那么你可以切换至并发 标记/回收 回收器(CMS)。这是与 Go 的垃圾回收器最接近的一个。它也是分代的,但它会有比 Go 的垃圾回收器更长的暂停时间:“新生代”空间在暂停时,除了回收垃圾外,还会试图通过移动对象来让自己变得更紧凑。在 CMS 中有两种暂停。第一种是较快速的,它大约需要 2-5 毫秒。第二种是慢速的,大约需要 20 毫秒。CMS 也是自适应的:因为它是并发的,所以它必须去猜测合适开始执行(正如 Go 一样)。然而 Go 要求预先使用大量额外的堆内存在避免“并发模式失败”,但 CMS 会在运行时进行自适应来尝试避免。

最新一代的 Java 垃圾回收器被称作 “G1” ,意为 “垃圾回收优先(garbage first)”。虽然它并不是 Java 8 的默认选项,但会是 Java 9 的。它被设计用来作为一个当下最通用普适的算法。对于几乎整个堆,都是并发,分代且内存紧凑的。它也可以根据环境进行自适应,但是正如所有的回收算法一样,它并不知你程序的真正意图,所以它也允许你执行一些额外的可配置参数:如它可以使用的最大内存量,以及目标暂停时间,然后会它调整其他的一切来尽力满足你的要求。G1 默认更倾向于让你的程序跑的更快,而不是拥有更短的暂停时间,所以默认的目标暂停时间是 100 毫秒。每次的暂停时间也并不是恒定的,大多暂停都非常短暂。大多数在使用了 TB 级别的堆内存的环境下,G1 的表现也非常不错。故 G1 的扩展性也非常不错。

最后,还有一种名为 Shenandoah 的新垃圾回收算法。它已进入 OpenJDK ,但不会再 Java 9 中出现。除非你使用来自 Red Hat 的特殊 Java 构建版本。它被设计为在任意的堆大小下,都拥有非常短暂的暂停时间,并且还可以保持内存紧凑。代价则是更多的堆使用量和实现复杂度。它需要在应用仍在运行时就可以移动对象的位置,这就要求指针地址的读和写操作都要和垃圾回收器进行交互。

结论

这篇文章的目的并不是说服去使用另一门编程语言或者工具。而是仅仅想要表达:垃圾回收是一个异常复杂的问题。所以对于这一领域中一切所生成的突破性成果都需要先保持一个怀疑的态度。它们很有可能仅仅是没有说出其他方面的权衡而已。

但是,如果你并不介意其他方面的代价,而仅仅想要最小化暂停时间,那么,请使用 Go 的垃圾回收器。

原文链接

https://medium.com/@octskyward/modern-garbage-collection-911ef4f8bd8e