【Go】内存管理

点击阅读更多查看文章内容

自动内存管理

  • 管理动态内存:动态内存是程序在运行时根据需求动态分配的内存:malloc()
    • 是用于动态内存分配的区域,其生命周期由程序员或垃圾回收器管理。
    • 栈是用于存储函数调用上下文和局部变量的区域,其生命周期与函数调用相关。栈内存由编译器自动管理,无需程序员干预。
  • 自动内存管理(垃圾回收):由程序语言的运行时系统回收动态内存
    • 避免手动内存管理,专注于实现业务逻辑
    • 保证内存使用的正确性和安全性
  • 三个任务:
    • 为新对象分配空间
    • 找到存活对象
    • 回收死亡对象的内存空间

相关概念:

  • Mutator:业务线程,分配新对象,修改对象指向关系
  • Collector:GC线程,找到存活对象,回收死亡对象的内存空间

三种GC模式

  • Serial GC:只有一个collector

    image-20250306135748003
  • Parallel GC:支持多个collectors同时回收的GC算法

    image-20250306135759766
  • Concurrent GC:mutator(s)和collector(s)可以同时执行

    image-20250306135810262

    同时执行中Collector必须能够感知对象指向关系的改变!如下图如果GC过程中o指向了一个新的对象b,那么b必须被标记为存活(因为o到b可达)

    image-20250306135839916

GC算法的评价

  • 安全性:不能回收存活的对象(基本要求)
  • 吞吐率:1 - (GC时间/程序执行总时间 )(花在GC上的时间)
  • 暂停时间:stop the world(STW)(业务是否感知)
  • 内存开销:GC元数据开销

标记清除

对象被回收的条件:指针指向关系不可达的对象

  1. 标记根对象

    • 静态变量、全局变量、常量、线程等
  2. 标记可达对象

    • 从根对象出发,找到所有可达对象
  3. 清理所有不可达对象

    • 将存活对象复制到另外的内存空间(Copying GC):将存活对象移走,清空原来的内存空间

      image-20250306140444757
    • 将死亡对象的内存标记为“可分配”(Mark-sweep GC):将死亡对象的内存空间通过free list管理起来,接下来分配free list中的内存

      image-20250306140501275
    • 移动并整理存活对象(Mark-compact GC):原地整理对象,将存活对象拷贝到空间开始,然后分配之后空间

      image-20250306140513102
  4. 根据对象的生命周期,使用不同的标记和清理策略

可达对象是什么意思?

可达性分析是从根对象出发,通过引用链遍历所有可以被访问到的对象的过程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package main

import "fmt"

type Node struct {
value int
next *Node
}

func main() {
// 根对象:局部变量 root
root := &Node{value: 1}
root.next = &Node{value: 2}
root.next.next = &Node{value: 3}

// 此时,root 及其 next 链上的所有节点都是可达的
fmt.Println(root.value, root.next.value, root.next.next.value)

// 断开引用链
root.next = nil

// 此时,root 仍然可达,但 root.next 和 root.next.next 不可达
fmt.Println(root.value)
}

可达性分析

  1. 初始时,root 是根对象,root.nextroot.next.next 通过引用链可达。
  2. root.next = nil 执行后,root.nextroot.next.next 不再被引用,变为不可达。
  3. 垃圾回收器会回收这些不可达的对象。

分代GC(Generational GC)

根据对象的生命周期,使用不同的标记和清理策略

  • 分代假说:大多数的对象在刚分配出来后很快就不再使用了
  • 每个对象都有年龄:经历过GC的次数
  • 目的:针对年轻和老年的对象,制定不同的GC策略,降低整体内存管理的开销
  • 不同年龄的对象处于heap的不同区域
image-20250306140737943

针对年轻代对象

  • 由于存活对象很少,可以采用copying collection,只需复制少量存活对象
image-20250306140814282

针对老年代对象

  • 老年对象存活率高,复制开销大,采用标记清除算法,可以避免大量对象的移动与复制
image-20250306140826506

引用计数

每个对象都有一个与之关联的引用数目

对象存活的条件:当且仅当引用数大于0

优点:

  • 内存管理的操作被平摊到程序执行过程中,在程序执行中可以随时判断对象的引用数是否为0
  • 内存管理不需要了解runtime的实现细节: C++ 智能指针(smart pointer)

缺点:

  • 维护引用计数的开销较大:通过原子操作保证对引用计数操作的原子性和可见性
  • 无法回收环形数据结构(右侧红色的环无法回收) —— weak reference
  • 内存开销:每个对象都引入的额外内存空间存储引用数目
  • 回收较大的内存时依然可能引发暂停(将左侧绿色的对象设为null,那么其所连的三个引用链全都会回收)
image-20250306140904264

三色标记

为了解决原始标记清除算法带来的长时间 STW,多数现代的追踪式垃圾收集器都会实现三色标记算法的变种以缩短 STW 的时间(可以并发执行)。三色标记算法将程序中的对象分成白色、黑色和灰色三类。

  • 白色对象(可能死亡):未被回收器访问到的对象。在回收开始阶段,所有对象均为白色,当回收结束后,白色对象均不可达。
  • 灰色对象(波面):已被回收器访问到的对象,但回收器需要对其中的一个或多个指针进行扫描,因为他们可能还指向白色对象。
  • 黑色对象(确定存活):已被回收器访问到的对象,其中所有字段都已被扫描,黑色对象中任何一个指针都不可能直接指向白色对象。

在垃圾收集器开始工作时,程序中不存在任何的黑色对象,垃圾收集的根对象会被标记成灰色,垃圾收集器只会从灰色对象集合中取出对象开始扫描,当灰色集合中不存在任何对象时,标记阶段就会结束。

tri-color-mark-sweep

执行过程

三色标记垃圾收集器的工作原理很简单,我们可以将其归纳成以下几个步骤:

  1. 从灰色对象的集合中选择一个灰色对象并将其标记成黑色;
  2. 将黑色对象指向的所有对象都标记成灰色,保证该对象和被该对象引用的对象都不会被回收;
  3. 重复上述两个步骤直到对象图中不存在灰色对象;

当三色的标记清除的标记阶段结束之后,应用程序的堆中就不存在任何的灰色对象,我们只能看到黑色的存活对象以及白色的垃圾对象,垃圾收集器可以回收这些白色的垃圾,下面是使用三色标记垃圾收集器执行标记后的堆内存,堆中只有对象 D 为待回收的垃圾:

tri-color-mark-sweep-after-mark-phase

因为用户程序可能在标记执行的过程中修改对象的指针,所以三色标记清除算法本身是不可以并发或者增量执行的,它仍然需要 STW,在如下所示的三色标记过程中,用户程序建立了从 A 对象到 D 对象的引用,但是因为A已经为黑色所以D的颜色不会改变会被垃圾收集器错误地回收。

tri-color-mark-sweep-and-mutator

并发标记——屏障技术

本来不应该被回收的对象却被回收了,这在内存管理中是非常严重的错误,我们将这种错误称为悬挂指针,即指针没有指向特定类型的合法对象,影响了内存的安全性,想要并发或者增量地标记对象还是需要使用屏障技术。

内存屏障技术是一种屏障指令,它可以让 CPU 或者编译器在执行内存相关操作时遵循特定的约束,目前多数的现代处理器都会乱序执行指令以最大化性能,但是该技术能够保证内存操作的顺序性,在内存屏障前执行的操作一定会先于内存屏障后执行的操作。

想要在并发或者增量的标记算法中保证正确性,我们需要达成以下两种三色不变性(Tri-color invariant)中的一种:

  • 强三色不变性 — 黑色对象不会指向白色对象,只会指向灰色对象或者黑色对象;(插入写屏障)

  • 弱三色不变性 — 黑色对象可以引用白色对象,但必须存在一条从灰色对象到白色对象的路径。(删除写屏障)

    strong-weak-tricolor-invariant

    上图分别展示了遵循强三色不变性和弱三色不变性的堆内存,遵循上述两个不变性中的任意一个,我们都能保证垃圾收集算法的正确性,而屏障技术就是在并发或者增量标记过程中保证三色不变性的重要技术。

垃圾收集中的屏障技术是在用户程序读取对象、创建新对象以及更新对象指针时执行的一段代码,根据操作类型的不同,我们可以将它们分成读屏障(Read barrier)和写屏障(Write barrier)两种,因为读屏障需要在读操作中加入代码片段,对用户程序的性能影响很大,所以编程语言往往都会采用写屏障保证三色不变性。

我们在这里想要介绍的是 Go 语言中使用的两种写屏障技术,分别是 Dijkstra 提出的插入写屏障和 Yuasa 提出的删除写屏障,这里会分析它们如何保证三色不变性和垃圾收集器的正确性。

插入写屏障

通过如下所示的写屏障,用户程序和垃圾收集器可以在交替工作的情况下保证程序执行的正确性:

在添加引用时,将新添加的对象标记为灰色,确保它会被垃圾回收器扫描。

触发时机

当用户程序将一个对象的引用从 A -> nil 修改为 A -> B 时,插入写屏障会捕获这一修改,并将新添加的对象 B 标记为灰色。

优点

  • 减少多标:只标记新添加的对象,避免不必要的扫描。
  • 适合精确控制:在某些场景下,插入写屏障可以更精确地控制标记过程。

缺点

  • 实现复杂:逻辑较为复杂,性能开销较高。
  • 可能导致漏标:如果引用关系被频繁修改,可能会导致某些对象未被正确标记。
dijkstra-insert-write-barrier

假设我们在应用程序中使用 Dijkstra 提出的插入写屏障,在一个垃圾收集器和用户程序交替运行的场景中会出现如上图所示的标记过程:

  1. 垃圾收集器将根对象指向 A 对象标记成黑色并将 A 对象指向的对象 B 标记成灰色;
  2. 用户程序修改 A 对象的指针,将原本指向 B 对象的指针指向 C 对象,这时触发写屏障将 C 对象标记成灰色;
  3. 垃圾收集器依次遍历程序中的其他灰色对象,将它们分别标记成黑色;

Dijkstra 的插入写屏障是一种相对保守的屏障技术,它会将有存活可能的对象都标记成灰色以满足强三色不变性。在如上所示的垃圾收集过程中,实际上不再存活的 B 对象最后没有被回收;而如果我们在第二和第三步之间将指向 C 对象的指针改回指向 B,垃圾收集器仍然认为 C 对象是存活的,这些被错误标记的垃圾对象只有在下一个循环才会被回收。

插入式的 Dijkstra 写屏障虽然实现非常简单并且也能保证强三色不变性,但是它也有明显的缺点。因为栈上的对象在垃圾收集中也会被认为是根对象,所以为了保证内存的安全,Dijkstra 必须为栈上的对象增加写屏障或者在标记阶段完成重新对栈上的对象进行扫描,这两种方法各有各的缺点,前者会大幅度增加写入指针的额外开销,后者重新扫描栈对象时需要暂停程序,垃圾收集算法的设计者需要在这两者之间做出权衡。

删除写屏障

该算法会使用如下所示的写屏障保证增量或者并发执行垃圾收集时程序的正确性:

在删除引用时,将被删除的对象标记为灰色,确保它不会被错误地回收。

触发时机

当用户程序将一个对象的引用从 A -> B 修改为 A -> C 时,删除写屏障会捕获这一修改,并将被删除的对象 B 标记为灰色。

优点

  • 减少漏标:确保被删除的对象及其引用的对象不会被错误地回收。
  • 实现简单:逻辑清晰,性能开销较低。
  • 适合并发标记:与三色标记法配合良好,能够有效减少 STW 时间。

缺点

  • 可能导致多标:被删除的对象可能会被多扫描一次,增加垃圾回收的开销。
yuasa-delete-write-barrier

假设我们在应用程序中使用 Yuasa 提出的删除写屏障,在一个垃圾收集器和用户程序交替运行的场景中会出现如上图所示的标记过程:

  1. 垃圾收集器将根对象指向 A 对象标记成黑色并将 A 对象指向的对象 B 标记成灰色;
  2. 用户程序将 A 对象原本指向 B 的指针指向 C,触发删除写屏障,但是因为 B 对象已经是灰色的,所以不做改变;
  3. 用户程序将 B 对象原本指向 C 的指针删除,触发删除写屏障,白色的 C 对象被涂成灰色(如果没有删除写屏障C对象将始终为白色);
  4. 垃圾收集器依次遍历程序中的其他灰色对象,将它们分别标记成黑色;

上述过程中的第三步触发了 Yuasa 删除写屏障的着色,因为用户程序删除了 B 指向 C 对象的指针,所以 C 和 D 两个对象会分别违反强三色不变性和弱三色不变性:

  • 强三色不变性 — 黑色的 A 对象直接指向白色的 C 对象;
  • 弱三色不变性 — 垃圾收集器无法从某个灰色对象出发,经过几个连续的白色对象访问白色的 C 和 D 两个对象;

Yuasa 删除写屏障通过对 C 对象的着色,保证了 C 对象和下游的 D 对象能够在这一次垃圾收集的循环中存活,避免发生悬挂指针以保证用户程序的正确性。


Go内存分配

分块

目的:为对象在heap上分配内存

提前将内存分块

  • 调用系统调用mmap()向OS申请一大块内存,例如4 MB
  • 先将内存划分成大块,例如8 KB,称作mspan
  • 再将大块继续划分成特定大小(如:8B、16B、24B)的小块,用于对象分配
  • mspan可以分为两类
    • noscan mspan:分配不包含指针的对象 —— GC 不需要扫描
    • scan mspan: 分配包含指针的对象 —— GC需要扫描
image-20250306141052959

对象分配:根据对象的大小,选择最合适的块返回


缓存

  • TCMalloc: thread caching
  • 每个p包含一个mcache用于快速分配,用于为绑定于p上的g分配对象
  • mcache管理一组mspan
  • 当mcache中的mspan分配完毕,向mcentral申请带有未分配块的mspan
  • 当mspan中没有分配的对象,mspan会被缓存在mcentral中,而不是立刻释放并归还给OS
image-20250306141749963

Go内存管理优化

  • 对象分配是非常高频的操作:每秒分配GB级别的内存
  • 小对象占比较高
  • Go内存分配比较耗时
    • 分配路径长: g-> m -> p -> mcache -> mspan -> memory block -> return pointer
    • pprof: 对象分配的函数是最频繁调用的函数之一
作者

ShiHaonan

发布于

2025-03-03

更新于

2025-04-22

许可协议

评论