Go语言内存管理与布局

2023-12-05  本文已影响0人  CXYMichael

内存管理

1布局

1.1操作系统内存布局

1.1.1逻辑布局

1.1.2物理布局

1.2 GO 内存布局

go没有使用操作系统提供的内存管理方案,而是自己实现了一套管理机制,其整体布局如下:


1.2.1 Arena

arena区域就是我们所谓的堆区,Go动态分配的内存都是在这个区域,它把内存分割成8KB大小的页,一些页组合起来称为mspan

1.2.2 Bitmap

bitmap区域标识arena区域哪些地址保存了对象,并且用4bit标志位表示对象是否包含指针、GC标记信息。bitmap中一个byte大小的内存对应arena区域中4个指针大小(指针大小为 8B )的内存,所以bitmap区域的大小是512GB/(4*8B)=16GB。


1.2.3 Spans

Spans存放指向mspan的指针
Mspanarena管理和分配的基本单元,指向Arena中一段连续的页
Go里mspan的Size Class共有67种,每种mspan分割的object大小是8*2n的倍数,这些class不是指数式翻倍的,因为大块连续内存空间很少见,会导致各class的mspan数量不均衡,这个是写死在代码里的:

// path: /usr/local/go/src/runtime/sizeclasses.go

const _NumSizeClasses = 67

var class_to_allocnpages = [_NumSizeClasses]uint8{0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 1, 2, 1, 2, 1, 3, 2, 3, 1, 3, 2, 3, 4, 5, 6, 1, 7, 6, 5, 4, 3, 5, 7, 2, 9, 7, 5, 8, 3, 10, 7, 4}
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}
Mcache

每个工作线程P都会绑定一个mcache,本地缓存可用的mspan资源,这样就可以直接给Goroutine分配,因为不存在多个Goroutine竞争的情况,所以不会消耗锁资源。
mcache在初始化的时候是没有任何mspan资源的,在使用过程中会动态地从mcentral申请,之后会缓存下来。

Mcentral

为所有mcache提供切分好的mspan资源。每个central保存一种特定大小的全局mspan列表,包括已分配出去的和未分配出去的。
每个mcentral对应一种mspan,而mspan的种类导致它分割的object大小不同。当工作线程的mcache中没有合适(也就是特定大小的)的mspan时就会从mcentral获取。
noscan是没有引用外部对象,GC时无需扫描的mspan链表


mcentral维护两个双向链表,nonempty表示有空闲对象的链表,empty表示没有空闲对象或 span 已经被 mcache 缓存的 span 链表。这两个变量名和实际的用途是反的。
//go:notinheap
type mcentral struct {
    lock      mutex
    sizeclass int32
    nonempty  mSpanList // list of spans with a free object, ie a nonempty free list
    empty     mSpanList // list of spans with no free objects (or cached in an mcache)
}

这个问题直到1.16版本才被修复:

// Central list of free objects of a given size.
//
//go:notinheap
type mcentral struct {
    spanclass spanClass

    // partial and full contain two mspan sets: one of swept in-use
    // spans, and one of unswept in-use spans. These two trade
    // roles on each GC cycle. The unswept set is drained either by
    // allocation or by the background sweeper in every GC cycle,
    // so only two roles are necessary.
    //
    // sweepgen is increased by 2 on each GC cycle, so the swept
    // spans are in partial[sweepgen/2%2] and the unswept spans are in
    // partial[1-sweepgen/2%2]. Sweeping pops spans from the
    // unswept set and pushes spans that are still in-use on the
    // swept set. Likewise, allocating an in-use span pushes it
    // on the swept set.
    //
    // Some parts of the sweeper can sweep arbitrary spans, and hence
    // can't remove them from the unswept set, but will add the span
    // to the appropriate swept list. As a result, the parts of the
    // sweeper and mcentral that do consume from the unswept list may
    // encounter swept spans, and these should be ignored.
    partial [2]spanSet // list of spans with a free object
    full    [2]spanSet // list of spans with no free objects
}

并且还更新了spans的存储数据结构为spanSet

// Central list of free objects of a given size.
//
//go:notinheap
type mcentral struct {
    spanclass spanClass

    // partial and full contain two mspan sets: one of swept in-use
    // spans, and one of unswept in-use spans. These two trade
    // roles on each GC cycle. The unswept set is drained either by
    // allocation or by the background sweeper in every GC cycle,
    // so only two roles are necessary.
    //
    // sweepgen is increased by 2 on each GC cycle, so the swept
    // spans are in partial[sweepgen/2%2] and the unswept spans are in
    // partial[1-sweepgen/2%2]. Sweeping pops spans from the
    // unswept set and pushes spans that are still in-use on the
    // swept set. Likewise, allocating an in-use span pushes it
    // on the swept set.
    //
    // Some parts of the sweeper can sweep arbitrary spans, and hence
    // can't remove them from the unswept set, but will add the span
    // to the appropriate swept list. As a result, the parts of the
    // sweeper and mcentral that do consume from the unswept list may
    // encounter swept spans, and these should be ignored.
    partial [2]spanSet // list of spans with a free object
    full    [2]spanSet // list of spans with no free objects
}
Mheap

代表Go程序持有的所有堆空间,Go程序使用一个mheap的全局对象_mheap来管理堆内存
当mcentral没有空闲的mspan时,会向mheap申请。而mheap没有资源时,会向操作系统申请新内存。mheap主要用于大对象的内存分配,以及管理未切割的mspan,用于给mcentral切割成小对象。
同时我们也看到,mheap中含有所有规格的mcentral,所以,当一个mcachemcentral申请mspan时,只需要在独立的mcentral中使用锁,并不会影响申请其他规格的mspan
heapcentral数组尺寸是134,因为每种spanclass又细分为scannoscanscan表示该mspan中的对象包含指针,需要进行GC扫描等管理操作,noscan表示对象中都是非引用类型,不需要进行扫描。

2堆管理

2.1 特点

2.2 分配

2.3 回收

2.3.1 STW(Go < 1.3)


go runtime在一定条件下(内存超过阈值、达到GC周期时间2min或者执行runtime.GC)会触发GC
首先启动垃圾回收goroutine,这个goroutine会将其他goroutine执行到安全点后停止



停止后将P与M全部解绑



再把所有Goroutine放到全局调度队列中

2.3.2 STW + 并发清除(Go < 1.5)

Mark完成后马上就重新启动被暂停的任务了,而是让sweep任务和普通协程任务一样并行的和其他任务一起执行


2.3.3 三色标记+插入写屏障(Go < 1.8)

插入写屏障重扫阶段需要栈区进行STW(Java CMS),删除写屏障则需要在初始阶段STW并生成快照SATB(Java G1),并且Go的栈不支持做写屏障



2.3.4


Go 1.8采用一种混合的 write barrier 方式 (Yuasa-style deletion write barrier [Yuasa ‘90] 和 Dijkstra-style insertion write barrier [Dijkstra ‘78])来避免堆栈重新扫描,优点如下:

3 栈管理

3.1 特点

在Go应用程序运行时,每个goroutine都维护着一个自己的栈区,这个栈区只能自己使用不能被其他goroutine使用。栈区的初始大小是2KB(比x86_64架构下线程的默认栈2M要小很多),在goroutine运行的时候栈区会按照需要增长和收缩,占用的内存最大限制的默认值在64位系统上是1GB。栈大小的初始值和上限这部分的设置都可以在Go的源码runtime/stack.go里找到。

type g struct {
 stack       stack
  ...
}
 
type stack struct {
 lo uintptr
 hi uintptr
}

其实栈内存空间、结构和初始大小在最开始并不是2KB,也是经过了几个版本的更迭:

函数栈中包含:

3.1.1 全局栈缓存

栈空间在运行时中包含两个重要的全局变量,分别是 runtime.stackpoolruntime.stackLarge,这两个变量分别表示全局的栈缓存和大栈缓存,前者可以分配小于 32KB 的内存,后者用来分配大于 32KB 的栈空间:

 // Number of orders that get caching. Order 0 is FixedStack
 // and each successive order is twice as large.
 // We want to cache 2KB, 4KB, 8KB, and 16KB stacks. Larger stacks
 // will be allocated directly.
 // Since FixedStack is different on different systems, we
 // must vary NumStackOrders to keep the same maximum cached size.
 //   OS               | FixedStack | NumStackOrders
 //   -----------------+------------+---------------
 //   linux/darwin/bsd | 2KB        | 4
 //   windows/32       | 4KB        | 3
 //   windows/64       | 8KB        | 2
 //   plan9            | 4KB        | 3
_NumStackOrders = 4 - sys.PtrSize/4*sys.GoosWindows - 1*sys.GoosPlan9
 
var stackpool [_NumStackOrders]mSpanList
 
type stackpoolItem struct {
 mu   mutex
 span mSpanList
}
 
var stackLarge struct {
 lock mutex
 free [heapAddrBits - pageShift]mSpanList
}
 
//go:notinheap
type mSpanList struct {
 first *mspan // first span in list, or nil if none
 last  *mspan // last span in list, or nil if none
}

可以看到这两个用于分配空间的全局变量都与内存管理单元 runtime.mspan 有关,所以我们栈内容的申请也和堆的申请相似,是先去当前线程的对应尺寸的mcache里去申请,不够的时候mache会从全局的mcental里取内存等等。
其实从调度器和内存分配的角度来看,如果运行时只使用全局变量来分配内存的话,势必会造成线程之间的锁竞争进而影响程序的执行效率,栈内存由于与线程关系比较密切,所以在每一个线程缓存 runtime.mcache 中都加入了栈缓存减少锁竞争影响。

type mcache struct {
  ...
  alloc [numSpanClasses]*mspan
  
 stackcache [_NumStackOrders]stackfreelist
  ...
}
 
type stackfreelist struct {
 list gclinkptr
 size uintptr
}

3.2 分配

3.2.1 分段栈(Go < 1.3)

随着goroutine 调用的函数层级的深入或者局部变量需要的越来越多时,运行时会调用 runtime.morestackruntime.newstack创建一个新的栈空间,这些栈空间是不连续的,但是当前 goroutine 的多个栈空间会以双向链表的形式串联起来,运行时会通过指针找到连续的栈片段.

分段栈虽然能够按需为当前 goroutine 分配内存并且及时减少内存的占用,但是它也存在一个比较大的问题:如果当前 goroutine 的栈几乎充满,那么任意的函数调用都会触发栈的扩容,当函数返回后又会触发栈的收缩,如果在一个循环中调用函数,栈的分配和释放就会造成巨大的额外开销,这被称为\color{red}{热分裂问题(Hot split)}

为了解决这个问题,Go在1.2版本的时候不得不将栈的初始化内存从4KB增大到了8KB。后来把采用连续栈结构后,又把初始栈大小减小到了2KB。

3.2.2 连续栈(Go >= 1.3)

连续栈可以解决分段栈中存在的两个问题,其核心原理就是每当程序的栈空间不足时,初始化一片比旧栈大两倍的新栈并将原栈中的所有值都迁移到新的栈中,新的局部变量或者函数调用就有了充足的内存空间。使用连续栈机制时,栈空间不足导致的扩容会经历以下几个步骤:

copystack会把旧栈里的所有内容拷贝到新栈里然后调整所有指向旧栈的变量的指针指向到新栈, 我们可以用下面这个程序验证下,栈扩容后同一个变量的内存地址会发生变化。

package main
 
func main() {
 var x [10]int
 println(&x)
 a(x)
 println(&x)
}
 
//go:noinline
func a(x [10]int) {
 println(`func a`)
 var y [100]int
 b(y)
}
 
//go:noinline
func b(x [100]int) {
 println(`func b`)
 var y [1000]int
 c(y)
}
 
//go:noinline
func c(x [1000]int) {
 println(`func c`)
}

程序的输出可以看到在栈扩容前后,变量x的内存地址的变化:

0xc000030738
...
...
0xc000081f38

3.3 回收

在goroutine运行的过程中,如果栈区的空间使用率不超过1/4,那么在垃圾回收的时候使用runtime.shrinkstack进行栈缩容,当然进行缩容前会执行一堆前置检查,都通过了才会进行缩容:

func shrinkstack(gp *g) {
 ...
 oldsize := gp.stack.hi - gp.stack.lo
 newsize := oldsize / 2
 if newsize < _FixedStack {
  return
 }
 avail := gp.stack.hi - gp.stack.lo
 if used := gp.stack.hi - gp.sched.sp + _StackLimit; used >= avail/4 {
  return
 }
 
 copystack(gp, newsize)
}

如果要触发栈的缩容,新栈的大小会是原始栈的一半,不过如果新栈的大小低于程序的最低限制 2KB,那么缩容的过程就会停止。缩容也会调用扩容时使用的 runtime.copystack 函数开辟新的栈空间,将旧栈的数据拷贝到新栈以及调整原来指针的指向。

在下面的例子里,当main函数里的其他函数执行完后,只有main函数还在栈区的空间里,如果这个时候系统进行垃圾回收就会对这个goroutine的栈区进行缩容。在这里我们可以在程序里通过调用runtime.GC,强制系统进行垃圾回收,来试验看一下栈缩容的过程和效果:

func main() {
   var x [10]int
   println(&x)
   a(x)
   runtime.GC()
   println(&x)
}

如果要触发栈的缩容,新栈的大小会是原始栈的一半,不过如果新栈的大小低于程序的最低限制 2KB,那么缩容的过程就会停止。缩容也会调用扩容时使用的 runtime.copystack 函数开辟新的栈空间,将旧栈的数据拷贝到新栈以及调整原来指针的指向。

在下面的例子里,当main函数里的其他函数执行完后,只有main函数还在栈区的空间里,如果这个时候系统进行垃圾回收就会对这个goroutine的栈区进行缩容。在这里我们可以在程序里通过调用runtime.GC,强制系统进行垃圾回收,来试验看一下栈缩容的过程和效果:

func main() {
   var x [10]int
   println(&x)
   a(x)
   runtime.GC()
   println(&x)
}

修改源码文件runtime.stack.go,把常量stackDebug的值修改为1,执行命令go build -gcflags -S main.go后会看到类似下面的输出:

...
shrinking stack 32768->16384
stackalloc 16384
  allocated 0xc000076000
copystack gp=0xc000000180 [0xc00007a000 0xc000081e60 0xc000082000] -> [0xc000076000 0xc000079e60 0xc00007a000]/16384
...

3.4 问题

问:局部变量什么时候分配在堆上,什么时候在栈上?
答:这个由编译器决定,和具体的语法无关,各版本golang的实现也有差异,golang刻意弱化了堆和栈的概念。
Golang 编译器会将函数的局部变量分配到函数栈帧(stack frame)上。然而,如果编译器不能确保变量在函数 return 之后不再被引用(逃逸分析),编译器就会将变量分配到堆上。而且,如果一个局部变量非常大,那么它也应该被分配到堆上而不是栈上。
比如这段代码,就不会在堆上分配内存,即使我们用new分配:

const Width, Height = 640, 480
type Cursor struct {
    X, Y int
}
 
func Center(c *Cursor) {
    c.X += Width / 2
    c.Y += Height / 2
}
 
func CenterCursor() {
    c := new(Cursor)
    Center(c)
    fmt.Println(c.X, c.Y)
}

验证结果如下:

go tool compile -m test.go
 
test.go:17: can inline Center
test.go:24: inlining call to Center
test.go:25: c.X escapes to heap
test.go:25: c.Y escapes to heap
test.go:23: CenterCursor new(Cursor) does not escape
test.go:25: CenterCursor ... argument does not escape
test.go:17: Center c does not escape

这段代码则会在堆上分配对象:

package main
 
import (
    "fmt"
)
 
func main() {
    var a [1]int
    c := a[:]
    fmt.Println(c)
}

汇编代码有调用newobject,其中test.go:8说明变量a的内存是在堆上分配的:

go tool compile -S test.golang
 
"".main t=1 size=336 value=0 args=0x0 locals=0x98
    0x0000 00000 (test.go:7)    TEXT    "".main(SB), $152-0
    0x0000 00000 (test.go:7)    MOVQ    (TLS), CX
    0x0009 00009 (test.go:7)    LEAQ    -24(SP), AX
    0x000e 00014 (test.go:7)    CMPQ    AX, 16(CX)
    0x0012 00018 (test.go:7)    JLS 320
    0x0018 00024 (test.go:7)    SUBQ    $152, SP
    0x001f 00031 (test.go:7)    FUNCDATA    $0, gclocals·7d2d5fca80364273fb07d5820a76fef4(SB)
    0x001f 00031 (test.go:7)    FUNCDATA    $1, gclocals·6e96661712a005168eba4ed6774db961(SB)
    0x001f 00031 (test.go:8)    LEAQ    type.[1]int(SB), BX
    0x0026 00038 (test.go:8)    MOVQ    BX, (SP)
    0x002a 00042 (test.go:8)    PCDATA  $0, $0
    0x002a 00042 (test.go:8)    CALL    runtime.newobject(SB)
    0x002f 00047 (test.go:8)    MOVQ    8(SP), AX
    0x0034 00052 (test.go:9)    CMPQ    AX, $0
    0x0038 00056 (test.go:9)    JEQ $1, 313
    0x003e 00062 (test.go:9)    MOVQ    $1, DX
    0x0045 00069 (test.go:9)    MOVQ    $1, CX

3.5 闭包

  1. Go语言支持闭包
  2. \color{red}{Go语言能通过逃逸分析(escape analyze)识别出变量的作用域,自动将变量在堆上分配。将闭包环境变量在堆上分配是Go实现闭包的基础}
  3. 返回闭包时并不是单纯返回一个函数,而是返回了一个结构体,记录下函数返回地址和引用的环境中的变量地址

4 Runtime与Debug

runtime.GC函数

会让运行时系统进行一次强制性的垃圾收集:

  1. 强制的垃圾回收:不管怎样,都要进行的垃圾回收。
  2. 非强制的垃圾回收:只会在一定条件下进行的垃圾回收(即运行时,系统自上次垃圾回收之后新申请的堆内存的单元(也成为单元增量)达到指定的数值)。
debug.SetGCPercent函数

用于设置一个比率(垃圾收集比率),前面所说的单元增量与前一次垃圾收集时的碎内存的单元数量和此垃圾手机比率有关。
<触发垃圾收集的堆内存单元增量>=<上一次垃圾收集完的堆内存单元数量>*(<垃圾收集比率>/100)

上一篇 下一篇

猜你喜欢

热点阅读