内存管理
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
的指针
Mspan是arena
管理和分配的基本单元,指向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
,所以,当一个mcache
从mcentral
申请mspan
时,只需要在独立的mcentral
中使用锁,并不会影响申请其他规格的mspan
。
heap
中central
数组尺寸是134,因为每种spanclass
又细分为scan
和noscan
,scan
表示该mspan
中的对象包含指针,需要进行GC扫描等管理操作,noscan
表示对象中都是非引用类型,不需要进行扫描。
2堆管理
2.1 特点
- 每次从操作系统申请一大块的内存,由Go来对这块内存做分配,减少系统调用
- 内存分配算法采用Google的
TCMalloc
算法。其核心思想就是把内存切分的非常的细小,分为多级管理,以降低锁的粒度 - 回收对象内存时,并没有将其真正释放掉,只是放回预先分配的大块内存中,以便复用。只有内存闲置过多的时候,才会尝试归还部分内存给操作系统,降低整体开销
- Go内存管理追求低延迟,适合高并发IO密集场景,高吞吐量用Java,高性能用C
2.2 分配
- 32KB 的对象,直接从
mheap
上分配; - <=16B 且没有指针的对象使用
mcache
的tiny分配器分配; - (16B,32KB] 的对象,首先计算对象的规格大小,然后使用
mcache
中相应规格大小的mspan分配; - 如果
mcache
没有相应规格大小的mspan
,则向mcentral
申请
○ 如果当前的span
中并没有可以使用的元素,这时就需要从mcentral
中加锁查找。之前介绍过,在mcentral中有两种类型的span
链表,分别是有空闲元素的nonempty
,以及没有空闲元素的empty
链表。会分别遍历这两个列表,查找是否有可用的span
。这是由于有些span
可能已经被垃圾回收器标记为空闲了,只是还没有来得及清理。这些Span在清扫后仍然是可以使用的,因此需要遍历。
○ 如果在mcentral
元素中查找到有空闲元素的span
,则将其赋值到mcache
中,并更新allocCache
,同时还需要将span
添加到mcentral
的empty
链表中去。 - 如果
mcentral
没有相应规格大小的mspan
,则向mheap
申请
○ 当要分配的page
过大或者在逻辑处理器P的cache
中没有找到可用的页数时,就需要对mheap加锁,并在整个mheap
管理的虚拟地址空间的位图中查找是否有可用的pages
。而且其在本质上涉及到Go语言是如何对线性的地址空间进行位图管理的。
○ 管理线性的地址空间的位图结构叫做基数树(radix tree), 他和一般的基数树结构有点不太一样,这个名字很大一部分是由于父节点包含了子节点的若干信息。 - 如果
mheap
中也没有合适大小的mspan
,则向操作系统申请
○ 每一次向操作系统申请内存时,Go语言规定必须为heapArena
大小的倍数。heapArena
是和平台有关的内存大小,在unix 64位系统中,其大小为64M。这意味着即便需要的内存大小很少,最终也至少向操作系统申请64M。多申请的内存可以用于下次分配使用。
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])来避免堆栈重新扫描,优点如下:
- 三色标记的变化是单向的:白->灰->黑
- 混合屏障不需要读屏障
用一句话来说,写屏障可以捕获“”,详见Golang三色标记+混合写屏障GC模式全分析
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
,也是经过了几个版本的更迭:
- v1.0 ~ v1.1 — 最小栈内存空间为
4KB
; - v1.2 — 将最小栈内存提升到了
8KB
; - v1.3 — 使用连续栈替换之前版本的分段栈;
- v1.4 — 将最小栈内存降低到了
2KB
;
函数栈中包含:
- 入参出参信息
- 调用函数信息
- 函数返回地址
- 局部变量、常量
- 寄存器信息
3.1.1 全局栈缓存
栈空间在运行时中包含两个重要的全局变量,分别是 runtime.stackpool
和runtime.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.morestack
和 runtime.newstack
创建一个新的栈空间,这些栈空间是不连续的,但是当前 goroutine 的多个栈空间会以双向链表的形式串联起来,运行时会通过指针找到连续的栈片段.
分段栈虽然能够按需为当前 goroutine 分配内存并且及时减少内存的占用,但是它也存在一个比较大的问题:如果当前 goroutine 的栈几乎充满,那么任意的函数调用都会触发栈的扩容,当函数返回后又会触发栈的收缩,如果在一个循环中调用函数,栈的分配和释放就会造成巨大的额外开销,这被称为。
为了解决这个问题,Go在1.2版本的时候不得不将栈的初始化内存从4KB增大到了8KB。后来把采用连续栈结构后,又把初始栈大小减小到了2KB。
3.2.2 连续栈(Go >= 1.3)
连续栈可以解决分段栈中存在的两个问题,其核心原理就是每当程序的栈空间不足时,初始化一片比旧栈大两倍的新栈并将原栈中的所有值都迁移到新的栈中,新的局部变量或者函数调用就有了充足的内存空间。使用连续栈机制时,栈空间不足导致的扩容会经历以下几个步骤:
- 调用
runtime.newstack
在内存空间中分配更大的栈内存空间,旧栈的大小是通过我们上面说的保存在goroutine中的stack信息里记录的栈区内存边界计算出来的,然后用旧栈两倍的大小创建新栈,创建前会检查是新栈的大小是否超过了单个栈的内存上限; - 如果目标栈的大小没有超出程序的限制,会将 goroutine 切换至
_Gcopystack
状态并调用runtime.copystack
开始栈的拷贝,在拷贝栈的内存之前,运行时会先通过runtime.stackalloc
函数分配新的栈空间; - 将指向旧栈对应变量的指针重新指向新栈;
- 调用
runtime.stackfree
销毁并回收旧栈的内存空间。
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 闭包
- Go语言支持闭包
- 返回闭包时并不是单纯返回一个函数,而是返回了一个结构体,记录下函数返回地址和引用的环境中的变量地址
4 Runtime与Debug
runtime.GC函数
会让运行时系统进行一次强制性的垃圾收集:
- 强制的垃圾回收:不管怎样,都要进行的垃圾回收。
- 非强制的垃圾回收:只会在一定条件下进行的垃圾回收(即运行时,系统自上次垃圾回收之后新申请的堆内存的单元(也成为单元增量)达到指定的数值)。
debug.SetGCPercent函数
用于设置一个比率(垃圾收集比率),前面所说的单元增量与前一次垃圾收集时的碎内存的单元数量和此垃圾手机比率有关。
<触发垃圾收集的堆内存单元增量>=<上一次垃圾收集完的堆内存单元数量>*(<垃圾收集比率>/100)
网友评论