Go:内存管理|青训营笔记

93 阅读6分钟

这是我参与「第五届青训营 」伴学笔记创作活动的第5天

内容梗概

本文梳理了以下内容:

本文主要内容为Go语言自动内存管理的基本概念、基本算法以及垃圾回收机制、Go语言内存分配以及Go语言内存管理优化。

背景

自动内存管理主要管理的是动态内存动态内存指的是程序在运行时根据需求动态分配的内存,比如C语言中的malloc()函数分配的内存。

自动内存管理也称为垃圾回收,主要目的是由程序语言的运行时系统管理动态内存,这样做有以下两方面的好处:

  • 避免手动内存管理,专注于实现业务逻辑
  • 保证内存使用的正确性和安全性,比如C语言中的内存多次释放:double-free problem, 释放后再次使用use-after-free problem

由此可见,手动释放内存存在很多问题,如果使用不当的话,可能会引起程序的崩溃、漏洞,而自动内存管理可以帮我们自动处理这些问题。

自动内存管理有3个主要的核心任务:

  1. 为新对象分配空间
  2. 找到存活对象
  3. 回收死亡对象的内存空间

1 基本概念

  • Mutator:业务线程,分配新对象,修改对象指向关系

  • Collector:GC线程,找到存活对象,回收死亡对象的内存空间

  • Serial GC:只有一个collector,会暂停(STW)

  • Parallec GC:支持多个collectors同时回收GC算法,会暂停(STW)

  • Concurrent GCmutator(s)collector(s)可以同时执行,Collectors必须感知对象指向关系的改变。

2 GC算法

接下来将介绍两种常见的GC相关的技术:

  • 追踪垃圾回收(Tracing garbage collection)
  • 引用计数(Reference counting)

2.1 追踪垃圾回收

追踪垃圾回收,当一个对象的指针指向关系不可达的时候,该对象就要被回收了。

追踪垃圾回收算法垃圾回收步骤:

  1. 标记根对象

    • 标记包括 静态变量、全局变量、常量、线程栈等
  2. 标记:找到可达对象

    • 求指针指向关系的传递闭包:从根对象触发,找到所有可达对象
  3. 清理:所有不可达对象

    • 将存活对象复制到另外的内存空间(Copying GC)
    • 将死亡对象的内存标记为”可分配“(Marking-sweep GC)
    • 移动并整理存活对象(Mark-compact GC)

清理策略有很多种,在实际清理的时候应该根据对象的生命周期,使用不同的标记和清理策略。

2.2 分代 GC

分代GC(Generational GC)是一种常见的内存管理方式,思想是基于分代假说(Generational hypothesis)——大多数对象很快就死掉了(most objects die young),很多对象在分配出来后很快就不再使用了。

每个对象都有年龄,也就是对象经历过GC的次数,比如经历了2次GC那么他的年龄就为2。

分代GC根据对象年龄的不同,把对象放在不同的区域,年轻代对象放在Young Generation区域,老年代放在Old Generation区域,这样做的目的为对年轻和老年的对象,制定不同的GC策略,降低整体内存管理的开销。

年轻代为常规的对象分配,由于存活对象很少,可以采用copying collection算法,GC吞吐量很高。

老年代内对象趋向于一直活着,反复复制开销较大,可以采用mark-sweep collection算法

2.3 引用计数

引用计数管理内存的方式为每个对象都有一个与之相关联的引用数目,对象存活的条件为当且仅当引用数大于0。

引用计数管理内存的优点如下:

  • 内存管理的操作被平摊到程序执行过程中
  • 内存管理不需要了解runtime的实现细节,有一些库可以帮助实现引用计数,比如C++智能指针(smart pointer)。

当然引用计数也有缺点:

  • 维护引用计数的开销较大:通过原子操作保证对引用计数操作的原子性和可见性
  • 无法回收环形数据结构——weak reference
  • 内存开销:每个对象都引入的额外内存空间存储引用数目
  • 回收内存时可能引发暂停——大的数据结构

3 内存分配

什么是内存分配?在Go中内存分配是指对象在堆(heap)上开辟一块空间出来。

内存分配主要分为几个部分:分块、缓存

3.1 分块

那么内存分配是如何做的呢?Go是提前将内存分成一个一个的小块,当创建对象的时候,在内存中找到一个与对象尺寸最接近的一个块分配给他,就完成了一次内存分配。

内存分块的步骤:

  1. 调用系统调用mmap()OS申请一大块内存,例如4MB
  2. 先将内存划分大块,例如8KB,称为mspan
  3. 再将大块继续划分成特定大小的小块,用于对象分配
  4. noscan mspan:分配不包含指针的对象——GC不需要扫描
  5. scan mspan:分配包括指针的对象——GC需要扫描

3.2 缓存

Go的内存分配借鉴了TCMalloc(Thread Caching)内存分配器的实现。

Go的内存分配也是做了很多级缓存,从而加快整体对象分配的速度。

Go在分配内存的时候,都是Goroutine上面执行的代码去分配一块内存,如下图所示,从g出发,找到mp(不懂啥意思??),在p上有一个数据接口mcache,在mcache中存了一组mspans,每个mspans的大小是不一样的。

mspans里面找到一个最合适的mspan里的一个空余的块,找到这个块之后,返回出去,就完成了一次对象的分配。

如果mcache里的mspan都是满的,那就就会从下一个级别的缓存,也就是mcentral里面找一个带有空余空间的mspan,并将其填充的mcache里面去,然后再继续分配。

如果一个mspan内存活动的对象都已经清理干净了,那他的所有的块都是可以使用的,Go的内存分配器不会把这块空间立刻还给OS,而是把这块空间缓存起来,如果还要分配的时候,就直接拿去分配,流程如下:

image.png