现代的垃圾回收机制(Go 垃圾回收机制概述)
关于 Go GC策略的见解
细节你可以到 Hacker News 和 Reddit 查看相关内容
最近我看到了很多关于Go 最近的垃圾回收机制的推广文章,甚至有一些来自于Go项目组,从他们的文字中感受到,Go的垃圾回收机制似乎发生了根本性的突破。
这里有一篇2015年8月份的,关于最新的收集器的介绍
Go 语言构建了一个新的GC机制,不只为了应对2015,甚至2025年以及更远的时间。Go 1.5版本的GC 迎来了一个全新的改变,让STW(Stop-the-world)的暂停不再是语言转向安全可靠的语言的障碍。在未来,应用程序随着硬件一起扩展变得更容易,GC不再是阻碍硬件变得更有强大的障碍,这是未来十年甚至更长时间上的一个突破。
同时Go的团队说,新的GC不仅解决了暂停的问题,而且让这个事情变得更简单。
此外,不受外界的影响,我们的进行时团队可以专注于提高用户程序反馈的真实问题。
毫无疑问,Go的很多用户对于这个新的运行时机制非常的满意,但是我对于这种说法还是有一些疑虑 —— 对我来说,这种说法就像是一种虚幻(misleading)的推销方式。由于这种说法在博客圈内反复出现,我们应该对于这件事进行深入的关注了。
事实上,Go GC并没有实现更多的新创意和新的研究成果。正如他们所公示的内容,他是一个很直接的并发标记/扫描收集的机制,这种方式还是基于1970年的研究想法。GC唯一不同的是他通过牺牲一些其他合理的性能来优化暂停的时间,但是GC的技术发言和推销材料上,却没有提及他们牺牲了什么内容,让那些不熟悉或者不关心垃圾回收机制的人忘记了这段牺牲的内容,甚至暗示出,Go语言的其他竞争对手(Java等)的垃圾回收很差。Go鼓励以下这种看法:
为了创造一个应用于未来10年的垃圾回收器,我们研究了过去几十年的算法内容。Go的新垃圾回收机制,是采用并发的三色标记法收集器,这个想法在1987年被Dijkstra提出。这个“企业级”垃圾回收期在今天依然是一个争议很大的产品,但我们坚持认为,对于当今时代的硬件设备,他是一个最适合的并且符合现代计算机对于延迟要求的产物。
读完了上述的内容,你可能会觉得过去40年的企业级GC架构根本没有任何价值。
GC相关的理论读物
关于设计一个GC的垃圾回收器,我们需要考虑一下方面:
- 程序的吞吐量:你的算法降低了多少程序性能,跟正常的工作相比,GC的时间占用了多少的百分比
- GC的吞吐量:单位时间内,GC可以清理多少垃圾。
- 堆的开销:垃圾回收时,需要多少额外的内存需求。如果你的算法在收集垃圾的时候,需要额外的临时结构,是否会让你的程序内存变得更紧张。
- 暂停时间:STW的时间
- 暂停的频率:关于STW的频率
- 暂停的分布情况:是否是有时候停顿很短,但有时候停顿很长,又或者停顿的时间稍微长一点,但是清理的效果更好。
- 分配的性能: 新内存的分配是很快、很慢还是无法预测的。
- 压缩情况: 当有足够的内存的时候,但是每块很小的时候,是否也会报出OOM的错误(内存过于碎片化),如果没有的话,及时你的程序有足够的内存,也依然会变得更慢,甚至死亡。
- 并发性:你的收集器是否利用了多线程的特性
- 伸缩性:当你的堆内存变得更大的时候,是否会改变工作策略。
- 调优: 对于开箱即用的情况,你的默认配置能提供更好的性能。
- 预热(调整?)时间:你的算法是否会根据不同的情况自我调整,如果是的话,有需要多久才能到达最佳状态呢。
- 迭代周期:你的算法是否会将释放的内存资源还给操作系统,如果是的话,出发的时机是什么时候。
- 可移植性:你的GC工作机制是否是基于CPU的,是否可以保证在x86这样的弱内存情况下,保证正常的工作(一致性)
- 兼容性:你的收集工作是基于哪些编程语言的,是否能够支持一些并未实现GC机制的编程语言使用,例如C++,是否需要修改编辑器。如果是的话,那么更改GC算法是否需要重新编译所有程序和依赖项
正如你所看到的,设计垃圾收集器设计很多因素,其中一些因素甚至会影响你平台下的一些生态的内容。而且我也不确定我以上列举的内容是否有遗漏,可能还有更多未曾提及的内容。
由于设计的复杂性,垃圾回收期一直是众多计算机科学领域研究论文的一个子领域。学术界和工业界都在研究并实现 稳定的速度 的新算法。但是不幸的是,没有人研究出能够适合所有场景的算法。
权衡无处不在
让我们更具体的来聊聊
第一代垃圾回收算法被设计用于单核处理器,很小堆的程序上面。那时候CPU和RAM非常贵,而且用户要求的不高。所以肉眼可见的暂停也可以让人接受。所以这个时代的算法,优先考虑最小化的堆内存和CPU开销。这意味着,在你没有分配新内存的时候,GC什么也不做,在需要的时候,他会启动,然后程序暂停,并且他需要完成所有的堆标记,扫描,尽可能快的释放一些不需要的局部区域。
尽管这样的垃圾收集器很旧,但是仍然有一定的学习价值。他非常简单,在不需要收集的时候,他不会拖慢你的程序,也不需要添加多余的堆内存。对于像Boehm GC这样的保守派的收集器,他们甚至不需要改变编辑器和编程语言。这可以使它们适用于通常具有小堆的桌面应用程序,包括AAA视频游戏,其中大部分RAM由不需要扫描的数据文件占用。
Stop-the-world (STW) 标记/扫描 是现在垃圾回收机制最常用的GC算法,在进行面试的时候,我经常会让候选人谈谈GC,但是不幸的是,候选人一般都把GC当做一个黑盒,几乎对他没有任何了解,有的甚至认为他是一个非常老的技术。
问题是简单的标记扫描算法,性能很差。随着你的核数和内存变大,这个算法甚至会停止工作。但是,通常情况下,通过更小的堆划分,来控制暂停的时间也是足够了。这种情况下,你可能更希望使用这种方式来保证低消耗。
另一种情况是,你可能用的是数十核 加上数百GB的机器,你的服务可能在进行复杂的金融交易或者执行一个搜索引擎,这种服务上面,低停顿对你来说可能很重要,在这些情况下,你可能更希望使用一种算法,在运行时降低程序的速度,以便于回收器在后台进行短暂停顿的收集。
这不是一个简单的系列,在更大的后端里面,可能有大批量的工作,非交互式的暂停无关紧要,只在意总的运行时间。这种情况下,最好使用一个比较大吞吐量的算法来覆盖他们呢。即完成有用的工作与收集时间的比例问题。
以上的三种情况,告诉了我们,没有一个单一的垃圾回收算法能同时适用于所有的项目,没有一个编程语言能知道,你的程序是一个有很多计算任务的服务,或者是一个对延迟很敏感的程序。这就是GC 调优存在的原因,这并不是因为工程师愚蠢,侧面反映了计算机科学能力上的一些限制。
分代设计
在1984年,这个想法就被大家所熟知,就是收集器分配的 "年轻代",即在分配内存后很快被回收的内容。这种想法只是一种假设上的划分,也算是整个PL工程领域中最强大的划分之一。他在不同的编程语言和软件行业数十年的变革中,始终如一。在很多函数式语言,命令式语言,弱类型语言中,也是这样做的。
理解这个东西对于程序很有用,因为GC算法的设计或多或少都会参照这个思路。一些新的垃圾回收机制,在旧的 停止-标记-扫描 的风格上,都有很多的改进。
- GC吞吐量:他可以更快的收集更多的垃圾。
- 分配性能:分配新的内存,不需要在堆中进行大面积的查找,所以分配效率上很高。
- 程序吞吐量:分配器会动态的处理内存间隙的问题(原文大概是这个意思),这样显著的提升了缓存的利用率。分代收集器在这个上面做了很多额外的工作,但是给予缓存效果带来了更大的提升,足以抵消的这种消耗。
- 暂停时间:大部分的暂停时间变得更短了。
同样的也带来了一些负面的效果
- 兼容性:实现一个垃圾回收期需要有足够的能力去在内存中来回移动一些内容,并在程序的某些情况下,能够在写入指针操作的时候做一些额外的工作,这意味着GC必须与编译器紧密地结合,所以C++没有垃圾回收期。
- 堆内存:这种收集器需要在各种"空间"中来回复制,所以必须保证有足够的空间可以复制,这意味着收集器会产生一些额外的开销。同时还需要维护各种指针之间相互的引用关系(译者加:类似Java 和 C# 的可达性分析),这种方式更加进一步的增加了内存的开销。
- 暂停的情况:由于GC的暂停不是很频繁,所以有时候需要做一次整个堆上面的标记扫描(full GC)
- 调优:垃圾回收期引入了 年轻带GC和原始区的概念,所以程序的性能对于空间大小非常敏感
- 预热:为了响应调优的问题,一些收集器通过观察运行的程序状态,来动态的分配年轻代的大小,但是暂停的时间取决于程序运行的时间,但这个内容在实际生产中很少有关注的。
然而整体上来说,好处是巨大的,基本上所有的现代的GC算法都是基于这个基础上做的,分代回收器可以通过各种方式增强功能,典型的现代的GC算法,基本上都同时具备平行,并行,生成,压缩的能力。
Go并发收集器
Go语言属于一种普通的拥有值类型的解释性语言,因此内存访问模式与C#相比,Go的当代假设成立,而.NET使用的是分代收集方式。
事实上,go程序的request和Response更像是http服务器,go程序展现出了极强的行为,go开发团队探索面向请求的收集起的开发模式。这就意味着他对于GC问题的内核已经发生了本质的改变。GC可以为request/response 处理器确保他足够大的新生代空间,所有的垃圾·都可以通过处理并符合要求
尽管如此,Go目前的GC还不是一个多代的模型,只是一个后台普通的旧的标记扫描模型。
这样做也有一个好处,你可以获得非常低的暂停时间,但是几乎所有的其他事情都会变得很糟糕。通过我们以上的结论,不难看出:
- GC吞吐量:时间用来清理堆。简单来说,你程序用的内存更多,内存释放的越慢,相对于正常工作,回收消耗的时间比例越大。唯一的不受影响的方法就是,你的程序完全没有并行化,那么你可以不受限制的把更多的内核让给GC使用。* 压缩:由于没有压缩的机制,你的程序可能最后可能被内存碎片沾满,我们在后面的时候,会讨论堆碎片的问题。由于没有压缩,所以也无法从连续的内存缓存中收益。
- 程序吞吐量:因为每个周期内,GC需要做很多的工作,所以这样会盗取CPU的时间,从而使程序变得很慢。
- 暂停分发:与你程序同时运行的垃圾回收机制,都会遇到Java世界中著名的"并发模式故障", 你程序执行过程产生的垃圾,比回收的速度快很多。这种情况下,运行时机制别无选择,只能停止整个程序的执行,等待GC的一个大循环的完成,尽管Go声称他们的GC暂停时间很短,但这种说法只能适用于GC有足够的CPU运行时间和足够的内存空间。所以这种情况,此外,Go编译器缺乏让所有线程快速暂停的能力,所以暂停时间很短这件事,很大程度上取决于你所执行的代码(例如,base64解码单个goroutine中的大blob会导致暂停时间的上升)。
- 堆开销:因为收集器标记和扫描的时间很慢,所以需要大量的内存空间,来保证不发生上述的"并发模式故障". Go默认的堆开销为100%的百分比,这意味着程序每次执行的所需内存会直接翻倍。
关于上述内容的权衡问题,我们可以看这篇文字:
由于Server 1 分配的内存高于 Server 2, 所以STW的暂停时间1比2 更高,但是 暂停的持续时间,相对来说还是下降了一个维度,在切换两个服务之后,CPU的使用量,增加了20%
所以在这个特殊的情况下,Go尽管暂停时间下降了,但是同样也拖慢了收集器的速度。这种方式是一个足够平衡的模式?还是说暂停时间无法继续优化了?官方没有给与一个解释。
但是这就产生了一个问题,付出更多的硬件来降低暂停时间是否有意义,如果你的暂停时间从10ms 降到1ms,用户是否可以真实感受到?是否值得你扩大两倍的硬件去得到它。
Go 优化暂停时间的方式,就是减慢你程序的执行效率,来获取更快的暂停。
与Java对比
HotSpot JVM 有众多的GC算法供你选择,没有像Go这样处理暂停时间,因为他们会权衡他们GC算法之间的区别,可以通过对比来比较谁好谁坏。只需要重启你的程序,并在GC算法中做选择,通过实践决定你用哪一个算法来执行你的程序。
任何现代的计算机,默认的收集算法都是吞吐量优先的收集器。这个是为了批量作业而设计的,默认情况下没有暂停时间的目标。之所以选择这个算法作为默认算法的原因是,所有人都开箱即用。所以Java只能让你的程序尽可能快的运行,减少内存开销和暂停时间。
如果你对于暂停时间特别敏感,那推荐你选择(CMS)的方式,这个是跟Go语言使用的最接近的算法,但是CMS同样也是基于分代策略的,这也是为什么他暂停时间比Go长的原因:
年轻代因压缩而应用暂停,因为他需要移动内存。CMS有两种暂停类型,一种是比较快的机制,大概2-5 ms,另一种大概要20ms,CMS是一种自适应的模型,因为他是并发的,他必须要像Go一样猜测执行的时机。Go建议你配置堆的开销来进行调整,而CMS是在运行时进行自适应,来防止并发模式失败。由于是普通的标记扫描方式,所以碎片化会导致减速的问题。
Java最新的垃圾回收期叫做"G1",寓意是"garbage first", 他并不是Java 8 默认的机制,在Java 9 之后会被设置为默认的机制。这种算法希望能够实现一刀切的方式。它主要是整个堆上面的并发,压缩和生成。他同样的也是自我调整的。但是就像所有的GC算法无法理解你的真实需求一样,他只能通过一些配置的方式,帮你完成一些平衡的操作。你只需要告诉他最大的RAM量和允许的最大暂停时间,他会自我调整其他的参数,来保证你的要求。默认的最大暂停时间是100ms。如果你不调整这些内容,G1的默认思路就是让你的应用运行的更快,暂停的更短。
同样的暂停的时间也不都是一致的,大多数情况下,都非常快(小于1ms),当堆被压缩的时候,会比较慢(50ms左右), G1的伸缩性很好,有报道称在TB级别的堆数据中,依然表现良好,而且还有一些比较酷的能力,例如在堆中复制字符串。
最后,一种名为Shenandoah的新GC算法。 它是OpenJDK贡献的,但除非您使用Red Hat(赞助项目)的特殊Java构建,否则不会使用Java 9。 无论堆大小如何,它都可以提供非常低的暂停时间,同时仍然可以进行压缩。 成本是额外的堆开销和更多障碍:在应用程序仍在运行时移动对象需要指针读取和写入以与GC交互。 在这个意义上,它类似于Azul的“无动作”收藏家。
结论
本文的主要观点并不是为了说服你使用不同的编程语言和工具,只为了让你理解一件事,GC是一个难题,真的非常难,几十年来一直都由众多的计算机科学家不断研究。所以
不要怀疑或者相信别人错过了某些思想上的突破,他们可能只是通过一些伪装或者不同寻常手段来平衡得失,避免一些可能带来的原因而做出努力。
如果你希望牺牲一切来达到最小的暂停时间的话,那强烈建议你,阅读GoGC
原文链接:Modern garbage collection
作者:Mike Hearn
译者:JYSDeveloper