译者 | 李睿
审校 | 重楼
本文对垃圾回收进行介绍,其中包括垃圾回收算法的概述,以及垃圾回收是如何在一些流行的编程语言(包括JAVA和Python/ target=_blank class=infotextkey>Python)中实现的。在讨论这个问题之前,首先考虑垃圾回收机制的优点和缺点。为什么它是内存分配错误的常见解决方案?以下从不使用垃圾回收的C和C++等语言中内存管理的风险开始。
内存分配问题只是C/C++常见问题的一个子集,这些问题会导致潜在的错误和漏洞,但它们是一个很大的子集,很难追踪和修复。内存分配错误包括以下场景:
其他常见的C/C++漏洞包括缓冲区溢出和字符串操作,它们可以用数据覆盖代码。“有趣”的部分是网络攻击者对数据进行处理,使其成为恶意可执行代码,然后设法运行代码。
很多人说这种情况不会再发生了,因为在保护模式系统中有单独的代码和数据段。不幸的是,这种情况仍然会发生。例如,一个程序在字符串中构造SQL语句,然后将其发送到数据库执行,这通常会创建SQL注入漏洞。当然,在避免SQL注入漏洞方面有一些良好的记录的最佳实践,但是在数据库客户端中不断出现这类错误,表明不是每一位程序员都会遵循最佳实践。
使用垃圾回收可以完全消除主要的内存分配和回收问题,但这是有代价的。最大的问题是垃圾回收器的开销;当垃圾回收器运行时出现不可预测的暂停;并且当服务器进程停止时增加了延迟。后一个问题经常出现在基于Java的服务器程序中。
垃圾回收的开销可能很大,涉及内存和性能之间的权衡。开发人员Matthew Hertz和Emery D.Berger在2005年发表的一篇论文指出:“具有五倍内存的苹果风格的分代回收器具有非复制的成熟空间,与基于访问的显式内存管理的性能相匹配。而如果只有三倍的内存,回收器的运行速度比显式内存管理平均慢17%。然而,如果只有两倍的内存,垃圾回收的性能降低了近70%。当物理内存不足时,分页导致垃圾回收的运行速度比显式内存管理慢一个数量级。”
苹果风格的分代回收器是保守的垃圾回收器,更具攻击性的垃圾回收器有时可以用更少的内存表现得更好。
停滞和延迟意味着基于垃圾回收的语言对于需要最小化延迟的实时程序和高吞吐量服务器来说可能不是最优的。例如,在实时Lisp和实时Java方面已经有了一些尝试,所有这些尝试都修改或消除了垃圾回收器。
最近,一些Java和Scala服务器采用非垃圾回收的编程语言进行了重写,例如Scylla(用C++重写Cassandra)和Redpanda(用C++替换Kafka插件)。在“Scylla”和“Redpanda”中,与最初的服务器相比,已经显著减少了延迟。对于相同的负载,两者都需要更小的集群。
垃圾回收有几十种算法,以下来看看一些最重要的算法及其显著特征。
在引用计数中,程序将对资源的引用、指针或句柄的数量存储为分配资源的一部分,并在添加或删除引用时增加或减少计数。当引用计数为零时,资源可以被释放。内存垃圾回收只是引用计数的应用之一;它也用于系统对象、windows COM对象和文件系统块或文件的释放控制。
引用计数有两个主要的缺点:过于频繁的更新和循环引用。控制更新频率的一种方法是允许编译器对相关对象进行批处理。处理循环引用的一种方法是不定期运行跟踪垃圾以删除不可访问的循环,循环引用使计数不会达到零。
跟踪垃圾回收是引用计数的主要替代方案,包括以下所有算法以及更多的算法。通常跟踪垃圾回收的思想是,跟踪过程从一些根对象(如当前局部变量、全局变量和当前函数参数)开始,并根据引用确定哪些对象是可访问的,然后对所有无法访问的对象进行垃圾回收。跟踪垃圾回收是如此普遍,有时简单地称之为垃圾回收。
1960年发布的“naïve”标记和扫描算法可以追溯到John McCarthy开发的Lisp编程语言,它的工作原理是首先冻结系统,然后将从根集中可访问的所有对象标记为“正在使用”。第三步是清除所有内存并释放未标记为“正在使用”的任何块。最后,清除所有剩余内存块中的“正在使用”位,为下一次回收做准备,并允许系统继续执行。显然,这对于实时系统是不合适的。
标记和扫描的一种变体使用了三种“颜色”的内存块:白色块是不可访问的,如果算法结束时它们仍然在白色集合中,则将释放它们;黑色块可以从根访问,并且没有对白色集合中的对象的外向引用;灰色块可以从根访问,但还需要扫描对“白色”对象的引用。在算法完成后,灰色块全部进入黑色集合。通常情况下,初始标记将根引用的所有块放入灰色集合中,将所有其他块放入白色集合中。
三色标记算法分为三步:
(1)从灰色集合中选择一个对象,并将其移动到黑色集合中。
(2)将其引用的每个白色对象移动到灰色集合。这确保了该对象及其引用的任何对象都不能被垃圾回收。
(3)重复最后两个步骤,直到灰色集合为空。
当灰色集合为空时,所有白色块都可以释放。三色标记算法可以在程序运行时在后台执行;开销仍然存在,但它不会让“整个世界停止”。
复制回收(又名半空间垃圾回收)的思想是将内存分为两个大小相等的区域,分别称为“从空间”和“到空间”。在空间中按顺序分配内存块,直到空间填满,然后执行回收。这交换了区域的角色,并将活动对象从“从空间”复制到“到空间”,在“到空间”的末尾留下一块空闲空间(对应于所有不可访问对象使用的内存)。
复制回收存在复杂性。最大的一个问题是,当复制数据块时,它们的地址会发生变化;一种解决方案是维护转发地址表。另一个主要问题是,复制集合所需的内存是标记和扫描所需内存的两倍。如果大部分内存是垃圾,复制回收比标记和扫描要快,但如果大部分内存可访问,则复制回收较慢。
标记和压缩回收的本质是复制在单个内存空间内运行的回收。标记和压缩回收器扫描所有可访问的对象,并将它们压缩在堆的底部,这使得堆的顶部可供使用。标记和压缩回收的最大缺点是比较耗时。
分代回收根据对象的年龄(也就是代)将堆划分为多个空间(通常是两个或三个)。一般来说,最近的对象比旧的对象更可能是垃圾,因此在大多数情况下扫描新对象以寻找垃圾,而不使用旧对象是有意义的。一些分代回收器在不同的代上使用不同的扫描频率或回收算法。
自从John McCarthy在1958年开发Lisp编程语言以来,Lisp就一直在用于垃圾回收。Java、Scala、Python和.NET/C#都是流行的垃圾回收语言。其他垃圾回收语言包括相对年轻的Go、Ruby、D、OCaml和Swift,以及较老的语言Eiffel、Haskell、ML、Modula-3、Perl、Prolog、Scheme和Smalltalk。
Java、Python和.Net/C#是一些比较流行的实现垃圾回收的编程语言。Java虚拟机(JVM)实际上提供了四种不同的垃圾回收器:串行、并行、并发标记、扫描,以及第一个垃圾回收器G1GC。G1GC现在是Java中的默认值,它是一个区域化和世代并行压缩收集器,可实现软实时目标。
Python(特别是标准的CPython实现)将引用计数与仅专注于清理容器对象的三级代收集相结合。.NETCLR(公共语言运行时)使用三级生成标记和紧凑收集算法。CLR还将内存对象分为两个堆,一个用于大型对象(85000字节或更高),另一个用于小型对象;大型对象堆通常不会被压缩,只是被标记和扫描,但如果需要可以被压缩。
正如人们所看到的,处理垃圾回收的方法有很多,其中大多数都有自己的用途。更成熟的垃圾回收实现结合了多种算法,并且多年来进行了大量调优,以尽量减少延迟。
原文标题:What is garbage collection? Automated memory management for your programs,作者:Martin Heller