这是我参与「第三届青训营 -后端场」笔记创作活动的第4篇笔记,非常感谢字节跳动无偿地分享技术知识并细致地讲解指导。本篇笔记主要介绍go语言的GC机制。
一、自动内存管理
GC (garbage collection)是指垃圾回收,也就是自动内存管理,即把动态内存交给程序语言的运行时系统来自动管理,而不需要程序员用代码手动管理。
没有GC的语言如c语言,它的动态内存管理就需要程序员手动进行,即通过malloc和free来进行heap堆内存的分配和释放。
其好处在于:
- 可以让程序员专注于实现业务逻辑,不需要考虑太多内存管理的问题。
- 保证内存使用的正确性和安全性,防止出现double-free problem, use-after-free problem。
其主要任务有:
- 为新对象分配空间
- 找到存活对象
- 回收死亡对象的内存空间
二、GC线程分类
如果我们使用带有GC机制的编程语言来开发应用程序,那么程序在运行时可以分为业务线程和GC线程两部分。
- Mutator: 业务线程:分配新对象,修改对象指向关系。
- Collector: GC 线程:找到存活对象,回收死亡对象的内存空间。
GC线程Collectors ,必须感知对象指向关系的改变!
即已存活对象所指向的全部对象,必须都被标记为存活。
2.1 Serial GC
Serial GC就是只有一个 collector,其运行过程如下:
显然,这种方式会使程序存在明显的卡顿,并且GC所花时间较长。
2.2 Parallel GC
Parallel GC就是并行 GC,是支持多个 collectors 同时回收的 GC 算法,其运行过程如下:
显然,这种方式比Serial GC的速度会快一些,但程序仍然可能出现卡顿。
2.3 Concurrent GC
Concurrent GC就是并发 GC,是支持 mutator(s) 和 collector(s) 同时执行的 GC 算法,其运行过程如下:
显然这种方法能够让程序几乎感受不到GC线程的存在,但其逻辑会更复杂,实现起来也更困难。
三、GC算法分类
3.1 垃圾标记阶段算法
垃圾标记阶段:主要是为了判断对象是否存活,只有被标记为已经死亡的对象,GC才会在执行垃圾回收的时候,释放掉其所占用的内存空间。
A. 算法一 引用计算法
引用计算法(Reference Counting)是比较简单的,是对每个对象保存一个整型的引用计数器属性,用于记录对象被引用的情况。
- 每个对象都有一个与之关联的引用数目
- 对象存活的条件:当且仅当引用数大于 0
-
优点
- 内存管理的操作被平摊到程序运行中:指针传递的过程中进行引用计数的增减
- 不需要了解 runtime 的细节:因为不需要标记 GC roots,因此不需要知道哪里是全局变量、线程栈等
-
缺点
- 开销大,因为对象可能会被多线程访问,对引用计数的修改需要原子操作来保证原子性和可见性
- 无法回收环形数据结构,即存在循环引用问题
- 每个对象都需要引入额外存储空间存储引用计数
- 虽然引用计数的操作被平摊到程序运行过程中,但是回收大的数据结构依然可能引发暂停
B. 算法二 追踪性垃圾回收
追踪性垃圾回收算法(Tracing garbage collection),也可以称为可达性分析算法、根搜索算法。
其基本思路为:通过一系列的“GC Roots”的根对象作为起始节点集合,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”,如果某个对象到GC Roots间没有任何引用链相连,或者用图论的话来说就是从GC Roots到这个对象是不可达的,那就说明这个对象时不可能再被引用的,也就是该对象是已经死亡的对象。
其被回收对象:不可达对象。
其过程:
- 标记根对象 (GC roots): 静态变量、全局变量、常量、线程栈等;
- 标记:从根对象开始找到所有可达对象,把可达对象都标记为存活,不可达对象都标记为死亡。
其特点:
- 相对于引用计数算法而言,可达性分析算法不仅同样具备实现简单和执行高效等特点,更重要的是该算法可以有效地解决在引用计数算法中循环引用的问题,防止内存泄漏的发生。
- 相较于引用计数算法,这里的可达性分析就是go、Java、C#选择的。
3.2 垃圾回收阶段算法
在垃圾标记阶段完成对象的标记工作后,GC就进入了垃圾回收阶段,常见的算法如下:
A. 算法一 标记-清除算法
Mark-sweep GC: 将死亡对象所在内存块标记为可分配,使用 free list 管理可分配的空间。
优点:非常基础和常见的垃圾收集算法容易理解
缺点:
-
执行效率不稳定 如果堆中包含了大量的对象,而且其中大部分都是需要回收的,这时必须大量标记和清除的动作,导致标记和清除两个过程的执行效率都随对象数量增长而降低。
-
内存空间碎片化 标记、清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致当以后在程序运行过程中需要分配较大的对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。
-
在进行GC的时候,需要停止整个应用程序,用户体验较差。
B. 算法二 标记-复制算法
Copying GC: 将存活对象从一块内存空间复制到另外一块内存空间,原先的空间可以直接进行对象分配。
优点:
- 没有标记和清除过程,实现简单,运行高效。
- 复制过去以后保证空间的连续性,不会出现“碎片的问题”。
缺点:
- 这个算法的确定也是相当的明显,将可用内存缩小为原来的一半,空间浪费巨大。
- 对于G1这种分拆成为大量 region 的GC,复制而不是移动,意味着GC需要维护region之间对象引用关系,不管是内存占用或者时间开销也不小。
C. 算法三 标记-整理算法
Mark-compact GC: 将存活对象复制到同一块内存区域的开头。
优点:
- 消除了标记 - 清除算法中,内存区域分散的特点,我们需要给新对象分配内存时,JVM只需要持有一个内存的起始地址即可。
- 消除了复制算法当中,内存减半的高额代价。
缺点:
- 从效率上来说,标记 - 整理算法要低于复制算法。
- 移动对象的同时,如果对象被其他对象引用,则还需要调整引用的地址。
- 移动过程中,需要全程暂停用户应用程序。即:Stop The World(STW)
D. 回收算法小结
从效率上来讲,标记-复制算法是当之无愧的老大,但是却浪费了太多的内存。
而为了尽量兼顾上面提到的上指标,标记-整理算法相对来说更平滑一些,但是效率上却不尽人意,它比标记-复制算法多出了一个标记阶段,比标记-清除多了一个整理内存的阶段
| 标记-清除 | 标记-复制 | 标记-整理 | |
|---|---|---|---|
| 速度 | 中等 | 最快 | 最慢 |
| 空间开销 | 少(但会堆积碎片) | 通常可用内存是总内存一半(不堆积碎片) | 少(不堆积碎片) |
| 移动对象 | 否 | 是 | 是 |