Golang的内存分配原理

Mon, Feb 21, 2022 阅读时间 1 分钟

程序运行中的堆和栈

一个程序在运行中占用的内存分为以下几部分:

  • 栈stack:系统自动分配,存放函数的参数值,局部变量和方法调用,操作方法和数据结构中的栈类似,有栈容量,会出现溢栈现象。
  • 堆heap:一般由程序员分配释放,程序员不释放就由OS释放(通过语言的GC),和数据结构中的堆不同,分配方式类似于链表。
  • 全局区(静态区)static:全局变量和静态变量的存储,经过初始化的在一块区域,未初始化的在另一区域。
  • 文字常量区:常量字符串存在这里。
  • 程序代码区:函数体的二进制代码。

操作系统把磁盘上的可执行文件加载到内存之前,会把可执行文件中的代码,数据放在内存中合适的位置上,分配好堆栈,所有准备工作完成后程序才可以运行。内存布局如下所示:

我们主要关注的是堆和栈内存。

  • 堆:程序运行时需要动态分配的内存都在这,C/C++需要程序员自己分配和释放,Go中有GC帮助清理释放。
  • 栈(函数调用栈):包括函数中的局部变量,向函数中传递的参数,函数的返回值,函数的返回地址。栈的大小会随着函数调用层级的增加而生长,随函数的返回而缩小(像个弹簧)。

如上图所示,栈是从高地址到低地址生长的,堆则是从低地址向高地址生长的。两者是对着生长的。原因可能是计算机科学家希望尽可能利用地址空间,第二是因为堆和栈不能沿着一个方向生长,因为两者会交织,导致乱掉(主要是栈会乱掉,堆其实无所谓,栈是用来提高程序运行效率的,所以不能乱)。

栈存在的原因:同一个函数中的参数和局部变量,都放到一个栈帧,这些参数会随着函数的调用而创建,随着函数的返回而销毁,有利于内存的统一释放和申请,对于Go、Java等有GC的语言,也能缓解GC的压力。

堆存在的原因:因为栈上的数据在函数返回时就会释放,无法将数据传递到函数外部,全局变量无法动态的产生,表现力有限。堆适合存储一些生存期较长的数据,这些数据在退出作用域后也不会消失,比如说我们的全局变量等等。

栈分配廉价,堆分配昂贵。

Go的内存逃逸分析

原本应该分配到栈上的数据(即函数里的局部作用域的变量),由于一些原因逃逸到了堆上,就叫做内存逃逸。

Go代码中的变量会携带一组校验数据,用来证明它的整个生命周期是否在运行时完全可知,如果是完全可知的,这个变量在编译时就会在栈上被分配,否则就说明它逃逸了,需要在堆上分配。go编译器会分析,哪些变量会在栈上分配,哪些会逃逸到堆上,这个分析就叫做逃逸分析。逃逸分析在大多数语言里属于静态分析,即在编译器就能确定一个值是要被分配在堆上,还是栈上。

逃逸分析的原因

进行逃逸分析主要是因为以下几点

  • 把不逃逸的对象存到栈里,则这个对象会在函数返回后被回收,减少GC的压力。
  • 堆上的存储会造成内存碎片,尽量把数据保存在栈上也可以减少内存碎片。
  • 减轻分配堆内存的开销,提高程序的执行效率。

逃逸策略

如果编译器不能证明一个变量在函数返回后不会再被引用,则该变量会被分配在堆上。

一个公式:Data.Field = Value,如果Data和Field都是引用的数据类型(指针),则会导致Value逃逸。Go中的引用数据类型有func、interface、slice、map、chan、*Type。

引起变量发生逃逸的典型情况:

  • 在方法中返回了一个局部变量的指针

    局部变量原本应该存在栈中,随着函数执行结束弹栈被回收,但是如果返回了它的指针,那么就会导致它可能会在函数调用以后依然被使用,导致它的生命周期大于了函数的生命周期,所以该变量会逃逸到堆上。

  • 发送指针或者带有指针的值到channel中

    编译时,是没办法知道哪个goroutine会在channel上接收数据,所以编译器也不知道这个变量什么时候会被释放。

  • 在一个切片上存储指针或带指针的值

    如[]*string,原因还是切片中每个元素都不知道会在哪里再次被引用,所以元素会发生内存逃逸到堆上,但是这个切片可能是在栈上被分配的。

  • slice的背后数组会被重新分配的情况

    因为append操作时可能会超过其容量cap,slice初始化的地方在编译时是可以知道的,它最开始会在堆上分配,如果切片背后的存储要基于运行时的数据进行扩充,就会在堆上分配。(关于slice自动扩容机制可以查看Golang的slice底层实现这篇文章)

  • 在interface类型上调用方法

    interface类型上调用方法都是动态调度的,方法的真正实现只能在运行时知道,所以interface都会在堆上分配。

  • 栈空间不足以分配内存

    如果一个局部变量超大,则这个对象就会逃逸到堆上。因为一个goroutine的栈大小是有限的,默认是分配 2KB(go1.4版本以上,栈的大小分配也经历了多个版本)。

  • 闭包引用逃逸

    一个函数返回了一个函数,并在返回的函数中带上了这个函数中的一个变量,则这个变量就会在函数返回后依然被使用,则这个变量就会逃逸。同理在一个for循环外声明的变量,在for循环内被分配,也会引起逃逸。

通过命令go build -gcflags=-m可以在编译看到关于内存逃逸的分析说明,如下是个例子:

package main

func main(){
	escape()
}

func escape()*int{
	a := 3
	return &a
}

运行逃逸分析:

$ go build -gcflags=-m
.\main.go:7:6: can inline escape
.\main.go:3:6: can inline main
.\main.go:4:8: inlining call to escape
.\main.go:8:2: moved to heap: a

可以看到变量 a 被移动到了堆上。

避开内存逃逸的方法(不建议使用)

runtime/stubs.go中有个函数叫noescape,noescape可以在逃逸分析中隐藏一个指针,让这个指针在逃逸分析中不会被检测为逃逸。noescape()函数的作用是遮蔽输入和输出的依赖关系,使编译器不认为p会通过x逃逸,因为uintptr()产生的引用是编译器无法理解的。noescape()在runtime包中被大量使用,在作者很清楚被unsafe.Pointer引用的数据肯定不会逃逸,但编译器不知道的情况下是很有用的。对于我们来说,如果不是十分清楚的情况下,最好还是别用。

Go中栈的扩容

Go语言在运行时,每个goroutine都维护着自己的栈区,一个goroutine的栈区只能它自己使用,栈区的初始大小是 2KB(比 x86_64架构下线程的 2MB 小得多)。在goroutine运行的时候,会按照需要进行增长和收缩,64位机器上最大不能超过1GB。

栈的默认大小在go最初的几个版本中经过多次变化,v1.0 到v1.1使用的是 4KB,v1.2 提升到了 8KB,这两个版本用的都是分段栈的栈扩容方式。在 v1.3 版本从分段栈改用了连续栈进行栈扩容,到了 v1.4 版本才稳定为 2KB 。

分段栈

分段栈的扩容方式就是,当需要栈扩容时,就开辟一个新的栈帧,然后在旧的栈帧中用指针指向新栈帧,如下图:

hot split问题

分段栈的实现方式存在 “hot spilit” 问题,如果栈快满了,那么下一次的函数调用,会强制触发栈扩容,而当函数返回时,扩容的新栈帧又会被回收,如果是在一个循环中调用一个函数,那么就会导致频繁的栈扩容和栈回收,出现严重的性能问题,这也是在 go1.3 版本将分段栈改为连续栈的原因。

连续栈

连续栈的扩容方式是当需要栈扩容时,分配一块两倍原栈大小的新栈,然后将老的栈中的数据复制到新栈中,并将指向旧栈中的指针指向新栈,然后将旧栈清理回收。如下图:

连续栈在 “hot split” 场景中不会频繁扩容缩容的问题,因为连续栈扩容以后,即使函数返回也不会进行缩容,并且成倍的扩容也让扩容的需求变得不再频繁。

连续栈也会进行缩容操作,在当栈区的空间利用率不足1/4时,在GC进行垃圾回收的时候会触发栈缩容,方式也是新开辟一块栈,然后把数据拷贝过去。

Go的内存管理

基于malloc和free的内存管理的问题

内存碎片

随着内存不断的申请和释放,内存上会存在大量的碎片,降低内存的使用率。下面就是一个产生内存碎片的例子:

先申请了4个字节的 p1,然后再申请5个字节的 p2,然后再申请6个字节的 p3,这时,释放掉p2,再次申请6个字节的 p4,此时原本 p2释放后的空位放不下 p4,所以只能继续向后申请。p2 位置就产生了内存碎片。

申请内存要加锁

由于同一个进程下的所有线程共享相同的内存空间,所以这些线程在申请内存时肯定会出现竞争,所以必须要加锁,每次申请内存都加锁就意味着效率肯定不高。


基于以上的原因,如果要更高效的分配内存,就必须要考虑编写一个内存分配器,Go语言的内存分配器就借鉴了 TCMalloc ,TCMalloc是 Thread Cache Malloc的简称,是 Google 开发的 C++ 的内存分配器。

Go的内存布局

  • page

    内存页。一块8KB的内存空间,Go与操作系统之间的内存申请和释放,都是以 page 为单位的。

  • span

    内存块。一个或者多个连续的page就组成一个span

  • sizeclass

    空间规格。每个span都带有一个sizeclass,标记着该span中的page应该如何使用。

  • object

    对象。用来存储一个变量数据内存空间,一个span在初始化时,会被切割成一堆等大的object,假设一个object是16B,span是一个page 8K,那么span就会被切割成 8K/16B = 512个object 。

大于16B小于32KB的内存分配

当程序发生了小于32KB的小块内存申请时,Go会从一个叫做mcache的本地缓存给程序分配内存。这样的一个内存块叫mspan。mcache是和GMP模型中的P绑定的(关于GMP可以查看Goroutine的调度模型这篇文章),如下:

当前运行的goroutine会从mcache中查找可用的mspan,由于同一时刻,一个P中只有一个goroutine在运行,从本地P的mcache中分配内存通过原子操作不需要加锁,所以这种分配效率很高,同时由于P和M的绑定,也使得goroutine和M的亲缘性更好。

mspan的大小并不是一样的,而是按照大小,从8KB到32KB分了不同种类的mspan,主要是为了不产生浪费,需要多少拿多少。

每个内存页也分为多级固定大小的“空闲列表”,这有助于减少碎片。类似的思路在Linux内核、Memcached中都可以见到。

当mcache中的mspan用光了时,Go还为每种类别的mspan维护着一个mcentral,mcentral被所有的工作线程共同享有,因此访问需要加锁。实际上mcentral是一个双向链表(类似堆),从mcentral中申请到的mspan,还是会链接到对应的mcache中。

当mcentral也用光了时,会再向mheap去申请mspan,如果mheap也没有空间时,才会向操作系统申请一块新内存。mheap主要是用于大对象的存储,和管理一些未切割的mspan,用于给mcentral切割成小对象。

所以总体看来获取空间就是取mspan,先从mcache中获取,没有再从mcentral中获取,没有再从mheap中获取,像一个工厂提供原料一样,分级的原因就是为了减少锁的使用。

mheap里的arena区域是go中真正的堆区,这里存储了所有在堆上初始化的对象。

大于32KB的内存分配

因为Go没法使用mcache和mcentral管理超过32KB的内存申请,会直接从堆上(mheap)分配对应大小的内存页(每页是8KB),如果没有就只能直接去操作系统上要了。

小于16B的内存分配

对于小于16字节的对象(且无指针),Go将其划分为了tiny对象,tiny对象存在的主要目的是为了处理极小的字符串和独立的转义变量。

对于这种小对象的分配,首先会查看之前分配的元素中是否有空闲的空间,以达到节约内存的目的。不过的话就去拿一个mspan,然后一个mspan中可以存储多个这样的小对象。

Go内存分配全景图

  • 一般小对象(小于32kb)通过mspan分配,大对象则直接从mheap分配。
  • Go程序在启动时,会箱操作系统申请一大块内存,由mheap全局管理。
  • Go内存管理的基本单位是mspan,每种mspan可以分配特定大小的object。
  • mcache、mcentral、mheap是Go内存管理的三大组件,mcache管理goroutine本地缓存的mspan,mcentral管理全局的mspan。