Golang内存管理详解

基础

存储金字塔

  • CPU寄存器
  • CPU Cache:三级Cache分别是L1、L2、L3,L1最快,L3最慢
  • 内存
  • 硬盘等辅助存储设备
  • 鼠标等外接设备

从上至下的访问速度越来越慢,访问时间越来越长。

虚拟内存

访问内存,实际访问的是虚拟内存,虚拟内存通过页表查看,当前要访问的虚拟内存地址,是否已经加载到了物理内存。如果已经在物理内存,则取物理内存数据,如果没有对应的物理内存,则从磁盘加载数据到物理内存,并把物理内存地址和虚拟内存地址更新到页表。

物理内存就是磁盘存储缓存层,在没有虚拟内存的时代,物理内存对所有进程是共享的,多进程同时访问同一个物理内存会存在并发问题。而引入虚拟内存后,每个进程都有各自的虚拟内存,内存的并发访问问题的粒度从多进程级别,可以降低到多线程级别。

栈和堆

代码中使用的内存地址都是虚拟内存地址,而不是实际的物理内存地址。栈和堆只是虚拟内存上2块不同功能的内存区域:

  • 栈在高地址,从高地址向低地址增长
  • 堆在低地址,从低地址向高地址增长

栈和堆相比有这么几个好处:

  • 栈的内存管理简单,分配比堆上快。
  • 栈的内存不需要回收,而堆需要进行回收,无论是主动free,还是被动的垃圾回收,这都需要花费额外的CPU。
  • 栈上的内存有更好的局部性,堆上内存访问就不那么友好了,CPU访问的2块数据可能在不同的页上,CPU访问数据的时间可能就上去了。

内存分区

Golang内存分区:代码区、数据区、堆区、栈区
// 低地址  ——----------------------------------------------------------------》 高地址
// 代码区   |   数据区(初始化数据区,未初始化数据区,常量区)  |   堆区  |  栈区(函数信息,内部变量)
// 函数地址(0x7c7620):代码区。是一个低地址位置,计算机指令
// 全局变量(0xd03250) :初始化数据区,如果初始化了:初始化数据;未初始化:未初始化数据
// 局部变量(0xc0000120b0):栈区,高地址
// 堆区:一个很大的空间,在使用时,开辟内存空间,结束时,释放内存空间。
// 栈区:用来存储程序执行过程中函数内部定义的信息和局部变量值。

最内层函数后进先出,最内层函数先执行后,释放内存,向上层传递结果。 函数return返回值将函数执行的结果保存下来,返回给调用者。

变量

局部变量

  • 在C语言中写在{}中或者函数中或者函数的形参, 就是局部变量
  • Go语言中的局部变量和C语言一样

全局变量

  • 在C语言中写在函数外面的就是全局变量
  • Go语言中的全局变量和C语言一样

局部变量和全局变量的作用域

  • 在C语言中局部变量的作用域是从定义的那一行开始, 直到遇到 } 结束或者遇到return为止
  • Go语言中局部变量的作用域和C语言一样
  • 在C语言中全局变量的作用域是从定义的那一行开始, 直到文件末尾为止
  • Go语言中的全局变量, 只要定义了, 在定义之前和定义之后都可以使用

局部变量和全局变量的生命周期

  • 在C语言中局部变量, 只有执行了才会分配存储空间, 只要离开作用域就会自动释放, C语言的局部变量存储在栈区
  • Go语言局部变量的生命周期和C语言一样
  • 在C语言中全局变量, 只要程序一启动就会分配存储空间, 只有程序关闭才会释放存储空间, C语言的全局变量存储在静态区(数据区)
  • Go语言全局变量的生命周期和C语言一样

局部变量和全局变量的注意点

  • 在C语言中相同的作用域内, 不能出现同名的局部变量
  • Go语言和C语言一样, 相同干的作用域内, 不能出现同名的局部变量
package main
import "fmt"
func main() {
    var num int; // 局部变量
    //var num int; // 报错,不能出现同名局部变量
}
  • 在C语言中相同的作用域内, 可以出现同名的全局变量
  • 在Go语言中相同的作用域内, 不能出现同名的全局变量
    例:
package main
import "fmt"
var value int // 全局变量
//var value int // 报错,不能出现同名全局变量
func main() {
}

特殊点

  • 在C语言中局部变量没有初始化存储的是垃圾数据, 在Go语言中局部变量没有初始化, 会默认初始化为0
  • 在C语言中全局变量没有初始化存储的是0, Go语言和C语言一样
  • 在Go语言中, 如果定义了一个局部变量, 但是没有使用这个局部变量, 编译会报错
  • 在Go语言中, 如果定义了一个全局变量, 但是没有使用这个全局变量, 编译不会报错

注意点

  • 相同的作用域内, 无论是全局变量还是局部变量, 都不能出现同名的变量
  • 变量离开作用域就不能使用
  • 局部变量如果没有使用, 编译会报错, 全局变量如果没有使用, 编译不会报错
  • :=只能用于局部变量, 不能用于全局变量
  • :=如果用于同时定义多个变量, 会有退化赋值现象,如果通过:=定义多个变量, 但是多个变量中有的变量已经在前面定义过了, 那么只会对没有定义过的变量执行:=, 而定义过的变量只执行=操作

堆内存管理

内存分配 Malloc : memory allocator

当我们说内存管理的时候,主要是指堆内存的管理,因为栈的内存管理不需要程序去操心。

当发现内存申请的时候,堆内存就会从未分配内存分割出一个小内存块(block),然后用链表把所有内存块连接起来。需要一些信息描述每个内存块的基本信息,比如大小(size)、是否使用中(used)和下一个内存块的地址(next),内存块实际数据存储在data中。

一个内存块包含了3类信息:元数据、用户数据和对齐字段。

释放内存实质是把使用的内存块从链表中取出来,然后标记为未使用,当分配内存块的时候,可以从未使用内存块中优先查找大小相近的内存块,如果找不到,再从未分配的内存中分配内存。

TCMalloc (Thread Cache Malloc)

TCMalloc是 Google 开发的内存分配器,Golang 使用了类似的算法进行内存分配。

同一进程下的所有线程共享相同的内存空间,它们申请内存时需要加锁,如果不加锁就存在同一块内存被2个线程同时访问的问题。

TCMalloc的做法是什么呢?为每个线程预分配一块缓存,线程申请小内存时,可以从缓存分配内存,这样有2个好处:

  1. 为线程预分配缓存需要进行1次系统调用,后续线程申请小内存时直接从缓存分配,都是在用户态执行的,没有了系统调用,缩短了内存总体的分配和释放时间,这是快速分配内存的第二个层次。
  2. 多个线程同时申请小内存时,从各自的缓存分配,访问的是不同的地址空间,从而无需加锁,把内存并发访问的粒度进一步降低了,这是快速分配内存的第三个层次。

基本原理

page

  • 操作系统对内存管理以页为单位
  • TCMalloc里的Page大小与操作系统里的大小并不一定相等,而是倍数关系
  • x64下Page大小是8KB。

Span

  • 一组连续的Page被称为Span,比如可以有2个页大小的Span,也可以有16页大小的Span
  • Span比Page高一个层级,是为了方便管理一定大小的内存区域
  • Span是TCMalloc内存管理的基本单位

ThreadCache

  • ThreadCache是每个线程各自的Cache
  • 一个Cache包含多个空闲内存块链表,每个链表连接的都是内存块,同一个链表上内存块的大小是相同的
  • 这样可以根据申请的内存大小,快速从合适的链表选择空闲内存块。由于每个线程有自己的ThreadCache
  • ThreadCache访问是无锁的

CentralCache

  • CentralCache是所有线程共享的缓存,也是保存的空闲内存块链表,链表的数量与ThreadCache中链表数量相同
  • 当ThreadCache的内存块不足时,可以从CentralCache获取内存块;当ThreadCache内存块过多时,可以放回CentralCache。
  • 由于CentralCache是共享的,所以它的访问是要加锁的。

PageHeap

  • PageHeap是对堆内存的抽象,PageHeap存的也是若干链表,链表保存的是Span。
  • 当CentralCache的内存不足时,会从PageHeap获取空闲的内存Span,然后把1个Span拆成若干内存块,添加到对应大小的链表中并分配内存;
  • 当CentralCache的内存过多时,会把空闲的内存块放回PageHeap中。
  • 可以有是1页Page的Span链表,2页Page的Span链表等,最后是large span set,这个是用来保存中大对象的。
  • PageHeap也是要加锁的。

TCMalloc对象大小的定义:

  • 小对象大小:0~256KB
  • 中对象大小:257KB~1MB
  • 大对象大小:>1MB

对象分配流程:

  • 小对象的分配流程:

    • ThreadCache -> CentralCache -> HeapPage
    • 大部分时候,ThreadCache缓存都是足够的,不需要去访问CentralCache和HeapPage,无系统调用配合无锁分配,分配效率是非常高的。
  • 中对象分配流程:直接在PageHeap中选择适当的大小即可,128 Page的Span所保存的最大内存就是1MB。
  • 大对象分配流程:从large span set选择合适数量的页面组成span,用来存储数据。

Go内存结构

Go在程序启动的时候,会先向操作系统申请一块内存(注意这时还只是一段虚拟的地址空间,并不会真正地分配内存),切成小块后自己进行管理。

申请到的内存块被分配了三个区域,在X64上分别是512MB,16GB,512GB大小。

Golang内存管理详解_第1张图片

arena

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

bitmap

bitmap区域标识arena区域哪些地址保存了对象,并且用4bit标志位表示对象是否包含指针、GC标记信息。

bitmap中一个byte大小的内存对应arena区域中4个指针大小(指针大小为 8B )的内存,所以bitmap区域的大小是512GB/(4*8B)=16GB

spans

spans区域存放mspan的指针(也就是一些arena分割的页组合起来的内存管理基本单元,后文会再讲),每个指针对应一页,所以spans区域的大小就是512GB/8KB*8B=512MB。除以8KB是计算arena区域的页数,而最后乘以8是计算spans区域所有指针的大小。创建mspan的时候,按页填充对应的spans区域,在回收object时,根据地址很容易就能找到它所属的mspan

Go内存管理

GO比TCMalloc还多了2件东西:逃逸分析和垃圾回收

基本概念

Golang内存管理详解_第2张图片

Golang内存管理详解_第3张图片

Golang内存管理详解_第4张图片

page

与TCMalloc中的Page相同

span

  • 与TCMalloc中的Span相同,代码中为mspan
  • Span是内存管理的基本单位

mcache

mcache 是提供给 P 的本地内存池。

mcache与TCMalloc中的ThreadCache类似,mcache保存的是各种大小的Span,并按Span class分类,小对象直接从mcache分配内存,它起到了缓存的作用,并且可以无锁访问。

不同点:

  • TCMalloc中是每个线程1个ThreadCache,Go中是每个P拥有1个mcache
  • 因为在Go程序中,当前最多有GOMAXPROCS个线程在运行,所以最多需要GOMAXPROCS个mcache就可以保证各线程对mcache的无锁访问,线程的运行又是与P绑定的,把mcache交给P刚刚好。

mcentral

mcentral与TCMalloc中的CentralCache类似,是所有线程共享的缓存,需要加锁访问。它按Span级别对Span分类,然后串联成链表,当mcache的某个级别Span的内存被分配光时,它会向mcentral申请1个当前级别的Span。

不同点:

  • CentralCache是每个级别的Span有1个链表
  • mcentral是每个级别的Span有2个链表

mheap

代表Go程序持有的所有堆空间,Go程序使用一个mheap的全局对象_mheap来管理堆内存。

  • mheap与TCMalloc中的PageHeap类似,它是堆内存的抽象,把从OS申请出的内存页组织成Span,并保存起来。
  • 当mcentral的Span不够用时会向mheap申请内存,而mheap的Span不够用时会向OS申请内存。
  • mheap向OS的内存申请是按页来的,然后把申请来的内存页生成Span组织起来,同样也是需要加锁访问的。

不同点:

  • mheap把Span组织成了树结构,而不是链表,并且还是2棵树
  • mheap把Span分配到heapArena进行管理,它包含地址映射和span是否包含指针等位图,这样做的主要原因是为了更高效的利用内存:分配、回收和再利用。

GO内存大小转化

  1. object size:代码里简称size,指申请内存的对象大小。
  2. size class:代码里简称class,它是size的级别,相当于把size归类到一定大小的区间段

    • size[1,8]属于size class 1
    • size(8,16]属于size class 2
    • size(16,32]属于size class 3
    • size(32,48]属于size class 4
  3. span class:指span的级别,但span class的大小与span的大小并没有正比关系。span class主要用来和size class做对应,1个size class对应2个span class,2个span class的span大小相同,只是功能不同,1个用来存放包含指针的对象,一个用来存放不包含指针的对象,不包含指针对象的Span就无需GC扫描了。
  4. num of page:代码里简称npage,代表Page的数量,其实就是Span包含的页数,用来分配内存。
class  1      2      3      4      5      6  ···   63      64      65      66

bytes  8      16     32     48     64     80 ···  24576   27264   28672   32768

Go内存分配

内存分配由内存分配器完成。分配器由3种组件构成:mcache, mcentral, mheap

内存分类

  • 当要分配大于 32K 的对象时,从 mheap 分配。
  • 当要分配的对象小于等于 32K 大于 16B 时,从 P 上的 mcache 分配,如果 mcache 没有内存,则从 mcentral 获取,如果 mcentral 也没有,则向 mheap 申请,如果 mheap 也没有,则从操作系统申请内存。
  • 当要分配的对象小于等于 16B 时,从 mcache 上的微型分配器上分配。

大小对象

  • 小对象:小对象是在mcache中分配的

    • Tiny对象:大小在1~16Byte之间并且不包含指针的对象
    • 其他小对象: 16Byte~32KB
  • 大对象:大于32KB,直接从mheap分配

小对象内存分配

  • size class数量:_NumSizeClasses=67
  • span class数量:numSpanClasses = _NumSizeClasses * 2 = 134
  • 也就是mcache最多有134个span
1. 为对象寻找span:
  1. 计算对象所需内存大小size
  2. 根据size到size class映射,计算出所需的size class
  3. 根据size class和对象是否包含指针计算出span class
  4. 获取该span class指向的span
  5. 举例:24Byte对象属于size class 3,对应的span class为7
2. 从span分配对象空间
  • Span可以按对象大小切成很多份:以size class 3对应的span为例,span大小是8KB,每个对象实际所占空间为32Byte,这个span就被分成了256块。
  • 随着内存的分配,span中的对象内存块,有些被占用,有些未被占用,当分配内存时,只要快速找到第一个可用的绿色块,并计算出内存地址即可。
  • 当span内的所有内存块都被占用时,没有剩余空间继续分配对象,mcache会向mcentral申请1个span,mcache拿到span后继续分配对象。
3. mcache向mcentral申请span

mcentral和mcache一样,都是0~133这134个span class级别,但每个级别都保存了2个span list,即2个span链表:

  1. nonempty:这个链表里的span,所有span都至少有1个空闲的对象空间。这些span是mcache释放span时加入到该链表的。
  2. empty:这个链表里的span,所有的span都不确定里面是否有空闲的对象空间。当一个span交给mcache的时候,就会加入到该链表

mcache向mcentral申请span时,mcentral会先从nonempty搜索满足条件的span,如果没有找到再从emtpy搜索满足条件的span,然后把找到的span交给mcache。

4. mheap的span管理

mheap里保存了两棵二叉排序树,按span的page数量进行排序:

  1. free:free中保存的span是空闲并且非垃圾回收的span。
  2. scav:scav中保存的是空闲并且已经垃圾回收的span。

如果是垃圾回收导致的span释放,span会被加入到scav,否则加入到free,比如刚从OS申请的的内存也组成的Span。

mheap中还有arenas(动态分配的堆区),由一组heapArena组成,每一个heapArena都包含了连续的pagesPerArena个span,这个主要是为mheap管理span和垃圾回收服务。arenas本身是一个全局变量,它里面的数据,也都是从OS直接申请来的内存,并不在mheap所管理的那部分内存以内。

5. mcentral向mheap申请span

当mcentral向mcache提供span时,如果empty里也没有符合条件的span,mcentral会向mheap申请span。

此时,mcentral需要向mheap提供需要的内存页数和span class级别,然后它优先从free中搜索可用的span。如果没有找到,会从scav中搜索可用的span。如果还没有找到,它会向OS申请内存,再重新搜索2棵树,必然能找到span。

如果找到的span比需要的span大,则把span进行分割成2个span,其中1个刚好是需求大小,把剩下的span再加入到free中去,然后设置需要的span的基本信息,然后交给mcentral。

6. mheap向OS申请内存

当mheap没有足够的内存时,mheap会向OS申请内存,把申请的内存页保存为span,然后把span插入到free树。此时,mcentral需要向mheap提供需要的内存页数和span class级别,然后它优先从free中搜索可用的span。如果没有找到,会从scav中搜索可用的span。如果还没有找到,它会向OS申请内存,再重新搜索2棵树,必然能找到span。

如果找到的span比需要的span大,则把span进行分割成2个span,其中1个刚好是需求大小,把剩下的span再加入到free中去,然后设置需要的span的基本信息,然后交给mcentral。

大对象内存分配

当要分配大于 32K 的对象时,从 mheap 分配。

大对象的分配比小对象省事多了,99%的流程与mcentral向mheap申请内存的相同,所以不重复介绍了。不同的一点在于mheap会记录一点大对象的统计信息,详情见mheap.alloc_m()。

垃圾回收和内存释放

  • 垃圾回收收集不再使用的span,调用mspan.scavenge()把span释放还给OS(并非真释放,只是告诉OS这片内存的信息无用了,如果你需要的话,收回去好了)
  • 然后交给mheap,mheap对span进行span的合并,把合并后的span加入scav树中
  • 等待再分配内存时,由mheap进行内存再分配

栈内存

每个goroutine都有自己的栈,栈的初始大小是2KB,100万的goroutine会占用2G,但goroutine的栈会在2KB不够用时自动扩容,当扩容为4KB的时候,百万goroutine会占用4GB。

应用程序的内存会分成堆区(Heap)和栈区(Stack)两个部分,程序在运行期间可以主动从堆区申请内存空间,这些内存由内存分配器分配并由垃圾收集器负责回收

栈区的内存由编译器自动进行分配和释放,栈区中存储着函数的参数以及局部变量,它们会随着函数的创建而创建,函数的返回而销毁

go语言编译器会自动决定把一个变量放在栈还是放在堆,编译器会做逃逸分析(escape analysis),当发现变量的作用域没有跑出函数范围,就可以在栈上,反之则必须分配在堆。

总结

Go内存分配管理的策略有如下几点:

  • Go在程序启动时,会向操作系统申请一大块内存,由mheap结构全局管理。
  • Go内存管理的基本单元是mspan,每种mspan可以分配特定大小的object
  • mcachemcentralmheapGo内存管理的三大组件:

    • mcache管理线程在本地缓存的mspan(无锁)
    • mcentral管理全局的mspan供所有线程使用(有锁)
    • mheap管理Go的所有动态分配内存。(有锁)
  • Tiny对象(0~16B且无指针),一般小对象通过mcache分配内存(16B~32K );大对象则直接由mheap分配内存(大于32K)。

Reference

https://zhuanlan.zhihu.com/p/...

https://zhuanlan.zhihu.com/p/...

https://blog.haohtml.com/arch...

https://zhuanlan.zhihu.com/p/...

https://blog.csdn.net/kevin_t...

你可能感兴趣的:(golang)