Sorry, your browser cannot access this site
This page requires browser support (enable) JavaScript
Learn more >

Go 内存管理

go 管理内存

Go语言的内存分配器的核心设计思想是:多级内存分配模块,减少内存分配时锁的使用与系统调用;多尺度内存单元,减少内存分配产生碎片。

Golang的内存管理实现主要涉及以下几个方面:

  1. 内存分配器(malloc)和释放器(free):Golang使用tcmalloc作为其默认的内存分配器,它是一个高效的内存分配器,可以减少内存碎片。在Go语言中,可以使用内置函数malloc和free来分配和释放内存。
  2. 垃圾回收机制:Golang使用并发标记清除算法(Concurrent Mark Sweep,CMS)作为其默认的垃圾回收机制。CMS是一种高效的垃圾回收算法,可以在不阻塞用户线程的情况下进行垃圾回收。
  3. 内存池技术:Golang使用内存池技术来提高内存分配和释放的效率。内存池是一种预先分配一定数量内存的技术,可以避免频繁地调用系统函数分配和释放内存,从而提高程序的性能。
  4. 大对象支持:Golang支持大对象,即超过1MB的对象。为了支持大对象,Golang使用了一种称为“可变大小数组”的数据结构,它可以在运行时动态调整数组的大小。

内存模型

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

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

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

    Golang 在堆 mheap 之上,依次细化粒度,建立了 mcentral、mcache 的模型

    • mheap:全局的内存起源,访问要加全局锁
    • mcentral:每种对象大小规格(全局共划分为 68 种)对应的缓存,锁的粒度也仅限于同一种规格以内
    • mcache:每个 P(正是 GMP 中的 P)持有一份的内存缓存,访问时无锁
  • 多级规格,提高利用率

1
2
3
- Golang 借鉴操作系统分页管理的思想,每个最小的存储单元也称之为页 page,但大小为 8 KB。
- mspan:最小的管理单元。mspan 大小为 page 的整数倍,且根据空间大小和面向分配对象的大小,从 8B 到 80 KB 被划分为 67 种不同的规格(实际上还有一种隐藏的 0 级,用于处理更大的对象,上不封顶)
- 分配对象时,会根据大小映射到不同规格的 mspan,从中获取空间.
  1. 根据规格大小,产生了等级的制度
  2. 消除了外部碎片,但不可避免会有内部碎片
  3. 宏观上能提高整体空间利用率
  4. 正是因为有了规格等级的概念,才支持 mcentral 实现细锁化

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

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

  • mheap:全局的内存起源,访问要加全局锁
  • mcentral:每种对象大小规格(全局共划分为 68 种)对应的缓存,锁的粒度也仅限于同一种规格以内
  • mcache:每个 P(正是 GMP 中的 P)持有一份的内存缓存,访问时无锁
  • mcache 是每个 P 独有的缓存,因此交互无锁
    • mcache 将每种 spanClass 等级的 mspan 各缓存了一个,总数为 2(nocan 维度) * 68(大小维度)= 136
    • mcache 中还有一个为对象分配器 tiny allocator,用于处理小于 16B 对象的内存分配.

对于微对象的分配流程:

(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)步.

  • object size > 32K,则使用 mheap 直接分配。
  • object size < 16 byte,不包含指针使用 mcache 的小对象分配器 tiny 直接分配;包含指针分配策略与[16 B, 32 K]类似。
  • object size >= 16 byte && size <=32K byte 时,先使用 mcache 中对应的 size class 分配。
  • 如果 mcache 对应的 size class 的 span 已经没有可用的块,则向 mcentral 请求。
  • 如果 mcentral 也没有可用的块,则向 mheap 申请,并切分。
  • 如果 mheap 也没有合适的 span,则向操作系统申请。

为什么分微对象,小对象,大对象

16B以上是小对象,32KB以上是大对象,16B一下是微对象

因为程序中的绝大多数对象的大小都在 32KB 以下,而申请的内存大小影响 Go 语言运行时分配内存的过程和开销,所以分别处理大对象和小对象有利于提高内存分配器的性能。

内存逃逸

go语言编译器会自动决定把一个变量放在栈还是放在堆,编译器会做逃逸分析(escape analysis)当发现变量的作用域没有跑出函数范围,就可以在栈上,反之则必须分配在堆

所谓逃逸,就是指变量的生命周期不仅限于函数栈帧,而是超出了函数的范围,需要在堆上分配内存。如果变量x没有发生逃逸,那么它会被分配在函数栈帧中,随着函数的返回而被自动销毁。

一般我们给一个引用类对象中的引用类成员进行赋值,可能出现逃逸现象。可以理解为访问一个引用对象实际上底层就是通过一个指针来间接的访问了,但如果再访问里面的引用成员就会有第二次间接访问,这样操作这部分对象的话,极大可能会出现逃逸的现象。

  • 在方法内把局部变量指针返回 局部变量原本应该在栈中分配,在栈中回收。但是由于返回时被外部引用,因此其生命周期大于栈,则溢出。
  • 发送指针或带有指针的值到 channel 中。 在编译时,是没有办法知道哪个 goroutine 会在 channel 上接收数据。所以编译器没法知道变量什么时候才会被释放。
  • 在 interface 类型上调用方法。 在 interface 类型上调用方法都是动态调度的 —— 方法的真正实现只能在运行时知道。想像一个 io.Reader 类型的变量 r , 调用 r.Read(b) 会使得 r 的值和切片b 的背后存储都逃逸掉,所以会在堆上分配。

我们得出了指针必然发生逃逸的三种情况:

  • 在某个函数中new或字面量创建出的变量,将其指针作为函数返回值,则该变量一定发生逃逸(构造函数返回的指针变量一定逃逸);
  • 被已经逃逸的变量引用的指针,一定发生逃逸;
  • 被指针类型的slice、map和chan引用的指针,一定发生逃逸;

同时我们也得出一些必然不会逃逸的情况:

  • 指针被未发生逃逸的变量引用;
  • 仅仅在函数内对变量做取址操作,而未将指针传出;

逃逸分析好处

通过逃逸分析,那些不需要分配到堆上的变量直接分配到栈上,堆上的变量少了不但同时减少 GC 的压力,还减轻了内存分配的开销。

go build -gcflags=-m main.go

如何避免内存逃逸

  • 尽量减少外部指针引用,必要的时候可以使用值传递;
  • 对于自己定义的数据大小,有一个基本的预判,尽量不要出现栈空间溢出的情况;
  • Golang中的接口类型的方法调用是动态调度,如果对于性能要求比较高且访问频次比较高的函数调用,应该尽量避免使用接口类型;
  • 尽量不要写闭包函数,可读性差且发生逃逸。
  • 当指针类型作为返回时,会发生内存逃逸

内存泄露

  • 临时性泄露,指的是该释放的内存资源没有及时释放,对应的内存资源仍然有机会在更晚些时候被释放,即便如此在内存资源紧张情况下,也会是个问题。这类主要是 string、slice 底层 buffer 的错误共享,导致无用数据对象无法及时释放,或者 defer 函数导致的资源没有及时释放。
  • 永久性泄露,指的是在进程后续生命周期内,泄露的内存都没有机会回收,如 goroutine 内部预期之外的for-loop或者chan select-case导致的无法退出的情况,导致协程栈及引用内存永久泄露问题。
  1. Goroutine泄漏:实际开发中更多的还是Goroutine引起的内存泄漏,因为Goroutine的创建非常简单,通过关键字go即可创建,由于开发的进度大部分程序猿只会关心代码的功能是否实现,很少会关心Goroutine何时退出。如果Goroutine在执行时被阻塞而无法退出,就会导致Goroutine的内存泄漏,一个Goroutine的最低栈大小为2KB,在高并发的场景下,对内存的消耗也是非常恐怖的!
  2. 互斥锁未释放:协程拿到锁未释放,其他协程获取锁会阻塞
  3. 死锁
  4. chan阻塞
  5. 定时器使用 time.Ticker是每隔指定的时间就会向通道内写数据。作为循环触发器,必须调用stop方法才会停止,从而被GC掉,否则会一直占用内存空间。

排查

  1. pprof

  2. hook 库函数

  3. 代码审查:仔细检查代码,特别关注goroutine的创建和终止逻辑,确保没有未释放的资源,没有未关闭的通道,以及没有无限循环。

  4. 调试工具:使用Go语言提供的调试工具,如goroutine分析器(go tool pprof)和内存分析器(go tool pprof -alloc_space),来检查运行时的goroutine数量和内存使用情况。这些工具可以帮助定位泄露的goroutine以及相关的资源。

  5. 监控和日志:在应用程序中添加监控和日志记录,以便及时发现异常的goroutine数量和行为。记录重要的事件和错误信息,以便追踪和分析泄露的原因。

  6. 单元测试和性能测试:编写单元测试来验证goroutine的创建和终止逻辑,并进行性能测试以模拟高并发和负载情况,以确定是否存在泄露问题。

垃圾回收机制

垃圾回收就是对程序中不再使用的内存资源进行自动回收的操作。

GoV1.3- 普通标记清除法,整体过程需要启动STW,效率极低。
GoV1.5- 三色标记法, 堆空间启动写屏障,栈空间不启动,全部扫描之后,需要重新扫描一次栈(需要STW),效率普通
GoV1.8-三色标记法,混合写屏障机制, 栈空间不启动,堆空间启动。整个过程几乎不需要STW,效率较高。

三色标记法

  • 初始化状态下所有对象都是白色的。
  • 从根节点开始遍历所有对象,把遍历到的对象变成灰色对象
  • 遍历灰色对象,将灰色对象引用的对象也变成灰色对象,然后将遍历过的灰色对象变成黑色对象
  • 循环步骤3,直到灰色对象全部变黑色。
  • 通过写屏障检测对象有变化。重复以上操作
  • 收集所有的白色对象(垃圾)

根节点

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

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

stop the world

为了防止在GC处理过程中对象依赖树被篡改,比如一个黑节点指向一个白节点,会导致白节点错误的被清理,所以需要在整个GC过程停止用户的代码执行,即STW(stop the word), 早期的go就是这样做的,带来的后果也是非常严重的,STW时间长达数百毫秒,对时延敏感的程序造成巨大的影响。

a.第一次是Mark阶段的开始。第一次STW会准备根对象的扫描, 启动写屏障(Write Barrier)和辅助GC(mutator assist).

b.第二次是Mark Termination(标记结束)阶段. re-scan过程,如果这个时候没有stw,那么mark将无休止。第二次STW会重新扫描部分根对象, 禁用写屏障(Write Barrier)和辅助GC(mutator assist). 需要注意的是, 不是所有根对象的扫描都需要STW, 例如扫描栈上的对象只需要停止拥有该栈的G. 从go 1.9开始, 写屏障的实现使用了Hybrid Write Barrier, 大幅减少了第二次STW的时间.

这里的写屏障(write barrier)是因为在GC的时候用户代码可以同时运行,这样在扫描的时候,对象的依赖树可能被改变了,为了避免这个问题,Golang在GC中标记阶段会启用写屏障。

写屏障:

当标记和程序是并发执行的,这就会造成一个问题. 在标记过程中,有新的引用产生,可能会导致误清扫.

清扫开始前,标记为黑色的对象引用了一个新申请的对象,它肯定是白色的,而黑色对象不会被再次扫描,那么这个白色对象无法被扫描变成灰色、黑色,它就会最终被清扫,而实际它不应该被清扫.

这就需要用到屏障技术,golang采用了写屏障,其作用就是为了避免这类误清扫问题. 写屏障即在==内存写操作前,维护一个约束,从而确保清扫开始前,黑色的对象不能引用白色对象==.

插入写屏障:当一个对象指向另一个对象时,被指向的对象被置灰,所以黑节点指向灰节点,不会错误清理对象。
删除写屏障:当删除一个对象对另一个对象的引用时,旧的被指向者被置灰。

强三色不变性

不存在黑色对象引用到白色对象的指针。

弱三色不变性

所有被黑色对象引用的白色对象都处于灰色保护状态.

黑色对象可以引用白色对象,当前仅当白色对象存在其他灰色对象引用,或者可达它的链路上游存在黑色对象
强制性不允许黑色对象引用白色对象

插入写屏障

具体操作: 在A对象引用B对象的时候,B对象被标记为灰色。(将B挂在A下游,B必须被标记为灰色)满足强三色不变性

我们知道,黑色对象的内存槽有两种位置, . 栈空间的特点是容量小,但是要求相应速度快,因为函数调用弹出频繁使用, 所以“插入屏障”机制,在栈空间的对象操作中不使用. 而仅仅使用在堆空间对象的操作中.

==为了防止栈空间内黑色对象引用白色对象,此时会在准备清扫白色对象时,进行STW再次扫描一遍栈空间。==

缺点:结束时需要STW重新扫描栈,10~100ms

删除写屏障

具体操作: 被删除的对象,如果自身为灰色或者白色,那么被标记为灰色。满足弱三色不变性

缺点:回收精度低,一个对象即时被删除了最后一个指向它的指针也依旧可以活过这一轮,在下一次GC中才被清理。GC开始时STW扫描堆栈来记录初始快照,这个过程会保护开始时刻的所有存活对象。

混合写屏障

  1. GC开始将栈上的对象全部扫描并标记为黑色(之后不再进行第二次重复扫描,无需STW),
  2. GC期间,任何在栈上创建的新对象,均为黑色。
  3. 被删除的对象标记为灰色。
  4. 被添加的对象标记为灰色。

满足: 变形的弱三色不变式(结合了插入、删除写屏障的优点).
只需要在开始时并发扫描各个goroutine的栈,使其变黑并一直保持,这个过程不需要STW,而标记结束后,因为栈在扫描后始终是黑色的,也无需再进行re-scan操作了,减少了STW的时间。

GC触发时机

  1. 主动触发,通过调用 runtime.GC 来触发 GC,此调用阻塞式地等待当前 GC 运行完毕。
  2. 被动触发,分为两种方式:
    • 使用系统监控,当超过两分钟没有产生任何 GC 时,强制触发 GC。
    • 当程序分配了一定数量的内存后,GC 也会被触发。
      当所分配的堆大小达到阈值(由控制器计算的触发堆的大小)时,将会触发。
      使用步调(Pacing)算法,其核心思想是控制内存增长的比例。
      第一次触发 GC 时强制设置触发第一次 GC 为 4MB
    • 当系统内存空间不足时,GC 会被触发来释放内存

缺点

stop the world是gc的最大性能问题,对于gc而言,需要停止所有的内存变化,即停止所有的goroutine,等待gc结束之后才恢复。

从1.8以后的golang将第一步的stop the world 也取消了,这又是一次优化; 1.9开始, 写屏障的实现使用了Hybrid Write Barrier, 大幅减少了第二次STW的时间.

评论