Skip to content

Go Runtime、调度、内存与 GC ​

返回完整题目索引

来源:用户提供的《golang 云原生面试题》PDF。下列为原文完整导入,按主题重排;页码为 PDF 物理页码。技术观点与示例保留原件语境,勘误请结合 校正说明 阅读。

uintptr和unsafe.Pointer的区别 ​

PDF 出处:第 17–18 页 · 原卷分组:代码分析 · 源标题 p017-h02。

  1. unsafe.Pointer只是单纯的通用指针类型,用于转换不同类型指针,它不可以参与指针运算;

  2. uintptr是用于指针运算的,GC 不把 uintptr 当指针,也就是说 uintptr 无法持有对象, uintptr 类型的目标会被回收;

  3. unsafe.Pointer 可以和普通指针进行相互转换

  4. unsafe.Pointer 可以和 uintptr 进行相互转换

示例代码:

go
package main

import (
	"fmt"
	"unsafe"
)

type W struct {
	b int32
	c int64
}

func main() {
	var w *W = new(W)
	//这时w的变量打印出来都是默认值0,0
	fmt.Println(w.b, w.c)

	//现在我们通过指针运算给b变量赋值为10
	b := unsafe.Pointer(uintptr(unsafe.Pointer(w)) + unsafe.Offsetof(w.b))
	*((*int)(b)) = 10
	//此时结果就变成了10,0
	fmt.Println(w.b, w.c)
}
  1. uintptr(unsafe.Pointer(w)) 获取了 w 的指针起始值

  2. unsafe.Offsetof(w.b) 获取 b 变量的偏移量

  3. 两个相加就得到了 b 的地址值,将通用指针 Pointer 转换成具体指针 ((*int)(b)),通过 * 符号取值,然后赋值。((int)(b)) 相当于把 (*int)(b) 转换成 int 了,最后对变量重新赋值成 10

知道golang的内存逃逸吗?什么情况下会发生内存逃逸? ​

PDF 出处:第 18–19 页 · 原卷分组:代码分析 · 源标题 p018-h01。

golang程序变量会携带有一组校验数据,用来证明它的整个生命周期是否在运行时完全可知。如果变量通过了这些校验,它就可以在栈上分配。否则就说它逃逸了,必须在堆上分配能引起变量逃逸到堆上的典型情况

  1. 在方法内把局部变量指针返回局部变量原本应该在栈中分配,在栈中回收。但是由于返回时被外部引用,因此其生命周期大于栈,则溢出

  2. 发送指针或带有指针的值到 channel 中。在编译时,是没有办法知道哪个 goroutine 会在 channel上接收数据。所以编译器没法知道变量什么时候才会被释放

  3. 在一个切片上存储指针或带指针的值。一个典型的例子就是 []*string。这会导致切片的内容逃逸。尽管其后面的数组可能是在栈上分配的,但其引用的值一定是在堆上

  4. slice 的背后数组被重新分配了,因为 append 时可能会超出其容量( cap )。 slice 初始化的地方在编译时是可以知道的,它最开始会在栈上分配。如果切片背后的存储要基于运行时的数据进行扩充,就会在堆上分配

  5. 在 interface 类型上调用方法。在 interface 类型上调用方法都是动态调度的 —— 方法的真正实现只能在运行时知道。想像一个 io.Reader 类型的变量 r , 调用 r.Read(b) 会使得 r 的值和切片b 的背后存储都逃逸掉,所以会在堆上分配

代码示例:

go
package main

import "fmt"

type A struct {
	s string
}

// 这是上面提到的 "在方法内把局部变量指针返回" 的情况
func foo(s string) *A {
	a := new(A)
	a.s = s
	return a //返回局部变量a,在C语言中妥妥野指针,但在go则ok,但a会逃逸到堆
}
func main() {
	a := foo("hello")
	b := a.s + " world"
	c := b + "!"
	fmt.Println(c)
}

执行go build -gcflags=-m main.go

text
go build -gcflags=-m main.go
# command-line-arguments
./main.go:7:6: can inline foo
./main.go:13:10: inlining call to foo
./main.go:16:13: inlining call to fmt.Println
/var/folders/45/qx9lfw2s2zzgvhzg3mtzkwzc0000gn/T/go-
build409982591/b001/_gomod_.go:6:6: can inline init.0
./main.go:7:10: leaking param: s
./main.go:8:10: new(A) escapes to heap
./main.go:16:13: io.Writer(os.Stdout) escapes to heap
./main.go:16:13: c escapes to heap
./main.go:15:9: b + "!" escapes to heap
./main.go:13:10: main new(A) does not escape
./main.go:14:11: main a.s + " world" does not escape
./main.go:16:13: main []interface {} literal does not escape
<autogenerated>:1: os.(*File).close .this does not escape
  1. ./main.go:8:10: new(A) escapes to heap 说明 new(A) 逃逸了,符合上述提到的常见情况中的第一种

  2. ./main.go:14:11: main a.s + " world" does not escape 说明 b 变量没有逃逸,因为它只在方法内存在,会在方法结束时被回收

  3. ./main.go:15:9: b + "!" escapes to heap 说明 c 变量逃逸,通过fmt.Println(a ...interface{})打印的变量,都会发生逃逸

怎么避免内存逃逸 ​

PDF 出处:第 19–21 页 · 原卷分组:代码分析 · 源标题 p019-h01。

在runtime/stubs.go:133有个函数叫noescape。noescape可以在逃逸分析中隐藏一个指针。让这个指针在逃逸分析中不会被检测为逃逸

go
// noescape hides a pointer from escape analysis.  noescape is
// the identity function but escape analysis doesn't think the
// output depends on the input.  noescape is inlined and currently
// compiles down to zero instructions.
// USE CAREFULLY!
//go:nosplit
func noescape(p unsafe.Pointer) unsafe.Pointer {
	x := uintptr(p)
	return unsafe.Pointer(x ^ 0)
}

示例代码

go
package main

import (
	"unsafe"
)

type A struct {
	S *string
}

func (f *A) String() string {
	return *f.S
}

type ATrick struct {
	S unsafe.Pointer
}

func (f *ATrick) String() string {
	return *(*string)(f.S)
}

func NewA(s string) A {
	return A{S: &s}
}

func NewATrick(s string) ATrick {
	return ATrick{S: noescape(unsafe.Pointer(&s))}
}

func noescape(p unsafe.Pointer) unsafe.Pointer {
	x := uintptr(p)
	return unsafe.Pointer(x ^ 0)
}

func main() {
	s := "hello"
	f1 := NewA(s)
	f2 := NewATrick(s)
	s1 := f1.String()
	s2 := f2.String()
	_ = s1 + s2
}

执行go build -gcflags=-m main.go

text
go build -gcflags=-m main.go
# command-line-arguments
./main.go:11:6: can inline (*A).String
./main.go:19:6: can inline (*ATrick).String
./main.go:23:6: can inline NewA
./main.go:31:6: can inline noescape
./main.go:27:6: can inline NewATrick
./main.go:28:29: inlining call to noescape
./main.go:36:6: can inline main
./main.go:38:14: inlining call to NewA
./main.go:39:19: inlining call to NewATrick
./main.go:39:19: inlining call to noescape
./main.go:40:17: inlining call to (*A).String
./main.go:41:17: inlining call to (*ATrick).String
/var/folders/45/qx9lfw2s2zzgvhzg3mtzkwzc0000gn/T/go-
build763863171/b001/_gomod_.go:6:6: can inline init.0
./main.go:11:7: leaking param: f to result ~r0 level=2
./main.go:19:7: leaking param: f to result ~r0 level=2
./main.go:24:16: &s escapes to heap
./main.go:23:13: moved to heap: s
./main.go:27:18: NewATrick s does not escape
./main.go:28:45: NewATrick &s does not escape
./main.go:31:15: noescape p does not escape
./main.go:38:14: main &s does not escape
./main.go:39:19: main &s does not escape
./main.go:40:10: main f1 does not escape
./main.go:41:10: main f2 does not escape
./main.go:42:9: main s1 + s2 does not escape

其中主要看中间一小段

text
./main.go:24:16: &s escapes to heap    //这个是NewA中的,逃逸了
./main.go:23:13: moved to heap: s
./main.go:27:18: NewATrick s does not escape // NewATrick里的s的却没逃逸 ./main.go:28:45: NewATrick &s does not escape
  1. 上段代码对A和ATrick同样的功能有两种实现:他们包含一个 string,然后用 String() 方法返回这个字符串。但是从逃逸分析看ATrick 版本没有逃逸

  2. noescape() 函数的作用是遮蔽输入和输出的依赖关系。使编译器不认为 p 会通过 x 逃逸,因为uintptr() 产生的引用是编译器无法理解的

  3. 内置的 uintptr 类型是一个真正的指针类型,但是在编译器层面,它只是一个存储一个指针地址的int 类型。代码的最后一行返回 unsafe.Pointer 也是一个 int

  4. noescape() 在 runtime 包中使用 unsafe.Pointer 的地方被大量使用。如果作者清楚被unsafe.Pointer 引用的数据肯定不会被逃逸,但编译器却不知道的情况下,这是很有用的

  5. 实际项目中不建议使用unsafe包!毕竟包的名字就叫做 unsafe, 而且源码中的注释也写明了 USE CAREFULLY!

Goroutine 定义 ​

PDF 出处:第 33–33 页 · 原卷分组:进阶-Go Runtime · 源标题 p033-h04。

Golang 在语言级别支持协程,称之为 Goroutine。Golang 标准库提供的所有系统调用操作(包括所有的同步 I/O 操作),都会出让 CPU 给其他 Goroutine。这让 Goroutine 的切换管理不依赖于系统的线程和进程,也不依赖于 CPU 的核心数量,而是交给 Golang 的运行时统一调度。

GMP 指的是什么 ​

PDF 出处:第 33–33 页 · 原卷分组:进阶-Go Runtime · 源标题 p033-h05。

G(Goroutine):我们所说的协程,为用户级的轻量级线程,每个 Goroutine 对象中的 sched 保存着其上下文信息。 M(Machine):对内核级线程的封装,数量对应真实的 CPU 数(真正干活的对象)。 P(Processor):即为 G 和 M 的调度对象,用来调度 G 和 M 之间的关联关系,其数量可通过GOMAXPROCS()来设置,默认为核心数。

1.0 之前 GM 调度模型 ​

PDF 出处:第 33–33 页 · 原卷分组:进阶-Go Runtime · 源标题 p033-h06。

调度器把 G 都分配到 M 上,不同的 G 在不同的 M 并发运行时,都需要向系统申请资源,比如堆栈内存等,因为资源是全局的,就会因为资源竞争照成很多性能损耗。为了解决这一的问题 go 从 1.1 版本引入,在运行时系统的时候加入 p 对象,让 P 去管理这个 G 对象,M 想要运行 G,必须绑定 P,才能运行 P 所管理的 G。 GM 调度存在的问题: 1.单一全局互斥锁(Sched.Lock)和集中状态存储 2. Goroutine 传递问题(M 经常在 M 之间传递”可运行”的 goroutine) 3.每个 M 做内存缓存,导致内存占用过高,数据局部性较差 4.频繁 syscall 调用,导致严重的线程阻塞/解锁,加剧额外的性能损耗。

GMP 调度流程 ​

PDF 出处:第 34–34 页 · 原卷分组:进阶-Go Runtime · 源标题 p034-h01。

原 PDF 第 34 页技术插图

  1. 每个 P 有个局部队列,局部队列保存待执行的 goroutine(流程 2),当 M 绑定的 P 的的局部队列已经满了之后就会把 goroutine 放到全局队列(流程 2-1)

  2. 每个 P 和一个 M 绑定,M 是真正的执行 P 中 goroutine 的实体(流程 3), M 从绑定的 P 中的局部队列获取 G 来执行

  3. 当 M 绑定的 P 的局部队列为空时,M 会从全局队列获取到本地队列来执行 G (流程 3.1),当从全局队列中没有获取到可执行的 G 时候,M 会从其他 P 的局部队列中偷取 G 来执行(流程 3.2),这种从其他 P 偷的方式称为 work stealing

  4. 当 G 因系统调用(syscall)阻塞时会阻塞 M,此时 P 会和 M 解绑即 hand off,并寻找新的 idle 的M,若没有 idle 的 M 就会新建一个 M(流程 5.1)

  5. 当 G 因 channel 或者 network I/O 阻塞时,不会阻塞 M,M 会寻找其他 runnable 的 G;当阻塞的 G 恢复后会重新进入 runnable 进入 P 队列等待执行

GMP 中 work stealing 机制 ​

PDF 出处:第 34–34 页 · 原卷分组:进阶-Go Runtime · 源标题 p034-h02。

获取 P 本地队列,当从绑定 P 本地 runq 上找不到可执行的 g,尝试从全局链表中拿,再拿不到从netpoll 和事件池里拿,最后会从别的 P 里偷任务。P 此时去唤醒一个 M。P 继续执行其它的程序。M 寻找是否有空闲的 P,如果有则将该 G 对象移动到它本身。接下来 M 执行一个调度循环(调用 G 对象->执行-> 清理线程→继续找新的 Goroutine 执行)

GMP 中 hand off 机制 ​

PDF 出处:第 34–34 页 · 原卷分组:进阶-Go Runtime · 源标题 p034-h03。

当本线程 M 因为 G 进行的系统调用阻塞时,线程释放绑定的 P,把 P 转移给其他空闲的 M 执行。细节:当发生上线文切换时,需要对执行现场进行保护,以便下次被调度执行时进行现场恢复。Go 调度器 M 的栈保存在 G 对象上,只需要将 M 所需要的寄存器(SP、PC 等)保存到 G 对象上就可以实现现场保护。当这些寄存器数据被保护起来,就随时可以做上下文切换了,在中断之前把现场保存起来。如果此时 G 任务还没有执行完,M 可以将任务重新丢到 P 的任务队列,等待下一次被调度执行。当再次被调度执行时,M 通过访问 G 的 vdsoSP、vdsoPC 寄存器进行现场恢复(从上次中断位置继续执行)。

协作式的抢占式调度 ​

PDF 出处:第 34–35 页 · 原卷分组:进阶-Go Runtime · 源标题 p034-h04。

在 1.14 版本之前,程序只能依靠 Goroutine 主动让出 CPU 资源才能触发调度。这种方式存在问题有:

  1. 某些 Goroutine 可以长时间占用线程,造成其它 Goroutine 的饥饿

  2. 垃圾回收需要暂停整个程序(Stop-the-world,STW),最长可能需要几分钟的时间,导致整个程序无法工作

基于信号的抢占式调度 ​

PDF 出处:第 35–35 页 · 原卷分组:进阶-Go Runtime · 源标题 p035-h01。

在任何情况下,Go 运行时并行执行(注意,不是并发)的 goroutines 数量是小于等于 P 的数量的。为了提高系统的性能,P 的数量肯定不是越小越好,所以官方默认值就是 CPU 的核心数,设置的过小的话,如果一个持有 P 的 M,由于 P 当前执行的 G 调用了 syscall 而导致 M 被阻塞,那么此时关键点: GO 的调度器是迟钝的,它很可能什么都没做,直到 M 阻塞了相当长时间以后,才会发现有一个 P/M 被syscall 阻塞了。然后,才会用空闲的 M 来强这个 P。通过 sysmon 监控实现的抢占式调度,最快在20us,最慢在 10-20ms 才会发现有一个 M 持有 P 并阻塞了。操作系统在 1ms 内可以完成很多次线程调度(一般情况 1ms 可以完成几十次线程调度),Go 发起 IO/syscall 的时候执行该 G 的 M 会阻塞然后被 OS 调度走,P 什么也不干,sysmon 最慢要 10-20ms 才能发现这个阻塞,说不定那时候阻塞已经结束了,这样宝贵的 P 资源就这么被阻塞的 M 浪费了。

GMP 调度过程中存在哪些阻塞 ​

PDF 出处:第 35–35 页 · 原卷分组:进阶-Go Runtime · 源标题 p035-h02。

  1. I/O,select

  2. block on syscall

  3. channel

  4. 等待锁

  5. runtime.Gosched()

Sysmon 有什么作用 ​

PDF 出处:第 35–35 页 · 原卷分组:进阶-Go Runtime · 源标题 p035-h03。

Sysmon 也叫监控线程,变动的周期性检查,好处

  1. 释放闲置超过 5 分钟的 span 物理内存;

  2. 如果超过 2 分钟没有垃圾回收,强制执行;

  3. 将长时间未处理的 netpoll 添加到全局队列;

  4. 向长时间运行的 G 任务发出抢占调度(超过 10ms 的 g,会进行 retake);

  5. 收回因 syscall 长时间阻塞的 P;

三色标记原理 ​

PDF 出处:第 36–36 页 · 原卷分组:进阶-Go Runtime · 源标题 p036-h01。

我们首先看一张图,大概就会对三色标记法有一个大致的了解:

原 PDF 第 36 页技术插图

原理:

  1. 首先把所有的对象都放到白色的集合中

  2. 从根节点开始遍历对象,遍历到的白色对象从白色集合中放到灰色集合中

  3. 遍历灰色集合中的对象,把灰色对象引用的白色集合的对象放入到灰色集合中,同时把遍历过的灰色集合中的对象放到黑色的集合中

  4. 循环步骤 3,知道灰色集合中没有对象

  5. 步骤 4 结束后,白色集合中的对象就是不可达对象,也就是垃圾,进行回收

写屏障 ​

PDF 出处:第 37–38 页 · 原卷分组:进阶-Go Runtime · 源标题 p037-h01。

Go 在进行三色标记的时候并没有 STW,也就是说,此时的对象还是可以进行修改。那么我们考虑一下,下面的情况。

原 PDF 第 37 页技术插图

我们在进行三色标记中扫描灰色集合中,扫描到了对象 A,并标记了对象 A 的所有引用,这时候,开始扫描对象 D 的引用,而此时,另一个 goroutine 修改了 D->E 的引用,变成了如下图所示

原 PDF 第 37 页技术插图

这样会不会导致 E 对象就扫描不到了,而被误认为为白色对象,也就是垃圾写屏障就是为了解决这样的问题,引入写屏障后,在上述步骤后,E 会被认为是存活的,即使后面 E 被 A 对象抛弃,E 会被在下一轮的 GC 中进行回收,这一轮 GC 中是不会对对象 E 进行回收的

插入写屏障 ​

PDF 出处:第 38–38 页 · 原卷分组:进阶-Go Runtime · 源标题 p038-h01。

Go GC 在混合写屏障之前,一直是插入写屏障,由于栈赋值没有 hook 的原因,栈中没有启用写屏障,所以有 STW。Golang 的解决方法是:只是需要在结束时启动 STW 来重新扫描栈。这个自然就会导致整个进程的赋值器卡顿。

删除写屏障 ​

PDF 出处:第 38–38 页 · 原卷分组:进阶-Go Runtime · 源标题 p038-h02。

Golang 没有这一步,Golang 的内存写屏障是由插入写屏障到混合写屏障过渡的。简单介绍一下,一个对象即使被删除了最后一个指向它的指针也依旧可以活过这一轮,在下一轮 GC 中才被清理掉

混合写屏障 ​

PDF 出处:第 38–38 页 · 原卷分组:进阶-Go Runtime · 源标题 p038-h03。

  1. 混合写屏障继承了插入写屏障的优点,起始无需 STW 打快照,直接并发扫描垃圾即可;

  2. 混合写屏障继承了删除写屏障的优点,赋值器是黑色赋值器,GC 期间,任何在栈上创建的新对象,均为黑色。扫描过一次就不需要扫描了,这样就消除了插入写屏障时期最后 STW 的重新扫描栈;

  3. 混合写屏障扫描精度继承了删除写屏障,比插入写屏障更低,随着带来的是 GC 过程全程无 STW;

  4. 混合写屏障扫描栈虽然没有 STW,但是扫描某一个具体的栈的时候,还是要停止这个 goroutine赋值器的工作(针对一个 goroutine 栈来说,是暂停扫的,要么全灰,要么全黑哈,原子状态切换)

GC 触发时机 ​

PDF 出处:第 38–38 页 · 原卷分组:进阶-Go Runtime · 源标题 p038-h04。

主动触发:调用 runtime.GC被动触发:使用系统监控,该触发条件由 runtime.forcegcperiod 变量控制,默认为 2 分钟。当超过两分钟没有产生任何 GC 时,强制触发 GC。使用步调(Pacing)算法,其核心思想是控制内存增长的比例。如 Go 的 GC 是一种比例 GC, 下一次 GC 结束时的堆大小和上一次 GC 存活堆大小成比例.。

Go 语言中 GC 的流程是什么? ​

PDF 出处:第 38–38 页 · 原卷分组:进阶-Go Runtime · 源标题 p038-h05。

Go1.14 版本以 STW 为界限,可以将 GC 划分为五个阶段: GCMark 标记准备阶段,为并发标记做准备工作,启动写屏障 STWGCMark 扫描标记阶段,与赋值器并发执行,写屏障开启并发GCMarkTermination 标记终止阶段,保证一个周期内标记任务完成,停止写屏障 GCoff 内存清扫阶段,将需要回收的内存归还到堆中,写屏障关闭 GCoff 内存归还阶段,将过多的内存归还给操作系统,写屏障关闭。

GC 如何调优 ​

PDF 出处:第 38–38 页 · 原卷分组:进阶-Go Runtime · 源标题 p038-h06。

通过 go tool pprof 和 go tool trace 等工具

  1. 控制内存分配的速度,限制 Goroutine 的数量,从而提高赋值器对 CPU 的利用率。

  2. 减少并复用内存,例如使用 sync.Pool 来复用需要频繁创建临时对象,例如提前分配足够的内存来降低多余的拷贝。

  3. 需要时,增大 GOGC 的值,降低 GC 的运行频率。

Golang GC 时会发生什么? ​

PDF 出处:第 39–40 页 · 原卷分组:进阶-Go Runtime · 源标题 p039-h01。

首先我们先来了解下垃圾回收.什么是垃圾回收?内存管理是程序员开发应用的一大难题。传统的系统级编程语言(主要指C/C++)中,程序开发者必须对内存小心的进行管理操作,控制内存的申请及释放。因为稍有不慎,就可能产生内存泄露问题,这种问题不易发现并且难以定位,一直成为困扰程序开发者的噩梦。如何解决这个头疼的问题呢?过去一般采用两种办法:

  1. 内存泄露检测工具。这种工具的原理一般是静态代码扫描,通过扫描程序检测可能出现内存泄露的代码段。然而检测工具难免有疏漏和不足,只能起到辅助作用。

  2. 智能指针。这是 c++ 中引入的自动内存管理方法,通过拥有自动内存管理功能的指针对象来引用对象,是程序员不用太关注内存的释放,而达到内存自动释放的目的。这种方法是采用最广泛的做法,但是对程序开发者有一定的学习成本(并非语言层面的原生支持),而且一旦有忘记使用的场景依然无法避免内存泄露。为了解决这个问题,后来开发出来的几乎所有新语言(java,python,php等等)都引入了语言层面的自动内存管理 – 也就是语言的使用者只用关注内存的申请而不必关心内存的释放,内存释放由虚拟机(virtual machine)或运行时(runtime)来自动进行管理。而这种对不再使用的内存资源进行自动回收的行为就被称为垃圾回收。

常用的垃圾回收的方法:

  1. 引用计数(reference counting)这是最简单的一种垃圾回收算法,和之前提到的智能指针异曲同工。对每个对象维护一个引用计数,当引用该对象的对象被销毁或更新时被引用对象的引用计数自动减一,当被引用对象被创建或被赋值给其他对象时引用计数自动加一。当引用计数为0时则立即回收对象。这种方法的优点是实现简单,并且内存的回收很及时。这种算法在内存比较紧张和实时性比较高的系统中使用的比较广泛,如ios cocoa框架,php,python等。但是简单引用计数算法也有明显的缺点

  2. 频繁更新引用计数降低了性能。一种简单的解决方法就是编译器将相邻的引用计数更新操作合并到一次更新;还有一种方法是针对频繁发生的临时变量引用不进行计数,而是在引用达到0时通过扫描堆栈确认是否还有临时对象引用而决定是否释放。等等还有很多其他方法,具体可以参考这里。

  3. 循环引用。当对象间发生循环引用时引用链中的对象都无法得到释放。最明显的解决办法是避免产生循环引用,如cocoa引入了strong指针和weak指针两种指针类型。或者系统检测循环引用并主动打破循环链。当然这也增加了垃圾回收的复杂度。

  4. 标记-清除(mark and sweep)标记-清除(mark and sweep)分为两步,标记从根变量开始迭代得遍历所有被引用的对象,对能够通过应用遍历访问到的对象都进行标记为“被引用”;标记完成后进行清除操作,对没有标记过的内存进行回收(回收同时可能伴有碎片整理操作)。这种方法解决了引用计数的不足,但是也有比较明显的问题:每次启动垃圾回收都会暂停当前所有的正常代码执行,回收是系统响应能力大大降低!当然后续也出现了很多mark&sweep算法的变种(如三色标记法)优化了这个问题。

  5. 分代搜集(generation) java的jvm 就使用的分代回收的思路。在面向对象编程语言中,绝大多数对象的生命周期都非常短。分代收集的基本思想是,将堆划分为两个或多个称为代(generation)的空间。新创建的对象存放在称为新生代(young generation)中(一般来说,新生代的大小会比老年代小很多),随着垃圾回收的重复执行,生命周期较⻓的对象会被提升(promotion)到老年代中(这里用到了一个分类的思路,这个是也是科学思考的一个基本思路)。因此,新生代垃圾回收和老年代垃圾回收两种不同的垃圾回收方式应运而生,分别用于对各自空间中的对象执行垃圾回收。新生代垃圾回收的速度非常快,比老年代快几个数量级,即使新生代垃圾回收的频率更高,执行效率也仍然比老年代垃圾回收强,这是因为大多数对象的生命周期都很短,根本无需提升到老年代

Golang GC 时会发生什么? Golang 1.5后,采取的是“非分代的、非移动的、并发的、三色的”标记清除垃圾回收算法。 golang 中的 gc 基本上是标记清除的过程:

原 PDF 第 40 页技术插图

gc的过程一共分为四个阶段:

  1. 栈扫描(开始时STW)

  2. 第一次标记(并发)

  3. 第二次标记(STW)

  4. 清除(并发)

整个进程空间里申请每个对象占据的内存可以视为一个图,初始状态下每个内存对象都是白色标记。

  1. 先STW,做一些准备工作,比如 enable write barrier。然后取消STW,将扫描任务作为多个并发的goroutine立即入队给调度器,进而被CPU处理

  2. 第一轮先扫描root对象,包括全局指针和 goroutine 栈上的指针,标记为灰色放入队列

  3. 第二轮将第一步队列中的对象引用的对象置为灰色加入队列,一个对象引用的所有对象都置灰并加入队列后,这个对象才能置为黑色并从队列之中取出。循环往复,最后队列为空时,整个图剩下的白色内存空间即不可到达的对象,即没有被引用的对象;

  4. 第三轮再次STW,将第二轮过程中新增对象申请的内存进行标记(灰色),这里使用了write barrier(写屏障)去记录Golang gc 优化的核心就是尽量使得 STW(Stop The World) 的时间越来越短。