linux内存布局
要搞懂gc前我们需要知道gc到底在回收什么。而想到知道gc在回收什么不可避免的就必须要清楚进程的内存布局了。
- kernel space 内核空间可以操作任意空间,而用户空间如果需要操纵内核空间,需要由操作系统来完成,调用操作称为系统调用(system call)。
- stack是栈区,常称为堆栈。它的分配由高地址往低地址扩展。栈空间用于分配函数的出入参和局部变量
- memory mapping是映射区,比如一些外部的动态链接库等。
- heap是堆,和数据结构中的堆没有关系,它的分配由低地址往高地址分配。它用于存储应用程序动态申请的对象。
- bss是Block Started by Symbol的简称,属于静态内存分配。一般用来存放程序中未初始化的全局变量。
- data用来存储一些常量数据,一般用来存储程序中已经初始化的全局变量。
- text用于加载程序自身代码段。
go内存管理
像go这种自带runtime的语言基本上抛弃了传统的内存分配方式,改为自动管理。这样可以自主地实现更好的内存使用模式,比如内存池、预分配等等。这样,不会每次内存分配都需要进行系统调用。go内存没有内存碎片也是因为这种分配模式而避免的。
协程栈虽然在堆上但是也是不需要gc回收
gc负责回收堆内存,而不回收栈(协程栈)中的内存。主要是原因是栈为函数执行准备的,存储着函数的局部变量以及调用栈,这块内存用完会直接释放所以不需要gc来管理。(go的协程栈也是在堆内存分配的,不是传统的栈,go的栈有个stack cache pool,管理栈对象,回收已销毁的栈还给stack cache pool,栈仍旧是调用完毕即销毁,包括栈中的临时对象)。
变量逃逸
逃逸分析基本原则
- 指向堆栈对象的指针不能存储在堆中。(堆上的指针不能指向栈)
- 指向堆栈对象的指针不能超过那个对象的寿命。
type User struct {
Name string
Age int
}
func main() {
GetUser()
}
func GetUser() *User {
user := User{}
return &user
}
/*由于 *user 这个指针被传到 main 函数中使用,而 User 这个对象在 GetUser 方法中 New 出来,如果不逃逸到堆上,则指针的寿命比对象长。因此需要逃逸到堆上,让对象的寿命比其指针长。因此上述例子发生了逃逸
*/
type User struct {
Name string
Age int
Car *Car
}
type Car struct {
}
func main() {
user := GetUser()
car := Car{}
user.Car = &car
}
func GetUser() *User {
return &User{}
}
/*
`GetUser` 返回的是一个逃逸的对象,即其已经存在堆中。我们给其一个字段赋值 `*Car`, 如果把 Car 对象分配在栈中,发生了指向 栈中对象(Car)的指针存储在堆中对象中(User) ,违背了原则 1,所以 Car 对象需要逃逸到堆。`[fmt.Println](https://link.zhihu.com/?target=https%3A//github.com/golang/go/issues/8618)` 也是这个原因存在逃逸
*/
GC主要流程
根对象
全局变量:程序在编译期就能确定的那些存在于程序整个生命周期的变量。
执行栈:每个 goroutine 都包含自己的执行栈,这些执行栈上包含栈上的变量及指向分配的堆内存区块的指针。
寄存器:寄存器的值可能表示一个指针,参与计算的这些指针可能指向某些赋值器分配的堆内存区块。
三色标记法
三色抽象只是一种描述追踪式回收器的方法,在实践中并没有实际含义,它的重要作用在于从逻辑上严密推导标记清理这种垃圾回收方法的正确性。
当垃圾回收开始时,只有白色对象。随着标记过程开始进行时,灰色对象开始出现(着色),这时候波面便开始扩大。当一个对象的所有子节点均完成扫描时,会被着色为黑色。当整个堆遍历完成时,只剩下黑色和白色对象,这时的黑色对象为可达对象,即存活;而白色对象为不可达对象,即死亡。这个过程可以视为以灰色对象为波面,将黑色对象和白色对象分离,使波面不断向前推进,直到所有可达的灰色对象都变为黑色对象为止的过程。
GC流程图
这应该是比较老版本的gc流程,但是大致正确。
- Off 代表gc当前未开启,一轮完整的Gc总是从Off状态开启的。
- Stack Scan 收集根对象(全局变量和goroutine栈上的变量)这个节点会开启写屏障 。
a. 不需要回收栈上的对象为什么需要扫描栈上的变量?
b. 开启写屏障需要进行stw。(这里go 1.14 做了什么优化?) - Mark 标记对象,知道标记完所有根对象和根对象可达对象,此时写屏障会记录所有指针的更改。
- Mark Termination 重新扫描部分全局变量和发生更改的栈变量,完成标记。(stw主要耗费时间)
- Sweep 并发清除为标记的对象。
GC的优化迭代
上图我们发现第二次的Stw需要很久,go是怎么优化的呢?答案是混合写屏障
首先我们要知道为什么需要stw?
因为标记工作和用户逻辑代码是并行的,标记完成后的栈重新执行用户代码后可能会破坏当前的标记现场。比如一个黑色对象因为业务代码引用了堆上的白色对象,如此垃圾回收的正确性就被破坏了。白色对象后续会被回收,相当于栈上的一个对象引用了一个需要被回收的对象。为此解决这种情况有3种方案:
- 栈上使用写屏障,(屏障就是用户对变量操作时插入的特定代码),被扫描后的栈就开启写屏障,所有引入的对象全部被标为黑色。
- 在gc过程中扫描后的栈正常运行,不做处理,等扫描阶段结束后,stw,重新对活跃的栈进行扫描。这也是上文做的方式。
- 对堆采用混合写屏障,对栈不做处理。栈上运行
最终go采用了第三种方案,性能和准确性上面都获得了保证。