[关闭]
@xuemingdeng 2017-01-09T22:22:53.000000Z 字数 6186 阅读 990

复盘GC算法的发展历程及现状

最近,我读到一些大肆宣传Go语言最新垃圾回收器的文章,这些文章对垃圾回收器的描述让我感到有些厌烦。这些文章有些是来自Go项目。他们宣称GC技术正迎来巨大突破。

下面Go团队在2015年8月发布的新垃圾回收器的启动声明

Go正在构建一个划时代垃圾回收器,2015年,甚至到2025年,或者更久……Go 1.5的GC把我们带入了一个新时代,垃圾回收停顿不再成为使用新语言的障碍。应用程序可以很容易地随着硬件进行伸缩,而且随着硬件越来越强大,GC不再是构建可伸缩软件的阻碍。一个新的时代正在开启。

Go团队不仅宣称他们已经解决了GC停顿问题,而且让整件事情变得更加“傻瓜”化:

从更高层面解决性能问题的方式之一是增加GC选项(也就是GC配置参数),每个性能问题使用一个选项。程序员可以通过选项为他们的应用程序找到合适的设置。不过,这种方式的不足之处在于,选项数量会不断增加,到最后很可能会需要一部“GC选项操作者就业草案”。Go不想继续走这条路。相反,我们只提供了一个选项,也就是GOGC。

而且,因为不需要支持太多的选项,运行时团队可以集中精力根据真实的用户反馈来改进运行时。

我相信很多Go语言用户对新的运行时还是很满意的。不过我对之前的那些观点有异议,对于我来说,它们是对市场的误导。这些观点在博客圈内重复出现,我想是时候对它们进行深入分析了。

事实上,Go的GC并没有真正实现任何新的想法或做出任何有价值的研究。他们在声明里也承认,他们的回收器是一种并发的标记并清除回收器,而这种想法在70年代就有了。他们的回收器之所以还值得一提,完全是因为它对停顿时间进行了改进,而这是以牺牲GC其它方面的特性为代价的。Go相关的技术讨论和发行材料并没有提到他们在这个问题上所做出的折衷,让那些不熟悉垃圾回收技术的开发人员不知道这些问题的存在,还暗示Go的其它竞争者制造的都是垃圾。而Go也在强化这种误导:

为了创建划时代的垃圾回收器,我们在很多年前就采用了一种算法。Go的新回收器是一种并发的、三基色的、标记并清除回收器,它的设计想法是由Dijkstra在1978年提出的。它有别于现今大多数“企业”级垃圾回收器,而且我们相信它跟现代硬件的属性和现代软件的低延迟需求非常匹配。

读完这段声明,你会感觉过去40年的“企业”级GC研究没有任何进展。

GC理论基础

下面列出了在设计垃圾回收算法时要考虑的一些因素:

可见,在设计垃圾回收器时需要考虑很多不同的因素,而且它们中有些还会影响到平台生态系统的设计。我甚至都不敢确定以上给出的列表是否包含了所有因素。

设计领域的工作相当复杂,垃圾回收作为计算机科学的一个子领域,人们对它有着广泛的理论研究。学院派和软件行业会时不时地提出新的算法和它们的实现。不过,目前还没有人可以找出一种可以应付所有场景的算法。

折衷无处不在

让我们说得更具体一点。

第一批垃圾回收算法是为单核机器和小内存程序而设计的。那个时候,CPU和内存价格昂贵,而且用户没有太多的要求,即使有明显的停顿也没有关系。这个时期的算法设计更注重最小化回收器对CPU和堆内存的开销。也就是说,除非内存不足,否则GC什么事也不做。而当内存不足时,程序会被暂停,堆空间会被标记并清除,部分内存会被尽快释放出来。

这类回收器很古老,不过它们也有一些优势——它们很简单,而且在空闲时不会拖慢你的程序,也不会造成额外的内存开销。像Boehm GC这种守旧的回收器,它甚至不要求你对编译器和编程语言做任何改动!这种回收器很适合用在桌面应用里,因为桌面应用的堆内存一般不会很大。比如虚幻游戏引擎,它会在内存里存放数据文件,但不会被扫描到。

计算机专业课程经常把会造成停顿(STW)的标记并清除GC算法作为授课内容。在工作面试时,有时候我会问候选人一些GC相关的问题,他们要么把GC看成一个黑盒,要么对GC一窍不通,要么认为现今仍然在使用这种老旧的技术。

标记并清除算法存在的最大问题是它的伸缩性很差。在增加CPU核数并加大堆空间之后,这种算法几乎无法工作。不过有时候你的堆空间不会很大,而且停顿时间可以接受。那么在这种情况下,你或许可以继续使用这种算法,毕竟它不会造成额外的开销。

反过来说,或许你的一台机器就有数百G的堆空间和几十核的CPU,这些服务器可能被用来处理金融市场的交易,或者运行搜索引擎,停顿时间对于你来说很敏感。在这种情况下,你或许希望使用一种能够在后台进行垃圾回收,并带来更短停顿时间的算法,尽管它会拖慢你的程序。

不过事情并不会像看上去的那么简单!在这种配置高端的服务器上可能运行着大批量的作业,因为它们是非交互性的,所以停顿时间对于你来说无关紧要,你只关心它们总的运行时间。在这种情况下,你最好使用一种可以最大化吞吐量的算法,尽量提高有效工作时间和回收时间之间的比率。

问题是,根本不存在十全十美的算法。没有任何一个语言运行时能够知道你的程序到底是一个批处理作业系统还是一个对延迟敏感的交互型应用。这也就是为什么会存在“GC调优”——并不是我们的运行时工程师无所作为。这也反映了我们在计算机科学领域的能力是有限的。

分代理论假说

1984年以来,人们就已知道大部分的内存对象“朝生夕灭”,它们在分配到内存不久之后就被作为垃圾回收。这就是分代理论假说的基础,它是整个软件产品线领域最贴合实际的发现。数十年来,在软件行业,这个现象在各种编程语言上表现出惊人的一致性,不管是函数式编程语言、命令式编程语言、没有值类型的编程语言,还是有值类型的编程语言。

这个现象的发现是很有意义的,我们可以基于这个现象改进GC算法的设计。新的分代回收器比旧的标记并清除回收器有很多改进:

不过分代回收器也引入了一些缺点:

因为分代算法的优点,现代垃圾回收器基本上都是基于分代算法。如果你能承受得起,就会想用它们,而一般来说你很可能会这样。分代回收器可以加入其它各种特性,一个现代回收器将会集并发、并行、压缩和分代于一身。

Go的并发回收器

Go是一种命令式的值类型编程语言,它的内存访问模式跟C#类似。C#是基于分代理论假说的,所以很自然地,.NET使用的是分代回收器。

实际上,Go程序经常被用来处理请求和响应,就像HTTP服务器一样。也就是说,Go程序表现出了十足的分代行为。Go团队正在发掘一种他们称之为“面向请求的回收器”的东西。据悉,它很可能是一种重新命名过的分代回收器,只不过加入了经过调整的分代策略。

在其它语言运行时上可以模拟这种回收器,特别是那些请求和响应类型的处理器,只要确保年轻代大到可以容下请求所生成的垃圾。

不过,目前Go的回收器并不是分代的,它在后台运行的仍然是老旧的标记并清除回收器。

Go这样做有一个好处——它的停顿时间非常短,不过除此之外,其它方面会变得很糟糕。比如说呢?

我们可以从golang-dev上的一些帖子中看到Go在这方面做出的折衷,比如:

Service 1比Service 2分配了更多的内存,所以停顿更加频繁。不过两个服务每次停顿的时间下降了一个数量级。我们可以看到,在对两个服务进行切换以后,CPU的使用增长了大约20%。

Go让停顿时间下降了一个数量级,但却是以更慢的垃圾回收为代价。这是一种更好的折衷吗?或者说停顿时间已经足够好了吗?这些问题在帖子里并没有得到回答。

不过在这种情况下,通过增加更多的硬件换取更短的停顿时间已经毫无意义。如果你的服务器停顿时间从10毫秒下降到1毫秒,你的用户会感觉得到吗?如果你需要为此投入双倍的机器,你还会这么做吗?

Go不断优化停顿时间以便保证GC的吞吐量,它似乎不惜以拖慢程序的速度为代价,哪怕可以缩短一点点的停顿时间。

与Java的比较

HotSpot虚拟机提供了几种GC算法,可以通过命令行来选择使用哪一种算法。这些算法的停顿时间不像Go所宣称的那么短,毕竟它们要在各个因素间做出平衡。通过比较不同的算法可以对它们有一个直观的感受。重启程序可以切换GC算法,因为编译工作在程序运行之时就已完成,不同算法所需要的各种屏障可以根据具体需要被添加到编译的代码里。

现代计算机使用的默认算法是吞吐量回收算法。这种算法是为批处理作业而设计的,默认情况下不对停顿时间做任何限制(不过可以通过命令行指定)。跟这种默认行为相比较,人们会觉得Java的GC简直有点糟糕了:默认情况下,Java试图让你的程序运行尽可能的快,使用尽可能少的内存,但停顿时间却很长。

如果你很在意停顿时间,或许可以使用并发标记并清除回收器(CMS)。这种回收器跟Go的回收器最为接近。不过CMS也是分代回收器,所以它的停顿时间仍然会比Go的要长一些:在年轻代被压缩时,程序会被暂停,因为回收器需要移动对象。CMS里有两种停顿,第一种是快速的停顿,可能会持续2到5毫秒,第二种可能会超过20毫秒。CMS是自适应的,因为它的并发性,它需要预测何时需要启动垃圾回收(类似Go)。你需要对Go的堆内存开销进行配置,而CMS会在运行时自适应调整,避免发生并发模式故障。不过CMS仍然使用标记并清除策略,堆内存仍然会出现碎片化,所以还是会出现问题,程序还是会被拖慢。

最新的Java回收器叫作“G1”(Garbage First)。在Java 8里G1不是默认的回收器,不过在Java 9里它将会是默认的回收器。它是一种“一刀切”的回收算法,它会尽量满足我们的各种需求。它几乎集并发、分代和堆空间压缩于一身。它在很大程度上可以自我调节,不过因为它无法知道你的真正需求(这个跟其它所有的回收器一样),所以你仍然可以对它做出折衷:告诉它可用的最大内存和预期的停顿时间(以毫秒为单位),它会通过自我调节来达到预期的停顿时间。默认的预期停顿时间是100毫秒,只有指定了更低的预期停顿时间才能看到更好的效果:G1会优先考虑程序的运行速度,而不是停顿时间。不过停顿时间并非一直保持一致,大部分情况下都非常短(少于1毫秒),在压缩堆空间时停顿会长一些(超过50毫秒)。G1具有良好的伸缩性,有人在TB级别的堆上使用过G1。G1还有一些很有意思的特性,比如堆内字符串去重。

Red Hat开发了一种新的算法Shenandoah,并把它贡献给OpenJDK,不过它并不会被用在Java 9里,除非自己从Red Hat编译一个特别版的Java。不管堆有多大,该算法都会保证很短的停顿时间,同时可以对堆空间进行压缩。

这种算法会使用额外的堆内存和更多的屏障:在程序运行的同时在堆内移动对象,并维护指针的读写操作。在这方面,它跟Azul的“无停顿”回收器有点类似。

结论

这篇文章的目的并不在于说服你使用某种语言或工具。只是希望你能意识到,垃圾回收是一个很复杂的问题,而且相当复杂,一大堆计算机科学家已经为此研究了数十年。如果有任何所谓的突破性进展,一定要谨慎看待。它们可能看起来很奇怪,或者更像是对折衷的伪装,到最后只会暴露无遗。

不过如果你愿意牺牲其它方面来获得最小化的停顿时间,那么可以看看Go的GC。

本文已获得原作者翻译授权,阅读英文原文:Modern garbage collection

添加新批注
在作者公开此批注前,只有你和作者可见。
回复批注