GC就是垃圾回收机制。而我们知道,内存区域是分成几个块儿的,例如:
那么我们GC回收的是堆区。栈区的内存是代码块结束的时候自动释放的。
那么垃圾回收到底是一个什么样子的过程呢?
垃圾回收的思想是:先找出垃圾,再回收垃圾
判定垃圾有两个典型的方案:
引用计数,就是通过一个变量来保存当前这个对象,被几个引用来指向~
优点:
缺点:
int
类型,就是4字节,如果你对象才4字节,那直接人麻了~从一组初始的位置出发,向下进行深度遍历,把所有能够访问到的对象都标记成可达,对应的,不可达的对象就是垃圾。
优点:
缺点:
垃圾回收中经典的策略/算法
标记清除算法是最常见的垃圾收集算法,标记清除收集器是跟踪式垃圾收集器,其执行过程可以分成标记(Mark)和清除(Sweep)两个阶段:
内存碎片,空闲的内存和正在使用的内存是交替出现的,此时如果你想要申请一块小内存,那还好,如果想申请一块儿大的连续的内容,此时可能就会分配失败
为了解决内存碎片的问题,引入复制算法
复制算法的缺点
复制算法适用于对象会被快速回收,并且整体内存不大的场景下~
为了解决复制算法空间利用率低的问题,引入标记整理
类似于顺序表删除元素,搬运。
缺点:整理过程复杂,需要多次遍历,导致STW时间有可能比标记清除还夸张。
因此实际实现的垃圾回收算法需要能够结合以上3种方式,取长补短。
因此产生了分代回收这种垃圾回收机制。
根据对象的"年龄"来去进行划分~
把年龄端的对象放在一起,年龄长的放在一起,不用年龄的对象就可以采取不同的垃圾回收算法来处理。
分代回收过程:
特殊情况,如果对象特别大,会直接进入老年代,如果把这个大对象直接放在新生代,拷贝来拷背去开销太大,生存区也放不下
俗称"走后门"
你是不是感觉很熟悉,我们上面讲的是常见的GC算法,但是已经蕴含了Java的JVM的GC机制,JVM采用的是可达性分析+分代回收。那么Go语言的GC采用的也是JVM的这种方式吗?
很显然不是的!
像Go、Julia和Rust这样的现代语言不需要像Java c#所使用的那样复杂的垃圾收集器。但这是为什么呢?
我们首先要了解垃圾收集器是如何工作的,以及各种语言分配内存的方式有什么不同。首先,我们看看为什么Java需要如此复杂的垃圾收集器。
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一样依赖垃圾回收器。
可以看到这个代码:
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中创建大的值对象,并将其作为函数指针传递,来优化性能。
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
,这个动作非常快速,并且代价也很小。逃逸分析本身只分析指针是否逃逸。
但是,Go使用逃逸分析来确定哪些对象可以在堆栈上分配。这大大减少了寿命短的对象的数量,这些对象本来可以从分代GC中受益。但是要记住,分代GC的全部意义在于利用最近分配的对象生存时间很短这一事实。然而,Go语言中的大多数对象可能会活得很长,因为生存时间短的对象很可能会被逃逸分析捕获。
与Java不同,在Go语言中,逃逸分析也适用于复杂对象。Java通常只能成功地对字节数组等简单对象进行逃逸分析。即使是内置的ByteBuffer也不能使用标量替换在堆栈上进行分配。
您可以读到许多垃圾收集器方面的专家声称,由于内存碎片,Go比Java更有可能耗尽内存。这个论点是这样的:因为Go没有压缩垃圾收集器,内存会随着时间的推移而碎片化。当内存被分割时,你将到达一个点,将一个新对象装入内存将变得困难。
但是,由于以下两个原因,这个问题大大减少了:
使用分代GC的Java策略旨在使垃圾收集周期更短。要知道,为了移动数据和修复指针,Java必须停止所有操作。如果停顿太久,将会降低程序的性能和响应能力。使用分代GC,每次检查的数据更少,从而减少了检查时间。
然而,Go用一些替代策略解决了同样的问题:
为什么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的垃圾回收(GC)算法使用的是无分代(对象没有代际之分)、不整理(回收过程中不对对象进行移动与整理)、并发(与用户代码并发执行)的三色标记清扫算法。原因在于:
tcmalloc
,基本上没有碎片问题。 并且顺序内存分配器在多线程的场景下并不适用。Go 使用的是基于tcmalloc
的现代内存分配算法,对对象进行整理不会带来实质性的性能提升。GC
回收的那些存活时间短的对象在 Go 中是直接被分配到栈上,当goroutine
死亡后栈也会被直接回收,不需要GC
的参与,进而分代假设并没有带来直接优势。在讲解三色标记法之前,我们还是先讲解一下Go老版本的垃圾回收算法,这样前后做对比印象更加深刻。
此算法主要有两个主要的步骤:
第一步,暂停程序业务逻辑, 找出不可达的对象,然后做上标记。第二步,回收标记好的对象。
操作非常简单,但是有一点需要额外注意:mark and sweep算法在执行的时候,需要程序暂停!即 STW(stop the world)
。也就是说,这段时间程序会卡在哪儿。
第二步, 开始标记,程序找出它所有可达的对象,并做上标记。如下图所示:
第三步, 标记完了之后,然后开始清除未标记的对象. 结果如下.
第四步, 停止暂停,让程序继续跑。然后循环重复这个过程,直到process程序生命周期结束。
Go V1.3 做了简单的优化,将STW提前, 减少STW暂停的时间范围.如下所示
这里面最重要的问题就是:mark-and-sweep 算法会暂停整个程序 。
Go是如何面对并这个问题的呢?接下来G V1.5版本 就用三色并发标记法来优化这个问题.
三色标记法的出现主要是为了减少这个STW时间或者不使用STW时间。
三色标记法将对象分为三类,并用不同的颜色相称:
标记过程如下:
上面描述的是三色标记法的大致雏形,只是雏形而已,这样的三色标记法仍然需要依赖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间.
插入屏障的好处在于可以立刻开始并发标记。但存在两个缺点:
Go
团队在最终实现时,没有为所有栈上的指针写操作,启用写屏障,而是当发生栈上的写操作时,将栈标记为灰色,但此举产生了灰色赋值器,将会需要标记终止阶段 STW 时对这些栈进行重新扫描。具体操作
: 被删除的对象,如果自身为灰色或者白色,那么被标记为灰色。
满足
: 弱三色不变式. (保护灰色对象到白色对象的路径不会断)
可能有老铁要说了,对象5和对象2和对象3不是垃圾吗?不应该被回收吗。为什么保留下来了?是的在这一次GC过程当中他们确实是被保留下来了,但是下一轮GC的时候就会被干掉了。
特点:标记结束不需要STW,但是回收精度低,GC 开始时STW 扫描堆栈记录初始快照,保护开始时刻的所有存活对象;且容易产生“冗余”扫描;
满足
: 变形的弱三色不变式.
Golang中的混合写屏障满足
弱三色不变式
,结合了删除写屏障和插入写屏障的优点,只需要在开始时并发扫描各个goroutine的栈,使其变黑并一直保持,这个过程不需要STW,而标记结束后,因为栈在扫描后始终是黑色的,也无需再进行re-scan操作了,减少了STW的时间。
注意!!!
混合写屏障是与程序并发执行的。
一次完整的垃圾回收会分为四个阶段,分别是标记准备、标记开始、标记终止、清理:
前面分析过,因为没有很多内存碎片问题,所以清理不会像Java那么复杂,很容易清理。