随便聊一聊……

栈和堆简介 & 变量分配

用过C/C++的人都知道,内存分为栈和堆两个部分(其实还有静态区什么的,先忽略);堆从低向高增长,栈从高向低增长;new出来的变量分配在堆上,函数里的变量分配在栈上……所以下面的代码会得到一个warning(但并不是不能跑,但结果是有问题的)。

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 看内存分配的汇编代码:

可以看出,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的帮助,使垃圾回收工作相对简单一丢丢,可以提升性能。