概述
GC就是垃圾回收机制。而我们知道,内存区域是分成几个块儿的,例如:
- 堆区:为对象分配内存空间,在栈区和bss区之间
- 存放函数参数,返回值,局部变量
- 全局区:常量区(const,字面常量,硬编码,字符串常量),初始化数据区(具体初始化数值),未初始化数据区(bss)
- 代码区:CPU执行的计算机指令,只读,共享
那么我们GC回收的是堆区。栈区的内存是代码块结束的时候自动释放的。
那么垃圾回收到底是一个什么样子的过程呢?
垃圾回收的思想是:先找出垃圾,再回收垃圾
如何找垃圾,标记垃圾,判定垃圾?
判定垃圾有两个典型的方案:
- 引用计数
- 可达性分析
引用计数
引用计数,就是通过一个变量来保存当前这个对象,被几个引用来指向~
优点:
- 规则简单,实现方便,比较高效
缺点:
- 空间利用率低,如果一个对象很大就算了,无所谓,如果你一个对象很小,但是对象很多,那么此时引用计数就会带来一个不可忽略的空间开销,因为每引用一次要加一个
int
类型,就是4字节,如果你对象才4字节,那直接人麻了~ - 存在循环引用问题(致命弱点)
可达性分析
从一组初始的位置出发,向下进行深度遍历,把所有能够访问到的对象都标记成可达,对应的,不可达的对象就是垃圾。
优点:
- 解决了循环引用的问题
- 占用的空间少了
缺点:
- 无法立即标识出垃圾对象,需要依赖GC线程
- 算法在标记时必须暂停整个程序,即STW,否则其他线程有可能会修改对象的状态从而回收不该回收的对象(这里就跟线程安全一样,要加锁的,你可以这么理解)
当前已经知道哪些是垃圾了,具体怎么去回收?
垃圾回收中经典的策略/算法
- 标记 - 清除
- 复制算法
- 标记 - 整理
标记清除
标记清除算法是最常见的垃圾收集算法,标记清除收集器是跟踪式垃圾收集器,其执行过程可以分成标记(Mark)和清除(Sweep)两个阶段:
-
标记阶段:暂停应用程序的执行,从根对象触发查找并标记堆中所有存活的对象;
-
清除阶段:遍历堆中的全部对象,回收未被标记的垃圾对象并将回收的内存加入空闲链表,恢复应用程序的执行;
内存碎片,空闲的内存和正在使用的内存是交替出现的,此时如果你想要申请一块小内存,那还好,如果想申请一块儿大的连续的内容,此时可能就会分配失败
为了解决内存碎片的问题,引入复制算法
复制算法
复制算法的缺点
- 可用的内存空间只有一半
- 如果要回收的对象比较少(剩下的对象比较多),复制的开销就很大了
复制算法适用于对象会被快速回收,并且整体内存不大的场景下~
为了解决复制算法空间利用率低的问题,引入标记整理
标记整理
类似于顺序表删除元素,搬运。
缺点:整理过程复杂,需要多次遍历,导致STW时间有可能比标记清除还夸张。
因此实际实现的垃圾回收算法需要能够结合以上3种方式,取长补短。
因此产生了分代回收这种垃圾回收机制。
分代回收
根据对象的"年龄"来去进行划分~
把年龄端的对象放在一起,年龄长的放在一起,不用年龄的对象就可以采取不同的垃圾回收算法来处理。
分代回收过程:
- 一个新的对象诞生于伊甸园区
- 如果活到1岁(对象经历了一轮GC还没有死),就拷贝到生存区
- 在生存区种,对象也要经历若干轮GC,每一轮GC逃过的对象,都通过复制算法拷贝到另外的生存区里面,这里面的对象来回拷贝,每一轮都会淘汰一波对象
- 在生存区种,熬过一定轮次的GC之后,这个对象仍然屹立不倒,就认为这个对象未来还会更持久的存在下去,因此拷贝到老年代
- 老年代使用标记整理算法
特殊情况,如果对象特别大,会直接进入老年代,如果把这个大对象直接放在新生代,拷贝来拷背去开销太大,生存区也放不下
俗称"走后门"
你是不是感觉很熟悉,我们上面讲的是常见的GC算法,但是已经蕴含了Java的JVM的GC机制,JVM采用的是可达性分析+分代回收。那么Go语言的GC采用的也是JVM的这种方式吗?
很显然不是的!
Go不需要Java风格的GC
像Go、Julia和Rust这样的现代语言不需要像Java c#所使用的那样复杂的垃圾收集器。但这是为什么呢?
我们首先要了解垃圾收集器是如何工作的,以及各种语言分配内存的方式有什么不同。首先,我们看看为什么Java需要如此复杂的垃圾收集器。
为什么Java比其他语言更需要快速的GC?
Java将内存管理完全外包给了它的垃圾回收器。事实证明这是一个巨大的错误。Java设计者把赌注押在高级垃圾收集器上,它能够解决内存管理中的所有挑战。由于这个原因,Java中的所有对象——除了整数和浮点值等基本类型——都被设计为在堆上分配。在讨论内存分配时,我们通常会区分所谓的堆和栈。
栈使用起来非常快,但空间有限,只能用于那些在函数调用的生命周期之内的对象。栈只适用于局部变量。
堆可用于所有对象。Java基本上忽略了栈,选择在堆上分配所有东西,除了整数和浮点等基本类型。无论何时,在Java中写下 new Something()
消耗的都是堆上的内存。
然而,就内存使用而言,这种内存管理实际上相当昂贵。你可能认为创建一个32位整数的对象只需要4字节的内存。
class Lxy {
int sakura;
}
这些数据通常为16字节。因此,头部信息与实际数据的比例是4:1。Java对象的c++源代码定义为:
class oopDesc {
volatile markOop _mark; // for mark and sweep
Klass* _klass; // the type
}
内存碎片
接下来的问题是内存碎片。当Java分配一个对象数组时,它实际上是创建一个引用数组,这些引用指向内存中的其他对象。这些对象最终可能分散在堆内存中。这对性能非常不利,因为现代微处理器不读取单个字节的数据。因为开始传输内存数据是比较慢的,每次CPU尝试访问一个内存地址时,CPU会读取一块连续的内存。
这块连续的内存块被称为cache line 。CPU有自己的缓存,它的大小比内存小得多。CPU缓存用于存储最近访问的对象,因为这些对象很可能再次被访问。如果内存是碎片化的,这意味着cache line也会被碎片化,CPU缓存将被大量无用的数据填满。CPU缓存的命中率就会降低。
Java如何克服内存碎片
这就要回到我们之前说过的了,最终Java权衡使用了分代回收的机制。
现代语言如何避免与Java相同的缺陷
现代语言不需要像Java那样复杂的垃圾收集器。这是在设计这些语言时,并没有像Java一样依赖垃圾回收器。
可以看到这个代码:
type Lxy struct {
x, y int
}
var sakuras [15000]Lxy
在这个Go语言代码中,我们分配了15000个Lxy结构体。这仅仅分配了一次内存,产生了一个指针,在Java中,这需要15000次内存分配,每次分配产生一个引用,这些引用也要单独管理起来,每一个Lxy都会有前面提到的16字节头部信息开销,而Go中,你是不会看到头部信息,对象(结构体)通常是没有这些头部信息的。
值类型
在除Java外的其他语言,基本上都支持值类型。下面的代码定义了一个矩形,用一个Min和Max点来定义它的范围。
type Rect struct {
Min, Max Point
}
这就变成了一个连续的内存块。在Java中,这将变成一个Rect
对象,它引用了两个单独的对象,Min
和Max
对象。因此在Java中,一个Rect
实例需要3次内存分配,但在Go、Rust、C/c++和Julia中只需要1次内存分配。
C只需要输入unsigned char[20]
并将其内联到容器的内存分配中。Java中的byte[20]
将额外消耗16个字节的内存,而且访问速度较慢,因为这10个字节和容器对象位于不相邻的内存区域。我们试图通过将一个byte[20]
转换为5个int来解决这个问题,但这需要耗费额外的CPU指令。
在Go语言中,我可以做和C/C++一样的事情,并定义一个像这样的结构:
type Lxy struct {
data [20]byte
}
这些字节将位于一个完整的内存块中。而Java将创建一个指向其他地方的指针。
值类型是不够的
仅仅有值类型是不够的,这并没有使Java站在Go和C++/C等语言的同等地位。Java是不支持指针的!!!
就像在C/C++中一样,你可以在Go中获取对象的地址或对象的字段,并将其存储在一个指针中。然后,您可以传递这个指针,并使用它来修改所指向的字段。这意味着您可以在Go中创建大的值对象,并将其作为函数指针传递,来优化性能。
分代GC和逃逸分析
Java垃圾收集器有更多的工作要做,因为它分配了更多的对象。为什么?我们刚刚讲过了。如果没有值对象和真正的指针,在分配大型数组或复杂的数据结构时,它将总是以大量的对象告终。因此,它需要分代GC。
分配更少对象的需求对Go语言有利。但Go语言还有另一个技巧。Go和Java在编译函数时都进行了逃逸分析。
逃逸分析包括查看在函数内部创建的指针,并确定该指针是否逃逸出了函数范围。
func escapingPtr() []int {
values := []int{4, 5, 10}
return values
}
fun nonEscapingPtr() int {
values = []int{4, 5, 10}
var total int = addUp(values)
return total
}
在第一个示例中,values
指向一个切片,这在本质上与指向数组的指针相同。它逃逸了是因为它被返回了。这意味着必须在堆上分配values
。
然而,在第二个例子中,指向values
的指针并不会离开nonEscapingPtr
函数。因此,可以在栈上分配values
,这个动作非常快速,并且代价也很小。逃逸分析本身只分析指针是否逃逸。
Java和Go的逃逸分析
但是,Go使用逃逸分析来确定哪些对象可以在堆栈上分配。这大大减少了寿命短的对象的数量,这些对象本来可以从分代GC中受益。但是要记住,分代GC的全部意义在于利用最近分配的对象生存时间很短这一事实。然而,Go语言中的大多数对象可能会活得很长,因为生存时间短的对象很可能会被逃逸分析捕获。
与Java不同,在Go语言中,逃逸分析也适用于复杂对象。Java通常只能成功地对字节数组等简单对象进行逃逸分析。即使是内置的ByteBuffer也不能使用标量替换在堆栈上进行分配。
现代语言不需要压缩GC
您可以读到许多垃圾收集器方面的专家声称,由于内存碎片,Go比Java更有可能耗尽内存。这个论点是这样的:因为Go没有压缩垃圾收集器,内存会随着时间的推移而碎片化。当内存被分割时,你将到达一个点,将一个新对象装入内存将变得困难。
但是,由于以下两个原因,这个问题大大减少了:
- Go不像Java那样分配那么多的小对象。它可以将大型对象数组作为单个内存块分配
- Go采用的模仿是TCMalloc的内存分配方式,基本上不存在碎片问题
分代GC vs 并发GC的暂停
使用分代GC的Java策略旨在使垃圾收集周期更短。要知道,为了移动数据和修复指针,Java必须停止所有操作。如果停顿太久,将会降低程序的性能和响应能力。使用分代GC,每次检查的数据更少,从而减少了检查时间。
然而,Go用一些替代策略解决了同样的问题:
- 因为不需要移动内存,也不需要固定指针,所以在GC运行期间要做的工作会更少。Go GC只做一个标记和清理:它在对象图中查找应该被释放的对象(Go的三色标记法)。
- 它并发运行。因此,单独的GC线程可以在不停止其他线程的情况下寻找要释放的对象。
为什么Go可以并发运行GC而Java却不行?因为Go不会修复任何指针或移动内存中的任何对象(这不代表代码没有并发问题,只是Go的内存不会像Java一样来回移动,所以可以用其他更好的方式解决问题,Java的内存在分代间不断移动,无法并发是无奈之举)。因此,不存在尝试访问一个对象的指针,而这个对象刚刚被移动,但指针还没有更新这种风险。不再有任何引用的对象不会因为某个并发线程的运行而突然获得引用。因此,平行移动“已经死亡”的对象没有任何危险。
这是怎么回事?假设你有4个线程在一个Go程序中工作。其中一个线程在任意时间T
秒内执行临时GC工作,时间总计为4秒。
现在想象一下,一个Java程序的GC只做了2秒的GC工作。哪个程序挤出了最多的性能?谁在T
秒内完成最多?听起来像Java程序,对吧?错了!
Java程序中的4个工作线程将停止所有线程2秒。这意味着 2×4 = 8秒的工作在T
秒中丢失。因此,虽然Go的停止时间更长,但每次停止对程序工作的影响更小,因为所有线程都没有停止。因此,缓慢的并发GC的性能可能优于依赖于停止所有线程来执行其工作的较快GC。
总结
虽然高级垃圾收集器解决了Java中的实际问题,但现代语言,如Go和Julia,从一开始就避免了这些问题,因此不需要使用Rolls Royce垃圾收集器。当您有了值类型、转义分析、指针、多核处理器和现代分配器时,Java设计背后的许多假设都被抛到了脑后。它们不再适用。
基于以上理由,Go的GC和Java的GC还是很有不同的。
Golang的垃圾回收算法
Golang的垃圾回收(GC)算法使用的是无分代(对象没有代际之分)、不整理(回收过程中不对对象进行移动与整理)、并发(与用户代码并发执行)的三色标记清扫算法。原因在于:
- 对象整理的优势是解决内存碎片问题以及“允许”使用顺序内存分配器。但 Go 运行时的分配算法基于
tcmalloc
,基本上没有碎片问题。 并且顺序内存分配器在多线程的场景下并不适用。Go 使用的是基于tcmalloc
的现代内存分配算法,对对象进行整理不会带来实质性的性能提升。 - Go 的编译器会通过逃逸分析将大部分新生对象存储在栈上(栈直接被回收),只有那些需要长期存在的对象才会被分配到需要进行垃圾回收的堆中。也就是说,分代
GC
回收的那些存活时间短的对象在 Go 中是直接被分配到栈上,当goroutine
死亡后栈也会被直接回收,不需要GC
的参与,进而分代假设并没有带来直接优势。 - Go 的垃圾回收器与用户代码并发执行,使得 STW 的时间与对象的代际、对象的 size 没有关系。Go 团队更关注于如何更好地让 GC 与用户代码并发执行(使用适当的 CPU 来执行垃圾回收),而非减少停顿时间这一单一目标上。况且,三色标记法实际上是可以减少STW甚至不使用STW时间的。
在讲解三色标记法之前,我们还是先讲解一下Go老版本的垃圾回收算法,这样前后做对比印象更加深刻。
Go1.3之前 – 标记清除法
此算法主要有两个主要的步骤:
- 标记(Mark phase)
- 清除(Sweep phase)
第一步,暂停程序业务逻辑, 找出不可达的对象,然后做上标记。第二步,回收标记好的对象。
操作非常简单,但是有一点需要额外注意:mark and sweep算法在执行的时候,需要程序暂停!即 STW(stop the world)
。也就是说,这段时间程序会卡在哪儿。
第二步, 开始标记,程序找出它所有可达的对象,并做上标记。如下图所示:
第三步, 标记完了之后,然后开始清除未标记的对象. 结果如下.
第四步, 停止暂停,让程序继续跑。然后循环重复这个过程,直到process程序生命周期结束。
标记-清扫(mark and sweep)的缺点
- STW,stop the world;让程序暂停,程序出现卡顿 (重要问题)。
- 标记需要扫描整个heap
- 清除数据会产生heap碎片
所以Go V1.3版本之前就是以上来实施的, 流程是
Go V1.3 做了简单的优化,将STW提前, 减少STW暂停的时间范围.如下所示
这里面最重要的问题就是:mark-and-sweep 算法会暂停整个程序 。
Go是如何面对并这个问题的呢?接下来G V1.5版本 就用三色并发标记法来优化这个问题.
三色标记法原理
三色标记法的出现主要是为了减少这个STW时间或者不使用STW时间。
三色标记法将对象分为三类,并用不同的颜色相称:
- 白色:尚未访问过
- 黑色对象已访问过,而且本对象 引用到 的其他对象 也全部访问过了
- 灰色对象已访问过,但是本对象 引用到 的其他对象 尚未全部访问完。全部访问后,会转换为黑色。
标记过程如下:
- 起初所有的对象都是白色的;
- 从根对象出发扫描所有可达对象,标记为灰色,放入待处理队列;
- 从待处理队列中取出灰色对象,将其引用的对象标记为灰色并放入待处理队列中,自身标记为黑色;
- 重复步骤(3),直到待处理队列为空,此时白色对象即为不可达的“垃圾”,回收白色对象;
上面描述的是三色标记法的大致雏形,只是雏形而已,这样的三色标记法仍然需要依赖STW。如果不依赖STW的话。
用户程序可能在标记执行的过程中修改对象的指针,所以三色标记清除算法本身是不可以并发或者增量执行的,它仍然需要 STW,在如下所示的三色标记过程中,用户程序建立了从 A 对象到 D 对象的引用,但是因为程序中已经不存在灰色对象了,所以 D 对象会被垃圾收集器错误地回收。
本来不应该被回收的对象却被回收了,这在内存管理中是非常严重的错误,我们将这种错误称为悬挂指针,即指针没有指向特定类型的合法对象,影响了内存的安全性,想要并发或者增量地标记对象还是需要使用屏障技术,屏障技术可以在保证并发的同时,减少STW的使用。
屏障机制
强三色不变式
不存在黑色对象引用到白色对象的指针。
弱三色不变式
所有被黑色对象引用的白色对象都处于灰色保护状态.
插入屏障
具体操作
: 在A对象引用B对象的时候,B对象被标记为灰色。(将B挂在A下游,B必须被标记为灰色)
满足
: 强三色不变式. (不存在黑色对象引用白色对象的情况了, 因为白色会强制变成灰色)
- 当插入对象到黑色对象时将插入对象转为灰色。
- 重新扫描栈中的对象
伪代码如下:
添加下游对象(当前下游对象slot, 新下游对象ptr) {
//1
标记灰色(新下游对象ptr)
//2
当前下游对象slot = 新下游对象ptr
}
我们知道,黑色对象的内存槽有两种位置, 栈
和堆
. 栈空间的特点是容量小,但是要求相应速度快,因为函数调用弹出频繁使用, 所以“插入屏障”机制,在栈空间的对象操作中不使用. 而仅仅使用在堆空间对象的操作中。
接下来,我们用几张图,来模拟整个一个详细的过程, 希望您能够更可观的看清晰整体流程。
但是如果栈不添加,当全部三色标记扫描之后,栈上有可能依然存在白色对象被引用的情况(如上图的对象9). 所以要对栈重新进行三色标记扫描, 但这次为了对象不丢失, 要对本次标记扫描启动STW暂停. 直到栈空间的三色标记结束.
最后将栈和堆空间 扫描剩余的全部 白色节点清除. 这次STW大约的时间在10~100ms间.
插入屏障的好处在于可以立刻开始并发标记。但存在两个缺点:
- 由于 Dijkstra 插入屏障的“保守”,在一次回收过程中可能会残留一部分对象没有回收成功,只有在下一个回收过程中才会被回收;
- 在标记阶段中,每次进行指针赋值操作时,都需要引入写屏障,这无疑会增加大量性能开销;为了避免造成性能问题,
Go
团队在最终实现时,没有为所有栈上的指针写操作,启用写屏障,而是当发生栈上的写操作时,将栈标记为灰色,但此举产生了灰色赋值器,将会需要标记终止阶段 STW 时对这些栈进行重新扫描。
删除写屏障
具体操作
: 被删除的对象,如果自身为灰色或者白色,那么被标记为灰色。
满足
: 弱三色不变式. (保护灰色对象到白色对象的路径不会断)
- 删除自身为白色或者灰色,标记为灰色
- 第二次扫描再重新扫描这些对象
可能有老铁要说了,对象5和对象2和对象3不是垃圾吗?不应该被回收吗。为什么保留下来了?是的在这一次GC过程当中他们确实是被保留下来了,但是下一轮GC的时候就会被干掉了。
特点:标记结束不需要STW,但是回收精度低,GC 开始时STW 扫描堆栈记录初始快照,保护开始时刻的所有存活对象;且容易产生“冗余”扫描;
混合写屏障
- GC开始将栈上的对象全部扫描并标记为黑色(之后不再进行第二次重复扫描,无需STW),
- GC期间,任何在栈上创建的新对象,均为黑色。
- 被删除的对象标记为灰色。
- 被添加的对象标记为灰色。
满足
: 变形的弱三色不变式.
Golang中的混合写屏障满足
弱三色不变式
,结合了删除写屏障和插入写屏障的优点,只需要在开始时并发扫描各个goroutine的栈,使其变黑并一直保持,这个过程不需要STW,而标记结束后,因为栈在扫描后始终是黑色的,也无需再进行re-scan操作了,减少了STW的时间。
历史演变
- Go 1.3版本:普通标记清除法,整体过程需要启动STW,效率极低。
- Go 1.5版本: 三色标记法, 堆空间启动写屏障,栈空间不启动,全部扫描之后,需要重新扫描一次栈(需要STW),效率普通
- Go 1.8版本:三色标记法,混合写屏障机制, 栈空间不启动,堆空间启动。整个过程几乎不需要STW,效率较高。
注意!!!
混合写屏障是与程序并发执行的。
Golang GC过程
一次完整的垃圾回收会分为四个阶段,分别是标记准备、标记开始、标记终止、清理:
- 标记准备(Mark Setup):打开写屏障(Write Barrier),需 STW(stop the world)
- 标记开始(Marking):使用三色标记法并发标记 ,与用户程序并发执行
- 标记终止(Mark Termination):对触发写屏障的对象进行重新扫描标记,关闭写屏障(Write Barrier),需 STW(stop the world)
- 清理(Sweeping):将需要回收的内存归还到堆中,将过多的内存归还给操作系统,与用户程序并发执行
前面分析过,因为没有很多内存碎片问题,所以清理不会像Java那么复杂,很容易清理。