
























GC(Garbage Collection) 垃圾回收,是编程语言中自动管理内存的一种机制,它能够自动识别并释放不再使用的内存空间,从而避免内存泄漏。GC 是编程语言中非常重要的一部分,对于提高程序的性能和稳定性具有重要意义。
Java 中垃圾回收器经历了多个版本的迭代和改进,从一开的串行回收器到并行回收器,再到 G1(Garbage First)回收器,再到 ZGC(Z Garbage Collector)回收器,每一代的垃圾回收器都朝着更低的停顿时间(STW, Stop The World)和支持更大的堆这两个目标前进,接下来将简要介绍一下 CMS、G1 以及 ZGC 这三种垃圾回收器。
CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器,基于并发标记清理实现,在标记清理过程中不会导致用户线程无法定位引用对象。仅作用于老年代收集。它的步骤如下:

可以看到 CMS 在重新标记时有一次 STW,以及并发标记和并发清理时需要占用一部分用户线程的资源,并且在清理时如果用户线程分配了大于剩下内存的对象,则会引发 Concurrent Mode Fail 的问题,此时会立刻暂停用户进程并且开启 Serial 收集器进行垃圾回收清理的操作。当垃圾回收完成之后,会开启用户用户线程并且恢复 CMS 收集器的工作。
并且如前面提到的,CMS 是主要是基于标记清理实现,在上述流程中只是对对象进行了标记和清理,并没有涉及到对象的移动,这就导致了内存碎片化,如果此时新生代出现大对象要进来,很容易造成频繁 Full GC,严重影响性能。官方对于此问题的解决方法是:在一定次数的 Full GC 后(默认 0 次,也就是每次 GC 都进行),进行一次标记整理,此阶段也是需要 STW,这对性能也会有一定影响。

从上面的结构图中可以看到 G1 收集器的内存结构完全区别去 CMS,弱化了 CMS 原有的分代模型,将堆内存划分成一个个 Region,这样有利于更灵活地管理堆。G1 的混合回收过程可以分为标记阶段、清理阶段和复制阶段。
标记阶段
清理阶段
清理阶段清点出有存活对象的分区和没有存活对象的分区,该阶段不会清理垃圾对象,也不会执行存活对象的复制。该阶段是 STW 的。
复制阶段
复制算法中的转移阶段需要分配新内存和复制对象的成员变量。转移阶段是 STW 的,其中内存分配通常耗时非常短,但对象成员变量的复制耗时有可能较长,这是因为复制耗时与存活对象数量与对象复杂度成正比。对象越复杂,复制耗时越长。1

上述三个阶段中,标记阶段因为对象较少,清理阶段因为分区较少,他们的 STW 时间也比较少,但是复制阶段因为对象多且复杂,所以 STW 时间较长。此时可以想到,如果让复制阶段也和用户线程并发处理,这样则可以大大提升性能,由此引出了 ZGC。
ZGC 和 G1 一样也使用了标记复制算法,不过 ZGC 对该算法做了重大改进:ZGC 在标记、转移和重定位阶段几乎都是并发的。

ZGC 只有三个 STW 阶段:初始标记,再标记,初始转移。其中,初始标记和初始转移分别都只需要扫描所有 GC Roots,一般情况耗时非常短;再标记阶段 STW 时间也很短,最多 1ms,超过 1ms 则再次进入并发标记阶段。1
在 G1 中,复制阶段无法并发的原因是无法确定对象的地址,如果此时对象移动,则用户线程会访问到错误地址,从而导致错误。在 ZGC 中,通过着色指针和读屏障两个关键技术解决了此问题。大概原理是:当用户线程访问对象将触发“读屏障”,如果发现对象被移动了,那么“读屏障”会把读出来的指针更新到对象的新地址上,这样用户线程始终访问的都是对象的新地址
着色指针:一种将信息存储在指针中的技术。
读屏障:JVM 向应用代码插入一小段代码的技术。当应用线程从堆中读取对象引用时,就会执行这段代码。
着色指针的结构如下:

ZGC 中地址视图的切换过程:
标记阶段存在两个地址视图 M0 和 M1,这是为了区别前一次标记和当前标记。当第二次进入并发标记阶段后,地址视图调整为 M1,而非 M0。

可以看到,着色指针和读屏障技术不仅应用在并发转移阶段,还应用在并发标记阶段设置对象状态。相比于传统的垃圾回收器需要进行一次内存访问,并将对象存活信息放在对象头中;在 ZGC 中,只需要设置指针地址的第 42~45 位即可,并且因为是寄存器访问,所以速度比访问内存更快。
与 Java 不同的是,Go 中的 GC 是不分代的 GC,没有新生代和老年代的划分。这是因为 Go 会在编译阶段进行逃逸分析,将生命周期长的对象分配到堆上,生命周期短的对象分配到栈上,GC 只需要关注堆上的对象即可。
一个逃逸分析的例子
package main
import "fmt"
type Person struct {
Name string
}
func NewPerson(name string) *Person {
p1 := &Person{Name: name}
p2 := &Person{}
p2.Name = p1.Name
return p2
}
func main() {
p := NewPerson("demo")
fmt.Println(p)
}
执行结果
go build -gcflags=-m main.go
# command-line-arguments
.\main.go:9:6: can inline NewPerson
.\main.go:17:16: inlining call to NewPerson
.\main.go:18:13: inlining call to fmt.Println
.\main.go:9:16: leaking param: name
.\main.go:10:8: &Person{...} does not escape
.\main.go:11:8: &Person{} escapes to heap
.\main.go:17:16: &Person{...} does not escape
.\main.go:17:16: &Person{} escapes to heap
.\main.go:18:13: ... argument does not escape
可以看到第 10 行的&Person{}(也就是p1)没有逃逸,是在栈上分配,而第 11 行的&Person{}(也就是p2)因为函数返回了这个指针,所以逃逸到了堆上。
Go 的内存分配是基于 TCMalloc 改造的一种内存分配方式,这种分配方式高效,减少并发粒度,并且没有内存碎片。2

这里我们不再表述 TCMalloc 的分配方式,直接进入到 Go 的内存分配模型。Go 的内存管理如上图,实际上已经非常清楚了,下面是几个要点:
除了上面的内存分配模型,还有一个大小转换在内存分配中也起到了很大的作用:

Go 中根据对象大小仅分为小对象和大对象,小对象从 mcache 中分配,大对象从 mheap 中分配内存。

当分配一个对象时,需要为该对象寻找 Span,此流程如下:
如果 Span 不够,mcache 则会从 mcentral 中请求 Span。mcentral 中的每个 Span class 包含有两个链表:一个链表里面的所有 Span 都至少有 1 个空闲的对象空间,叫做 nonempty;另一个链表中的所有 Span 都不确定里面是否有空闲的对象空间,叫做 empty。每次优先从 nonempty 中寻找合适 Span,如果没找到再从 emtpy 搜索满足条件的 Span,然后把找到的 Span 交给 mcache。
上面内存模型图中的 mheap 中也有三个数据结构,这里介绍如下:
在看完 Go 的内存分配后,我们正式进入到 Go 的垃圾回收,Go 的 GC 大致分为标记和清扫两个阶段:
在标记阶段 Go 使用了三色标记法来减少 STW 时间(前面 CMS 和 G1 中使用的标记算法也是此方法)。
简要来讲,三色标记就把对象标记为黑、灰、白三种颜色,三种颜色含义如下:
标记过程如下3:

从上面可以看到如果在标记时一个用户线程恰巧让一个黑色对象引用一个白色对象,这个白色对象又没有被其他的灰色对象引用,因为黑色对象已经被标记完无法再次标记,此时这个白色对象将会被漏标。

一套解决上述漏标问题的方法被称为强弱三色不变式:
Go 语言中为了解决上述问题,引入了插入写屏障和删除写屏障,这两个合称为混合写屏障。
插入写屏障
黑色对象试图指向白色对象时触发插入写屏障,白色对象先被置灰,然后再完成指向。
删除写屏障
白色对象被删除引用前出发删除写屏障,先被置灰,然后再被删除引用。
在实际实现中,因为栈对象涉及到频繁的轻量操作,这些操作如果都要触发屏障机制则会对性能产生极大影响,因此写屏障对栈对象不起作用,所以 Go 需要同时引入插入写屏障和删除写屏障来解决漏标问题,最终机制如下:
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。