垃圾收集——面向所有人的入门介绍
程序运行时需要内存。它们使用内存来存储自身,并在执行过程中存储值和数据结构。但内存并非无限资源,因此需要高效地管理。为了管理内存,程序会收集并释放未使用的内存(即垃圾)。这个过程称为垃圾回收。编程语言允许你手动或自动管理内存。
垃圾回收的教科书定义如下:
垃圾回收(GC),也称为自动内存管理,是指自动回收动态分配的内存。垃圾回收器负责执行垃圾回收,它会回收那些经验证永远不会再次使用的内存。使用垃圾回收的系统和语言可以被称为“垃圾回收型”系统和语言。
手动内存管理
手动内存管理为开发人员提供了灵活的方式来优化内存的分配和释放。如果操作得当,它可以提供高性能、低延迟、低暂停时间和高吞吐量,因为开发人员可以精确地知道何时需要内存。然而,由于这是一个手动过程,因此对于开发人员来说,它既容易出错,又增加了重复性工作。
如果内存管理不当,会导致:
- 内存泄漏是指程序没有释放已失效的对象。
- 内存损坏,是指程序试图释放已经释放过的内存。
- 悬空指针,是指程序试图访问已被释放的内存。
什么是暂停时间?
垃圾回收发生时,程序会暂停执行并移除已失效的对象。暂停时间与垃圾回收量成正比。暂停时间越长,回收的垃圾越多,但性能下降;反之,暂停时间越短,性能越高,但回收的垃圾越少。
什么是吞吐量?
吞吐量是指未用于垃圾回收的时间占总执行时间的百分比。理想情况下,编程语言的目标是提高吞吐量。吞吐量越高,暂停时间就越短。
什么是延迟?
延迟是指应用程序的响应速度。垃圾回收暂停会影响应用程序的响应速度。
垃圾回收算法的效率可以通过上述属性来衡量。
自动内存管理
自动内存管理不如手动内存管理高效,因为它需要猜测垃圾的位置以及何时进行回收。但它解决了手动内存管理过程中容易出错的问题。自动垃圾回收确保在不再需要内存时能够正确回收。当所有引用都被释放后,对象将被彻底移除。
与(高效的)手动内存管理相比,自动内存管理性能更低、延迟更高、吞吐量更低。但另一方面,它减轻了开发人员手动管理内存的malloc负担dealloc。
自动内存管理机制并不了解程序如何使用内存/对象。为了正确地释放内存,它们会从根节点开始扫描整个对象树,并移除那些不可达的对象或跟踪对象引用。根据其运行方式,垃圾回收过程大致可分为两类:
- 参考计数
- 追踪
参考计数
引用计数是最直观的垃圾回收方法,它非常简单。在引用计数中,一个对象存活当且仅当它拥有大于零的引用次数。为了实现这一点,引用计数中的每个对象引用都附带一个计数器。
考虑一个GCObject类。该类有一个rc引用计数器(reference counter)来统计其引用次数。引用计数在对象初始化和反初始化期间进行操作。
引用计数世界中对象的伪结构如下:
rc给对象添加计数器
class GCObject(val name: String) {
var rc = 0
}
想了解 Kotlin 语法?请查看以下帖子。
- 对于该对象创建的每个新实例,计数器都会增加。
class GCObject(val name: String) {
var rc = 0
fun addNewInstance() {
rc++
}
}
- 每次解引用时,计数器减一。当计数器归零时,回收内存。
class GCObject(val name: String) {
var rc = 0
fun removeNewInstance() {
rc--;
if(rc == 0) {
reclaimSpace(this)
}
}
}
优势
- 当对象引用计数归零时,内存会被立即回收。这使得引用计数垃圾回收比其他方法更高效。
- 内存管理压力均匀分布在程序执行过程中。
缺点
-
引用计数必须是原子性的,这将增加程序的性能开销。
-
回收循环数据结构非常麻烦,因为检测循环引用很困难。
-
引用计数在回收内存时停顿时间较短。但如果回收的对象很大,引用计数仍然可能导致停顿。
消除性能开销
引用计数算法简单直观。
对于每个对象的分配/释放,运行时都需要递增/递减引用计数。可能存在多个进程/线程同时执行对象的初始化/反初始化操作。引用计数是并发更新的。当某个操作递增引用计数时,rc它必须知道引用计数的准确值。换句话说,所有对引用计数的操作都必须是原子性的。引用计数不匹配会导致内存泄漏或数据损坏。
先获取锁,然后再更新引用计数。这种原子操作会增加开销,提高延迟,降低整体性能。
我们使用缓冲区来解决这个性能问题。与其在每次创建对象时都更新引用计数,不如使用缓冲区来排队等待引用计数的更新。向队列中添加元素无需是原子操作。这提高了性能。使用缓冲区还可以消除并发问题。
请参阅Levanoni、Petrank 等人的论文《即时引用计数垃圾收集器》。
修复循环引用
引用计数的另一个主要问题是查找循环引用。
例如,当我们遇到如下所示的循环引用时:
data class Person(val name: String) {
var house: House
}
data class House(val name: String, val owner: Person)
引用计数器无需知道何时回收循环对象。它是否必须在 Person 引用计数归零时就回收 Person 对象,还是要等到 House 引用计数归零时才进行回收?
无法检测循环是引用计数算法的主要缺点之一。
运行时必须了解循环引用,才能正确管理内存。运行时在处理循环对象时,还应处理一些更特殊和极端的情况。
在 Swift 中,我们使用 ` <redirect> owned` 或weak`<redirect>` 来表示循环引用。` owned<redirect>` 表示依赖项是循环的,并且其生命周期与循环引用相同或更长。`<redirect>`weak表示依赖项是循环的,并且其生命周期比循环引用更短。运行时环境会使用这种启发式方法来管理内存。
class Person {
let name: String
var house: House?
}
class House {
let name: String
var owner: Person?
}
更多信息请点击此处。
替代方案
基于David F. Bacon 等人的论文。
他们提供了一种新的并发循环检测算法,该算法可以局部追踪循环。他们使用了Lins 的惰性循环引用计数方法,将渐近复杂度从 O(n) 降低到O(n^2)O(n) O(n)。
请查看以下代码库,以了解更多关于纯引用计数的工作原理。选择您喜欢的语言:Go或Java
sendilkumarn / java-purerc
Java 中的纯引用计数垃圾回收
吞吐量:
朴素的引用计数需要同步的计数变化,这会增加垃圾回收时间,进而降低吞吐量。但使用缓冲区可以提高吞吐量(即减少垃圾回收时间)。
暂停时间:
朴素的引用计数算法没有暂停时间,垃圾回收过程均匀分布在整个程序中。当引用计数达到零时,垃圾回收才会启动。使用改进的引用计数算法检测循环时,程序应该在检测到循环时暂停所有操作。
谁在使用它?
Swift、SmallTalk、LISP 和 Perl 都使用引用计数进行垃圾回收。AssemblyScript 将引用计数垃圾回收引入了 WebAssembly 世界。
追踪垃圾收集
追踪式垃圾回收器是最流行的垃圾回收方式之一,已被广泛研究和应用。在追踪式垃圾回收器中,它会遍历所有存活对象,并移除那些没有任何引用的对象。
也就是说,追踪收集器处理活动对象,而引用收集器处理已失效对象。
跟踪垃圾回收器并非在每次对象初始化/反初始化时都会执行。这降低了性能开销。垃圾回收会以随机间隔或根据需要触发(例如,当服务器请求量低或内存使用率非常高时)。
追踪垃圾回收的工作原理如下:
标记
垃圾回收器会遍历垃圾堆中所有可达对象,并将可达对象标记为存活对象。
扫
除了标记的物品外,清扫整个堆。
这就是mark and sweep算法。这是最简单的追踪式垃圾回收算法。
优势
-
引用计数会增加性能开销,但标记清除算法不会增加任何开销。
-
标记步骤会遍历所有存活对象。这样可以解决循环依赖问题。
-
跟踪垃圾回收机制是可调的。我们可以调整它来提高延迟或吞吐量。
缺点
-
只有在清理阶段才会移除死对象。在此之前,死对象会一直留在堆中。
-
清除内存后,内存分配变得碎片化。
-
内存管理压力并非均匀分布在程序执行过程中。垃圾回收以周期性(可配置)间隔运行。垃圾回收发生时,程序执行会暂停。
标记 - 扫描 - 压缩
清空操作会造成内存碎片化。在碎片化的堆中分配新内存非常困难。因此,最好使用可连续分配的内存。
为什么传染性记忆检查扁平记忆模型?
标记-扫描-压缩功能的工作原理如下:
标记
垃圾回收器会遍历垃圾堆中所有可达对象,并将可达对象标记为存活对象。
扫
除了标记的物品外,清扫整个堆。
袖珍的
滑动内存并清除碎片。在此阶段,垃圾回收器将再次遍历所有存活对象。
优势
-
与标记清除类似,标记清除压缩不会给程序执行增加任何开销。
-
标记步骤会遍历所有存活对象。这样可以解决循环依赖问题。
-
压缩步骤提高了分配新内存的速度。
缺点
-
只有在清理阶段才会移除死对象。在此之前,死对象会一直留在堆中。
-
内存管理压力并非均匀分布在程序执行过程中。垃圾回收以周期性(可配置)间隔运行。垃圾回收发生时,程序执行会暂停。
-
压缩步骤需要对活动对象进行第二次遍历。内存重定位操作的开销也很大,这会增加暂停时间并降低吞吐量。
标记 - 副本
标记-扫描-压缩算法的主要缺点在于压缩步骤。
压缩步骤分为两步:
- 追踪并标记碎片化的内存
- 压缩内存。这两个步骤都会停止程序执行。
我们在标记-复制算法中避免了这种内存压缩。可用内存首先被分成两部分,我们称之为A和B。
A负责分配新的内存。B起到备份作用。
标记
垃圾回收器遍历“A”部分中所有可达对象,并将可达对象标记为活动对象。
复制
将所有标记的对象移动到“B”段。与压缩不同,复制不会增加任何开销。
扫
清空“A”段。现在“A”和“B”的角色互换了。“B”负责新的内存分配,“A”则起到备份作用。
优势
- 内存分配速度更快,并且由于内存压缩,复杂度降低。
- 与标记清除类似,标记压缩不会给程序执行增加任何开销。
- 标记步骤会遍历所有存活对象。这样可以解决循环依赖问题。
缺点
-
由于堆内存被分成两个部分,因此可用内存总量减少。
-
在主区域和次区域之间反复复制对象可能会导致性能下降。
吞吐量和暂停时间:
在追踪垃圾回收过程中,吞吐量越高,暂停时间越长。暂停时间的增加会降低应用程序的响应速度。
有兴趣进一步了解。
讨论区🐦 Twitter // 💻 GitHub // ✍️ 博客
如果你喜欢这篇文章,请点赞或留言。❤️
文章来源:https://dev.to/sendilkumarn/garbage-collection-5hj1


