golang内存管理与垃圾回收
随便聊一聊……
栈和堆简介 & 变量分配
用过C/C++的人都知道,内存分为栈和堆两个部分(其实还有静态区什么的,先忽略);堆从低向高增长,栈从高向低增长;new出来的变量分配在堆上,函数里的变量分配在栈上……所以下面的代码会得到一个warning(但并不是不能跑,但结果是有问题的)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
#include <iostream> int *func() { int x = 5; return &x; } int main() { int *p = func(); int x[100] = {1}; // 为了抹掉栈空间 std::cout << (*p) << std::endl; } # malloc.cpp:5:12: warning: address of stack memory associated with local variable 'x' returned [-Wreturn-stack-address] # return &x; # ^ |
Go语言里也有栈和堆的概念,栈就是函数使用的栈,堆是GC堆。
但是Go作为一个内存安全的语言,如果使用这种分配方法,会导致出现dangling pointer。最简单的解决方案是:所有变量都分配到GC堆上,栈只作为调用栈使用。
这种一刀切的方式明显对性能是有很大影响的,需要GC的对象急剧增加,会使得STW时间变长。还好有静态分析技术 Escape Analysis 可以帮助我们在编译器知道:哪些变量是会在作用域外被使用的,将这些变量分配到GC堆中,而其他的变量分配在栈上。
我们可以通过 go tool compile -N -m test.go 查看escape analyse的日志(-N是禁止inlining,-m是打印verbose),也可以通过 go tool compile -N -S -l go.test 看内存分配的汇编代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
"".f STEXT size=314 args=0x8 locals=0x90 // 省略部分代码 0x003b 00059 (test.go:9) MOVQ $100, (SP) 0x0043 00067 (test.go:9) PCDATA $0, $0 0x0043 00067 (test.go:9) CALL math/rand.Intn(SB) 0x0048 00072 (test.go:9) MOVQ 8(SP), AX 0x004d 00077 (test.go:9) MOVQ AX, "".y+64(SP) 0x0052 00082 (test.go:9) LEAQ type.int(SB), AX 0x0059 00089 (test.go:10) MOVQ AX, (SP) 0x005d 00093 (test.go:10) PCDATA $0, $0 0x005d 00093 (test.go:10) CALL runtime.newobject(SB) 0x0062 00098 (test.go:10) MOVQ 8(SP), AX 0x0067 00103 (test.go:10) MOVQ AX, "".&x+88(SP) 0x006c 00108 (test.go:10) MOVQ "".y+64(SP), CX 0x0071 00113 (test.go:10) ADDQ $4, CX 0x0075 00117 (test.go:10) MOVQ CX, (AX) // 省略部分代码 |
可以看出,y是直接操作了栈指针,而x的分配是调用了 runtime.newobject 。类似结论也可通过GDB看出。
综上,golang在编译器会动态分辨出哪些变量需要分配在堆上、哪些需要在栈上,以此提高gc效率,对开发者来说是透明的。JVM中也有类似的分析机制,但是JVM的escape analysis是运行期的,达不到Go这种效果( RednaxelaFX的回答 – 知乎 )。
堆内存管理
golang的内存管理是基于tcmalloc实现的(源代码),关于常见分配器的原理,这篇文章总结的比较全,虽然时代在发展,但是分配器的核心思想还是没什么变化的。
tcmalloc核心思想是多级缓存、定长分配和管理。主要的数据结构有:
- fixalloc(runtime/mgixalloc.go)链表结构,管理了一些固定长度的内存块
- mheap(runtime/mheap.go)用于页管理
- mspan(runtime/mheap.go)mheap中连续的几个页
- mcentral(runtime/mcentral.go)某长度所有span的集合
- mcache(runtime/mcache.go)绑定线程的mspan的cache
每个mcache内维护了70个分配器(fixalloc),每个分配器只管理定长的内存块(8字节、16字节、32字节……),但是不大于32kB。
mspan表示连续的几个page,每个mspan可以被切成若干相同大小的块。
对于<=32kB的分配请求(Small Object Allocation),会找到合适的分配器,从链表中取出一个内存块返回,此过程是不需要锁的(绑定线程)。但是如果链表为空,需要从mcentral申请内存,这个操作需要加锁。具体分配的代码在runtime/malloc.go mallocgc()。
对于>32kB的分配请求要在mheap中分配(mheap.alloc -> mheap.alloc_m())。mheap中也维护了一组不同size的span的列表,如果有合适的size可以容纳当前的申请,那么直接从该span分配内存,否则申请一个新的span(mheap.allocSpanLocked方法),并记录span的长度,从该span分配内存。
这里这样设计我理解是为了方便内存回收。当一个内存块被释放的时候,需要更新所在span的状态,通过内存地址可以得到page,而通过mheap可以将page和span对应起来。mcentral作为span的管理器,可以很容易知道每个span的状态,从而知道哪些span可以被释放给mheap(mheap.sweepgen)。
栈空间的伸缩
在Golang1.3之前,golang使用的是分段栈机制,即每个栈的大小是固定的,需要扩容时,申请一段新的栈,使用链表将他们串联起来。这样做的好处是可以按需增长,空间利用率比较高;但是在某些场景下,栈的伸缩操作会过于频繁,导致性能降低。
所以从1.3之后golang开始使用连续栈,即栈空间是一块连续的内存,而不是由链表组织的零散内存块。关于两种栈组织方式的性能对比可以简单看下这里。
栈的伸缩算法有点类似于c++ stl里的vector(runtime/stack.go),当栈需要扩容时,申请一个两倍于当前栈大小的新栈,将所有数据迁移过去。
golang设置了一个stackguard指针,用于判断是否需要执行栈扩容。另外设置两个栈大小的阈值,SmallStack(128B)和BigStack(4KB)用于执行不同的扩容策略,当被调用的函数栈帧:
- 小于SmallStack时,如果sp<guard(栈是从高向低增长的),执行扩容;否则直接执行函数(对于小函数调用,go允许sp指针暂时越过guard,但在下次调用函数时会执行扩容)。
- 介于SmallStack和BigStack中间时,如果sp+StackSmall<guard,执行扩容;否则直接执行函数。
- 大于BigStack时,总是执行扩容。
执行扩容会调用morestack函数(没找到这个的实现,只在stub.go里有个定义,难道汇编写的?),morestack函数会调用stack.go中的newstack,完成一些列检查之后,调用copystack进行复制。
栈除了能扩容之外还能缩容(shrinkStack),在gc阶段,回收器会检查栈的使用情况,如果使用空间低于栈空间的1/4,则会尝试将栈空间减小一半。缩容也是需要申请新的栈空间进行复制。
刚才说过在远古时代栈的频繁伸缩会导致性能降低,在使用连续栈的时代这种问题同样存在的,只是发生的可能性远没有那么高。但是在高并发场景下,这种潜在的性能问题也是需要关注的(当然我们也可以通过debug库关闭shrinkstack功能)。
垃圾回收
Golang现在使用的垃圾回收算法是CMS,并行的mark-and-sweep,使用的是四色标注法(也有叫三色的):
- 新建的内存节点标记为白色(为了防止误删,引入了“当前白色”和“其他白色”两个状态,所以为啥叫四色);
- 每次从根节点进行扫描,遇到白色节点就将其变成灰色,放入灰色链表(初始化);
- 遍历灰色链表,本元素标记为黑色;相邻元素如果是白色,则标记为灰色,放入灰色链表(mark);
- 扫描所有节点,删除白色元素(sweep)。
这个过程的具体描述在这里有详细解释(还有一个生动的流程图)。
这种算法乍一看似乎跟java之流没什么区别,但是由于tcmalloc协程绑定,标记过程实际是可以轮流执行的,即不需要所有gorountine都进入STW的状态。而且tcmalloc碎片化程度较低,加上escape analysis的帮助,使垃圾回收工作相对简单一丢丢,可以提升性能。