内存管理

Golang 的内存模型,为什么小对象多了会造成 gc 压力。

通常小对象过多会导致 GC 三色法消耗过多的 GPU。优化思路是,减少对象分配。

Go 语言什么时候垃圾回收,写代码的时候如何减少对象分配

goroutine 申请新的内存管理单元时触发垃圾回收

写代码的时候如何减少对象分配,这是一个关于性能的问题,

  • 例如如果需要把数字转换成字符串,使用 strconv.Itoa () 比 fmt.Sprintf () 要快一倍左右。

  • 如果需要把数字转换成字符串,使用 strconv.Itoa () 比 fmt.Sprintf () 要快一倍左右。这里就不一一展开了。

给大家丢脸了,用了三年 Golang,我还是没答对这道内存泄漏题

https://mp.weixin.qq.com/s?__biz=MzkxNTU5MjE0MQ==&mid=2247492761&idx=1&sn=7b3660c3da402fa7f4c49b1ca8048f22&source=41#wechat_redirect

Go 内存泄漏?不是那么简单

https://colobu.com/2019/08/28/go-memory-leak-i-dont-think-so/ 

Go 内存分配,和 tcmalloc 的区别?

GO 内存分配

Go 内存分配核心思想就是把内存分为多级管理,从而降低锁的粒度。

它将可用的堆内存采用二级分配的方式进行管理:每个线程都会自行维护一个独立的内存池,进行内存分配时优先从该内存池中分配,当内存池不足时才会向全局内存池申请,以避免不同线程对全局内存池的频繁竞争

  • Go 在程序启动时,会向操作系统申请一大块内存,之后自行管理。
  • Go 内存管理的基本单元是 mspan,它由若干个页组成,每种 mspan 可以分配特定大小的 object。
  • Mcache, mcentral, mheap 是 Go 内存管理的三大组件,层层递进。
    • Mcache 管理线程在本地缓存的 mspan;
    • mcentral 管理全局的 mspan 供所有线程使用;
    • mheap 管理 Go 的所有动态分配内存。
  • 分配对象
    • 极小的对象 (16B)会分配在一个 object 中,以节省资源,使用 tiny 分配器分配内存;
    • 一般对象(16B-32KB)通过 mspan 分配内存;
    • 大对象(>32 KB)则直接由 mheap 分配内存。

tcmalloc

Tcmalloc 是 google 开发的内存分配算法库,最开始它是作为 google 的一个性能工具库 perftools 的一部分。TCMalloc 是用来替代传统的 malloc 内存分配函数。它有减少内存碎片,适用于多核,更好的并行性支持等特性。
TC 就是 Thread Cache 两英文的简写。它提供了很多优化,如:

  1. TCMalloc 用固定大小的 page (页)来执行内存获取、分配等操作。这个特性跟 Linux 物理内存页的划分是不是有同样的道理。
    1. TCMalloc 用固定大小的对象,比如 8 KB,16 KB 等用于特定大小对象的内存分配,这对于内存获取或释放等操作都带来了简化的作用。
  2. TCMalloc 还利用缓存常用对象来提高获取内存的速度
  3. TCMalloc 还可以基于每个线程或者每个 CPU 来设置缓存大小,这是默认设置。
    1. TCMalloc 基于每个线程独立设置缓存分配策略,减少了多线程之间锁的竞争。

Go 中的内存分类并不像 TCMalloc 那样分成小、中、大对象,但是它的小对象里又细分了一个 Tiny 对象,Tiny 对象指大小在 1 Byte 到 16 Byte 之间并且不包含指针的对象。小对象和大对象只用大小划定,无其他区分。

Go 内存管理与 tcmalloc 最大的不同在于,它提供了逃逸分析和垃圾回收机制。

Go 语言中的堆和栈

  • 栈主要用来存储值类型的数据,如整数、浮点数、布尔值等。因为值类型的数据大小是固定的,所以可以直接分配在栈上,访问速度非常快。

  • 堆主要用来存储引用类型的数据,如字符串、切片、字典等。因为引用类型的数据大小是不固定的,所以需要动态分配内存,通常在堆上进行。同时,由于引用类型的数据通常需要共享和修改,因此使用指针来进行引用和操作,从而避免了复制大量的数据。

可以看出,栈的性能会更好——不需要额外的垃圾回收机制(离开该作用域,它们的内存就会被自动回收),CPU 可以连续缓存(内存空间是连续的)。堆是通过GC 回收内存的。

Go 内存分配机制?

Go 语言内置运行时(就是 runtime),抛弃了传统的内存分配方式,改为自主管理。这样可以自主地实现更好的内存使用模式,比如内存池、预分配等等。这样,不会每次内存分配都需要进行系统调用。

操作系统的内存管理

 操作系统存储模型

image.png

观察上图,我们可以从中捕捉到的关键词是:

  • 多级模型

  • 动态切换

虚拟内存与物理内存

image.png

操作系统内存管理中,另一个重要概念是虚拟内存,其作用如下:

  • 在用户与硬件间添加中间代理层(没有什么是加一个中间层解决不了的)

  •  优化用户体验(进程感知到获得的内存空间是“连续”的)

  •  “放大”可用内存(虚拟内存可以由物理内存+磁盘补足,并根据冷热动态置换,用户无感知)

 分页管理

操作系统中通常会将虚拟内存和物理内存切割成固定的尺寸,于虚拟内存而言叫作“页”,于物理内存而言叫作“帧”,原因及要点如下:

  • 提高内存空间利用(以页为粒度后,消灭了不稳定的外部碎片,取而代之的是相对可控的内部碎片)

  •  提高内外存交换效率(更细的粒度带来了更高的灵活度)

  •  与虚拟内存机制呼应,便于建立虚拟地址物理地址的映射关系(聚合映射关系的数据结构,称为页表)

  •  linux 页/帧的大小固定,为 4KB(这实际是由实践推动的经验值,太粗会增加碎片率,太细会增加分配频率影响效率)

GO 内存模型设计思想

  • 内存分配算法采用 Google 的 TCMalloc算法每个线程都会自行维护一个独立的内存池,进行内存分配时优先从该内存池中分配,当内存池不足时才会向加锁向全局内存池申请,减少系统调用并且避免不同线程对全局内存池的锁竞争
  • 把内存切分的非常的细小,分为多级管理,以降低锁的粒度
  • 回收对象内存时,并没有将其真正释放掉,只是放回预先分配的大块内存中,以便复用。只有内存闲置过多的时候,才会尝试归还部分内存给操作系统,降低整体开销

具体来说

  1. 以空间换时间,一次缓存,多次复用

由于每次向操作系统申请内存的操作很重,那么不妨一次多申请一些,以备后用.

Golang 中的堆 mheap 正是基于该思想,产生的数据结构. 我们可以从两个视角来解决 Golang 运行时的堆:

  • I 对操作系统而言,这是用户进程中缓存的内存

  • II 对于 Go 进程内部,堆是所有对象的内存起源

  1.  多级缓存,实现无/细锁化

图片

堆是 Go 运行时中最大的临界共享资源,这意味着每次存取都要加锁,在性能层面是一件很可怕的事情.

在解决这个问题,Golang 在堆 mheap 之上,依次细化粒度,建立了 mcentral、mcache 的模型,下面对三者作个梳理:

  • mheap:全局的内存起源,访问要加全局锁

  • mcentral:每种对象大小规格(全局共划分为 68 种)对应的缓存,锁的粒度也仅限于同一种规格以内

  •  mcache:每个 P(正是 GMP 中的 P)持有一份的内存缓存,访问时无锁

这些概念,我们在第 2 节中都会再作详细展开,此处可以先不深究,注重于宏观架构即可.

  •  多级规格,提高利用率

图片

首先理下 page 和 mspan 两个概念:

(1)page:最小的存储单元.

Golang 借鉴操作系统分页管理的思想,每个最小的存储单元也称之为页 page,但大小为 8 KB

(2)mspan:最小的管理单元.

mspan 大小为 page 的整数倍,且从 8B 到 80 KB 被划分为 67 种不同的规格,分配对象时,会根据大小映射到不同规格的 mspan,从中获取空间.

于是,我们回头小节多规格 mspan 下产生的特点:

  • I 根据规格大小,产生了等级的制度

  • II 消除了外部碎片,但不可避免会有内部碎片

  • III 宏观上能提高整体空间利用率

  • IV 正是因为有了规格等级的概念,才支持 mcentral 实现细锁化

  1. • 全局总览,留个印象

图片

上图是 Thread-Caching Malloc 的整体架构图,Golang 正是借鉴了该内存模型. 我们先看眼架构,有个整体概念,后续小节中,我们会不断对细节进行补充.

分配组件

Go 的内存管理组件主要有:mspanmcachemcentralmheap image.png

内存管理单元:mspan

image.png

  •  mspan 是 Golang 内存管理的最小单元,该结构体中包含 next 和 prev 两个字段,它们分别指向了前一个和后一个 mspan

  • mspan 大小是 page 的整数倍(Go 中的 page 大小为 8KB),且内部的页是连续的(至少在虚拟内存的视角中是这样),这里的页不是操作系统中的内存页,它们是操作系统内存页的整数倍。

  •  每个 mspan 根据空间大小以及面向分配对象的大小,会被划分为不同的等级(2.2小节展开)

  •  同等级的 mspan 会从属同一个 mcentral,最终会被组织成链表,因此带有前后指针(prev、next)

  •  由于同等级的 mspan 内聚于同一个 mcentral,所以会基于同一把互斥锁管理

  •  mspan 会基于 bitMap 辅助快速找到空闲内存块(块大小为对应等级下的 object 大小),此时需要使用到 Ctz64 算法.

page 是内存存储的基本单元,“对象”放到 page

type mspan struct {
    // 标识前后节点的指针 
    next *mspan     
    prev *mspan    
    // ...
    // 起始地址
    startAddr uintptr 
    // 包含几页,页是连续的
    npages    uintptr 


    // 标识此前的位置都已被占用 
    freeindex uintptr
    // 最多可以存放多少个 object
    nelems uintptr // number of object in the span.


    // bitmap 每个 bit 对应一个 object 块,标识该块是否已被占用
    allocCache uint64
    // ...
    // 标识 mspan 等级,包含 class 和 noscan 两部分信息
    spanclass             spanClass    
    // ...
}

内存单元等级 spanClass

Go 有 68 种不同大小的 spanClass,用于小对象的分配

const _NumSizeClasses = 68
var class_to_size = [_NumSizeClasses]uint16{0, 8, 16, 32, 48, 64, 80, 96, 112, 128, 144, 160, 176, 192, 208, 224, 240, 256, 288, 320, 352, 384, 416, 448, 480, 512, 576, 640, 704, 768, 896, 1024, 1152, 1280, 1408, 1536,1792, 2048, 2304, 2688, 3072, 3200, 3456, 4096, 4864, 5376, 6144, 6528, 6784, 6912, 8192, 9472, 9728, 10240, 10880, 12288, 13568, 14336, 16384, 18432, 19072, 20480, 21760, 24576, 27264, 28672, 32768}

如果按照序号为 1 的 spanClass(对象规格为 8 B)分配,每个 span 占用堆的字节数:8 k,mspan 可以保存 1024个对象

如果按照序号为 2 的 spanClass(对象规格为 16 B)分配,每个 span 占用堆的字节数:8 k,mspan 可以保存 512个对象

如果按照序号为 67 的 spanClass(对象规格为 32 K)分配,每个 span 占用堆的字节数:32 k,mspan 可以保存1个对象

image.png

字段含义:

  • Class: class ID,每个 span 结构中都有一个 class ID, 表示该 span 可处理的对象类型
  • Bytes/obj:该 class 代表对象的字节数
  • Bytes/span:每个 span 占用堆的字节数,也即页数*页大小
  • Objects: 每个 span 可分配的对象个数,也即(bytes/spans)/(bytes/obj)
  • Waste bytes: 每个 span 产生的内存碎片,也即(bytes/spans)%(bytes/obj)

大于 32 k 的对象出现时,会直接从 heap 分配一个特殊的 span,这个特殊的 span 的类型 (class)是 0, 只包含了一个大对象

代码位于 runtime/mheap.go

type spanClass uint8


// uint8 左 7 位为 mspan 等级,最右一位标识是否为 noscan
func makeSpanClass(sizeclass uint8, noscan bool) spanClass {
    return spanClass(sizeclass<<1) | spanClass(bool2int(noscan))
}


func (sc spanClass) sizeclass() int8 {
    return int8(sc >> 1)
}


func (sc spanClass) noscan() bool {
    return sc&1 != 0
}

线程缓存:mcache

image.png

mcache 管理线程在本地缓存的 mspan,每个 goroutine 绑定的 P 都有一个 mcache 字段

type mcache struct {
	 // 微对象分配器相关
    tiny       uintptr
    tinyoffset uintptr
    tinyAllocs uintptr

	 // mcache 中缓存的 mspan,每种 spanClass 各一个
    alloc [numSpanClasses]*mspan
    
}

_NumSizeClasses = 68
numSpanClasses = _NumSizeClasses << 1
  • mcache 是每个 P 独有的缓存,因此交互无锁

  • mcacheSpan Classes 作为索引管理多个用于分配的 mspan,它包含所有规格的 mspan。它是 _NumSizeClasses 的 2 倍,也就是 68*2=136

    • 其中* 2 是将 spanClass 分成了有指针和没有指针两种, 方便与垃圾回收。
    • 对于每种规格,有 2 个 mspan,一个 mspan 不包含指针,另一个 mspan 则包含指针。对于无指针对象的 mspan 在进行垃圾回收的时候无需进一步扫描它是否引用了其他活跃的对象。
  • mcache 在初始化的时候是没有任何 mspan 资源的,在使用过程中会动态地从 mcentral 申请,之后会缓存下来。当对象小于等于 32 KB 大小时,使用 mcache 的相应规格的 mspan 进行分配。

  • mcache 中还有一个为对象分配器 tiny allocator,用于处理小于 16B 对象的内存分配,在 3.3 小节中详细展开.

中心缓存:mcentral

image.png

Mcentral 管理全局的 mspan 供所有线程使用,全局 mheap 变量包含 central 字段,每个 mcentral 结构都维护在mheap结构内

type mcentral struct {
    spanclass spanClass // 指当前规格大小

    partial [2]spanSet // 有空闲object的mspan列表
    full    [2]spanSet // 没有空闲object的mspan列表
}
  • 每个 mcentral 管理一种 spanClass 的 mspan,
  • 每个 mcentral 下聚合了该 spanClass 下的 mspan
  • 并将有空闲空间和没有空闲空间的 mspan 分开管理。Partial 和 full 的数据类型为 spanSet,表示 mspans 集,可以通过 pop、push 来获得 mspans
  • 每个 mcentral 一把锁
type spanSet struct {
    spineLock mutex
    spine     unsafe.Pointer // 指向[]span的指针
    spineLen  uintptr        // Spine array length, accessed atomically
    spineCap  uintptr        // Spine array cap, accessed under lock

    index headTailIndex  // 前32位是头指针,后32位是尾指针
}

简单说下 mcachemcentral 获取和归还 mspan 的流程:

  • 获取;加锁,从 partial 链表找到一个可用的 mspan;并将其从 partial 链表删除;将取出的 mspan 加入到 full 链表;将 mspan 返回给工作线程,解锁。
  • 归还;加锁,将 mspanfull 链表删除;将 mspan 加入到 partial 链表,解锁。

页堆:mheap

Mheap 管理 Go 的所有动态分配内存,可以认为是 Go 程序持有的整个堆空间,全局唯一

var mheap_ mheap

type mheap struct {
    // 堆的全局锁
    lock mutex


    // 空闲页分配器,底层是多棵基数树组成的索引,每棵树对应 16 GB 内存空间
    pages pageAlloc 


    // 记录了所有的 mspan. 需要知道,所有 mspan 都是经由 mheap,使用连续空闲页组装生成的
    allspans []*mspan


    // heapAreana 数组,64 位系统下,二维数组容量为 [1][2^22]
    // 每个 heapArena 大小 64M,因此理论上,Golang 堆上限为 2^22*64M = 256T
    arenas [1 << arenaL1Bits]*[1 << arenaL2Bits]*heapArena


    // ...
    // 多个 mcentral,总个数为 spanClass 的个数
    central [numSpanClasses]struct {
        mcentral mcentral
        // 用于内存地址对齐
        pad      [cpu.CacheLinePadSize - unsafe.Sizeof(mcentral{})%cpu.CacheLinePadSize]byte
    }


    // ...
}
  • 对于 Golang 上层应用而言,堆是操作系统虚拟内存的抽象

  • 所有 mcentral 的集合则是存放于 mheap 中的。mheap 里的 arena 区域是堆内存的抽象,运行时会将 8KB 看做一页,这些内存页中存储了所有在堆上初始化的对象

  • 以页(8KB)为单位,作为最小内存存储单元

  • 负责将连续页组装成 mspan

  • 全局内存基于 bitMap 标识其使用情况,每个 bit 对应一页,为 0 则自由,为 1 则已被 mspan 组装

  • 通过 heapArena 聚合页,记录了页到 mspan 的映射信息(2.7小节展开)

  • 建立空闲页基数树索引 radix tree index,辅助快速寻找空闲页(2.6小节展开)

  • 是 mcentral 的持有者,持有所有 spanClass 下的 mcentral,作为自身的缓存

  • 内存不够时,向操作系统申请,申请单位为 heapArena(64M) 运行时使用二维的 runtime. HeapArena 数组管理所有的内存,每个 runtime. HeapArena 都会管理 64 MB 的内存。

  • 当申请内存时,依次经过 mcache 和 mcentral 都没有可用合适规格的大小内存,这时候会向 mheap 申请一块内存。然后按指定规格划分为一些列表,并将其添加到相同规格大小的 mcentral 的 非空闲列表 后面

 空闲页索引 pageAlloc

代码位于 runtime/mpagealloc.go

const summaryLevels = 5


type pageAlloc struct {
    // 共有五层基数树,第一层有 2^14 个节点,因此共用 2^14棵基数树
    // 总空间大小为 2^14*16GB = 256T
    // 接下来每层的节点数为上层的 8 倍
    summary [summaryLevels][]pallocSum
    
    // ...
    // 类似于 tiny offset,小于此值的地址无锁检索,必然没有空间可用
    searchAddr offAddr


    // ...
}

(1)数据结构背后的含义:

  • mheap 会基于 bitMap 标识内存中各页的使用情况,bit 位为 0 代表该页是空闲的,为 1 代表该页已被 mspan 占用.

  • 每棵基数树聚合了 16 GB 内存空间中各页使用情况的索引信息,用于帮助 mheap 快速找到指定长度的连续空闲页的所在位置

  • mheap 持有 2^14 棵基数树,因此索引全面覆盖到 2^14 * 16 GB = 256 T 的内存空间.

(2)基数树节点设定

image.png

基数树中,每个节点称之为 PallocSum,是一个 uint64 类型,体现了索引的聚合信息,包含以下四部分:

  •  start:最右侧 21 个 bit,标识了当前节点映射的 bitMap 范围中首端有多少个连续的 0 bit(空闲页)

  • •max:中间 21 个 bit,标识了当前节点映射的 bitMap 范围中最多有多少个连续的 0 bit(空闲页),称之为 max;

  • • end:左侧 21 个 bit,标识了当前节点映射的 bitMap 范围中最末端有多少个连续的 0 bit(空闲页),称之为 end.

  • • 最左侧一个 bit,弃置不用

(2)基数树节点设定

image.png

  • 每个父 pallocSum 有 8 个子 pallocSum

  • 根 pallocSum 总览全局,映射的 bitMap 范围为全局的 16 GB 空间(其 max 最大值为 2^21,因此总空间大小为 2^21*8KB=16GB);

  • 从首层向下是一个依次八等分的过程,每一个 pallocSum 映射其父节点 bitMap 范围的八分之一,因此第二层 pallocSum 的 bitMap 范围为 16GB/8 = 2GB,以此类推,第五层节点的范围为 16GB / (8^4) = 4 MB,已经很小

  • •聚合信息时,自底向上. 每个父 pallocSum 聚合 8 个子 pallocSum 的 start、max、end 信息,形成自己的信息,直到根 pallocSum,坐拥全局 16 GB 的 start、max、end 信息

  •  mheap 寻页时,自顶向下. 对于遍历到的每个 pallocSum,

    • 先看起 start 是否符合,是则寻页成功;
    • 再看 max 是否符合,是则进入其下层孩子 pallocSum 中进一步寻访;
    • 最后看 end 和下一个同辈 pallocSum 的 start 聚合后是否满足,是则寻页成功.

    基数树节点

const(
    logMaxPackedValue = 21
    maxPackedValue    = 1 << logMaxPackedValue
)


type pallocSum uint64


// 基于 start、max、end 组装成一个基数树节点 pallocSum
func packPallocSum(start, max, end uint) pallocSum {
    // ...
    return pallocSum((uint64(start) & (maxPackedValue - 1)) |
        ((uint64(max) & (maxPackedValue - 1)) << logMaxPackedValue) |
        ((uint64(end) & (maxPackedValue - 1)) << (2 * logMaxPackedValue)))
}


// 当前节点对应区域内,首部连续空闲页的长度
// 通过 uint64 最右侧 21 个 bit 标识
func (p pallocSum) start() uint {
    // ...
    return uint(uint64(p) & (maxPackedValue - 1))
}


// 当前节点对应区域内,连续空闲页的最大长度
// 通过 uint64 左数 23~43 个 bit 标识
func (p pallocSum) max() uint {
    // ...
    return uint((uint64(p) >> logMaxPackedValue) & (maxPackedValue - 1))
}


// 当前节点对应区域内,尾部连续空闲页的长度
// 通过 uint64 左数 2~22 个 bit 标识
func (p pallocSum) end() uint {
    return uint((uint64(p) >> (2 * logMaxPackedValue)) & (maxPackedValue - 1))
}

记录页到 mspan 的映射:heapArena

  • 每个 heapArena 包含 8192 个页,大小为 8192 * 8KB = 64 MB

  •  heapArena 记录了页到 mspan 的映射. 因为 GC 时,通过地址偏移找到页很方便,但找到其所属的 mspan 不容易. 因此需要通过这个映射信息进行辅助.

  •  heapArena 是 mheap 向操作系统申请内存的单位(64MB)

const pagesPerArena = 8192


type heapArena struct {
    // ...
    // 实现 page 到 mspan 的映射
    spans [pagesPerArena]*mspan


    // ...
}

分配流程

下面来串联 Golang 中分配对象的流程,不论是以下哪种方式,最终都会殊途同归步入 mallocgc 方法中,并且根据 3.1 小节中的策略执行分配流程:

  • new(T)

  •  &T{}

  •  make(xxxx)

分配对象

  • 微对象 (0, 16 B):先使用线程缓存上的微型分配器 (tiny allocator),再依次尝试线程缓存、中心缓存、堆分配内存;
  • 小对象 [16 B, 32 KB]:依次尝试线程缓存、中心缓存、堆分配内存;
  • 大对象 (32 KB, +∞):直接尝试堆分配内存;

分配策略

不同类型的对象,会有着不同的分配策略,这些内容在 mallocgc 方法中都有体现.

核心流程类似于读多级缓存的过程,由上而下,每一步只要成功则直接返回. 若失败,则由下层方法兜底.

对于微对象的分配流程:

(1)从 P 专属 mcache 的 tiny 分配器取内存(无锁)

(2)根据所属的 spanClass,从 P 专属 mcache 缓存的 mspan 中取内存(无锁)

(3)根据所属的 spanClass 从对应的 mcentral 中取 mspan 填充到 mcache,然后从 mspan 中取内存(spanClass 粒度锁)

(4)根据所属的 spanClass,从 mheap 的页分配器 pageAlloc 取得足够数量空闲页组装成 mspan 填充到 mcache,然后从 mspan 中取内存(全局锁)

(5)mheap 向操作系统申请内存,更新页分配器的索引信息,然后重复(4).

对于小对象的分配流程是跳过(1)步,执行上述流程的(2)-(5)步;

对于大对象的分配流程是跳过(1)-(3)步,执行上述流程的(4)-(5)步.

图片

Go 内存逃逸机制?

概念

在一段程序中,每一个函数都会有自己的内存区域存放自己的局部变量、返回地址等,这些内存会由编译器在栈中进行分配,每一个函数都会分配一个栈桢,在函数运行结束后进行销毁,但是有些变量我们想在函数运行结束后仍然使用它,那么就需要把这个变量在堆上分配,这种从”栈”上逃逸到”堆”上的现象就成为内存逃逸

在栈上分配的地址,一般由系统申请和释放,不会有额外性能的开销,比如函数的入参、局部变量、返回值等。在堆上分配的内存,如果要回收掉,需要进行 GC,那么 GC 一定会带来额外的性能开销。编程语言不断优化 GC 算法,主要目的都是为了减少 GC 带来的额外性能开销,变量一旦逃逸会导致性能开销变大。

逃逸机制

编译器会根据变量是否被外部引用来决定是否逃逸:

  1. 如果函数外部没有引用,则优先放到栈中;
  2. 如果函数外部存在引用,则必定放到堆中;
  3. 如果栈上放不下,则必定放到堆上;

逃逸分析也就是由编译器决定哪些变量放在栈,哪些放在堆中,通过编译参数 -gcflag=-m 可以查看编译过程中的逃逸分析,发生逃逸的几种场景如下:

指针逃逸

package main

func escape1() *int {
    var a int = 1
    return &a
}

func main() {
    escape1()
}

通过 go build -gcflags=-m main.go 查看逃逸情况:

./main.go:4:6: moved to heap: a

函数返回值为局部变量的指针,函数虽然退出了,但是因为指针的存在,指向的内存不能随着函数结束而回收,因此只能分配在堆上。

栈空间不足

package main

func escape2() {
    s := make([]int, 0, 10000)
    for index, _ := range s {
        s[index] = index
    }
}

func main() {
    escape2()
}

通过 go build -gcflags=-m main.go 查看逃逸情况:

./main.go:4:11: make([]int, 10000, 10000) escapes to heap

当栈空间足够时,不会发生逃逸,但是当变量过大时,已经完全超过栈空间的大小时,将会发生逃逸到堆上分配内存。局部变量 s 占用内存过大,编译器会将其分配到堆上

变量大小不确定

package main

func escape3() {
    number := 10
    s := make([]int, number) // 编译期间无法确定slice的长度
    for i := 0; i < len(s); i++ {
        s[i] = i
    }
}

func main() {
    escape3()
}

编译期间无法确定 slice 的长度,这种情况为了保证内存的安全,编译器也会触发逃逸,在堆上进行分配内存。直接 s := make([]int, 10) 不会发生逃逸

动态类型

动态类型就是编译期间不确定参数的类型、参数的长度也不确定的情况下就会发生逃逸

空接口 interface{} 可以表示任意的类型,如果函数参数为 interface{},编译期间很难确定其参数的具体类型,也会发生逃逸。

package main

import "fmt"

func escape4() {
    fmt.Println(1111)
}

func main() {
    escape4()
}

通过 go build -gcflags=-m main.go 查看逃逸情况:

./main.go:4:6: moved to heap: i

Fmt.Println (a …interface{})函数参数为 interface,编译器不确定参数的类型,会将变量分配到堆上

闭包引用对象

package main

func escape5() func() int {
    var i int = 1
    return func() int {
        i++
        return i
    }
}

func main() {
    escape5()
}

通过 go build -gcflags=-m main.go 查看逃逸情况:

./main.go:4:6: moved to heap: i

闭包函数中局部变量 i 在后续函数是继续使用的,编译器将其分配到堆上

总结

  1. 栈上分配内存比在堆中分配内存效率更高
  2. 栈上分配的内存不需要 GC 处理,而堆需要
  3. 逃逸分析目的是决定内分配地址是栈还是堆
  4. 逃逸分析在编译阶段完成

因为无论变量的大小,只要是指针变量都会在堆上分配,所以对于小变量我们还是使用传值效率(而不是传指针)更高一点。

怎么避免内存逃逸?

  1. 不要盲目使用变量指针作为参数,虽然减少了复制,但变量逃逸的开销更大。
  2. 预先设定好 slice 长度,避免频繁超出容量,重新分配。
  3. 一个经验是,指针指向的数据大部分在堆上分配的,请注意。

出现内存逃逸的情况有:

  1. 发送指针或带有指针的值到 channel,因为编译时候无法知道那个 goroutine 会在 channel 接受数据,编译器无法知道什么时候释放。

  2. 在一个切片上存储指针或带指针的值。比如[]*string,导致切片内容逃逸,其引用值一直在堆上。

  3. 切片的 append 导致超出容量,切片重新分配地址,切片背后的存储基于运行时的数据进行扩充,就会在堆上分配。

  4. 调用接口类型时,接口类型的方法调用是动态调度,实际使用的具体实现只能在运行时确定,如一个接口类型为 io. Reader 的变量 r,对r.Read (b)的调用将导致 r 的值和字节片 b 的后续转义并因此分配到堆上。

  5. 在方法内把局部变量指针返回,被外部引用,其生命周期大于栈,导致内存溢出。

Go 内存对齐机制?

什么是内存对齐

为了能让 CPU 可以更快的存取到各个字段,Go 编译器会帮你把 struct 结构体做数据的对齐。所谓的数据对齐,是指内存地址是所存储数据大小(按字节为单位)的整数倍,以便 CPU 可以一次将该数据从内存中读取出来。 编译器通过在结构体的各个字段之间填充一些空白已达到对齐的目的。

对齐系数

  • 不同硬件平台占用的大小和对齐值都可能是不一样的 32 位系统对齐系数是 4,64 位系统对齐系数是 8

  • 不同类型的对齐系数也可能不一样,使用 Go 语言中的 unsafe.Alignof 函数可以返回相应类型的对齐系数,对齐系数都符合 2^n 这个规律,最大也不会超过8

package main

import (
    "fmt"
    "unsafe"
)

func main() {
    fmt.Printf("bool alignof is %d\n", unsafe.Alignof(bool(true)))
    fmt.Printf("string alignof is %d\n", unsafe.Alignof(string("a")))
    fmt.Printf("int alignof is %d\n", unsafe.Alignof(int(0)))
    fmt.Printf("float alignof is %d\n", unsafe.Alignof(float64(0)))
    fmt.Printf("int32 alignof is %d\n", unsafe.Alignof(int32(0)))
    fmt.Printf("float32 alignof is %d\n", unsafe.Alignof(float32(0)))
}

可以查看到各种类型在 Mac 64 位上的对齐系数如下:

bool alignof is 1
string alignof is 8
int alignof is 8
int32 alignof is 4
float32 alignof is 4
float alignof is 8

优点

  1. 提高可移植性,有些 CPU 可以访问任意地址上的任意数据,而有些 CPU 只能在特定地址访问数据,因此不同硬件平台具有差异性,这样的代码就不具有移植性,如果在编译时,将分配的内存进行对齐,这就具有平台可以移植性了
  2. 提高内存的访问效率,32 位 CPU 下一次可以从内存中读取 32 位(4 个字节)的数据,64 位 CPU 下一次可以从内存中读取 64 位(8 个字节)的数据,这个长度也称为 CPU 的字长。CPU 一次可以读取 1 个字长的数据到内存中,如果所需要读取的数据正好跨了 1 个字长,那就得花两个 CPU 周期的时间去读取了。因此在内存中存放数据时进行对齐,可以提高内存访问效率。

缺点

  1. 存在内存空间的浪费,实际上是空间换时间

结构体对齐

对齐原则:

  1. 结构体变量中成员的偏移量必须是成员大小的整数倍
  2. 整个结构体的地址必须是最大字节的整数倍(结构体的内存占用是 1/4/8/16 byte…)
package main

import (
    "fmt"
    "runtime"
    "unsafe"
)

type T1 struct {
    i16  int16 // 2 byte
    bool bool  // 1 byte
}

type T2 struct {
    i8  int8  // 1 byte
    i64 int64 // 8 byte
    i32 int32 // 4 byte
}

type T3 struct {
    i8  int8  // 1 byte
    i32 int32 // 4 byte
    i64 int64 // 8 byte
}

func main() {
    fmt.Println(runtime.GOARCH) // amd64

    t1 := T1{}
    fmt.Println(unsafe.Sizeof(t1)) // 4 bytes

    t2 := T2{}
    fmt.Println(unsafe.Sizeof(t2)) // 24 bytes

    t3 := T3{}
    fmt.Println(unsafe.Sizeof(t3)) // 16 bytes
}

以 T 1 结构体为例,实际存储数据的只有 3 字节,但实际用了 4 字节,浪费了 1 个字节:

I 16 并没有直接放在 bool 的后面,而是在 bool 中填充了一个空白后,放到了偏移量为 2 的位置上。如果 i 16 从偏移量为 1 的位置开始占用 2 个字节,根据对齐原则 2:构体变量中成员的偏移量必须是成员大小的整数倍,套用公式 1 % 2 = 1,就不满足对齐的要求,所以 i 16 从偏移量为2的位置开始 image.png

以 T 2 结构体为例,实际存储数据的只有 13 字节,但实际用了 24 字节,浪费了 11 个字节: image.png

以 T 3 结构体为例,实际存储数据的只有 13 字节,但实际用了 16 字节,浪费了 3 个字节:

image.png

Go GC 实现原理?

什么是 GC?

垃圾回收也称为 GC(Garbage Collection),是一种自动内存管理机制。由垃圾收集器以类似守护协程的方式在后台运作,按照既定的策略为用户回收那些不再被使用的对象,释放对应的内存空间

现代高级编程语言管理内存的方式分为两种:自动和手动,

  • 像 C、C++ 等编程语言使用手动管理内存的方式,工程师编写代码过程中需要主动申请或者释放内存;
  • 而 PHP、Java 和 Go 等语言使用自动的内存管理系统,有内存分配器和垃圾收集器来代为分配和回收内存,其中垃圾收集器就是我们常说的 GC。

在应用程序中会使用到两种内存,分别为堆(Heap)和栈(Stack),

  • GC 负责回收堆内存,而不负责回收栈中的内存

  • 栈是线程的专用内存,专门为了函数执行而准备的,存储着函数中的局部变量以及调用栈,函数执行完后,编译器可以将栈上分配的内存可以直接释放,不需要通过 GC 来回收。

堆是程序共享的内存,需要 GC 进行回收在堆上分配的内存。

垃圾回收器的执行过程被划分为两个半独立的组件:

  • 赋值器(Mutator):这一名称本质上是在指代用户态的代码。因为对垃圾回收器而言,用户态的代码仅仅只是在修改对象之间的引用关系,也就是在对象图(对象之间引用关系的一个有向图)上进行操作。
  • 回收器(Collector):负责执行垃圾回收的代码。

主流 GC 算法

目前比较常见的垃圾回收算法有三种:

  1. 引用计数:为每个对象维护一个引用计数,当引用该对象的对象销毁时,引用计数 -1,当对象引用计数为 0 时回收该对象。 image.png

    • 代表语言:PythonPHPSwift
    • 优点:对象回收快,不会出现内存耗尽或达到某个阈值时才回收。
    • 缺点:不能很好的处理循环引用,而实时维护引用计数也是有损耗的。
  2. 分代收集:按照对象生命周期长短划分不同的代空间,生命周期长的放入老年代,短的放入新生代,不同代有不同的回收算法和回收频率。

    • 代表语言:Java
    • 优点:回收性能好
    • 缺点:算法复杂
  3. 标记-清除:从根变量开始遍历所有引用的对象,标记引用的对象,没有被标记的进行回收。

image.png

- 代表语言:**Golang**(三色标记法)
- 优点:解决了引用计数的缺点。
- 缺点:需要 STW,暂时停掉程序运行。 

image.png 4. 标记-压缩:是在标记清扫算法的基础上做了升级,在第二步”清扫“的同时还会对存活对象进行压缩整合,使得整体空间更为紧凑,从而解决内存碎片问题. image.png

  1. 半空间复制:
  • 分配两片相等大小的空间,称为 fromspace 和 tospace
  • 每轮只使用 fromspace 空间,以GC作为分水岭划分轮次
  • GC时,将fromspace存活对象转移到tospace中,并以此为契机对空间进行压缩整合
  •  GC后,交换fromspace和tospace,开启新的轮次

三色标记法

此算法是在 Go 1.5 版本开始使用,Go 语言采用的是标记清除算法,并在此基础上使用了三色标记法和混合写屏障技术,GC 过程和其他用户 goroutine 可并发运行,但需要一定时间的 STW

这里的三色,对应了垃圾回收过程中对象的三种状态:

  • 灰色:对象还在标记队列中等待
  • 黑色:对象已被标记,gcmarkBits 对应位为 1 (该对象不会在本次 GC 中被回收)
  • 白色:对象未被标记,gcmarkBits 对应位为 0 (该对象将会在本次 GC 中被清理)

简单概括:

  • 标记开始前,将根对象(全局对象、栈上局部变量等)置黑,将其所指向的对象置灰

  • 标记规则是,从灰对象出发,将其所指向的对象都置灰. 所有指向对象都置灰后,当前灰对象置黑

  •  标记结束后,白色对象就是不可达的垃圾对象,需要进行清扫.

执行的步骤

  • Step 1: 创建:白、灰、黑三个集合

  • Step 2: 将所有对象放入白色集合中

  • Step 3: 遍历所有root 对象,把遍历到的对象从白色集合放入灰色集合 (这里放入灰色集合的都是根节点的对象)

  • Step 4: 遍历灰色集合,将灰色对象引用的对象从白色集合放入灰色集合,自身标记为黑色

  • Step 5: 重复步骤 4,直到灰色中无任何对象,其中用到 2 个机制:

    • 写屏障(Write Barrier):上面说到的 STW 的目的是防止 GC 扫描时内存变化引起的混乱,而写屏障就是让 goroutine 与 GC 同时运行的手段,虽然不能完全消除 STW,但是可以大大减少 STW 的时间。写屏障在 GC 的特定时间开启,开启后指针传递时会把指针标记,即本轮不回收,下次 GC 时再确定。
    • 辅助 GC(Mutator Assist):为了防止内存分配过快,在 GC 执行过程中,GC 过程中 mutator 线程会并发运行,而 mutator assist 机制会协助 GC 做一部分的工作。
  • Step 6: 收集所有白色对象(垃圾)

并发垃圾回收会遇到的问题

漏标问题

image.png

  • 条件:初始时刻,对象 B 持有对象 C 的引用
  •  moment1:GC协程下,对象A被扫描完成,置黑;此时对象B是灰色,还未完成扫描
  •  momen2:用户协程下,对象A建立指向对象C的引用
  •  moment3:用户协程下,对象B删除指向对象C的引用
  •  moment4:GC 协程下,开始执行对对象 B 的扫描

漏标问题是无法接受,其引起的误删现象可能会导致程序出现致命的错误. 针对漏标问题,Golang 给出的解决方案是屏障机制的使用

多标问题

image.png

  •  条件:初始时刻,对象 A 持有对象 B 的引用
  • moment1:GC协程下,对象A被扫描完成,置黑;对象B被对象A引用,因此被置灰
  •  momen2:用户协程下,对象 A 删除指向对象 B 的引用

内存碎片

标记清扫算法会存在产生“内存碎片”的缺陷

Golang 采用 TCMalloc 机制,依据对象的大小将其归属为到事先划分好的 spanClass 当中,这样能够消解外部碎片的问题,将问题限制在相对可控的内部碎片当中..

为什么不选择分代垃圾回收机制

Golang中存在内存逃逸机制,会在编译过程中将生命周期更长的对象转移到堆中,将生命周期短的对象分配在栈上,并以栈为单位对这部分对象进行回收.

Golang中存在内存逃逸机制,会在编译过程中将生命周期更长的对象转移到堆中,将生命周期短的对象分配在栈上,并以栈为单位对这部分对象进行回收.

root 对象

根对象在垃圾回收的术语中又叫做根集合,它是垃圾回收器在标记过程时最先检查的对象,包括:

  • 全局变量:程序在编译期就能确定的那些存在于程序整个生命周期的变量。
  • 执行栈:每个 goroutine 都包含自己的执行栈,这些执行栈上指向堆内存的指针。
  • 寄存器:寄存器的值可能表示一个指针,参与计算的这些指针可能指向某些赋值器分配的堆内存区块。

强弱三色不变式

漏标问题的本质就是,一个已经扫描完成的黑对象指向了一个被灰\白对象删除引用的白色对象.

构成这一场景的要素拆分如下:

(1)黑色对象指向了白色对象

(2)灰、白对象删除了白色对象

(3)(1)、(2)步中谈及的白色对象是同一个对象

(4)(1)发生在(2)之前

一套用于解决漏标问题的方法论称之为强弱三色不变式:

  • • 强三色不变式:白色对象不能被黑色对象直接引用(直接破坏(1))

  • • 弱三色不变式:白色对象可以被黑色对象引用,但要从某个灰对象出发仍然可达该白对象(间接破坏了(1)、(2)的联动)

插入写屏障

image.png

保证当一个黑色对象指向一个白色对象前,会先触发屏障将白色对象置为灰色,再建立引用.

对象被引用时触发的机制(只在堆内存中生效):赋值器这一行为通知给并发执行的回收器,被引用的对象标记为灰色

缺点:结束时需要 STW 来重新扫描栈,标记栈上引用的白色对象的存活

删除写屏障

image.png

保证当一个白色对象即将被上游删除引用前,会触发屏障将其置灰,之后再删除上游指向其的引用.

对象被删除时触发的机制(只在堆内存中生效):赋值器将这一行为通知给并发执行的回收器,被删除的对象,如果自身为灰色或者白色,那么标记为灰色

缺点:一个对象的引用被删除后,即使没有其他存活的对象引用它,它仍然会活到下一轮,会产生很大冗余扫描成本,且降低了回收精度

混合写屏障

插入写屏障、删除写屏障二者择其一,即可解决并发GC的漏标问题,至于错标问题,则采用容忍态度,放到下一轮GC中进行延后处理即可.

GC 没有混合写屏障前,一直是插入写屏障;混合写屏障是插入写屏障 + 删除写屏障,写屏障只应用在堆上应用,栈上不启用(栈上启用成本很高)

  • • GC 开始前,以栈为单位分批扫描,将栈中所有对象置黑

  • • GC 期间,栈上新创建对象直接置黑

  • • 堆对象正常启用插入写屏障

  • • 堆对象正常启用删除写屏障

show case

(1)case 1:堆对象删除引用,栈对象建立引用

图片

  • 背景:存在栈上对象A,黑色(扫描完);

存在堆上对象B,白色(未被扫描);

存在堆上对象C,被堆上对象B引用,白色(未被扫描)

  • moment1:A建立对C的引用,由于栈无屏障机制,因此正常建立引用,无额外操作

  • moment2:B尝试删除对C的引用,删除写屏障被触发,C被置灰,因此不会漏标

(2)case 2:一个堆对象删除引用,成为另一个堆对象下游

图片

  • • 背景:存在堆上对象A,白色(未被扫描);

存在堆上对象B,黑色(已完成扫描);

存在堆上对象C,被堆上对象B引用,白色(未被扫描)

  • • moment1:B尝试建立对C的引用,插入写屏障被触发,C被置灰

  • • moment2:A删除对C的引用,此时C已置灰,因此不会漏标

(3)case 3:栈对象删除引用,成为堆对象下游

图片

  • • 背景:存在栈上对象A,白色(未完成扫描,说明对应的栈未扫描);

存在堆上对象B,黑色(已完成扫描);

存在堆上对象C,被栈上对象A引用,白色(未被扫描)

  • • moment1:B尝试建立对C的引用,插入写屏障被触发,C被置灰

  • • moment2:A删除对C的引用,此时C已置灰,因此不会漏标

(4)case 4:一个栈中对象删除引用,另一个栈中对象建立引用

图片

  • • 背景:存在栈上对象A,白色(未扫描,这是因为对应的栈还未开始扫描);

存在栈上对象B,黑色(已完成扫描,说明对应的栈均已完成扫描);

存在堆上对象C,被栈上对象A引用,白色(未被扫描)

  • • moment1:B建立对C的引用;

  • • moment2:A删除对C的引用.

  • • 结论:这种场景下,C要么已然被置灰,要么从某个灰对象触发仍然可达C.

  • • 原因在于,对象的引用不是从天而降,一定要有个来处. 当前 case 中,对象B能建立指向C的引用,至少需要满足如下三个条件之一:

I 栈对象B原先就持有C的引用,如若如此,C就必然已处于置灰状态(因为B已是黑色)

II 栈对象B持有A的引用,通过A间接找到C. 然而这也是不可能的,因为倘若A能同时被另一个栈上的B引用到,那样A必然会升级到堆中,不再满足作为一个栈对象的前提;

III B同栈内存在其他对象X可达C,此时从X出发,必然存在一个灰色对象,从其出发存在可达C的路线.

综上,我们得以证明混合写屏障是能够胜任并发GC场景的解决方案,并且满足栈无须添加屏障的前提.

GC 流程

一次完整的垃圾回收会分为四个阶段,分别是标记准备、标记开始、标记终止、清理:

  1. 标记准备(Mark Setup):打开写屏障(Write Barrier),需 STW(stop the world)
  2. 标记开始(Marking):使用三色标记法并发标记,与用户程序并发执行
  3. 标记终止(Mark Termination):对触发写屏障的对象进行重新扫描标记,关闭写屏障(Write Barrier),需 STW(stop the world)
  4. 清理(Sweeping):将需要回收的内存归还到堆中,将过多的内存归还给操作系统,与用户程序并发执行 image.png

GC 触发时机

主动触发:

  • 调用 runtime.GC () 方法,触发 GC

被动触发:

  • 定时触发,该触发条件由 runtime.forcegcperiod 变量控制,默认为 2 分钟。当超过两分钟没有产生任何 GC 时,触发 GC
  • 根据内存分配阈值触发,该触发条件由环境变量 GOGC 控制,默认值为 100(100%),当前堆内存占用是上次 GC 结束后占用内存的 2 倍时,触发 GC

Go GC 如何调优?

  • 控制内存分配的速度,限制 Goroutine 的数量,提高赋值器 mutator 的 CPU 利用率(降低 GC 的 CPU 利用率)
  • 少量使用 + 连接 string
  • Slice 提前分配足够的内存来降低扩容带来的拷贝
  • 避免 map key 对象过多,导致扫描时间增加
  • 变量复用,减少对象分配,例如使用 sync. Pool 来复用需要频繁创建临时对象、使用全局变量等
  • 增大 GOGC 的值,降低 GC 的运行频率

Go 如何查看 GC 信息?

1. GODEBUG=’gctrace=1’

package main
func main() {
    for n := 1; n < 100000; n++ {
        _ = make([]byte, 1<<20)
    }
}
$ GODEBUG='gctrace=1' go run main.go

gc 1 @0.003s 4%: 0.013+1.7+0.008 ms clock, 0.10+0.67/1.2/0.018+0.064 ms cpu, 4->6->2 MB, 5 MB goal, 8 P
gc 2 @0.006s 2%: 0.006+4.5+0.058 ms clock, 0.048+0.070/0.027/3.6+0.47 ms cpu, 4->5->1 MB, 5 MB goal, 8 P
gc 3 @0.011s 3%: 0.021+1.3+0.009 ms clock, 0.17+0.041/0.41/0.046+0.072 ms cpu, 4->6->2 MB, 5 MB goal, 8 P
gc 4 @0.013s 5%: 0.025+0.38+0.26 ms clock, 0.20+0.054/0.15/0.009+2.1 ms cpu, 4->6->2 MB, 5 MB goal, 8 P
gc 5 @0.014s 5%: 0.021+0.16+0.002 ms clock, 0.17+0.098/0.028/0.001+0.016 ms cpu, 4->5->1 MB, 5 MB goal, 8 P
gc 6 @0.014s 7%: 0.025+1.6+0.003 ms clock, 0.20+0.061/2.9/1.5+0.025 ms cpu, 4->6->2 MB, 5 MB goal, 8 P
gc 7 @0.016s 7%: 0.019+1.0+0.002 ms clock, 0.15+0.053/1.0/0.018+0.017 ms cpu, 4->6->2 MB, 5 MB goal, 8 P
gc 8 @0.017s 7%: 0.029+0.17+0.002 ms clock, 0.23+0.037/0.10/0.063+0.022 ms cpu, 4->4->0 MB, 5 MB goal, 8 P
gc 9 @0.018s 7%: 0.019+0.23+0.002 ms clock, 0.15+0.040/0.16/0.023+0.018 ms cpu, 4->5->1 MB, 5 MB goal, 8 P
gc 10 @0.018s 7%: 0.022+0.23+0.003 ms clock, 0.17+0.061/0.13/0.006+0.024 ms cpu, 4->6->2 MB, 5 MB goal, 8 P
gc 11 @0.018s 7%: 0.019+0.11+0.001 ms clock, 0.15+0.033/0.051/0.013+0.015 ms cpu, 4->5->1 MB, 5 MB goal, 8 P
gc 12 @0.019s 7%: 0.018+0.19+0.001 ms clock, 0.14+0.035/0.10/0.018+0.014 ms cpu, 4->5->1 MB, 5 MB goal, 8 P
gc 13 @0.019s 7%: 0.018+0.35+0.002 ms clock, 0.15+0.21/0.054/0.013+0.016 ms cpu, 4->5->1 MB, 5 MB goal, 8 P
gc 14 @0.019s 8%: 0.024+0.27+0.002 ms clock, 0.19+0.022/0.13/0.014+0.017 ms cpu, 4->5->1 MB, 5 MB goal, 8 P
gc 15 @0.020s 8%: 0.019+0.42+0.038 ms clock, 0.15+0.060/0.28/0.007+0.31 ms cpu, 4->17->13 MB, 5 MB goal, 8 P
gc 16 @0.021s 8%: 0.018+0.53+0.060 ms clock, 0.14+0.045/0.39/0.005+0.48 ms cpu, 21->28->7 MB, 26 MB goal, 8 P
gc 17 @0.021s 10%: 0.020+0.91+0.64 ms clock, 0.16+0.050/0.36/0.027+5.1 ms cpu, 12->16->4 MB, 14 MB goal, 8 P
gc 18 @0.023s 10%: 0.020+0.55+0.002 ms clock, 0.16+0.053/0.50/0.081+0.023 ms cpu, 7->9->2 MB, 8 MB goal, 8 P

字段含义由下表所示:

字段含义
gc 2第二个 GC 周期
0.006程序开始后的 0.006 秒
2%该 GC 周期中 CPU 的使用率
0.006标记开始时, STW 所花费的时间(wall clock)
4.5标记过程中,并发标记所花费的时间(wall clock)
0.058标记终止时, STW 所花费的时间(wall clock)
0.048标记开始时, STW 所花费的时间(cpu time)
0.070标记过程中,标记辅助所花费的时间(cpu time)
0.027标记过程中,并发标记所花费的时间(cpu time)
3.6标记过程中,GC 空闲的时间(cpu time)
0.47标记终止时, STW 所花费的时间(cpu time)
4标记开始时,堆的大小的实际值
5标记结束时,堆的大小的实际值
1标记结束时,标记为存活的对象大小
5标记结束时,堆的大小的预测值
8P 的数量

2. Go tool trace

package main

import (
    "os"
    "runtime/trace"
)

func main() {
    f, _ := os.Create("trace.out")
    defer f.Close()
    trace.Start(f)
    defer trace.Stop()
    for n := 1; n < 100000; n++ {
        _ = make([]byte, 1<<20)
    }
}
$ go run main.go
$ go tool trace trace.out

打开浏览器后,可以看到如下统计: image.png

点击 View trace,可以查看当时的 trace 情况 image.png

点击 Minimum mutator utilization,可以查看到赋值器 mutator (用户程序)对 CPU 的利用率 74.1%,接近 100%则代表没有针对 GC 的优化空间了 image.png

3. Debug. ReadGCStats

package main

import (
    "fmt"
    "runtime/debug"
    "time"
)

func printGCStats() {
    t := time.NewTicker(time.Second)
    s := debug.GCStats{}
    for {
        select {
        case <-t.C:
            debug.ReadGCStats(&s)
            fmt.Printf("gc %d last@%v, PauseTotal %v\n", s.NumGC, s.LastGC, s.PauseTotal)
        }
    }
}
func main() {
    go printGCStats()
    for n := 1; n < 100000; n++ {
        _ = make([]byte, 1<<20)
    }
}
$ go run main.go

gc 3392 last@2022-05-04 19:22:52.877293 +0800 CST, PauseTotal 117.524907ms
gc 6591 last@2022-05-04 19:22:53.876837 +0800 CST, PauseTotal 253.254996ms
gc 10028 last@2022-05-04 19:22:54.87674 +0800 CST, PauseTotal 376.981595ms
gc 13447 last@2022-05-04 19:22:55.87689 +0800 CST, PauseTotal 511.420111ms
gc 16938 last@2022-05-04 19:22:56.876955 +0800 CST, PauseTotal 649.293449ms
gc 20350 last@2022-05-04 19:22:57.876756 +0800 CST, PauseTotal 788.003014ms

字段含义由下表所示:

字段含义
NumGCGC 总次数
LastGC上次 GC 时间
PauseTotalSTW 总耗时

4. Runtime. ReadMemStats

package main

import (
    "fmt"
    "runtime"
    "time"
)

func printMemStats() {
    t := time.NewTicker(time.Second)
    s := runtime.MemStats{}
    for {
        select {
        case <-t.C:
            runtime.ReadMemStats(&s)
            fmt.Printf("gc %d last@%v, heap_object_num: %v, heap_alloc: %vMB, next_heap_size: %vMB\n",
                s.NumGC, time.Unix(int64(time.Duration(s.LastGC).Seconds()), 0), s.HeapObjects, s.HeapAlloc/(1<<20), s.NextGC/(1<<20))
        }
    }
}
func main() {
    go printMemStats()
    fmt.Println(1 << 20)
    for n := 1; n < 100000; n++ {
        _ = make([]byte, 1<<20)
    }
}

$ go run main.go

gc 2978 last@2022-05-04 19:38:04 +0800 CST, heap_object_num: 391, heap_alloc: 20MB, next_heap_size: 28MB
gc 5817 last@2022-05-04 19:38:05 +0800 CST, heap_object_num: 370, heap_alloc: 4MB, next_heap_size: 4MB
gc 9415 last@2022-05-04 19:38:06 +0800 CST, heap_object_num: 392, heap_alloc: 7MB, next_heap_size: 8MB
gc 11429 last@2022-05-04 19:38:07 +0800 CST, heap_object_num: 339, heap_alloc: 4MB, next_heap_size: 5MB
gc 14706 last@2022-05-04 19:38:08 +0800 CST, heap_object_num: 436, heap_alloc: 6MB, next_heap_size: 8MB
gc 18253 last@2022-05-04 19:38:09 +0800 CST, heap_object_num: 375, heap_alloc: 4MB, next_heap_size: 6M

字段含义由下表所示:

字段含义
NumGCGC 总次数
LastGC上次 GC 时间
HeapObjects堆中已经分配的对象总数,GC 内存回收后 HeapObjects 取值相应减小
HeapAlloc堆中已经分配给对象的字节数,GC 内存回收后 HeapAlloc 取值相应减小
NextGC下次 GC 目标堆的大小