searchusermenu
  • 发布文章
  • 消息中心
点赞
收藏
评论
分享
原创

golang内存管理详解

2023-05-29 01:37:02
73
0

内存逃逸

golang 程序在定义一个变量时,编译器会对变量的生命周期进行分析,如果变量只在当前函数体内部被引用,则分配在栈上,否则就说该变量发生了“逃逸”,需要分配到堆上。

golang 变量分配到堆上还是栈上,不由任何关键字来决定(比如 make,new 也可能分配到栈上),只由编译器对代码做逃逸分析决定。

通常情况下,编译器是倾向于将变量分配到栈上的,因为它的开销小,最极端的就是"zero garbage",所有的变量都会在栈上分配,这样就不会存在内存碎片,垃圾回收之类的东西。

逃逸分析

golang 的逃逸分析是决定变量分配到堆上还是栈上的过程,发生在编译期。

查看变量逃逸

go build -gcflags '-m’
 

变量逃逸场景

  • 全局变量。
  • 变量申请的内存超过 goroutine 的栈空间(2k)。
  • 在方法内把局部变量指针返回,造成外部引用。
  • 发送指针或带有指针的值到 channel 中。
  • 在一个切片上存储指针或带指针的值, slice 的底层数据在 append 时会在堆上分配。
func Test() *int {
    val := 10
    return &val // Test函数调用结束,val仍然被引用,发生逃逸,分配在堆上
}

func Test1(data *Data) {
    obj := Object{}
    data.Obj = &obj // obj发生逃逸,分配在堆上
}

func Work() {
    data := Data{}
    Test1(&data) // 虽然传递指针,但不逃逸,data分配在栈上,work函数调用结束,data没有其他地方引用
}

func Work() {
    data := Data{}
    go func() {
        data.Val = "" // data发生逃逸,因为work调用结束,协程可能还在运行,所以data需要分配在堆上
    }
}
 

内存分配

golang 的内存分配算法主要源自 google 为 c 语言开发的 tcmalloc 算法,全称 thread-caching malloc。

golang 的内存分配算法是在 tcmalloc 基础之上做的优化。

tcmalloc

主要解决多线程下内存分配效率问题,核心思想就是把内存分为多级管理,从而降低锁的粒度。

每个线程都会自行维护一个独立的内存池,进行内存分配时优先从该内存池中分配,当内存池不足时才会向全局内存池申请,以避免不同线程对全局内存池的频繁竞争。

golang 内存分配

go 程序启动的时候,会先向操作系统申请一块内存(虚拟的地址空间),切成小块后自己进行管理。

内存管理单元

golang 内存管理的基本单元是 mspan,是由一片连续的 8KB 的页组成的大块内存。

mspan 内部会划分出若干个不同大小的 object,每个 object 可存储一个对象,mspan 会分配给和 object 尺寸大小接近的对象。

内存管理组件

mcache, mcentral, mheap 是Go内存管理的三大组件,层层递进。

  • mcache 管理线程在本地缓存的 mspan,每个 P 拥有一个 mcache,所以是无锁访问,小对象直接从 mcache 分配。
  • mcentral 是所有线程共享,需要加锁访问,mcache 内存不足时,会向 mcentral 申请访问。
  • mheap 主要用于大对象的内存分配,以及管理未切割的 mspan,用于给 mcentral 切割成小对象。

clipboard

分配流程

  • 小于 16B 的对象,使用 mcache 的 tiny 分配器分配。
  • 16B ~ 32KB 的对象,使用 mcache 中相应规格大小的 mspan 分配,如果 mcache 没有相应规格的 mspan,则向 mcentral 申请。
  • 大于32KB 的对象,直接从 mheap 上分配,如果 mheap 中也没有合适大小的 mspan,则向操作系统申请。

垃圾回收

golang 会定时释放不再使用的内存对象,叫做垃圾回收(GC),golang 在 1.5 版本之后采用 “三色标记法” 实现垃圾回收。

标记清除

GC 的核心思想就是先标记,再清除

  • 标记阶段:从根对象开始扫描,对所有可达的内存对象进行标记,被标记的对象代表被其他对象引用。
  • 清除阶段:扫描堆上的所有内存对象,对未被标记的内存对象进行回收,这里是将内存还给内存分配单元。

标记阶段需要通过 STW 挂起所有运行的 goroutine,防止标记过的对象在清除阶段开始前又引用了新的对象,造成新对象没有被标记,在清除阶段被误删除。

STW 会造成系统延迟,是 GC 的主要开销,所以 goalng 对 GC 的优化主要就是降低 STW 的时间。

根对象

golang 通过有向图代表各个对象的引用关系,只引用别人,而自己没有被引用的对象就是根对象,全局变量都是根对象。

对象可达是指引用它的对象也是可达的,如果引用他的对象不存在了(比如栈回收),则该对象变的不可达,需要在 GC 时回收。

三色标记法

三色标记法通过开启写屏障,减少了 STW 的时间,让用户程序 和 GC 几乎同时进行,减少 GC 造成的系统延迟。

标记中面对的问题是,GC 和程序同时运行,对象 A 被标记完成后,又引用了新对象 B,对象 B 没有办法再被标记,导致被清除。

解决办法是在 GC 中开启写屏障,将新产生的引用对象保存下来,标记完成后,开启 STW 再对写屏障新增对象再次标记,防止误删除。 由于 GC 中新产生的引用对象很少,所以 STW 的时间很短,对系统造成的延迟也很小。

写屏障

写屏障(Write barrier)是编译器在写操作的前面,生成的一小段代码段,来确保不要打破一些约束条件。

写屏障负责将 GC 标记过程中新产生的对象和引用保存下来,并等待 GC 处理。

写屏障会让写操作变的复杂,在一定程度上也会影响系统的吞吐量。

三色和状态

  • 白色:初始状态,如果标记完成还是白色那就代表对象不可达,要清除回收。
  • 灰色:可达对象的中间状态,等待扫描它的子对象。
  • 黑色:可达对象的最终状态,子对象扫描标记完成。

标记阶段

  • 初始化写屏障,用来收集 GC 扫描过程中,用户程序新产生的对象及引用变化。
  • 访问根对象集合,将根对象标记为灰色,放入一个栈中。
  • 从栈中取出一个对象,并把该对象所有引用的子对象入栈,并标记为灰色,并且把该对象标记为黑色,则该对象标记完成。
  • 无限重复处理,只到栈为空,所有灰色对象扫描完成标记为黑色。
  • 执行 STW,标记写屏障产生的灰色对象,并关闭写屏障。

清除阶段

清除阶段会扫描堆内存,并回收白色对象,这里不必 STW,因为白色对象不可达,就不可能再被任何对象引用到,可以放心清除。

清除时有可能产生新的对象,新创建的对象默认是白色的,有可能被清除。解决办法扫描清除的位置是知道的,程序创建新对象时,如果对象位置在扫描前,可以不用管,不会被清除,如果在扫描后面,则直接由用户程序标记为黑色。

触发 GC 时机

  • 主动触发,通过调用 runtime.GC() 来触发 GC,此调用在 GC 任务完成之后才返回。
  • 被动触发:系统监控到两分钟内没有产生任何 GC,强制触发 GC;使用 Pacing 算法,其核心思想是控制内存增长的比例。

申请速度超过GC 找到内存分配过快的goroutine,暂停内存分配,转而去执行标记任务,加快GC速度。

GC 性能调优

GC 对性能的影响主要关注两个指标:cpu 利用率和 GC 停顿时间。

golang 没有提供关于 GC 的调优参数,通常说的 GC 调优,都是指优化用户代码,减少堆对象申请,降低 GC 压力。

  • 采用对象池,降低并复用已经申请的内存,比如使用sync.Pool。
  • 降低 goroutine 的数目,goroutine 会加重 GC 时标记扫描的负担。
  • 合并小对象,尽量将小对象放到一个大结构体里面,方便 GC 扫描标记。
  • 少用指针返回,因为指针返回会导致变量逃逸,如果是小对象,可以直接返回对象,通过栈拷贝避免 GC 产生。
  • 少用 "+" 连接 string,这样会生成新的对象,好的方式是通过 append() 进行。
  • 避免 string 和 []byte 之间的转换,转换时底层会发生拷贝,可以一直使用 []byte,不得已时使用 unsafe.Pointer 直接进行转化。

GC 导致的程序假死

在 1.13 版本之前,一个 goroutine 如果处于无函数调用的死循环状态,这个 goroutine 会无法被 GC 的 STW 挂起,导致其他 goroutine 都处于等待 GC 状态,程序假死。

在 1.14 版本开始,支持抢占式调度,goroutine 如果处于无函数调用的死循环状态,会被抢占式调度,不会影响 GC,也不会假死。

打印 GC

编译时增加 -gcflags 选项

go build -gcflags "-l" -o test test.go 
 

运行时通过 GODEBUG 启动

GODEBUG="gctrace=1" ./test 
 

结果及分析

gc 1 @0.005s 15%: ..., 6->6->6 MB, 4 MB goal, 2 P 
gc 2 @0.016s  8%: ..., 8->8->8 MB, 13 MB goal, 2 P 
gc 3 @0.022s  9%: ..., 14->14->14 MB, 17 MB goal, 2 P 
 
1 表示第一次执行
@0.038s 表示程序执行的总时间
1% 垃圾回收时间占用总的运行时间百分比
0.018+1.3+0.076 ms clock 垃圾回收的时间,分别为STW(stop-the-world)清扫的时间, 并发标记和扫描的时间,STW标记的时间
0.054+0.35/1.0/3.0+0.23 ms cpu 垃圾回收占用cpu时间
4->4->3 MB 堆的大小,gc后堆的大小,存活堆的大小
5 MB goal 整体堆的大小
4 P 使用的处理器数量
0条评论
作者已关闭评论
董明高
6文章数
2粉丝数
董明高
6 文章 | 2 粉丝
原创

golang内存管理详解

2023-05-29 01:37:02
73
0

内存逃逸

golang 程序在定义一个变量时,编译器会对变量的生命周期进行分析,如果变量只在当前函数体内部被引用,则分配在栈上,否则就说该变量发生了“逃逸”,需要分配到堆上。

golang 变量分配到堆上还是栈上,不由任何关键字来决定(比如 make,new 也可能分配到栈上),只由编译器对代码做逃逸分析决定。

通常情况下,编译器是倾向于将变量分配到栈上的,因为它的开销小,最极端的就是"zero garbage",所有的变量都会在栈上分配,这样就不会存在内存碎片,垃圾回收之类的东西。

逃逸分析

golang 的逃逸分析是决定变量分配到堆上还是栈上的过程,发生在编译期。

查看变量逃逸

go build -gcflags '-m’
 

变量逃逸场景

  • 全局变量。
  • 变量申请的内存超过 goroutine 的栈空间(2k)。
  • 在方法内把局部变量指针返回,造成外部引用。
  • 发送指针或带有指针的值到 channel 中。
  • 在一个切片上存储指针或带指针的值, slice 的底层数据在 append 时会在堆上分配。
func Test() *int {
    val := 10
    return &val // Test函数调用结束,val仍然被引用,发生逃逸,分配在堆上
}

func Test1(data *Data) {
    obj := Object{}
    data.Obj = &obj // obj发生逃逸,分配在堆上
}

func Work() {
    data := Data{}
    Test1(&data) // 虽然传递指针,但不逃逸,data分配在栈上,work函数调用结束,data没有其他地方引用
}

func Work() {
    data := Data{}
    go func() {
        data.Val = "" // data发生逃逸,因为work调用结束,协程可能还在运行,所以data需要分配在堆上
    }
}
 

内存分配

golang 的内存分配算法主要源自 google 为 c 语言开发的 tcmalloc 算法,全称 thread-caching malloc。

golang 的内存分配算法是在 tcmalloc 基础之上做的优化。

tcmalloc

主要解决多线程下内存分配效率问题,核心思想就是把内存分为多级管理,从而降低锁的粒度。

每个线程都会自行维护一个独立的内存池,进行内存分配时优先从该内存池中分配,当内存池不足时才会向全局内存池申请,以避免不同线程对全局内存池的频繁竞争。

golang 内存分配

go 程序启动的时候,会先向操作系统申请一块内存(虚拟的地址空间),切成小块后自己进行管理。

内存管理单元

golang 内存管理的基本单元是 mspan,是由一片连续的 8KB 的页组成的大块内存。

mspan 内部会划分出若干个不同大小的 object,每个 object 可存储一个对象,mspan 会分配给和 object 尺寸大小接近的对象。

内存管理组件

mcache, mcentral, mheap 是Go内存管理的三大组件,层层递进。

  • mcache 管理线程在本地缓存的 mspan,每个 P 拥有一个 mcache,所以是无锁访问,小对象直接从 mcache 分配。
  • mcentral 是所有线程共享,需要加锁访问,mcache 内存不足时,会向 mcentral 申请访问。
  • mheap 主要用于大对象的内存分配,以及管理未切割的 mspan,用于给 mcentral 切割成小对象。

clipboard

分配流程

  • 小于 16B 的对象,使用 mcache 的 tiny 分配器分配。
  • 16B ~ 32KB 的对象,使用 mcache 中相应规格大小的 mspan 分配,如果 mcache 没有相应规格的 mspan,则向 mcentral 申请。
  • 大于32KB 的对象,直接从 mheap 上分配,如果 mheap 中也没有合适大小的 mspan,则向操作系统申请。

垃圾回收

golang 会定时释放不再使用的内存对象,叫做垃圾回收(GC),golang 在 1.5 版本之后采用 “三色标记法” 实现垃圾回收。

标记清除

GC 的核心思想就是先标记,再清除

  • 标记阶段:从根对象开始扫描,对所有可达的内存对象进行标记,被标记的对象代表被其他对象引用。
  • 清除阶段:扫描堆上的所有内存对象,对未被标记的内存对象进行回收,这里是将内存还给内存分配单元。

标记阶段需要通过 STW 挂起所有运行的 goroutine,防止标记过的对象在清除阶段开始前又引用了新的对象,造成新对象没有被标记,在清除阶段被误删除。

STW 会造成系统延迟,是 GC 的主要开销,所以 goalng 对 GC 的优化主要就是降低 STW 的时间。

根对象

golang 通过有向图代表各个对象的引用关系,只引用别人,而自己没有被引用的对象就是根对象,全局变量都是根对象。

对象可达是指引用它的对象也是可达的,如果引用他的对象不存在了(比如栈回收),则该对象变的不可达,需要在 GC 时回收。

三色标记法

三色标记法通过开启写屏障,减少了 STW 的时间,让用户程序 和 GC 几乎同时进行,减少 GC 造成的系统延迟。

标记中面对的问题是,GC 和程序同时运行,对象 A 被标记完成后,又引用了新对象 B,对象 B 没有办法再被标记,导致被清除。

解决办法是在 GC 中开启写屏障,将新产生的引用对象保存下来,标记完成后,开启 STW 再对写屏障新增对象再次标记,防止误删除。 由于 GC 中新产生的引用对象很少,所以 STW 的时间很短,对系统造成的延迟也很小。

写屏障

写屏障(Write barrier)是编译器在写操作的前面,生成的一小段代码段,来确保不要打破一些约束条件。

写屏障负责将 GC 标记过程中新产生的对象和引用保存下来,并等待 GC 处理。

写屏障会让写操作变的复杂,在一定程度上也会影响系统的吞吐量。

三色和状态

  • 白色:初始状态,如果标记完成还是白色那就代表对象不可达,要清除回收。
  • 灰色:可达对象的中间状态,等待扫描它的子对象。
  • 黑色:可达对象的最终状态,子对象扫描标记完成。

标记阶段

  • 初始化写屏障,用来收集 GC 扫描过程中,用户程序新产生的对象及引用变化。
  • 访问根对象集合,将根对象标记为灰色,放入一个栈中。
  • 从栈中取出一个对象,并把该对象所有引用的子对象入栈,并标记为灰色,并且把该对象标记为黑色,则该对象标记完成。
  • 无限重复处理,只到栈为空,所有灰色对象扫描完成标记为黑色。
  • 执行 STW,标记写屏障产生的灰色对象,并关闭写屏障。

清除阶段

清除阶段会扫描堆内存,并回收白色对象,这里不必 STW,因为白色对象不可达,就不可能再被任何对象引用到,可以放心清除。

清除时有可能产生新的对象,新创建的对象默认是白色的,有可能被清除。解决办法扫描清除的位置是知道的,程序创建新对象时,如果对象位置在扫描前,可以不用管,不会被清除,如果在扫描后面,则直接由用户程序标记为黑色。

触发 GC 时机

  • 主动触发,通过调用 runtime.GC() 来触发 GC,此调用在 GC 任务完成之后才返回。
  • 被动触发:系统监控到两分钟内没有产生任何 GC,强制触发 GC;使用 Pacing 算法,其核心思想是控制内存增长的比例。

申请速度超过GC 找到内存分配过快的goroutine,暂停内存分配,转而去执行标记任务,加快GC速度。

GC 性能调优

GC 对性能的影响主要关注两个指标:cpu 利用率和 GC 停顿时间。

golang 没有提供关于 GC 的调优参数,通常说的 GC 调优,都是指优化用户代码,减少堆对象申请,降低 GC 压力。

  • 采用对象池,降低并复用已经申请的内存,比如使用sync.Pool。
  • 降低 goroutine 的数目,goroutine 会加重 GC 时标记扫描的负担。
  • 合并小对象,尽量将小对象放到一个大结构体里面,方便 GC 扫描标记。
  • 少用指针返回,因为指针返回会导致变量逃逸,如果是小对象,可以直接返回对象,通过栈拷贝避免 GC 产生。
  • 少用 "+" 连接 string,这样会生成新的对象,好的方式是通过 append() 进行。
  • 避免 string 和 []byte 之间的转换,转换时底层会发生拷贝,可以一直使用 []byte,不得已时使用 unsafe.Pointer 直接进行转化。

GC 导致的程序假死

在 1.13 版本之前,一个 goroutine 如果处于无函数调用的死循环状态,这个 goroutine 会无法被 GC 的 STW 挂起,导致其他 goroutine 都处于等待 GC 状态,程序假死。

在 1.14 版本开始,支持抢占式调度,goroutine 如果处于无函数调用的死循环状态,会被抢占式调度,不会影响 GC,也不会假死。

打印 GC

编译时增加 -gcflags 选项

go build -gcflags "-l" -o test test.go 
 

运行时通过 GODEBUG 启动

GODEBUG="gctrace=1" ./test 
 

结果及分析

gc 1 @0.005s 15%: ..., 6->6->6 MB, 4 MB goal, 2 P 
gc 2 @0.016s  8%: ..., 8->8->8 MB, 13 MB goal, 2 P 
gc 3 @0.022s  9%: ..., 14->14->14 MB, 17 MB goal, 2 P 
 
1 表示第一次执行
@0.038s 表示程序执行的总时间
1% 垃圾回收时间占用总的运行时间百分比
0.018+1.3+0.076 ms clock 垃圾回收的时间,分别为STW(stop-the-world)清扫的时间, 并发标记和扫描的时间,STW标记的时间
0.054+0.35/1.0/3.0+0.23 ms cpu 垃圾回收占用cpu时间
4->4->3 MB 堆的大小,gc后堆的大小,存活堆的大小
5 MB goal 整体堆的大小
4 P 使用的处理器数量
文章来自个人专栏
文章 | 订阅
0条评论
作者已关闭评论
作者已关闭评论
0
0