最近,我读到一些大肆宣传Go语言最新垃圾回收器的文章,这些文章对垃圾回收器的描述让我感到有些厌烦和恼怒,因为这其中有些是来自Go项目,他们宣称GC技术正迎来巨大突破。
下面Go团队在2015年8月发布的新垃圾回收器的启动声明:
Go正在构建一个划时代垃圾回收器,2015年,甚至到2025年,或者更久……Go 1.5的GC把我们带入了一个新时代,垃圾回收停顿不再成为使用新语言的障碍。应用程序可以很容易地随着硬件进行伸缩,而且随着硬件越来越强大,GC不再是构建可伸缩软件的阻碍。一个新的时代正在开启。
Go团队不仅宣称他们已经解决了GC停顿问题,而且让整件事情变得更加“傻瓜”化:
从更高层面解决性能问题的方式之一是增加GC选项,为不同的性能问题设置不同的选项。程序员可以通过选项为他们的应用程序找到合适的设置。不过,这种方式的不足之处在于,选项(就是经常用到的配置参数)数量会不断增加,到最后很可能会需要一部“GC选项操作者就业草案”。
Go不想继续走这条路。相反,我们只提供了一个选项,也就是GOGC
。
而且,因为不需要支持太多的选项,运行时团队可以集中精力根据真实的用户反馈来改进运行时。
我相信很多Go语言用户对新的运行时还是很满意的。不过我对之前的那些观点有异议,对于我来说,它们是对市场的误导。这些观点在博客圈内重复出现,
我想是时候对它们进行深入分析了
。
事实上,
Go的GC并没有真正实现任何新的想法或做出任何有价值的研究
。他们在声明里也承认,他们的回收器是一种并发的标记并清除回收器,而这种想法在70年代就有了。他们的回收器之所以还值得一提,完全是因为它对停顿时间进行了改进,而这是以牺牲GC其它方面的特性为代价的。Go相关的技术讨论和发行材料并没有提到他们在这个问题上所做出的折衷,让那些不熟悉垃圾回收技术的开发人员不知道这些问题的存在,还暗示Go的其它竞争者制造的都是垃圾。而Go也在强化这种误导:
为了创建划时代的垃圾回收器,我们在很多年前就采用了一种算法。Go的新回收器是一种并发的、三基色的、标记清除回收器,它的设计想法是由Dijkstra在1978年提出的。它有别于现今大多数“企业”级垃圾回收器,而且我们相信它跟现代硬件的属性和现代软件的低延迟需求非常匹配。
读完这段声明,你会感觉过去40年的“企业”级GC研究没有任何进展。
GC理论基础
下面列出了在设计垃圾回收算法时要考虑的一些因素:
-
程序吞吐量
:回收算法会在多大程度上拖慢程序?有时候,这个是通过回收占用的CPU时间与其它CPU时间的百分比来描述的。
-
GC吞吐量
:在给定的CPU时间内,回收器可以回收多少垃圾?
堆内存开销:回收器最少需要多少额外的内存开销?如果回收器在回收垃圾时需要分配临时的内存,对于程序的内存使用是否会有严重影响?
-
停顿时间
:回收器会造成多长时间的停顿?
-
停顿频率
:回收器造成的停顿频率是怎样的?
-
停顿分布
:停顿有时候很短暂,有时候很长?还是选择长一点但保持一致的停顿时间?
-
分配性能
:新内存的分配是快、慢还是无法预测?
-
压缩
:当堆内存里还有小块碎片化的内存可用时,回收器是否仍然抛出内存不足(OOM)的错误?如果不是,那么你是否发现程序越来越慢,并最终死掉,尽管仍然还有足够的内存可用?
-
并发
:回收器是如何利用多核机器的?
-
伸缩
:当堆内存变大时,回收器该如何工作?
-
调优
:回收器的默认使用或在进行调优时,它的配置有多复杂?
-
预热时间
:回收算法是否会根据已发生的行为进行自我调节?如果是,需要多长时间?
-
页释放
:回收算法会把未使用的内存释放回给操作系统吗?如果会,会在什么时候发生?
-
可移植性
:回收器是否能够在提供了较弱内存一致性保证的CPU架构上运行?
-
兼容性
:回收器可以跟哪些编程语言和编译器一起工作?它可以跟那些并非为GC设计的编程语言一起工作吗,比如C++?它要求对编译器作出改动吗?如果是,那么是否意味着改变GC算法就需要对程序和依赖项进行重新编译?
可见,在设计垃圾回收器时需要考虑很多不同的因素,而且它们中有些还会影响到平台生态系统的设计。我甚至都不敢确定以上给出的列表是否包含了所有因素。
设计领域的工作相当复杂,垃圾回收作为计算机科学的一个子领域,人们对它有着广泛的理论研究。学院派和软件行业会时不时地提出新的算法和它们的实现。不过,目前还没有人可以找出一种可以应付所有场景的算法。
折衷无处不在
让我们说得更具体一点。
第一批垃圾回收算法是为单核机器和小内存程序而设计的。那个时候,CPU和内存价格昂贵,而且用户没有太多的要求,即使有明显的停顿也没有关系。这个时期的算法设计更注重最小化回收器对CPU和堆内存的开销。也就是说,除非内存不足,否则GC什么事也不做。而当内存不足时,程序会被暂停,堆空间会被标记并清除,部分内存会被尽快释放出来。
这类回收器很古老,不过它们也有一些优势——它们很简单,而且在空闲时不会拖慢你的程序,也不会造成额外的内存开销。像Boehm GC这种守旧的回收器,它甚至不要求你对编译器和编程语言做任何改动!这种回收器很适合用在桌面应用里,因为桌面应用的堆内存一般不会很大。比如虚幻游戏引擎,它会在内存里存放数据文件,但不会被扫描到。
计算机专业课程经常把会造成停顿(STW)的标记并清除GC算法作为授课内容。在工作面试时,有时候我会问候选人一些GC相关的问题,他们要么把GC看成一个黑盒,要么对GC一窍不通,要么认为现今仍然在使用这种老旧的技术。
标记并清除算法存在的最大问题是它的伸缩性很差。在增加CPU核数并加大堆空间之后,这种算法几乎无法工作。不过有时候你的堆空间不会很大,而且停顿时间可以接受。那么在这种情况下,你或许可以继续使用这种算法,毕竟它不会造成额外的开销。
反过来说,或许你的一台机器就有数百G的堆空间和几十核的CPU,这些服务器可能被用来处理金融市场的交易,或者运行搜索引擎,停顿时间对于你来说很敏感。在这种情况下,你或许希望使用一种能够在后台进行垃圾回收,并带来更短停顿时间的算法,尽管它会拖慢你的程序。
不过事情并不会像看上去的那么简单!在这种配置高端的服务器上可能运行着大批量的作业,因为它们是非交互性的,所以停顿时间对于你来说无关紧要,你只关心它们总的运行时间。在这种情况下,你最好使用一种可以最大化吞吐量的算法,尽量提高有效工作时间和回收时间之间的比率。
问题是,根本不存在十全十美的算法。没有任何一个语言运行时能够知道你的程序到底是一个批处理作业系统还是一个对延迟敏感的交互型应用。这也就是为什么会存在“GC调优”——并不是我们的运行时工程师无所作为。这也反映了我们在计算机科学领域的能力是有限的。
分代理论假说
从1984年以来,人们就已知道大部分的内存对象“朝生夕灭”,它们在分配到内存不久之后就被作为垃圾回收。这就是分代理论假说的基础,它是整个软件产品线领域最贴合实际的发现。数十年来,在软件行业,这个现象在各种编程语言上表现出惊人的一致性,不管是函数式编程语言、命令式编程语言、没有值类型的编程语言,还是有值类型的编程语言。
这个现象的发现是很有意义的,我们可以基于这个现象改进GC算法的设计。新的分代回收器比旧的标记并清除回收器有很多改进:
-
GC吞吐量:它们可以更快地回收更多的垃圾。
-
分配内存的性能:分配新内存时不再需要从堆里搜索可用空间,因此内存分配变得很自由。
-
程序的吞吐量:分配的内存空间几乎相互邻接,对缓存的利用有显著的改进。分代回收器要求程序在运行时要做一些额外的工作,不过这点开销完全可以被缓存的改进所带来的好处抵消掉。
-
停顿时间:大多数时候(不是所有)停顿时间变得更短。
不过分代回收器也引入了一些缺点: