Golang——内存(内存管理、内存逃逸、垃圾回收 (GC) 机制)
本文详细介绍Go内存相关的内容,包括内存管理、内存逃逸、垃圾回收 (GC) 机制的三色标记,写屏障。
文章目录
- Go 的内存管理
- mspan && arenas
- arenas
- mspan
- mcache
- mcentral
- mheap
- 内存分配逻辑
- 内存回收逻辑
- 优势
- 内存碎片
- Go 的内存逃逸
- 什么是内存逃逸?
- 内存逃逸的检测:逃逸分析
- 典型的内存逃逸
- 内存逃逸的影响
- 优化建议
- 总结
- Go 的垃圾回收 (GC) 机制
- 特点
- 三色标记法工作原理
- 垃圾回收的触发条件
- GC 的影响与优化
- Go GC 流程
- 非分代回收 (Non-generational GC)
- 分代回收
- 为什么 Go 不使用分代回收?
- 非分代回收缺点
- Go 的写屏障 (Write Barrier)
- 写屏障是什么?
- 为什么需要写屏障?
- 混合写屏障策略
- 插入屏障 (Insertion Barrier)
- 删除屏障 (Deletion Barrier)
- 混合写屏障 (Hybrid Write Barrier)
- 混合写屏障的特点
- 混合写屏障的规则
- 写屏障的性能优化
- 写屏障的作用
- 写屏障的局限性
Go 的内存管理
Go 借鉴了 Google 的 TCMalloc(高性能的、用于 C++ 的内存分配器)。其核心思想是 内存池 + 多级对象管理,能加快分配速度,降低资源竞争。
在 Go 里用于内存管理的对象结构主要是:arenas && mspan、mcache、mcentral、mheap。
- mspan 是一个基础结构,分配内存时,基本以它为单位,并通过 arena 管理更大范围的内存【每级都是多个 mspan 组成】。
mheap
、mcentral
和mcache
起到了多级内存池的作用,当有对应大小的对象需要分配时会先到它们这一层请求。- 如果当前层内存池不够用时,会按照【mcache -> mcentral -> mheap -> 操作系统】顺序一层一层地往上申请内存。
在 Go 内存管理中,构成了多层次的内存管理架构。它们分别负责不同级别的内存分配需求,从全局分配到本地缓存,确保内存分配高效且低竞争。
- mcache: 本地缓存,为
P
提供快速分配的内存块。- mcentral: 中间管理层,按
sizeclass
分类管理mspan
。- mheap: 全局内存管理器,负责大对象分配和向操作系统申请内存。
三者之间的分层设计,使得 Go 的内存管理既高效又灵活。
总体上来看,Go 内存管理也是一个金字塔结构:
mspan && arenas
先来看看 mspan 这个基础结构体。首先,当 Go 在程序初始化的时候,会将申请到的虚拟内存划分为以下三个部分:
总体上来讲,spans 和 bitmap 区域可以看作是 arenas 区域的元数据信息,辅助内存管理。
- arenas:
- 表示内存池,划分为连续的页面(8KB 为单位)。
- spans:
- 管理一组连续页面,作为分配内存的单位。
- bitmap:
- 记录 arena 的分配状态和 GC 信息,辅助垃圾回收。
arenas
arenas: 动态分配的堆区,它将分配到的内存以 8k 为一页进行管理。
mspan
arenas这个单位还是太细了,因此再抽象出 mspan 这一层来管理arenas。mspan 记录了这组连续页面的起止地址、页数量、以及类型规格。
关于 mspan 的类型规格有 67 种,每一种都被定义了一个固定大小,当有对象需要分配内存时,就会挑选合适规格的 mspan 分配给对象。
mcache
mcache 是 Go 中为每个逻辑处理器(P
)分配的本地内存缓存。每个 P
拥有一个独立的 mcache
。
- alloc: 缓存不同规格的
mspan
,用于快速分配内存。 - tiny: 提供微型分配器,用于分配 ≤16B 的小对象。
作用
- 本地内存分配:
- 优先从
mcache
分配内存,避免线程间的竞争。
- 优先从
- 快速分配:
mcache
是运行时的最底层内存分配器,分配速度最快。
- 减少锁开销:
mcache
的分配无需加锁,因为每个P
独占一个mcache
。
mcentral
mcentral 是 Go 中用于管理不同规格(sizeclass
)的 mspan
的中间层。它负责为 mcache 提供内存块(mspan
)。
作用
- 分级管理:
mcentral
会按 sizeclass 将mspan
分为 67 种规格。
- 分配内存:
- 当本地缓存(
mcache
)的内存不足时,会向mcentral
请求新的mspan
。
- 当本地缓存(
- 减少竞争:
- 通过分级管理,避免了对不同大小内存的分配竞争。
mheap
mheap 是 Go 内存管理的全局对象,负责向操作系统申请内存,并管理大块的内存区域。它是整个内存分配系统的顶层。
作用
- 大对象分配:
- 当对象大小超过 32KB 时,直接从
mheap
分配内存。
- 当对象大小超过 32KB 时,直接从
- 内存来源:
- 当
mcentral
或mcache
需要新的内存时,会向mheap
请求。
- 当
- 内存管理:
- 管理所有的 arenas 和大块的内存区域。
- 提供空闲的 span 给下级(
mcentral
和mcache
)。
内存分配逻辑
- 如果
object size > 32KB
,则直接使用 mheap 来分配空间,mheap
中有一个 freelarge 字段管理着超大 span; - 如果
object size < 16Byte
,则通过 mcache 的 tiny 分配器来分配(tiny
可看作是一个指针 offset); - 如果
object size
在上述两者之间,首先尝试通过 sizeclass 对应的分配器分配; - 如果 mcache 的分配器没有空闲的 span 分配,则向 mcentral 申请空闲块;
- 如果 mcentral 也没空闲块,则向 mheap 申请并进行切分;
- 如果 mheap 也没合适的 span,则向系统申请新的内存空间。
内存回收逻辑
- 如果
object size > 32KB
,直接将 span 返还给 mheap 的自由链; - 如果
object size < 32KB
,查找 object 对应 sizeclass,归还到 mcache 自由链; - 如果 mcache 自由链过长或内存过大,将部分 span 归还到 mcentral;
- 如果某个范围的 mspan 都已经归还到 mcentral,则将这部分 mspan 归还到 mheap 页堆;
- mheap 不会定时将内存归还到系统,但会归还虚拟地址到物理内存的映射关系,当系统需要的时候可以回收这部分内存,否则暂时先留着给 Go 使用。
优势
-
用户态完成分配:
- 内存分配大多在用户态完成,不需要频繁进入内核态。
-
快速分配,优化碎片管理:
- 本地缓存 (
mcache
) 无需加锁,分配效率极高。 - Go 自己在用户态管理内存,固定大小分类(67 种
sizeclass
)减少碎片。
- 本地缓存 (
-
减少CPU 竞争,支持高并发:
- 每个逻辑处理器 (
P
) 独享本地缓存,避免线程间锁竞争。 - 按
sizeclass
分类分配,降低资源冲突,适合处理大量 goroutine。
- 每个逻辑处理器 (
内存碎片
系统在内存管理过程中产生的一种现象,表现为无法被有效利用的内存空间。解决内存碎片问题是内存管理优化的重要目标。分为 内部碎片 和 外部碎片。
- 内部碎片: 分配的内存大于实际需求,导致浪费。
- 外部碎片: 小块空闲内存分散分布,难以利用。
- 内部碎片:
- 分配给进程的内存区域中,有些部分未被使用。
- 原因:
- 字节对齐:
- 为了满足字节对齐的要求,会额外分配一些内存空间,但这些空间未被实际使用。
- 固定大小分配:
- 申请 28B 内存时,系统可能按 32B 的单位分配,多出的 4B 即为浪费。
- 字节对齐:
- 特点:
- 内部碎片主要表现为分配的内存大于实际需求,但超出的部分不能被利用。
- 外部碎片:
- 内存中存在一些小的空闲分区,这些分区没有被分配给任何进程,但由于过小,难以被再次利用。
- 原因:
- 内存反复分配和释放,导致小块内存分布在内存的各个位置。
- 空闲的内存块难以合并成更大的内存区域。
- 特点:
- 外部碎片导致内存空间虽然有剩余,但因为分布不连续,无法满足大块内存的分配需求。
Go 的内存逃逸
什么是内存逃逸?
- 定义: 当一个对象从 栈 中逃逸到 堆 中分配内存时,就称为内存逃逸。
- 结果:
- 栈内存对象在函数结束时会被自动回收。
- 堆内存对象需要垃圾回收器(GC)处理。
-
栈 (Stack):
- 特点:
- 线性存储,由编译器自动分配和回收。
- 用于存储函数参数和局部变量。
- 优点:
- 内存分配和释放的代价极低,仅需两条指令(
PUSH
和POP
)。 - 内存生命周期与函数执行周期一致,无需 GC 介入。
- 内存分配和释放的代价极低,仅需两条指令(
- 缺点:
- 受限于栈的大小,生命周期有限。
- 特点:
-
堆 (Heap):
- 特点:
- 用于动态分配,存储生命周期不确定的对象。
- 内存由垃圾回收器(GC)管理。
- 优点:
- 适合存储生命周期超出函数范围的对象。
- 缺点:
- 分配和回收的代价较高,依赖 GC,会增加性能开销。
- 特点:
内存逃逸的检测:逃逸分析
- 逃逸分析:
编译器在编译阶段对变量生命周期进行分析,决定变量分配在堆还是栈。jvm是运行时逃逸分析;
程序变量会携带有一组校验数据,检查变量的生命周期是否是完全可知的,如果通过检查,则可以在栈上分配。否则,就是所谓的逃逸,必须在堆上进行分配。 - 分析准则:
- 如果变量在函数外部没有引用,则分配在栈中。
- 如果变量在函数外部存在引用,则分配在堆中。
- 栈上分配更高效,不需要 GC 处理。
- 堆上分配适用于生命周期不可知或较长的变量。
典型的内存逃逸
以下是容易导致变量逃逸到堆的情况:
- 返回局部变量的指针。
- 指针或带指针的值被发送到
channel
。 - 切片中存储指针。
- 切片的底层数组因扩容而重新分配。
- 使用
interface
类型或动态分配内存。
-
指针返回:
- 函数内定义的局部变量通过指针返回:
func escape() *int { x := 10 return &x // x 逃逸到堆中 }
- 函数内定义的局部变量通过指针返回:
-
指针发送到 channel:
- 变量的指针或带指针的值被发送到
channel
中:go func(ch chan *int) { val := 10 ch <- &val // val 逃逸到堆中 }(ch)
- 变量的指针或带指针的值被发送到
-
切片存储指针:
- 切片中存储指针值导致背后数组的内存逃逸:
func example() { strs := []*string{} val := "hello" strs = append(strs, &val) // val 逃逸到堆中 }
- 切片中存储指针值导致背后数组的内存逃逸:
-
切片扩容:
- 切片的容量动态扩展,导致新的内存分配到堆:
func example() { slice := make([]int, 2) slice = append(slice, 1) // 扩容时,背后的数组逃逸到堆中 }
- 切片的容量动态扩展,导致新的内存分配到堆:
-
动态类型调用:
-
使用
interface
类型时,接口的动态方法调用使得编译器无法确定对象的生命周期。会导致内存逃逸:io.Reader 是接口类型func readExample(r io.Reader) { buf := make([]byte, 10) r.Read(buf) // buf 逃逸到堆中 }
-
内存逃逸的影响
-
优点:
- 提供灵活性,支持生命周期不确定或超出函数范围的对象。
-
缺点:
- 增加垃圾回收压力,降低程序性能。
- 堆分配的内存需要更多的系统资源和管理成本。
优化建议
-
减少不必要的指针传递:
- 避免使用指针返回局部变量,改为直接返回值。
-
减少动态分配:
- 使用固定大小的数组或切片,避免频繁扩容。
-
优化
interface
使用:- 尽量使用具体类型,减少
interface
动态分配。
- 尽量使用具体类型,减少
总结
- 内存逃逸 是 Go 内存分配过程中,为了确保变量生命周期正确而将其从栈移到堆的现象。
- 编译器通过 逃逸分析 决定变量是否需要从栈逃逸到堆。
- 堆分配虽然提供灵活性,但增加了性能开销,应尽量避免不必要的逃逸。
- 在性能敏感的代码中,通过值传递和优化动态分配可以有效减少逃逸的发生。
Go 的垃圾回收 (GC) 机制
GC(Garbage Collection)垃圾回收是一种自动管理内存的方式,无需手动管理内存,程序能够检测和清除不再被使用的内存块,避免内存泄漏,使开发人员从内存管理上解脱出来。
Go 语言内置了一套现代化、高效的垃圾回收 (Garbage Collection, GC) 机制,用于自动管理内存分配和释放。以下是 Go GC 的详细介绍:
特点
-
并发 (Concurrent):
- Go 的 GC 与程序的其他部分同时运行,不会完全阻塞程序的执行。
- 标记阶段主要部分是并发的,通过写屏障确保标记的正确性。
- 清理阶段默认是并发,极少使用串行清理;
-
三色标记清除算法 (Tri-color Mark and Sweep):
- Go 使用三色标记清除算法,是一种高效的垃圾回收技术。
-
非分代回收 (Generational):
- Go 的 GC 会优先回收短生命周期对象,同时针对长期存在的对象优化性能。
-
可配置性:
- 开发者可以通过 GOGC 环境变量调整垃圾回收器的灵敏度。
-
写屏障:
- Go 的GC使用混合写屏障策略,在标记阶段与程序并发运行,同时保持标记的准确性和一致性。
三色标记法工作原理
-
初始状态:
- 所有对象都被标记为白色。
- 根对象(全局变量、栈上的变量等)被标记为灰色。
-
标记过程:
- 遍历灰色对象,并将其引用的对象标记为灰色,直到没有灰色对象。
- 将已处理的灰色对象标记为黑色。
-
清理过程:
- 删除仍为白色的对象,因为这些对象不可达。
垃圾回收的触发条件
-
手动触发:
- 使用
runtime.GC()
主动触发垃圾回收。
- 使用
-
自动触发:
- Go 的 GC 会根据内存使用情况自动触发。
- GOGC 环境变量控制触发频率:
- 在申请内存的时候,检查当前已分配的内存是否大于上次GC后的内存的2倍(默认)。
- 监控线程(sysmon)检测到自上次 GC 已超过一定时间(例如两分钟),它会触发一次 GC。
GC 的影响与优化
-
优点:
- 简化内存管理,减少内存泄漏。
- 自动化管理内存,开发者不需要手动释放内存。
- 支持并发和实时性,适合高并发场景。
-
缺点:
- 存在一定的性能开销。
- 对延迟敏感的程序可能会受到影响。
-
优化方法:
- 优化内存分配: 减少短生命周期对象的创建,避免频繁触发 GC。
- 减少逃逸: 使用值类型替代指针,尽量避免变量逃逸到堆中。
- 调整 GOGC: 根据场景调整 GOGC 值以平衡性能和内存使用。
Go GC 流程
GC 的正确流程是:
- Stop-the-World: 暂停所有业务逻辑,确保初始状态的一致性。
- 标记: 并发标记存活对象,标记过程与应用程序同时运行(并发标记,需要用到写屏障)。
- 再次Stop-the-World: 主要写屏障带来的问题,确保标记阶段中所有引用的动态变化都被正确处理。
- 清理: 回收未标记的对象,清理可以并发或串行完成。
- Start-the-World: 恢复业务逻辑执行。
STW(Stop-the-World)仅用于标记的开始和结束阶段。标记阶段后需要一个非常短暂的 Stop-the-World 来确保标记阶段的一致性和完成状态。确保标记过程中的所有写屏障操作都已处理。
清理阶段(Sweep)通常是并发完成的,不需要 STW。
- Stop-the-World (STW):
-
目的:
- 确保垃圾回收器(GC)在标记阶段能够准确扫描所有活跃对象。
- 阻止新的对象分配干扰标记过程。
-
具体操作:
- 设置
gcwaiting=1
,通知所有 M(系统线程)进入休眠状态。 - 通过让当前运行中的 G(goroutine)完成或中断任务,确保所有 M 都暂停。
- 设置
-
解释:
- Modern Go (>= 1.5) 使用了并发 GC,尽量缩短 STW 的时间。在并发标记清除的算法下,STW 时间通常仅限于标记过程的开始和结束,而标记本身是在并发阶段完成的。
-
-
标记 (Mark):
-
目的:
- 找出程序中仍然存活的对象,并标记它们为活跃。
-
过程:
- 分配任务:
- 将标记任务分成若干段,分配给
gcproc
个 M(系统线程),其中gcproc
的默认值为逻辑处理器 P 的数量。 - 每个 M 在被唤醒后,检查自身的
helpgc
标记是否为true
,如果是,则开始执行标记任务。
- 将标记任务分成若干段,分配给
- 并发标记:
- 当前运行的 M 和其他被唤醒的 M 会并发执行标记任务。
- 如果某个 M 的任务完成,会从其他未完成任务的 M 中“偷取”标记任务,直到所有标记任务完成。
- 进入休眠:
- 标记任务完成后,所有参与 GC 的 M 再次进入休眠状态。
- 分配任务:
-
解释:
- Go 的三色标记法(白、灰、黑)用于跟踪对象引用状态。
- 标记阶段尽量减少对程序逻辑的影响,大部分标记任务是在并发模式下完成的。
-
-
标记阶段结束的 STW
-
目的:
- 确保标记阶段中所有引用的动态变化都被正确处理。
- 处理通过 写屏障 收集的对象引用变更。
-
过程:
- 在标记阶段,应用程序可能继续运行,导致对象的引用关系发生变化。
- 写屏障 记录了这些动态变更。
- 在标记结束时,STW 确保所有这些变更被应用,保证标记的最终一致性。
-
特点:
- 这一 STW 时间非常短,通常只需数十微秒到几毫秒。
- 这是现代 Go (>= 1.5) 的重要优化点,尽量将 STW 时间压缩到最小。
- 清理 (Sweep):
- 目的:
- 回收未被标记的对象所占用的内存。
- 过程:
- 清理阶段可以选择串行或并发执行:
- 并发清理: 通过单独的 Goroutine 执行,不需要阻塞业务逻辑(Go >= 1.3 默认行为)。
- 串行清理: 清理任务与主 GC 线程绑定,可能导致较长的 STW 时间。
- 清理阶段可以选择串行或并发执行:
- 解释:
- 并发清理的引入极大地降低了 GC 对业务逻辑的影响。
- 清理阶段主要释放未标记的内存,回收至内存池。
- 目的:
- Start-the-World (STW 结束):
- 目的:
- 恢复业务逻辑执行,唤醒所有线程(最多等于 P 的数量)。
- 过程:
- 设置
gcwaiting=0
,通知所有的 M 线程业务逻辑恢复正常。 - 唤醒 P 个 M,继续调度执行业务逻辑中的 G。
- 设置
- 解释:
- Start-the-World 阶段保证垃圾回收完成后程序能够立即恢复。
- 目的:
Go 不是分代回收 (Generational GC),而是采用了 非分代回收 (Non-generational GC) 策略。
非分代回收 (Non-generational GC)
-
Go 的 GC 是非分代的:
- 不区分对象的生命周期,对所有对象一视同仁地标记和回收。
- 强调简洁、高效和低延迟的实现。
-
虽然非分代回收可能在短生命周期对象的处理上效率不如分代回收,但 Go 的整体设计使其在并发、高性能场景中表现优异。
分代回收
分代回收是一种常见的垃圾回收优化技术,它将内存中的对象按照生命周期的长短分成不同的“代”,通常包括:
- 年轻代: 短生命周期的对象,回收频率高。
- 老年代: 长生命周期的对象,回收频率低。
- 持久代: 很少或几乎不会被回收的对象(如类元数据)。
核心思想:
- 大多数对象会很快变成垃圾(“弱代假设”)。
- 针对短生命周期和长生命周期的对象,采用不同的回收策略可以提高性能。
分代回收的典型代表是 Java 的 JVM 的垃圾回收器。
为什么 Go 不使用分代回收?
-
简化实现:
- 分代回收需要额外的数据结构和复杂性来管理不同代的内存区域,而 Go 的设计目标是简洁高效。
-
内存模型不同:
- Go 的并发模型(大量 Goroutine 和高频率的内存分配)使得分代回收的复杂性和性能开销可能得不偿失。
-
实时性需求:
- Go 强调低延迟、实时性,非分代回收的设计让 Go 更容易实现垃圾回收与业务逻辑的并发执行。
非分代回收缺点
- 短生命周期对象的回收成本较高:
- 对于短生命周期的对象,仍然需要完整扫描,增加了一定的性能开销。
- 内存使用效率可能稍低:
- 无法像分代回收那样对不同生命周期的对象采用优化策略。
Go 的写屏障 (Write Barrier)
写屏障是现代垃圾回收器中的一项关键技术,用于在垃圾回收的 并发标记阶段 追踪程序运行时对象引用的动态变化。Go 的垃圾回收器使用 混合写屏障 策略,这种设计能够在标记阶段与程序并发运行,在保证 GC 效率和正确性的同时,将 Stop-the-World (STW) 的时间压缩到最短,这是 Go 高效 GC 的重要基础。
写屏障是什么?
写屏障是一种机制,当程序修改对象的引用关系(即写入内存)时,写屏障会执行额外的操作,确保垃圾回收器能够正确地跟踪这些引用的变化。
- Go 的写屏障特点:
- 采用 混合写屏障 策略,结合插入屏障和删除屏障。
- 确保三色不变性,防止白色对象被错误回收。
- 仅在标记阶段启用,并发运行,与程序逻辑干扰最小。 在清理阶段或非 GC 期间无影响。
为什么需要写屏障?
Go 的垃圾回收 (GC) 使用 并发标记 (Concurrent Marking),在标记阶段,垃圾回收器和应用程序会同时运行。这种并发运行可能导致程序修改对象的引用关系,从而产生潜在问题,如下:
应用程序和垃圾回收器可以同时运行,因此对象确实可以在标记阶段被修改。我们考虑一下,下面的情况:
我们在进行三色标记中扫描灰色集合中,扫描到了对象 A,并标记了对象 A 的 所有引用,这时候,开始扫描对象 D 的引用,而此时,另一个 goroutine 修改 了 D->E 的引用,变成了如下图所示
这样会不会导致 E 对象就扫描不到了,而被误认为为白色对象,也就是垃圾。
写屏障就是为了解决这样的问题,引入写屏障后,在上述步骤后,E 会被认为 是存活的,即使后面 E 被 A 对象抛弃,E 会被在下一轮的 GC 中进行回收,这一 轮 GC 中是不会对对象 E 进行回收的。
混合写屏障策略
Go 的写屏障实现采用了 混合写屏障 (Hybrid Write Barrier) 策略。这种设计结合了两种经典的写屏障策略:插入屏障 (Insertion Barrier) 和 删除屏障 (Deletion Barrier)。
- 混合屏障:
- 插入屏障: 确保新加入的引用不会丢失,目标对象被正确标记。
- 删除屏障: 记录被移除的旧引用,防止对象错误回收。
- Go 的混合写屏障在实现时优先基于插入屏障的逻辑进行优化,而删除屏障的逻辑则只在特定情况下被补充实现。
插入屏障 (Insertion Barrier)
插入屏障的作用是在 对象引用发生新增时,对新引用的对象执行某些操作,以确保它们不会被错误回收。
工作原理:
- 当一个对象的引用被修改为指向另一个对象(新增引用)时,插入屏障会立即将新引用的对象从白色(未标记)变为灰色(待标记)。
- 插入屏障保证了新加入的对象会被正确处理。
优点:
- 保证新增的引用关系不会丢失。
- 简化了垃圾回收器的实现逻辑。
缺点:
- 可能无法处理对象引用被移除的场景。
删除屏障 (Deletion Barrier)
删除屏障的作用是在 对象引用被移除时,对被移除的对象(尤其是白色和灰色对象)执行某些操作,以确保它们在标记阶段被正确处理。
移除引用的对象不一定是需要回收的,它们可能仍然是存活的对象,只是被移除了当前的引用关系。
工作原理:
- 当对象的引用被移除时,删除屏障会记录下被移除的对象,确保这些对象在当前回收周期中不会被错误地处理或遗漏。
- 删除屏障强调保护“旧引用”。
如果被移除引用的对象是白色(未标记):
- 删除屏障会立即 将该对象从白色变为灰色。
- 目的:确保该对象在标记阶段被进一步处理,而不是被错误回收。
如果被移除引用的对象是灰色(已访问但其引用未完全处理):
- 删除屏障保持灰色状态不变。
- 目的:确保 GC 正确完成对灰色对象的递归标记。
如果被移除引用的对象是黑色(已完全处理):
- 删除屏障无需任何额外操作。
- 目的:黑色对象已经完全处理,不会受引用变化的影响。
优点:
- 能够处理对象引用被移除的场景。
- 防止白色对象被错误回收。
缺点:
- 当引用频繁新增和移除时,性能开销较大。
混合写屏障 (Hybrid Write Barrier)
混合写屏障结合了插入屏障和删除屏障的特点,能够同时处理 引用新增 和 引用移除 的场景。Go 的写屏障实现采用了这种策略。
工作原理:
- 新增引用:
- 当一个对象的引用被修改为指向新对象时,插入屏障逻辑会确保新引用的对象被标记为灰色,避免漏标。
- 移除引用:
- 当一个对象的引用被移除时,删除屏障逻辑会确保被移除的对象不会被错误回收。
混合写屏障的特点
优点:
- 完整性:
- 同时处理新增和移除引用,保证三色标记算法的正确性。
- 性能优化:
- 优化了插入屏障的逻辑,在标记阶段优先处理新增引用。
- 对删除屏障的逻辑只在必要时执行,避免了不必要的性能开销。
- 只对三色标记阶段生效:
缺点:
- 写屏障的逻辑复杂度略高,需要额外处理插入和删除两种场景。
- 增加了一定的内存写操作开销。
混合写屏障的规则
-
目标:确保三色不变性 (Tri-color Invariant):
- 三色标记法中对象的三种状态:
- 白色:尚未访问,可能是垃圾。
- 灰色:已访问但其引用未完全处理。
- 黑色:已访问且其引用已完全处理。
- 写屏障的任务是保证标记过程中,白色对象不会因为程序的引用变更被错误遗漏。
- 三色标记法中对象的三种状态:
-
操作逻辑:
- 当某个对象的引用被修改时:
- 如果目标对象是白色,将目标对象标记为灰色(放入灰色队列)。
- 确保修改后的引用关系被正确追踪。
- 当某个对象的引用被修改时:
写屏障的性能优化
写屏障会引入一定的性能开销,但 Go 的实现通过以下方式优化性能:
-
启用条件:
- 写屏障仅在 GC 的标记阶段启用,在非 GC 阶段没有额外开销。
-
批量标记:
- 写屏障会尽量以批量方式处理引用变更,减少对性能的影响。
-
内存屏障优化:
- 写屏障逻辑使用轻量级的原子操作,最大程度降低对应用程序性能的干扰。
-
只处理必要对象:
- 只有在标记阶段,且目标对象是白色时,写屏障才会执行额外操作,减少了不必要的屏障逻辑。
写屏障的作用
-
保证三色不变性:
- 避免白色对象被程序动态引用后未被标记,确保对象不会被错误回收。
-
减少 Stop-the-World 时间:
- 通过写屏障捕获动态引用,GC 无需在 STW 阶段完全重新扫描内存。
-
支持并发标记:
- 写屏障与并发标记结合,提升了垃圾回收器的效率。
写屏障的局限性
-
性能开销:
- 写屏障逻辑会增加内存写操作的开销,尤其是在高频引用修改的场景下。
-
复杂性:
- 写屏障的实现需要保证低延迟,同时处理动态引用的复杂性。