前言
现代计算机语言大多数都带有自动内存管理功能,也就是垃圾收集(GC)。程序可以使用堆中的内存,但我们没必要手工去释放。垃圾收集器可以知道哪些内存是垃圾,然后归还给操作系统。垃圾收集包括标记-清除、停止-拷贝两大类算法,停止-拷贝算法被认为是最快的垃圾收集算法,但停止-拷贝算法有缺陷STW(Stop-The-World)。 在自动内存管理领域的一个研究的重点,就是如何缩短这种停顿时间。以 Go 语言为例,它的停顿时间从早期的几十毫秒,已经降低到了几毫秒。甚至有一些激进的算法,力图实现不用停顿。增量收集和并发收集算法,就是在这方面的有益探索。
增量收集可以每次只回收部分对象,没必要一次把活干完,从而减少停顿。并发收集就是在不影响程序执行的情况下,并发地执行垃圾收集工作。
为了讨论增量和并发收集算法,我们定义两个角色:一个是收集器(Collector),负责垃圾收集;一个是变异器(Mutator),其实就是程序本身,它会造成可达对象的改变。
如果在狗乱跑的情况下,扫地机器人把地尽量扫干净
采取 STW 这样凶残的策略,主要还是防止 mutator 在 GC 的时候捣乱——这跟你用扫地机器人的时候先把狗先锁起来,等房子全部打扫完再把狗放开 的道理是一样的(Stop The Dog)。V8 增量 GC 之三色标记
因为增量回收是并发的(concurrent),因此它的过程像上图一样(可以想象一下 CPU 的时间片轮转),这就意味着 GC 可能被随时暂停、重启,因此暂停时需要保存当时的扫描结果,等下一波 GC 来之后还能继续启动。而双色标记实际上仅仅是对扫描结果的描述:非黑即白,但忽略了对扫描进行状态的描述:这个点的子节点扫完了没有?
为了处理这种情况,Dijkstra 引入了另外一种颜色:灰色。
- 灰色,它表示这个节点被 Root 引用到,但子节点我还没处理;
- 黑色的意思就变为:这个节点被 Root 引用到,而且子节点都已经标记完成。
- 白色表示,算法还没有访问的对象
用三色标记法来分析的话,你会发现前面的算法有两个特点:
- 不会有黑色对象指向白色对象,因为黑色对象都已经被扫描完毕了
- 每一个灰色对象都处于收集器的待处理工作区中,比如在标记-除算法的 todo 列表中(标记算法就是一个图的广度优先遍历过程,需要一个队列的支持)。
我们发现,只要保证这两个特点一直成立,那么收集器和变异器就可以一起工作,互不干扰,从而实现增量收集或并发收集。
- Collector/GC线程得到cpu后,GC只需要处理灰色节点即可。当图中没有灰色节点时,便是整个图标记完成之时,就可以进行清理工作了。
- Mutator 可以访问 黑色或白色节点(存疑)
如何保证上面两个特点一直成立?比如,如果变异器要在一个黑色对象 a 里存储一个指针 b,把 a 涂成灰色,或者把 b 涂成灰色,都会保持上面两条的成立。或者当变异器要读取一个白色指针 a 的时候,就把它涂成灰色,这样的话也不会违背上面两条。不同的算法会采取不同的策略,但无论采取哪种算法,收集器和变异器都是通过下面三种机制来协作:
- 读屏障(read barrier 或 load barrier)。在 load 指令(从内存到寄存器)之后立即执行的一小段代码,用于维护垃圾收集所需的数据。包括把内存对象涂成正确的颜色,并保证所有灰色对象都在算法的工作区里。
- 写屏障(write barrier 或 store barrier)。在 store 指令(从寄存器到内存)之前执行的一小段代码,也要为垃圾收集做点儿工作。
- 安全点(safepoint)。安全点是代码中的一些点,在这些点上,指针的值是可以安全地修改的。有时,你修改指针的值是有问题的,比如正在做一个大的数组的拷贝,拷到一半,你把数组的地址改了,这就有问题。所以安全点一般都在方法调用、循环跳转、异常跳转等地方。
三色标记主要是为了解决传统双色标记过程无法分片的问题(或者说STW是 串行执行的,不需要分片),有了三色标记,Collector线程便可以暂停、重启,变成跟 mutator并发的方式来运行;
三色标记法是什么?三色标记过程其实是一个波面不断前进的过程。
就物理位置来说,三个颜色的对象是“犬牙交错”的,通过标记颜色,使其变成了几块“泾渭分明”的区域。
Go比Java产生更少的内存垃圾
Go的对象(即struct类型)是可以分配在栈上的。Go会在编译时做静态逃逸分析(Escape Analysis), 如果发现某个对象并没有逃出当前作用域,则会将对象分配在栈上而不是堆上,从而减轻了GC压力。其实JVM也有逃逸分析,但与Go不同的是Java无法在编译时做这项工作,分析是在运行时完成的,这样做一是会占用更多的CPU时间,二是不可能会把所有未逃逸的对象都优化到栈中。
整体实现
Golang源码探索(三) GC的实现原理GO的GC是并行GC, 也就是GC的大部分处理和普通的go代码是同时运行的, 这让GO的GC流程比较复杂.
首先GC有四个阶段, 它们分别是:
- Sweep Termination: 对未清扫的span进行清扫, 只有上一轮的GC的清扫工作完成才可以开始新一轮的GC
- Mark: 扫描所有根对象, 和根对象可以到达的所有对象, 标记它们不被回收
- Mark Termination: 完成标记工作, 重新扫描部分根对象(要求STW)
- Sweep: 按标记结果清扫span
在GC过程中会有两种后台任务(G), 一种是标记用的后台任务, 一种是清扫用的后台任务.
- 标记用的后台任务会在需要时启动, 可以同时工作的后台任务数量大约是P的数量的25%, 也就是go所讲的让25%的cpu用在GC上的根据.
- 清扫用的后台任务在程序启动时会启动一个, 进入清扫阶段时唤醒.
源码入口
$GOROOT/src/runtime/mgc.go
go触发gc会从gcStart函数开始
编译器与gc的关系
总的来说,垃圾收集器是一门语言,运行期的一部分,不是编译器的职责。所以,LLVM 并没有为我们提供垃圾收集器。但是,要想让垃圾收集器发挥功能,必须要编译器配合,LLVM 能够支持:
- 在代码中创建安全点,只有在这些点上才可以执行 GC。
- 计算栈图(Stack Map)。在安全点上,栈桢中的指针会被识别出来,作为 GC 根节点被 GC 所使用。
- 提供写屏障和读屏障的支持,用于支持增量和并发收集。
GC友好的代码
避免内存分配和赋值
- 尽量使用引用传递
- 初始化至合适的大小
- 复用内存