Go语言内存模型及堆的分配管理(下)

Go内存管理

原文

  • 前文提到Go内存管理源自TCMalloc
  • 但它比TCMalloc还多了2件东西:
    • 逃逸分析
    • 垃圾回收
  • 这是2项提高生产力的绝佳武器
  • 这一大章节
    • 我们先介绍Go内存管理和Go内存分配
    • 最后涉及一点垃圾回收和内存释放

Go内存管理的基本概念

  • Go内存管理的许多概念在TCMalloc中已经有了
    • 含义是相同的
    • 只是名字有一些变化
  • 先给大家上一幅宏观的图
    • 借助图一起来介绍
      在这里插入图片描述
  • Page

    • 与TCMalloc中的Page相同
    • x64架构下1个Page的大小是8KB
    • 上图的最下方
      • 1个浅蓝色的长方形代表1个Page。
  • Span

  • Span与TCMalloc中的Span相同
  • Span是内存管理的基本单位
    • 代码中为mspan
    • 一组连续的Page组成1个Span
      • 所以上图一组连续的浅蓝色长方形
        • 代表的是一组Page组成的1个Span
    • 另外,1个淡紫色长方形为1个Span。
  • mcache

    • mcache与TCMalloc中的ThreadCache类似
    • mcache保存的是各种大小的Span
      • 并按Span class分类
      • 小对象直接从mcache分配内存
        • 它起到了缓存的作用
        • 并且可以无锁访问
    • 但是mcache与ThreadCache也有不同点
      • 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
    • 但是mcentral与CentralCache也有不同点
      • CentralCache是每个级别的Span有1个链表
      • mcache是每个级别的Span有2个链表
        • 这和mcache申请内存有关
        • 稍后我们再解释
  • mheap

    • mheap与TCMalloc中的PageHeap类似
    • 它是堆内存的抽象
      • 从OS申请出的内存页组织成Span
      • 并保存起来
    • 当mcentral的Span不够用时会向mheap申请内存
    • mheap的Span不够用时会向OS申请内存
      • mheap向OS的内存申请是按页来的
      • 然后把申请来的内存页生成Span组织起来
      • 同样也是需要加锁访问的
    • 但是mheap与PageHeap也有不同点:
    • mheap把Span组织成了树结构,而不是链表,并且还是2棵树
      • 然后把Span分配到heapArena进行管理
        • 它包含地址映射和span是否包含指针等位图
      • 这样做的主要原因是为了更高效的利用内存:分配、回收和再利用
Go内存大小转换

在这里插入图片描述

  • object size

    • 代码里简称size指申请内存的对象大小
  • size class

    • 代码里简称class
      • 它是size的级别
    • 相当于把size归类到一定大小的区间段
      • 比如size[1,8]属于size class 1
      • size(8,16]属于size class 2
  • span class:

    • 指span的级别
    • 但span class的大小与span的大小并没有正比关系
    • span class主要用来和size class做对应
    • 1个size class对应2个span class
      • 2个span class的span大小相同,只是功能不同
        • 1个用来存放包含指针的对象
        • 一个用来存放不包含指针的对象
          • 不包含指针对象的Span就无需GC扫描了
  • num of page:

    • 代码里简称npage
    • 代表Page的数量
      • 其实就是Span包含的页数,用来分配内存

Go内存分配

  • Go中的内存分类并不像TCMalloc那样分成小、中、大对象
    • 但是它的小对象里又细分了一个Tiny对象
    • Tiny对象指大小在1Byte到16Byte之间
    • 并且不包含指针的对象
  • 小对象和大对象只用大小划定,无其他区分

在这里插入图片描述

  • 小对象是在mcache中分配的
  • 大对象是直接从mheap分配的
  • 从小对象的内存分配看起
小对象的内存分配

在这里插入图片描述

  • 大小转换这一小节
  • 我们介绍了转换表
  • size class从1到66共66个
    • 代码中_NumSizeClasses=67
      • 代表了实际使用的size class数量
      • 即67个,从1到67
      • size class 0实际并未使用到
// 上文提到1个size class对应2个span class:
numSpanClasses = _NumSizeClasses * 2
  • numSpanClasses为span class的数量为134个
    • 所以span class的下标是从0到133
    • 所以上图中mcache标注了的span class是
      • span class 0到span class 133
      • 每1个span class都指向1个span
      • 也就是mcache最多有134个span
  • 为对象寻找span
    • 寻找span的流程如下:
      • 计算对象所需内存大小size
      • 根据size到size class映射,计算出所需的size class
      • 根据size class和对象是否包含指针计算出span class
      • 获取该span class指向的span
  • 以分配一个不包含指针的,大小为24Byte的对象为例,根据映射表:

在这里插入图片描述

  • 对应的size class为3
    • 它的对象大小范围是(16,32]Byte
    • 24Byte刚好在此区间
    • 所以此对象的size class为3
  • Size class到span class的计算如下:
// noscan为true代表对象不包含指针
func makeSpanClass(sizeclass uint8, noscan bool) spanClass {
    return spanClass(sizeclass<<1) | spanClass(bool2int(noscan))
}
  • 所以对应的span class为7
  • 所以该对象需要的是span class 7指向的span
span class = 3 << 1 | 1 = 7
  • 从span分配对象空间
    • Span可以按对象大小切成很多份
    • 这些都可以从映射表上计算出来
      • 以size class 3对应的span为例
      • span大小是8KB
      • 每个对象实际所占空间为32Byte
      • 这个span就被分成了256块
      • 可以根据span的起始地址计算出每个对象块的内存地址

在这里插入图片描述

  • 随着内存的分配

  • span中的对象内存块,有些被占用,有些未被占用

  • 比如上图

    • 整体代表1个span
      • 蓝色块代表已被占用内存,绿色块代表未被占用内存
      • 当分配内存时
        • 只要快速找到第一个可用的绿色块
        • 并计算出内存地址即可
      • 如果需要
        • 还可以对内存块数据清零。
  • 当span内的所有内存块都被占用时

    • 没有剩余空间继续分配对象
    • mcache会向mcentral申请1个span
    • mcache拿到span后继续分配对象
  • mcache向mcentral申请span
    • mcentral和mcache一样
      • 都是0~133这134个span class级别
      • 但每个级别都保存了2个span list,即2个span链表:
        • nonempty:
          • 这个链表里的span
            • 至少有1个空闲的对象空间
            • 是mcache释放span时加入到该链表的
        • empty:
          • 这个链表里的span
            • 所有的span都不确定里面是否有空闲的对象空间
            • 当一个span交给mcache的时候,就会加入到empty链表
  • 这两个东西名称一直有点绕
    • 建议直接把empty理解为没有对象空间就好了

在这里插入图片描述

  • mcache向mcentral申请span时
    • mcentral会先从nonempty搜索满足条件的span
    • 如果没有找到再从emtpy搜索满足条件的span
    • 然后把找到的span交给mcache
  • mheap的span管理
    • mheap里保存了两棵二叉排序树
    • 按span的page数量进行排序:
      • free:
        • free中保存的span是空闲并且非垃圾回收的span
      • scav:
        • scav中保存的是空闲并且已经垃圾回收的span
    • 如果是垃圾回收导致的span释放
      • span会被加入到scav
    • 否则加入到free
      • 比如刚从OS申请的的内存也组成的Span
  • mheap中还有arenas
    • 由一组heapArena组成
      • 每一个heapArena都包含了连续的pagesPerArena个span
    • 这个主要是为mheap管理span和垃圾回收服务
    • mheap本身是一个全局变量
      • 它里面的数据
        • 也都是从OS直接申请来的内存
        • 并不在mheap所管理的那部分内存以内
  • 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
  • mheap向OS申请内存

    • 当mheap没有足够的内存时
    • mheap会向OS申请内存
      • 把申请的内存页保存为span
      • 然后把span插入到free树
    • 在32位系统中,mheap还会预留一部分空间
      • 当mheap没有空间时
      • 先从预留空间申请
        • 如果预留空间内存也没有了
        • 才向OS申请
大对象的内存分配
  • 大对象的分配比小对象省事多了
  • 99%的流程与mcentral向mheap申请内存的相同
    • 所以不重复介绍了
  • 不同的一点在于
    • mheap会记录一点大对象的统计信息
      • 详情见mheap.alloc_m()

Go垃圾回收和内存释放

  • 如果只申请和分配内存,内存终将枯竭
  • Go使用垃圾回收收集不再使用的span
    • 调用mspan.scavenge()把span释放还给OS
      • (并非真释放,只是告诉OS这片内存的信息无用了,如果你需要的话,收回去好了)
    • 然后交给mheap
      • mheap对span进行span的合并
      • 把合并后的span加入scav树中
        • 等待再分配内存时
    • 由mheap进行内存再分配
  • Go程序是怎么把内存释放给操作系统的?
    • 释放内存的函数是sysUnused,它会被mspan.scavenge()调用:
func sysUnused(v unsafe.Pointer, n uintptr) {
    // MADV_FREE_REUSABLE is like MADV_FREE except it also propagates
    // accounting information about the process to task_info.
    madvise(v, n, _MADV_FREE_REUSABLE)
}
  • 注释说 _MADV_FREE_REUSABLE 与 MADV_FREE 的功能类似
  • 它的功能是给内核提供一个建议:
    • 这个内存地址区间的内存已经不再使用,可以进行回收
    • 但内核是否回收,以及什么时候回收,这就是内核的事情了
    • 如果内核真把这片内存回收了
      • 当Go程序再使用这个地址时
      • 内核会重新进行虚拟地址到物理地址的映射
      • 所以在内存充足的情况下
        • 内核也没有必要立刻回收内存

Go的栈内存

  • 从一个宏观的角度看
    • 内存管理不应当只有堆,也应当有栈
  • 每个goroutine都有自己的栈
    • 栈的初始大小是2KB
      • 100万的goroutine会占用2G
    • 但goroutine的栈会在2KB不够用时自动扩容
      • 当扩容为4KB的时候
      • 百万goroutine会占用4GB

总结

Go的内存分配原理就不再回顾了,它主要强调两个重要的思想:

  • 使用缓存提高效率

    • 在存储的整个体系中到处可见缓存的思想
    • Go内存分配和管理也使用了缓存,利用缓存
      • 一是减少了系统调用的次数
      • 二是降低了锁的粒度、减少加锁的次数
      • 从这2点提高了内存管理效率
  • 以空间换时间,提高内存管理效率

    • 空间换时间是一种常用的性能优化思想
    • 这种思想其实非常普遍
      • 比如Hash、Map、二叉排序树等数据结构的本质就是空间换时间
      • 在数据库中也很常见
        • 比如数据库索引、索引视图和数据缓存等
        • 再如Redis等缓存数据库也是空间换时间的思想


  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值