- 常见的垃圾回收算法有标记清除(Mark-Sweep) 和引用计数(Reference Count)
- Python采用的是引用计数+标记清除并扩展了分代回收。
- Go 语言采用的是标记清除算法,并在此基础上使用了三色标记法和写屏障技术。
什么是 GC,有什么作用?
- GC ,全称 Garbage Collection ,即垃圾回收,是一种自动内存管理的机制。
- 当程序向操作系统申请的内存不再需要时,垃圾回收主动将其回收并供其他代码进行内存申请时候复用,或者将其归还给操作系统,这种针对内存级别资源的自动回收过程,即为垃圾回收。而负责垃圾回收的程序组件,即为垃圾回收器。
- 一方面,程序员受益于 GC,无需操心、也不再需要对内存进行手动的申请和释放操作,GC 在程序运行时自动释放残留的内存。另一方面,GC 对程序员几乎不可见,仅在程序需要进行特殊优化时,通过提供可调控的 API,对 GC 的运行时机、运行开销进行把控的时候才得以现身。
- 通常,垃圾回收器的执行过程被划分为两个半独立的组件:
- 赋值器(Mutator):这一名称本质上是在指代用户态的代码。因为对垃圾回收器而言,用户态的代码仅仅只是在修改对象之间的引用关系,也就是在对象图(对象之间引用关系的一个有向图)上进行操作。
- 回收器(Collector):负责执行垃圾回收的代码。
程序在内存上被分为堆区、栈区、全局数据区、代码段、数据区五个部分。对于 C++ 等早期编程语言栈上的内存由编译器管理回收,堆上的内存空间需要编程人员负责申请与释放。在 Go 中栈上内存仍由编译器负责管理回收,而堆上的内存由编译器和垃圾收集器负责管理回收,给编程人员带来了极大的便利性。
垃圾是指程序向堆栈申请的内存空间,随着程序的运行已经不再使用这些内存空间,这时如果不释放他们就会造成垃圾也就是内存泄漏。
根对象到底是什么?
根对象在垃圾回收的术语中又叫做根集合,它是垃圾回收器在标记过程时最先检查的对象,包括:
- 全局变量:程序在编译期就能确定的那些存在于程序整个生命周期的变量。
- 执行栈:每个 goroutine 都包含自己的执行栈,这些执行栈上包含栈上的变量及指向分配的堆内存区块的指针。
- 寄存器:寄存器的值可能表示一个指针,参与计算的这些指针可能指向某些赋值器分配的堆内存区块。
常见的 GC 实现方式有哪些?Go 语言的 GC 使用的是什么?
所有的 GC 算法其存在形式可以归结为追踪(Tracing)和引用计数(Reference Counting)这两种形式的混合运用。
- 追踪式 GC
从根对象出发,根据对象之间的引用信息,一步步推进直到扫描完毕整个堆并确定需要保留的对象,从而回收所有可回收的对象。Go、 Java、V8 对 JavaScript 的实现等均为追踪式 GC。 - 引用计数式 GC
每个对象自身包含一个被引用的计数器,当计数器归零时自动得到回收。因为此方法缺陷较多,在追求高性能时通常不被应用。Python、Objective-C 等均为引用计数式 GC。
目前比较常见的 GC 实现方式包括:
- 追踪式,分为多种不同类型,例如:
- 标记清扫:从根对象出发,将确定存活的对象进行标记,并清扫可以回收的对象。
- 标记整理:为了解决内存碎片问题而提出,在标记过程中,将对象尽可能整理到一块连续的内存上。
- 增量式:将标记与清扫的过程分批执行,每次执行很小的部分,从而增量的推进垃圾回收,达到近似实时、几乎无停顿的目的。
- 增量整理:在增量式的基础上,增加对对象的整理过程。
- 分代式:将对象根据存活时间的长短进行分类,存活时间小于某个值的为年轻代,存活时间大于某个值的为老年代,永远不会参与回收的对象为永久代。并根据分代假设(如果一个对象存活时间不长则倾向于被回收,如果一个对象已经存活很长时间则倾向于存活更长时间)对对象进行回收。
- 引用计数:根据对象自身的引用计数来回收,当引用计数归零时立即回收。
对于 Go 而言,Go 的 GC 目前使用的是无分代(对象没有代际之分)、不整理(回收过程中不对对象进行移动与整理)、并发(与用户代码并发执行)的三色标记清扫算法。
从宏观的角度来看,Go 运行时的垃圾回收器主要包含五个阶段:
阶段 | 说明 | 赋值器状态 |
---|---|---|
清扫终止 | 为下一个阶段的并发标记做准备工作,启动写屏障 | STW |
标记 | 与赋值器并发执行,写屏障处于开启状态 | 并发 |
标记终止 | 保证一个周期内标记任务完成,停止写屏障 | STW |
内存清扫 | 将需要回收的内存归还到堆中,写屏障处于关闭状态 | 并发 |
内存归还 | 将过多的内存归还给操作系统,写屏障处于关闭状态 | 并发 |
-
对象整理的优势是解决内存碎片问题以及“允许”使用顺序内存分配器。但 Go 运行时的分配算法基于 tcmalloc(https://github.com/google/tcmalloc/blob/master/docs/design.md),基本上没有碎片问题。 并且顺序内存分配器在多线程的场景下并不适用。Go 使用的是基于 tcmalloc 的现代内存分配算法,对对象进行整理不会带来实质性的性能提升。
-
在这五个阶段中,只有标记、内存清扫和内存归还三个阶段的写屏障状态是保持不变的。 在清扫终止过程中,写屏障先出于关闭状态, 而后对上个垃圾回收阶段进行一些收尾工作(例如清理缓存池、停止调度器等等), 然后才被启动;在标记终止阶段,写屏障先出于启动状态,完成标记阶段的收尾工作后, 写屏障被关闭,并随后对整个 GC 阶段进行的各项数据进行统计等等收尾工作。 而在实际实现过程中,垃圾回收器通过 _GCoff、_GCMark 和 _GCMarktermination 三个标记来确定写屏障状态,这时写屏障的启动状态严格的在 _GCoff 到 _GCMark 到 _GCMarktermination 再到 _GCoff 的切换中进行变化。
-
分代 GC 依赖分代假设,即 GC 将主要的回收目标放在新创建的对象上(存活时间短,更倾向于被回收),而非频繁检查所有对象。但 Go 的编译器会通过逃逸分析将大部分新生对象存储在栈上(栈直接被回收),只有那些需要长期存在的对象才会被分配到需要进行垃圾回收的堆中。也就是说,分代 GC 回收的那些存活时间短的对象在 Go 中是直接被分配到栈上,当 goroutine 死亡后栈也会被直接回收,不需要 GC 的参与,进而分代假设并没有带来直接优势。并且 Go 的垃圾回收器与用户代码并发执行,使得 STW 的时间与对象的代际、对象的 size 没有关系。Go 团队更关注于如何更好地让 GC与用户代码并发执行(使用适当的 CPU 来执行垃圾回收),而非减少停顿时间这一单一目标上。
Python(引用计数,标记清除,分代回收)
2个结构体和3个宏定义
结构体:
- PyObject,此结构体中包含3个元素。
- _PyObject_HEAD_EXTRA,用于构造双向链表。
- ob_refcnt,引用计数器。
- *ob_type,数据类型。
typedef struct_object { int ob_refcnt; struct_typeobject *ob_type; } PyObject;
- PyVarObject,此结构体中包含4个元素(ob_base中包含3个元素)
- ob_base,PyObject结构体对象,即:包含PyObject结构体中的三个元素。
- ob_size,内部元素个数。
宏定义:
- PyObject_HEAD,代指PyObject结构体。
- PyVarObject_HEAD,代指PyVarObject对象。
- _PyObject_HEAD_EXTRA,代指前后指针,用于构造双向队列。
Python中所有类型创建对象时,底层都是与PyObject和PyVarObject结构体实现,一般情况下由单个元素组成对象内部会使用PyObject结构体(float)、由多个元素组成的对象内部会使用PyVarObject结构体(str/int/list/dict/tuple/set/自定义类),因为由多个元素组成的话是需要为其维护一个 ob_size(内部元素个数)。
python变量内存分配
在定义变量时,变量名与变量值都是需要存储的,分别对应内存中的两块区域:堆区与栈区。
1、变量名与值内存地址的关联关系存放于栈区
2、变量值存放于堆区,内存管理回收的则是堆区的内容。
定义了两个变量x = 10,y = 20:
当执行x=y时,内存中的栈区与堆区变化如下:
直接引用与间接引用:
- 直接引用指的是从栈区出发直接引用到的内存地址。
- 间接引用指的是从栈区出发引用到堆区后,再通过进一步引用才能到达的内存地址。
l2 = [20, 30] # 列表本身被变量名l2直接引用,包含的元素被列表间接引用
x = 10 # 值10被变量名x直接引用
l1 = [x, l2] # 列表本身被变量名l1直接引用,包含的元素被列表间接引用
在 Python 中,大多数对象(包括变量、数据结构等)都是分配在堆区(Heap)而不是栈区(Stack)。
-
堆区(Heap)
- 对象存储:Python 中的几乎所有对象(如整数、字符串、列表、字典、自定义类的实例等)都是动态分配在堆区的。
- 管理方式:堆区内存由 Python 的内存管理器和垃圾回收器(GC)来负责管理。引用计数和循环垃圾收集机制都会对堆区中的对象进行管理。
- 引用:Python 变量实际上是对堆区中对象的引用或指针。这意味着当你为一个变量赋值时,变量名指向的是堆区中的实际数据。
-
栈区(Stack)
- 函数调用与作用域:栈区主要用于管理函数调用时的局部变量和函数调用栈。每次函数调用时,Python 会在栈区中创建一个新的栈帧(Stack Frame),用于保存该函数的局部变量和控制流信息。
- 局部变量:局部变量名本身在栈区中,但它们所引用的对象通常仍然在堆区。栈区中的变量仅保存了对堆区中对象的引用。
- 作用范围小:栈区的内容在函数调用结束后会自动释放,因此在栈区中存储的数据只能在函数运行期间存活。
而Golang的变量分配会根据“逃逸分析”
具体判断需要分配到堆区还是栈区。
引用计数器
每个对象内部都维护了一个值,该值记录这此对象被引用的次数,如果次数为0,则Python垃圾回收机制会自动清除此对象。
import sys
name = "Generalzy"
print(sys.getrefcount(name))
ref_name = name
print(sys.getrefcount(name))
del ref_name
print(sys.getrefcount(name))
def getrefcount(): # real signature unknown; restored from __doc__
"""
Return the reference count of object.
The count returned is generally one higher than you might expect,
because it includes the (temporary) reference as an argument to
getrefcount().
"""
pass
用一个float对象举例:
- float对象在创建对象时会把为其开辟内存并初始化引用计数器为1,然后将其加入到名为 refchain 的双向链表中;
- float对象在增加引用时,会执行 Py_INCREF在内部会让引用计数器+1;
- 最后执行销毁float对象时,会先判断float内部free_list中缓存的个数,如果已达到300个,则直接在内存中销毁,否则不会真正销毁而是加入free_list单链表中,以后后续对象使用,销毁动作的最后再在refchain中移除即可。
引用计数的优点
- 简单
- 实时性高,只要引用计数为0,对象就会被销毁,内存被释放,回收内存的的时间平摊到了平时
引用计数的缺点
- 为了维护引用计数消耗了很多资源
- 循环引用,循环引用导致内存泄漏
循环引用问题
import gc
import objgraph
class Foo(object):
def __init__(self):
self.data = None
# 在内存创建两个对象,即:引用计数器值都是1
obj1 = Foo()
obj2 = Foo()
# 两个对象循环引用,导致内存中对象的应用+1,即:引用计数器值都是2
obj1.data = obj2
obj2.data = obj1
# 删除变量,并将引用计数器-1。
del obj1
del obj2
# 关闭垃圾回收机制,因为python的垃圾回收机制是:引用计数器、标记清除、分代回收 配合已解决循环引用的问题,关闭他便于之后查询内存中未被释放对象。
gc.disable()
# 至此,由于循环引用导致内存中创建的obj1和obj2两个对象引用计数器不为0,无法被垃圾回收机制回收。
# 所以,内存中Foo类的对象就还显示有2个。
print(objgraph.count('Foo'))
除非手动操作,他们不可能被GC回收,但如果你手动将其释放回收,那么GC机制岂不是形同虚设?针对这种情况,python引入了标记清除和分代回收机制作为补充。
标记清除&分代回收
Python为了解决循环引用,针对 lists, tuples, instances, classes, dictionaries, and functions 类型,每创建一个对象都会将对象放到一个双向链表中,每个对象中都有 _ob_next 和 _ob_prev 指针,用于挂靠到链表中。
/* Nothing is actually declared to be a PyObject, but every pointer to
* a Python object can be cast to a PyObject*. This is inheritance built
* by hand. Similarly every pointer to a variable-size Python object can,
* in addition, be cast to PyVarObject*.
*/
typedef struct _object {
_PyObject_HEAD_EXTRA # 双向链表
Py_ssize_t ob_refcnt;
struct _typeobject *ob_type;
} PyObject;
typedef struct {
PyObject ob_base;
Py_ssize_t ob_size; /* Number of items in variable part */
} PyVarObject;
/* Define pointers to support a doubly-linked list of all live heap objects. */
#define _PyObject_HEAD_EXTRA \
struct _object *_ob_next; \
struct _object *_ob_prev;
随着对象的创建,该双向链表上的对象会越来越多。
-
当对象个数超过 700个 时,Python解释器就会进行垃圾回收。
-
当代码中主动执行 gc.collect() 命令时,Python解释器就会进行垃圾回收。
import gc gc.collect()
-
Python解释器在垃圾回收时,会遍历链表中的每个对象,如果存在循环引用,就将存在循环引用的对象的引用计数器 -1,同时Python解释器也会将计数器等于0(可回收)和不等于0(不可回收)的一分为二,把计数器等于0的所有对象进行回收,把计数器不为0的对象放到另外一个双向链表表(即:分代回收的下一代)。GC
# 默认情况下三个阈值为 (700,10,10) ,也可以主动去修改默认阈值。 import gc gc.set_threshold(threshold0[, threshold1[, threshold2]])
举例
为了便于观察循环引用导致的内存泄漏问题,我定义了两个类,DictA,和DictB两个类,他们均继承了字典类。
import gc
class DictA(dict):
def __del__(self):
print('DictA对象被销毁')
class DictB(dict):
def __del__(self):
print('DictB对象被销毁')
a = DictA()
b = DictB()
gc.collect()
a['b'] = b # 循环引用
b['a'] = a
a = 1
b = 1
print('ok')
由于存在循环引用,因此,内存中DictA对象的引用计数是2,当a = 1被执行时,引用计数减少为1,但仍然大于0,不会被回收,DictB的对象同样如此,下图是存在循环引用时的内存对象示意图:
我们预期的程序的执行结果为:
DictA对象被销毁
DictB对象被销毁
ok
而实际是:
ok
DictA对象被销毁
DictB对象被销毁
对象销毁的信息是在print('ok')
以后才被输出的,这说明,当a = 1被执行时,原来a所指向的那个字典对象并没有立即被销毁。而是靠其他手段清除掉的。
标记清除的原理
标记清除可以处理这种循环引用的情况,它分为两个阶段:
第1阶段,标记阶段:GC会把所有活动对象打上标记,这些活动的对象就如同一个点,他们之间的引用关系构成边,最终点个边构成了一个有向图,如下图所示
第2阶段,搜索清除阶段:从根对象(root)出发,沿着有向边遍历整个图,不可达的对象就是需要清理的垃圾对象。这个根对象就是全局对象,调用栈,寄存器。
在上图中,从root出发后,可以到达 1 2 3 4,而5, 6, 7均不能到达,其中6和7互相引用,这3个对象都会被回收。
通俗地讲就是: 栈区相当于“根”,凡是从根出发可以访达(直接或间接引用)的,都称之为“有根之人”,有根之人当活,无根之人当死。 具体地:标记的过程其实就是,遍历所有的GC Roots对象(栈区中的所有内容或者线程都可以作为GC Roots对象), 然后将所有GC Roots的对象可以直接或间接访问到的对象标记为存活的对象,其余的均为非存活对象,应该被清除。
清除的过程将遍历堆中所有的对象,将没有标记的对象全部清除掉。
分代回收原理
分代回收建立标记清除的基础之上,是一种以空间换时间的操作方式。标记清除可以回收循环引用的垃圾,但是,回收的频次是需要控制的,如果时时刻刻做标记清除,可以想象,python的程序会慢成什么样子。(GC 需要暂停所有运行中的代码,遍历整个对象图,寻找循环引用的对象。虽然这个过程通常很快,但在大对象图或有大量循环引用的情况下,可能会导致明显的延迟。)
分代回收,根据内存中对象的存活时间将他们分为3代,新生的对象放入到0代,如果一个对象能在第0代的垃圾回收过程中存活下来,GC就会将其放入到1代中,如果1代里的对象在第1代的垃圾回收过程中存活下来,则会进入到2代。
import gc
print(gc.get_threshold())
上面的代码执行结果是(700, 10, 10)
- 当分配对象的个数减去释放对象的个数的差值大于700时,就会产生一次0代回收
- 10次0代回收会导致一次1代回收
- 10次1代回收会导致一次2代回收
对于第0代的对象来说,他们很可能就被使用一次,因此需要经常被回收。
经过一轮一轮的回收后,能够活着成为第2代的对象,必然是那些使用频繁的对象,而且他们已经存活很久的时间了,大概率的,还会存活很久,因此,2代回收的就不那么频繁,
可以通过设置这三个阈值,来改变分代回收的触发条件
import gc
gc.set_threshold(600, 10, 5)
print(gc.get_threshold())
经过了上面的设置,0代和2代的回收会更加频繁。
分代指的是根据存活时间来为变量划分不同等级(也就是不同的代)。 新定义的变量,放到新生代这个等级中,假设每隔1分钟扫描新生代一次,如果发现变量依然被引用,那么该对象的权重(权重本质就是个整数)加一,当变量的权重大于某个设定得值(假设为3),会将它移动到更高一级的青春代,青春代的gc扫描的频率低于新生代(扫描时间间隔更长),假设5分钟扫描青春代一次,这样每次gc需要扫描的变量的总个数就变少了,节省了扫描的总时间,接下来,青春代中的对象,也会以同样的方式被移动到老年代中。也就是等级(代)越高,被垃圾回收机制扫描的频率越低。
go(三色标记法)
垃圾回收(Garbage Collection,简称GC)是编程语言中提供的内存管理功能。
在传统的系统级编程语言(主要指C/C++)中,程序员定义了一个变量,就是在内存中开辟了一段相应的空间来存值。由于内存是有限的,所以当程序不再需要使用某个变量的时候,就需要销毁该对象并释放其所占用的内存资源,好重新利用这段空间。在C/C++中,释放无用变量内存空间的事情需要由程序员自己来处理。就是说当程序员认为变量没用了,就手动地释放其占用的内存。但是这样显然非常繁琐,如果有所遗漏,就可能造成资源浪费甚至内存泄露。当软件系统比较复杂,变量多的时候程序员往往就忘记释放内存或者在不该释放的时候释放内存了。这对于程序开发人员是一个比较头痛的问题。
为了解决这个问题,后来开发出来的几乎所有新语言(java,python,php等等)都引入了语言层面的自动内存管理 – 也就是语言的使用者只用关注内存的申请而不必关心内存的释放,内存释放由虚拟机(virtual machine)或运行时(runtime)来自动进行管理。而这种对不再使用的内存资源进行自动回收的功能就被称为垃圾回收。
三色标记法是什么?
- 三色抽象只是一种描述追踪式回收器的方法,在实践中并没有实际含义,它的重要作用在于从逻辑上严密推导
标记清理这种垃圾回收方法的正确性。
- 谈及三色标记法时,通常指
标记清扫的垃圾回收
。
当垃圾回收开始时,只有白色对象。随着标记过程开始进行时,灰色对象开始出现(着色),这时候波面便开始扩大。当一个对象的所有子节点均完成扫描时,会被着色为黑色。当整个堆遍历完成时,只剩下黑色和白色对象,这时的黑色对象为可达对象,即存活;而白色对象为不可达对象,即死亡。这个过程可以视为以灰色对象为波面,将黑色对象和白色对象分离,使波面不断向前推进,直到所有可达的灰色对象都变为黑色对象为止的过程。如下图所示:
三色标记算法将程序中的对象分成白色、黑色和灰色三类。
- 白色:不确定对象。
- 灰色:存活对象,子对象待处理。
- 黑色:存活对象。
- 标记开始时,所有对象加入白色集合(这一步需 STW )。
- 首先将根对象标记为灰色,加入灰色集合
- 垃圾搜集器取出一个灰色对象,将其标记为黑色,并将其指向的对象标记为灰色,加入灰色集合。
- 重复这个过程,直到灰色集合为空为止,标记阶段结束。
- 白色对象即可清理的对象,而黑色对象均为根可达的对象,不能被清理。
三色标记法因为多了一个白色的状态来存放不确定对象,所以后续的标记阶段可以并发地执行。当然并发执行的代价是可能会造成一些遗漏,因为那些早先被标记为黑色的对象可能目前已经是不可达的了。所以三色标记法是一个 false negative(假阴性)的算法。
B->D的引用没了,D应该是白色,但是因为先前D已经被标记成灰色了,所以D对象仍然会被当成存活对象遍历下去。
最终结果:这部分对象仍然会被标记为存活对象,本轮GC不会回收他们的内存。这部分因为并发而造成的本应该回收但是没有回收的对象被称为"浮动垃圾",浮动垃圾不会影响应用程序的正确性,只需要等到下一轮GC到来就会被回收了。
golang GC发展史
最初的Golang GC采用简单的标记清除法,在整个GC期间需要STW,将整个程序暂停。
因为如果不进行STW的话,会出现已经被标记的对象A,引用了新的未被标记的对象B,但由于对象A已经标记过了,不会再重新扫描A对B的可达性,从而将B对象当做垃圾回收掉。
这种全程STW的GC算法真的是如过街老鼠,人见人打,让程序停下来,专门去做垃圾回收这件事,在追求高性能的今天,很难有人可以接受这种性能损耗。
普通三色标记法
对于上述的三色标记法来讲,仍然需要依赖STW的. 因为如果不暂停程序, 程序的逻辑改变对象引用关系, 这种动作如果在标记阶段做了修改,会影响标记结果的正确性。
其实总结来看,在三色标记法的过程中对象丢失,需要同时满足下面两个条件:
- 条件一:白色对象被黑色对象引用
- 条件二:灰色对象与白色对象之间的可达关系遭到破坏
只要把上面两个条件破坏掉一个,就可以保证对象不丢失,所以golang团队就提出了两种破坏条件的方式:强三色不变式和弱三色不变式。
强三色不变式:不允许黑色对象引用白色对象,破坏了条件一白色对象被黑色对象引用。如果一个黑色对象不直接引用白色对象,那么就不会出现白色对象扫描不到,从而被当做垃圾回收掉的尴尬。
弱三色不变式:黑色对象可以引用白色对象,但是白色对象的上游必须存在灰色对象。破坏了条件二灰色对象与白色对象之间的可达关系遭到破坏。如果一个白色对象的上游有灰色对象,则这个白色对象一定可以扫描到,从而不被回收。
屏障机制+三色标记
Golang团队遵循上述两种不变式提到的原则,分别提出了两种实现机制:插入写屏障和删除写屏障。
插入写屏障:当一个对象引用另外一个对象时,将另外一个对象标记为灰色。满足强三色不变式,不会存在黑色对象引用白色对象。(这里需要注意一点,插入屏障仅会在堆内存中生效,不对栈内存空间生效,这是因为go在并发运行时,大部分的操作都发生在栈上,函数调用会非常频繁。数十万goroutine的栈都进行屏障保护自然会有性能问题。)
可以发现,对象3在插入写屏障机制下,得到了保护,但是由于栈上的对像没有插入写机制,在扫描完成后,仍然可能存在栈上的白色对象被黑色对象引用,所以在最后需要对栈上的空间进行STW,防止对象误删除。
插入写屏障最大的弊端就是,在一次正常的三色标记流程结束后,需要对栈上重新进行一次stw,然后再rescan一次。
删除写屏障:在删除引用时,如果被删除引用的对象自身为灰色或者白色,那么被标记为灰色。满足弱三色不变式,灰色对象到白色对象的路径不会断。白色对象始终会被灰色对象保护。
但是引入删除写屏障,有一个弊端,就是一个对象的引用被删除后,即使没有其他存活的对象引用它,它仍然会活到下一轮。如此一来,会产生很多的冗余扫描成本,且降低了回收精度。
插入写屏障机制和删除写屏障机制中任一机制均可保护对象不被丢失。在V1.5的版本中采用的是插入写机制实现。
对比插入写屏障和删除写屏障:
- 插入写屏障:插入写屏障哪里都好,就是栈上的操作管不到,所以最后需要对栈空间进行stw保护,然后rescan保证引用的白色对象存活。
- 删除写屏障:在GC开始时,会扫描记录整个栈做快照,从而在删除操作时,可以拦截操作,将白色对象置为灰色对象。回收精度低。
三色标记+混合写屏障机制
- GC刚开始的时候,会将栈上的可达对象全部标记为黑色。
- GC期间,任何在栈上新创建的对象,均为黑色。
- 堆上被删除的对象标记为灰色
- 堆上新添加的对象标记为灰色
上面两点只有一个目的,将栈上的可达对象全部标黑,最后无需对栈进行STW,就可以保证栈上的对象不会丢失。有人说,一直是黑色的对象,那么不就永远清除不掉了么,这里强调一下,标记为黑色的是可达对象,不可达的对象一直会是白色,直到最后被回收。
此时有个问题:万一栈上的对象1引用了堆上的对象8,由于不触发混合写屏障机制,那对象8一直是白色的,最后不就被垃圾回收走了么,谁来保护它?
实际上,这个情况是不会发生的,因为一个对象之所以可以引用另外一个对象,它的前提是需要另外一个对象可达,图中的8号显然是不可达的,所以不会出现这种情况。
为什么1号对象可以引用7号对象呢?这是因为1号对象在引用7号对象的时候,对象7是在对象6的下游,本身是可达。
Golang v1.3之前采用传统采取标记-清除法,需要STW,暂停整个程序的运行。
在v1.5版本中,引入了三色标记法和插入写屏障机制,其中插入写屏障机制只在堆内存中生效。但在标记过程中,最后需要对栈进行STW。
在v1.8版本中结合删除写屏障机制,推出了混合屏障机制,屏障限制只在堆内存中生效。避免了最后节点对栈进行STW的问题,提升了GC效率。
总结
Go语言的并发垃圾回收器(Concurrent Garbage Collector)基于混合写屏障(Hybrid Write Barrier)和三色标记法(Tri-color Marking),并通过STW(Stop-the-World)机制来协调整个过程。理解它们的顺序和交互有助于理解Go的并发GC是如何高效运行的。
-
初始标记阶段(Initial Mark Phase, STW)
- 触发STW:在这个阶段,GC会短暂地停止所有的goroutine(Stop-the-World),以便标记从根对象(Root Set)出发的所有直接可达的对象。
- 标记根对象:GC会遍历栈、全局变量和其他根对象,并将它们标记为“灰色”,表示这些对象已被发现但它们引用的对象还未被扫描。
-
并发标记阶段(Concurrent Mark Phase)
- 恢复goroutine:STW解除,用户的goroutine恢复执行。
- 三色标记法:GC在后台并发地进行标记操作,使用三色标记法将对象分为“白色”、“灰色”和“黑色”三种状态。
- 白色:未访问的对象,可能是垃圾。
- 灰色:已标记但其引用的对象尚未扫描的对象。
- 黑色:已标记且其引用的对象都已扫描的对象。
- 混合写屏障:此时,用户代码和GC同时运行,混合写屏障机制被激活。它确保当用户代码修改对象引用时(特别是从黑色对象指向白色对象时),这些修改能够正确反映在GC的标记过程中,避免未标记的对象被错误地回收。
-
并发清扫阶段(Concurrent Sweep Phase)
- 标记完成:一旦所有的对象都被标记完毕,GC会将所有未被标记的白色对象视为垃圾,并开始清除它们。
- 清扫过程:这个过程也是并发进行的,允许用户的goroutine继续执行。回收的内存可以立即用于新的对象分配。
-
最终标记阶段(Final Mark Phase, STW)
- 再次STW:在清扫阶段快结束时,GC会再次短暂地触发STW,确保没有漏掉任何需要标记的对象。此时,所有灰色的对象都会被处理完毕,并将其标记为黑色。
-
并发重置阶段(Concurrent Reset Phase)
- 重置GC状态:GC重置内部状态,为下一次垃圾回收做好准备。
关键点总结:
- STW(Stop-the-World):STW会在初始标记和最终标记阶段短暂触发,以确保内存状态的一致性。
- 三色标记法:通过三色标记法,GC能够区分对象的状态,并在并发情况下正确标记对象,避免对象被误删。
- 混合写屏障:在并发标记阶段,混合写屏障确保用户代码对对象引用的修改不会干扰GC的工作。
Go的并发垃圾回收器通过这几个步骤,能够在大多数时间不暂停用户的程序运行,有效地进行内存回收,并最大限度地减少对程序性能的影响。
STW 是什么意思?
-
STW 可以是 Stop the World 的缩写,也可以是 Start the World 的缩写。通常意义上指指代从 Stop
the World 这一动作发生时到 Start the World 这一动作发生时这一段时间间隔,即万物静止。STW 在垃圾回收过程中为了保证实现的正确性、防止无止境的内存增长等问题而不可避免的需要停止赋值器进一步操作对象图的一段过程。 -
在这个过程中整个用户代码被停止或者放缓执行, STW 越长,对用户代码造成的影响(例如延迟)就越大,早期 Go 对垃圾回收器的实现中 STW 长达几百毫秒,对时间敏感的实时通信等应用程序会造成巨大的影响。
package main
import (
"runtime"
"time"
)
func main() {
go func() {
for {
}
}()
time.Sleep(time.Millisecond)
runtime.GC()
println("OK")
}
上面的这个程序在 Go 1.14 以前永远都不会输出 OK ,其罪魁祸首是进入 STW 这一操作的执行无限制的被延长。
原因:
- GC 在需要进入 STW 时,需要通知并让所有的用户态代码停止,但是 for {} 所在的 goroutine 永远都不会被中断,从而始终无法进入 STW 阶段。当程序的某个 goroutine 长时间得不到停止,强行拖慢进入 STW 的时机,这种情况下造成的影响就是卡死。
- 在自 Go 1.14 之后,这类 goroutine 能够被异步地抢占,从而使得进入 STW 的时间不会超过抢占信号触发的周期,程序也不会因为仅仅等待一个 goroutine 的停止而停顿在进入 STW 之前的操作上。
有了 GC,为什么还会发生内存泄露?
常说的内存泄漏,用严谨的话来说应该是:预期的能很快被释放的内存由于附着在了长期存活的内存上、或生命期意外地被延长,导致预计能够立即回收的内存而长时间得不到回收。
在 Go 中,由于 goroutine 的存在,所谓的内存泄漏除了附着在长期对象上之外,还存在多种不同的形式。
预期能被快速释放的内存因被根对象引用而没有得到迅速释放
当有一个全局对象时,可能不经意间将某个变量附着在其上,且忽略的将其进行释放,则该内存永远不会得到释放。例如:
var cache = map[interface{}]interface{}{}
func keepalloc() {
for i := 0; i < 10000; i++ {
m := make([]byte, 1<<10)
cache[i] = m
}
}
验证:
package main
import (
"os"
"runtime/trace"
)
func main() {
f, _ := os.Create("trace.out")
defer f.Close()
trace.Start(f)
defer trace.Stop()
keepalloc()
}
对生成的out文件执行go tool trace trace.out
,
可以看到,途中的 Heap 在持续增长,没有内存被回收,产生了内存泄漏的现象。
goroutine 泄漏
Goroutine 作为一种逻辑上理解的轻量级线程,需要维护执行用户代码的上下文信息。在运行过程中也需要消耗一定的内存来保存这类信息,而这些内存在目前版本的 Go 中是不会被释放的。因此,如果一个程序持续不断地产生新的 goroutine、且不结束已经创建的 goroutine 并复用这部分内存,就会造成内存泄漏的现象。
func keepalloc2() {
for i := 0; i < 100000; i++ {
go func() {
select {}
}()
}
}
验证:
package main
import (
"os"
"runtime/trace"
)
func main() {
f, _ := os.Create("trace.out")
defer f.Close()
trace.Start(f)
defer trace.Stop()
keepalloc2()
}
可以看到,途中的 Heap 在持续增长,没有内存被回收,产生了内存泄漏的现象。
值得一提的是,这种形式的 goroutine 泄漏还可能由 channel 泄漏导致。而 channel 的泄漏本质上与 goroutine 泄漏存在直接联系。Channel 作为一种同步原语,会连接两个不同的 goroutine,如果一个 goroutine 尝试向一个没有接收方的无缓冲 channel 发送消息,则该 goroutine 会被永久的休眠,整个 goroutine 及其执行栈都得不到释放,例如:
var ch = make(chan struct{})
func keepalloc3() {
for i := 0; i < 100000; i++ {
// 没有接收方,goroutine 会一直阻塞
go func() { ch <- struct{}{} }()
}
}
并发标记清除法的难点是什么?
在没有用户态代码并发修改三色抽象的情况下,回收可以正常结束。但是并发回收的根本问题在于,用户态代码在回收过程中会并发地更新对象图,从而造成赋值器和回收器可能对对象图的结构产生不同的认知。这时以一个固定的三色波面作为回收过程前进的边界则不再合理。
- 初始状态:假设某个黑色对象 C 指向某个灰色对象 A ,而 A 指向白色对象 B;
- C.ref3 = C.ref2.ref1 :赋值器并发地将黑色对象 C 指向(ref3)了白色对象 B;
- A.ref1 = nil :移除灰色对象 A 对白色对象 B 的引用(ref2);
- 最终状态:在继续扫描的过程中,白色对象 B 永远不会被标记为黑色对象了(回收器不会重新扫描黑色对象),进而对象B 被错误地回收。
什么是写屏障、混合写屏障,如何实现?
写屏障是一个在并发垃圾回收器中才会出现的概念,垃圾回收器的正确性体现在:不应出现对象的丢失,也不应错误的回收还不需要回收的对象。
可以证明,当以下两个条件同时满足时会破坏垃圾回收器的正确性:
- 条件 1: 赋值器修改对象图,导致某一黑色对象引用白色对象;
- 条件 2: 从灰色对象出发,到达白色对象的、未经访问过的路径被赋值器破坏。
只要能够避免其中任何一个条件,则不会出现对象丢失的情况,因为:
- 如果条件 1 被避免,则所有白色对象均被灰色对象引用,没有白色对象会被遗漏;
- 如果条件 2 被避免,即便白色对象的指针被写入到黑色对象中,但从灰色对象出发,总存在一条没有访问过的路径,从而找到到达白色对象的路径,白色对象最终不会被遗漏。
因此:
- 当满足原有的三色不变性定义(或上面的两个条件都不满足时)的情况称为强三色不变性(strong tricolor
invariant) - 当赋值器令黑色对象引用白色对象时(满足条件 1 时)的情况称为弱三色不变性(weak tricolor invariant)
当赋值器进一步破坏灰色对象到达白色对象的路径时(进一步满足条件 2 时),即打破弱三色不变性,
也就破坏了回收器的正确性;或者说,在破坏强弱三色不变性时必须引入额外的辅助操作。
弱三色不变形的好处在于:只要存在未访问的能够到达白色对象的路径,就可以将黑色对象指向白色对象。
如果我们考虑并发的用户态代码,回收器不允许同时停止所有赋值器,就是涉及了存在的多个不同状态的赋值器。为了对概念加以明确,还需要换一个角度,把回收器视为对象,把赋值器视为影响回收器这一对象的实际行为(即影响 GC 周期的长短),从而引入赋值器的颜色:
- 黑色赋值器:已经由回收器扫描过,不会再次对其进行扫描。
- 灰色赋值器:尚未被回收器扫描过,或尽管已经扫描过但仍需要重新扫描。
赋值器的颜色对回收周期的结束产生影响:
- 如果某种并发回收器允许灰色赋值器的存在,则必须在回收结束之前重新扫描对象图。
- 如果重新扫描过程中发现了新的灰色或白色对象,回收器还需要对新发现的对象进行追踪,但是在新追踪的过程中,赋值器仍然可能在其根中插入新的非黑色的引用,如此往复,直到重新扫描过程中没有发现新的白色或灰色对象。
于是,在允许灰色赋值器存在的算法,最坏的情况下,回收器只能将所有赋值器线程停止才能完成其跟对象的完整扫描,也就是我们所说的 STW。
为了确保强弱三色不变性的并发指针更新操作,需要通过赋值器屏障技术来保证指针的读写操作一致。因此我们所说的 Go 中的写屏障、混合写屏障,其实是指赋值器的写屏障,赋值器的写屏障作为一种同步机制,使赋值器在进行指针写操作时,能够“通知”回收器,进而不会破坏弱三色不变性。
有两种非常经典的写屏障:Dijkstra 插入屏障和 Yuasa 删除屏障。
Dijkstra 插入屏障
灰色赋值器的 Dijkstra 插入屏障的基本思想是避免满足条件 1:
// 灰色赋值器 Dijkstra 插入屏障
func DijkstraWritePointer(slot *unsafe.Pointer, ptr unsafe.Pointer) {
shade(ptr)
*slot = ptr
}
为了防止黑色对象指向白色对象,应该假设 *slot
可能会变为黑色,为了确保 ptr 不会在被赋值到 *slot
前变为白色, shade(ptr) 会先将指针 ptr 标记为灰色,进而避免了条件 1。如图所示:
Dijkstra 插入屏障的好处在于可以立刻开始并发标记。但存在两个缺点:
- 由于 Dijkstra 插入屏障的“保守”,在一次回收过程中可能会残留一部分对象没有回收成功,只有在下一个回收过程中才会被回收;
- 在标记阶段中,每次进行指针赋值操作时,都需要引入写屏障,这无疑会增加大量性能开销;为了避免造成性能问题,Go团队在最终实现时,没有为所有栈上的指针写操作,启用写屏障,而是当发生栈上的写操作时,将栈标记为灰色,但此举产生了灰色赋值器,将会需要标记终止阶段 STW 时对这些栈进行重新扫描。
Yuasa 删除屏障
其基本思想是避免满足条件 2:
// 黑色赋值器 Yuasa 屏障
func YuasaWritePointer(slot *unsafe.Pointer, ptr unsafe.Pointer) {
shade(*slot)
*slot = ptr
}
为了防止丢失从灰色对象到白色对象的路径,应该假设 *slot 可能会变为黑色,为了确保 ptr 不会在被赋值到
*slot 前变为白色, shade(*slot) 会先将 *slot 标记为灰色,进而该写操作总是创造了一条灰色到灰色或
者灰色到白色对象的路径,进而避免了条件 2。
Yuasa 删除屏障的优势则在于不需要标记结束阶段的重新扫描,结束时候能够准确的回收所有需要回收的白色对象。缺陷是Yuasa 删除屏障会拦截写操作,进而导致波面的退后,产生“冗余”的扫描:
Go 在 1.8 的时候为了简化 GC 的流程,同时减少标记终止阶段的重扫成本,将 Dijkstra 插入屏障和 Yuasa 删除屏障进行混合,形成混合写屏障。该屏障提出时的基本思想是:对正在被覆盖的对象进行着色,且如果当前栈未扫描完成,则同样对指针进行着色。
Go 语言中 GC 的流程是什么?
当前版本的 Go 以 STW 为界限,可以将 GC 划分为五个阶段:
阶段 | 说明 | 赋值器状态 |
---|---|---|
SweepTermination | 清扫终止阶段,为下一个阶段的并发标记做准备工作,启动写屏障 | STW |
Mark | 扫描标记阶段,与赋值器并发执行,写屏障开启 | 并发 |
MarkTermination | 标记终止阶段,保证一个周期内标记任务完成,停止写屏障 | STW |
GCoff | 内存清扫阶段,将需要回收的内存归还到堆中,写屏障关闭 | 并发 |
GCoff | 内存归还阶段,将过多的内存归还给操作系统,写屏障关闭 | 并发 |
具体而言,各个阶段的触发函数分别为:
触发 GC 的时机是什么?
Go 语言中对 GC 的触发时机存在两种形式:
- 主动触发,通过调用 runtime.GC 来触发 GC,此调用阻塞式地等待当前 GC 运行完毕。
- 被动触发,分为两种方式:
- 使用系统监控,当超过两分钟没有产生任何 GC 时,强制触发 GC。
- 使用步调(Pacing)算法,其核心思想是控制内存增长的比例。