发生内存泄露时,要知道内存如何进行分配,垃圾回收要找出哪些对象有用,哪些对象无用。尤其当系统到达高并发瓶颈时,更需要垃圾回收这一角色做好风险把控,否则就是大型灾难现场。今天就来聊聊内存管理与垃圾回收。

背景介绍

垃圾回收 Garbage Collection ,我们一般称为 GC。在现实世界中,说到垃圾,指的就是那些不读的书、不穿的衣服等。在计算机中,GC 把程序不用的内存空间视为垃圾。但究其本质,GC 本身也是一个程序,如果满足两项功能的程序,我们就可以叫它 GC:

  1. 找到内存空间中的垃圾
  2. 回收空间,让程序员能够再次利用这部分空间。

在没有 GC 的年代,像 C 语言程序员必须自己手动去分配内存,必须确保申请多少大小的内存空间,在程序执行完释放不再需要的空间。因为在当时计算机的内存资源是稀缺和昂贵的,现在我们买一个 G 内存条的价格相当于当时买 1 KB 的价格,所以程序员写代码时操作都是小心谨慎的。

人为操作,难免有疏忽的地方。如果忘记释放内存空间,该内存空间就会发生泄露。意味着这块空间将会继续维持被使用的状态,无法被使用。一部分内存泄露放任不管,直到所有内存被占满了,整个系统也就崩溃了。

另外,在释放内存空间时,如果忘记初始化用于释放内存的指针,这个指针就会一直指向释放完成的内存空间。更有甚者,释放的空间错误,导致下次程序使用这个空间时发生故障。这些内存上的 bug 都是难以确定真实原因的,因为与内存分配时疏忽造成的 bug 和真实场景下发生的位置(或时间)是不一致的。

为了略去以上种种的麻烦与困难,聪明的人们研发了 GC,即把内存管理的工作交给计算机,程序员就不用想着什么时候要释放内存,不用再担心忘记释放内存所导致的 bug,从而大大减轻负担,将更多精力和注意力放在业务开发上。

在学习难度上,如果说内存分配难度在 2 ,那么垃圾回收的难度就在 4,相比之下难度翻倍。并且学习垃圾回收必须掌握扎实的理论基础,否则难以读懂代码,不知道在干什么。有三本关于垃圾回收的书,可以去读一读:《垃圾回收的算法与实现》、《垃圾回收算法手册-自动内存管理的艺术》、《深入 Java 虚拟机》。

实际上,Go 官方的 runtime 作者中能够去维护 GC 代码的人也很少。

据说,GC 是因为 Java 的发布而一举成名。

基础概念

我们在学习内存分配的和 GC 的时候需要经常去问自己一些问题,顺着问题的思路去找代码会方便些。如果直接埋头去看 Go 语言内存相关的代码肯定会很蒙蔽的,毕竟内部数据结构真的很多。

内存分配:

  1. 内存从哪里来?
  2. 内存要到哪里去?

GC 中标记流程:

  1. 标记对象从哪里来?
  2. 标记对象到哪里去?

GC 中清扫:

  1. 垃圾从哪里来?
  2. 垃圾到哪里去?

当掌握了对象的流向以后,一些中间遇到的数据结构就能够连接起来。这在往期文章谈“调度”的时候提到的“生产-消费”流程非常相似。只要我们为这些问题找到了答案,也就慢慢地学会每一个流程具体是怎么实现的,并且写出自己的总结。

在任何一门编程语言中,都会有栈分配和堆分配的概念。

栈分配,是指函数调用返回后,函数栈帧自动销毁(SP 下移)。函数调用时,会有函数栈帧,这个栈帧上面所有的变量都是函数的局部变量,在做函数调用的时候就相当于把栈从高地址向低地址增长的过程,然后移动栈顶的寄存器。在函数地址返回以后,被调用栈上的内容也就无效了。把这个栈直接干掉,返回回去,这部分内存就得到释放了。这个才做非常轻量化,既不需要对这段内存上的东西进行任何置零操作,也不需要关心这段代码做了什么事。

示例代码调用规约

而堆分配就不是一个轻量级的操作。一个 C 语言示例,思考一下:返回函数的局部变量会怎样变化?

1int * func(void) {
2    int num = 1234;
3    /* ... */
4    return #
5}

在 func 中声明 num 变量并返回它的引用,熟悉 C 语言的朋友肯定知道,这段代码有问题的。这里相当于把被调用函数栈上的地址返回回来了,在被调用函数被销毁的时候,这个地址就是非法的。

这里由于悬垂指针 Dangling pointer,可能会触发问题 Segementation fault。

虽然这段代码在 C 语言中是非法的,但在 Go 语言中却是合法的。因为 Go 语言底层,帮我们做了逃逸分析(Escap analysis),我们不用担心 dangling pointer。当发生类似于上段 C 语言代码情况的时候 Go 语言会自动把这些看起来是局部变量的内容分配在堆上。这个堆在函数返回之后依然是有效的。我们可以利用 Go 语言的工具直接去看逃逸分析的结果,示例代码如下,文件名 escape.go

1package main
2
3func main() {
4    var m = make([]int, 10240)
5    println(m[0])
6}
1go build -gcflags="-m" escape.go

在进行编译的时候,用到参数 gcflags,它其实等于 go compile -flags。参数 -m 可以看到逃逸分析的结果:

image-20210912143727082

如果想知道变量 m 为什么会分配到堆上,可以在 -gcflags 中加入更多的 -m 参数。加的 -m 越多,输出的信息越详细包括逃逸的理由、逃逸代码位置、分析过程等等,如图所示:

image-20210912144141459

网上也有逃逸分析的文章,但大多不靠谱,多是基于工作中的案例。这些案例不是那么全。所以如果想要真正地去学习逃逸分析,还需要看官方的一些资料,比如:

  • 高难度:cmd/compile/internal/gc/escape.go
  • 低难度(新手推荐):https://github.com/golang/go/tree/master/test

小结一下,不管是栈还是堆,都要做内存分配的工作。不过问题又来了:

  • 内存需要分配,谁来分配?自动 allocator 或手工分配
  • 内存需要回收,谁来回收?自动 collector 或手工回收

在 C 语言中调用 malloc 手工分配,Go 语言中没有用到 malloc 而是自动分配的。当对象被分配在堆上的时候,底层发现它在堆上了,那么就在堆上自动找到一块空间,将这个对象放进去。当对象增长的时候也不需要手动再去 malloc 更大的空间,手动将原来对象拷贝过去。这些过程,带有内存分配器的编程语言都自动做好了。

自动内存回收技术 = 垃圾回收技术。这体现在代码中的话也就是说不用去写 free 语句。

再来补充解释一下,内存管理中的三个角色:

  • Mutator:写的应用程序,即 App。它会不断地修改对象的引用关系,即对象图。(也是研究 GC 的人取的花哨的名字 fancy word for application)
  • Allocator:内存分配器,负责管理从操作系统中分配出的内存空间。malloc 其实是底层就有一个内存分配器的实现(glibc 中)(因为 malloc 实现简单,多数情况需要加锁),tcmalloc是 malloc 多线程改进版。Go 中的实现类似 tcmalloc。
  • Collector:垃圾收集器,负责清理死对象,释放不再使用内存空间。

内存管理中的三个角色与分工

栈上我们不用操心内存管理的问题,只需要声明变量,只要不逃逸就会在栈上。而 allcator 和 collector 说的主要是堆内存相关的功能。

Go 程序运行后的,如果 mutator 在堆上分配内存,用到逆向工具一定难看到它调用 runtime.newobject 函数,进而出发 allocator 分配内存的操作。

allocator 为了提升性能会有本地的缓存,如果本地缓存用完了就会用系统调用去向操作系统申请内存。allocator 从操作系统拿回内存后,会去维护一些内存管理中的数据结构 memory management struct,这个动作主要都是用来做优化的。在内存、缓存、列表处理以后,将地址返回给应用程序。

collector 垃圾收集器主要负责扫描内存管理中的数据结构。通过特殊的比特位来判断数据是否变为了垃圾没有人再使用。如果已经变为了垃圾就需要去做收集操作。收集回的内存,一部分会做数据合并并放回原来的本地缓存中,还有一部分通过 syscall 调用系统调用 madvise 最终把内存返还为操作系统。

因为 Go 语言是一门跨平台的语言,所以它和系统交互的时候不能只考虑 Linux(以上分工讲的是 Linux 和 FreeBSD 上的系统调用),在 windows 是另外一套系统调用。所以 Go 语言在这之间做了一套抽象层:内存可能有各种状态,各种状态之间要做些转换,在做转换的时候有些时候会涉及到与系统的交互,然后把所有系统的系统调用都封装在了统一的函数中,即多平台统一抽象:

  • sysMap
  • sysUsed
  • sysUnused
  • sysFree
  • sysFault

内存管理抽象

每个操作系统都有相应的实现,如果去深入了解可以去看看:

  • mem_linux.go
  • mem_windows.go

相关的抽象描述在 runtime/malloc.go 注释中。

总结,了解内存管理与垃圾回收的一些背景和基础概念,有了准备,现在就可以去动手做些性能分析和性能优化的事情。