Go内存管理
目录
一、虚拟内存是什么?为什么会有虚拟内存?
操作系统管理内存的机制——为什么要设置虚拟内存? - wj_hubei - 博客园
总结如下 :
使用虚拟内存和不使用虚拟内存的区别 :
- 使用虚拟内存后程序只能访问属于自己的内存地址, 避免进程数据被其他进程修改。
- 程序直接使用的内存地址(虚拟内存地址)是确定的(大家都是从0x00000000 开始), 然后通过页表将这些地址映射到内存中一个不确定的地址中。
- 在管理内存的时候不使用虚拟内存需要把整个程序作为一个整体进行管理, 如果当前A程序在运行,要优先运行B程序而内存不足时,可能就需要把A程序的所有内存都拷贝都磁盘。比如总内存100M, A程序运行时用了50M, B程序运行需要51M, 这个时候内存不足, 只能将A程序停掉,然后将A使用的内存全部拷贝到磁盘。使用虚拟内存之后,每次申请的内存是按页算的(比如4KB), 这样粒度就小了, 在上述情况下可能只需要将A程序中的1M拷贝到磁盘就行了, 而A程序在恰好访问到被放到磁盘的数据时, 发生缺页异常,将被访问的数据加载到内存中。
虚拟内存的工作原理:
当一个进程试图访问虚拟地址空间中的某个数据时,会经历下面两种情况的过程:
1、CPU想访问某个虚拟内存地址,找到进程对应的页表中的条目,判断有效位, 如果有效位为1,说明在页表条目中的物理内存地址不为空,根据物理内存地址,访问物理内存中的内容,返回。
2、CPU想访问某个虚拟内存地址,找到进程对应的页表中的条目,判断有效位,如果有效位为0,但页表条目中还有地址,这个地址是磁盘空间的地址,这时触发缺页异常,系统把物理内存中的一些数据拷贝到磁盘上,腾出所需的空间,并且更新页表。此时重新执行访问之前虚拟内存的指令,就会发现变成了情况1。
Linux虚拟内存空间分布如下:
Linux虚拟内存空间分布_wyq_5的博客-CSDN博客_虚拟内存分布
.reserve(预留)段 :一共占用128M,属于预留空间,进程是禁止访问的。
.text(代码段) :可执行文件加载到内存中的只有数据和指令之分,而指令被存放在.text段中,一般是共享的,编译时确定,只读,不允许修改。
.data : 存放在编译阶段(而非运行时)就能确定的数据,可读可写。也就是通常所说的静态存储区,赋了初值的全局变量和赋初值的静态变量存放在这个区域,常量也存放在这个区域。
.bss段 : 通常用来存放程序中未初始化以及初始化为0的全局/静态变量的一块内存区域,在程序载入时由内核清0
.heap(堆) : 用于存放进程运行时动态分配的内存,可动态扩张或缩减,这块内存由程序员自己管理,通过malloc/new可以申请内存,free/delete用来释放内存,heap的地址从低向高扩展,是不连续的空间
共享库(libc.so)
.stack(栈) : 记录函数调用过程相关的维护性信息,栈的地址从高地址向低地址扩展,是连续的内存区域
我们写好的程序编译完变成可执行文件,在被执行的时候, 可执行文件被分成了代码段和数据段两部分, 代码段里面存的是各种if、else、for、赋值等机器指令。而数据段里面存的是定义的各种变量、常量等。数据段又分全局变量、常量等在编译期就能确定大小的数据,和一些只有在运行时才会赋值和初始化的变量。
程序在被执行的时候, 一行行的读取机器指令,其中可能某些指令会操作数据, 这个时候可能就需要去数据段里面找对应的变量的地址, 然后修改变量、给变量分配内存、回收内存等。
其中的代码段和数据段是一开始就加载进内存里面了,堆和栈是动态的。
二、内存分配器是什么?解决了什么问题
自己动手实现一个malloc内存分配器 | 30图【图文】_mb600aa3928e8ce_51CTO博客
我们现在思考另外一个问题, 假如你现在有一排1000米的地要租出去(半条街),不同的租户需要的面积不同, 你怎么管理这块地做到以下两点:
- 尽可能多的把面积利用起来
- 尽可能快的找到可以租给别人的地、回收别人不租了的地。
假设我们不能使用额外的账簿之类的来记录哪些地租给了谁,只能在我们那1000米里面记录。那我们还要解决怎么知道哪些地是空闲的, 哪些地是租出去了的。
- 解决哪些地是空闲的, 哪些地是租出去了的?首先我们的起始位置肯定是知道的。在租地的时候,在这块地的开头划一个区域来专门记录这块地的使用情况等(一个标志位记录这块地是否租出去了, 一个数字记录这块地的面积)。
- 在分配新的地的时候从起始位置开始找, 按照租出去的每块地的头区域找下一个地址,看是否空闲,大小是多少。 找到第一个面积比需求的大的立马返回。 这里会存在找到的地可能是之前分配给别人的划分了大小的地,使用面积可能比需要的大, 此时可能需要对找到的地重新切割划分。
- 在回收的时候, 可能你后面的地也是空着的, 这个时候你可以通过头里面的信息进行合并。 但是如果你前面的地也是空闲的, 也要合并呢?你不知道前一块地是否被分配出去了, 面积是多少。 所以你在划分租出去的地的时候, 还要在地的尾部也设置一块区域来记录地的大小等信息, 这样就解决了合并的问题。
最终我们租给用户的地可能是这样的 :
内存分配器其实要解决的是相同的问题, 不过他管理的是堆内存, 而存放的是程序员申请的内存或各种类型、各种大小的变量, 而且设计得要更加复杂等。
如果对里面有足够多的内存供分配, 分配器直接分配, 如果没有, 则分配器将会通过系统调用函数 brk 来扩展堆,通常是增加变量 MMAP_THRESHOLD 的默认值 (128KB)。
三、Go里面的内存分配器
go的内存分配器同样要做到提高内存使用率(减少内存碎片)和更快的分配和释放内存。
而且多线程下在分配、释放的时候是需要加锁的, 也影响了分配、释放的效率。
Go的分配器与 TC-Malloc非常相似, TCMalloc(Thread Cache Malloc)的核心思想是将内存分解为多层、多块,相当于使用了多级缓存,减小内存锁的粒度和查找需要的内存大小空间的时间。
TCMalloc :
组件整体设计如下:
各部分作用和定位如下 :
Page : 操作系统对内存管理以页为单位, 这里也是一样, 不过页的大小可能跟操作系统中不一样,倍数关系。
Span : 一组连续的page组成的小数组合为一个span,这样我们就可以通过底层page的数量将span分成大小不一的多种span。
ThreadCache : 线程缓存, 因为所有线程使用的是同一块虚拟内存, 所以需要加锁,但是给每个线程单独分配一个缓存池之后, 线程操作自己的缓存池就不存在并发的问题, 也就不需要加锁。一个cache包含多个空闲内存块链表, 每条链表中存储的是相同类型的span,不同的链表中span的大小不同,分配内存的时候直接先找到合适的span链表, 再在链表中分配
CentralCache : 缓存中心, 里面跟ThreadCache一样存的是各种大小的span链表, 不同之处在于他是全局的,当ThreadCache里面的span分配完之后会来这里获取, 当然这是要加锁的。
PageHeap : 是对整个堆内存的抽象,存的也是若干保存各种span的链表,CentralCache内存或者过多时,会从这里获取或者返回到这里。除了存各种span链表, 还存储了 large span set, 这个是用来保存中、大对象的。
小对象大小:0~256KB
中对象大小:257~1MB
大对象大小:>1MB
小对象的分配流程:ThreadCache -> CentralCache -> HeapPage,大部分时候,ThreadCache缓存都是足够的,不需要去访问CentralCache和HeapPage,无系统调用配合无锁分配,分配效率是非常高的。
中对象分配流程:直接在PageHeap中选择适当的大小即可,128 Page的Span所保存的最大内存就是1MB。
大对象分配流程:从large span set选择合适数量的页面组成span,用来存储数据。
总结起来就是, 我们使用内存的时候大多一次申请的都是较小的内存块, 我划分出多种小内存块放到线程自己的内存池中,访问自己线程池中的内存不需要加锁。 这些内存块之间排列可以比较紧密, 我分配、回收的时候先确定需要哪种类型的小内存块, 然后直接去获取提前确定大小的内存 减少了内存碎片。
核心思想是使用了缓存, 以空间换时间。
Go的内存管理借鉴了TCMalloc, 也是利用了缓存, 他的设计如下 :
各部分作用和定位如下 :
Page : 与TCMalloc 中一样。
Span : 与TCMalloc 中一样, 代码中为mspan, 不同点在于每种大小(page数量相同)的span都有两类,scan类型的span包含指针, 在GC的时候会被扫描, noscan类型的span不包含指针, 在GC的时候不会被扫描。
Mcanche : 与TCMalloc 中 ThreadCache 类似, 只不过因为go使用GPM模型, 所以Mcanche不属于线程, 而是属于每个P(算是P的一部分)。
Mcentral : 与 TCMalloc 中CentralCache 类似,不同点在于CentralCache是每个级别的Span只有1条链表,mcache是每个级别的Span有2条链表。这两条链表可以理解为被分配过的内存段nonempty链表(可能有些被释放了的也在这里)和未被分配过的内存段empty链表(全部可用)。Mcanche在获取Mcentral中的span时, 先去非空那条找, 看有没有之前分出去后被归还的span, 没有的话再去另一条链表拿一个新的给他。
Mheap : 与 TCMalloc 中PageHeap 类似, 不同点在于mheap把Span组织成了树结构,而不是链表,并且还是2棵树,然后把Span分配到heapArena进行管理,它包含地址映射和span是否包含指针等位图,这样做的主要原因是为了更高效的利用内存:分配、回收和再利用。
Go中的内存分类并不像TCMalloc那样分成小、中、大对象,但是它的小对象里又细分了一个Tiny对象,Tiny对象指大小在1Byte到16Byte之间并且不包含指针的对象。小对象和大对象只用大小划定,无其他区分。
小对象是在mcache中分配的,而大对象是直接从mheap分配的。
Go中寻找span的流程如下:
1、计算对象所需内存大小size
2、根据size到size class映射,计算出所需的size class
3、根据size class和对象是否包含指针计算出span class
4、获取该span class指向的span
四、Go内存分配和GC流程
【golang】GC详解 - Go语言中文网 - Golang中文社区
内存分配 :
首先会检查GC是否在工作中, 如果GC在工作中并且当前的G分配了一定大小的内存则需要协助GC做一定的工作,
这个机制叫GC Assist, 用于防止分配内存太快导致GC回收跟不上的情况发生.
之后会判断是小对象还是大对象, 如果是大对象则直接调用largeAlloc从堆中分配,
如果是小对象分3个阶段获取可用的span, 然后从span中分配对象:
1、首先从P的缓存(mcache)获取
2、从全局缓存(mcentral)获取, 全局缓存中有可用的span的列表
3、从mheap获取, mheap中也有span的自由列表, 如果都获取失败则从arena区域分配
GC流程图 :
在GC过程中会有两种后台任务(G), 一种是标记用的后台任务, 一种是清扫用的后台任务.
标记用的后台任务会在需要时启动, 可以同时工作的后台任务数量大约是P的数量的25%, 也就是go所讲的让25%的cpu用在GC上的根据.
清扫用的后台任务在程序启动时会启动一个, 进入清扫阶段时唤醒.
目前整个GC流程会进行两次STW(Stop The World), 第一次是Mark阶段的开始, 第二次是Mark Termination阶段.
第一次STW会准备根对象的扫描, 启动写屏障(Write Barrier)和辅助GC(mutator assist).
第二次STW会重新扫描部分根对象, 禁用写屏障(Write Barrier)和辅助GC(mutator assist).
需要注意的是, 不是所有根对象的扫描都需要STW, 例如扫描栈上的对象只需要停止拥有该栈的G.