• 设为首页
  • 点击收藏
  • 手机版
    手机扫一扫访问
    迪恩网络手机版
  • 关注官方公众号
    微信扫一扫关注
    公众号

go内存分配器详解-摘自go语言设计与实现

原作者: [db:作者] 来自: [db:来源] 收藏 邀请

go设计与实现把go内存分配器介绍的很详细,起始一般情况下程序员不怎么会用到。需要简单了解下即可。如果没时间看,看看下述内容即可。

栈区堆区概念要理解。分配方法其实就是基于算法中的数组和链表,优缺点都类似。

go采用的空闲链表分配。并采取了隔离适应来规避链表的缺陷。

通过将对象大小分成微对象[<16B]小对象[16-32KB] 大对象[>32KB],并且通过多级缓存提高分配效率。

通过将对象大小分成微对象[<16B]小对象[16-32KB] 大对象[>32KB],并且通过多级缓存提高分配效率。

通过将对象大小分成微对象[<16B]小对象[16-32KB] 大对象[>32KB],并且通过多级缓存提高分配效率。(重要的事情说三遍,将对象分类并使用多级缓存是go内存管理的重要思想)

 

最重要的内存管理组件。内存管理单元mspan、线程缓存mcache、中心缓存mcentral、页堆mheap简单的了解下。

这些组件的源码都在runtime包中

go以页为单位管理内存(跟操作系统的页不同),每个mspan会持有多个页。

mspan是基础,mcache是为每个goroutine各自分配的缓存,mcentral可以理解成是内存池,mheap是更大的池。

当mspan不足,会向mcentral申请,mcentral不足向mheap申请,mheap不足向操作系统申请。

一般了解到这些内容就可以了。有时间可以详细往下看。

一、内存管理的基础概念

内存空间分为两个重要区域。栈区Stack和堆区Heap。函数调用的参数,返回值以及局部遍历大都分配到栈上,由编译器管理。不同编程语言使用不同的方法管理堆区的内存,C++ 等编程语言会由工程师主动申请和释放内存,Go 以及 Java 等编程语言会由工程师和编译器共同管理,堆中的对象由内存分配器分配并由垃圾收集器回收。

设计原理

内存管理一般包含三个不同的组件,分别是用户程序(Mutator)、分配器(Allocator)和收集器(Collector),当用户程序申请内存时,它会通过内存分配器申请新的内存,而分配器会负责从堆中初始化相应的内存区域。

分配方法

编程语言的内存分配器一般包含两种分配方法,一种是线性分配器(Sequential Allocator,Bump Allocator),另一种是空闲链表分配器(Free-List Allocator)

线性分配器

线性分配(Bump Allocator)是一种高效的内存分配方法,但是有较大的局限性。当我们在编程语言中使用线性分配器,我们只需要在内存中维护一个指向内存特定位置的指针,当用户程序申请内存时,分配器只需要检查剩余的空闲内存、返回分配的内存区域并修改指针在内存中的位置,即移动下图中的指针:

 

 

 优点:执行快,容易实现

缺点:无法重用内存,需要搭配合适的垃圾回收算法

 

 

回收中产生的内存碎片,需要搭配合适的垃圾回收算法。标记压缩(Mark-Compact)、复制回收(Copying GC)和分代回收(Generational GC)等算法可以通过拷贝的方式整理存活对象的碎片,将空闲内存定期合并,这样就能利用线性分配器的效率提升内存分配器的性能了。

空闲链表分配器

空闲链表分配器(Free-List Allocator)可以重用已经被释放的内存,它在内部会维护一个类似链表的数据结构。当用户程序申请内存时,空闲链表分配器会依次遍历空闲的内存块,找到足够大的内存,然后申请新的资源并修改链表:

 

 

 优点:内存复用方便

缺点:分配内存需要遍历整个链表,耗时长。

空闲链表分配器可以选择不同的策略在链表中的内存块中进行选择,最常见的就是以下四种方式:

首次适应(First-Fit)— 从链表头开始遍历,选择第一个大小大于申请内存的内存块;

  • 循环首次适应(Next-Fit)— 从上次遍历的结束位置开始遍历,选择第一个大小大于申请内存的内存块;
  • 最优适应(Best-Fit)— 从链表头遍历整个链表,选择最合适的内存块;
  • 隔离适应(Segregated-Fit)— 将内存分割成多个链表,每个链表中的内存块大小相同,申请内存时先找到满足条件的链表,再从链表中选择合适的内存

Go 语言使用的内存分配策略与第四种策略有些相似,我们通过下图了解一下该策略的原理:

 

 

 如上图所示,该策略会将内存分割成由 4、8、16、32 字节的内存块组成的链表,当我们向内存分配器申请 8 字节的内存时,我们会在上图中的第二个链表找到空闲的内存块并返回。隔离适应的分配策略减少了需要遍历的内存块数量,提高了内存分配的效率。

分级分配

线程缓存分配(Thread-Caching Malloc,TCMalloc)是用于分配内存的的机制,它比 glibc 中的 malloc 函数还要快很多。Go 语言的内存分配器就借鉴了 TCMalloc 的设计实现高速的内存分配,它的核心理念是使用多级缓存根据将对象根据大小分类,并按照类别实施不同的分配策略。

 

对象大小

Go 语言的内存分配器会根据申请分配的内存大小选择不同的处理逻辑,运行时根据对象的大小将对象分成微对象、小对象和大对象三种:

 

类别 大小
微对象 (0, 16B)
小对象 [16B, 32KB]
大对象 (32KB, +∞)

多级缓存

内存分配器不仅会区别对待大小不同的对象,还会将内存分成不同的级别分别管理,TCMalloc 和 Go 运行时分配器都会引入线程缓存(Thread Cache)、中心缓存(Central Cache)和页堆(Page Heap)三个组件分级管理内存:

线程缓存属于每一个独立的线程,它能够满足线程上绝大多数的内存分配需求,因为不涉及多线程,所以也不需要使用互斥锁来保护内存,这能够减少锁竞争带来的性能损耗。当线程缓存不能满足需求时,就会使用中心缓存作为补充解决小对象的内存分配问题;在遇到 32KB 以上的对象时,内存分配器就会选择页堆直接分配大量的内存。

这种多层级的内存分配设计与计算机操作系统中的多级缓存也有些类似,因为多数的对象都是小对象,我们可以通过线程缓存和中心缓存提供足够的内存空间,发现资源不足时就从上一级组件中获取更多的内存资源。

 

内存管理组件

Go 语言的内存分配器包含内存管理单元、线程缓存、中心缓存和页堆几个重要组件,这几种最重要组件对应的数据结构 runtime.mspanruntime.mcacheruntime.mcentral 和 runtime.mheap

 

 

所有的 Go 语言程序都会在启动时初始化如上图所示的内存布局,每一个处理器都会被分配一个线程缓存 runtime.mcache 用于处理微对象和小对象的分配,它们会持有内存管理单元 runtime.mspan

每个类型的内存管理单元都会管理特定大小的对象,当内存管理单元中不存在空闲对象时,它们会从 runtime.mheap 持有的 134 个中心缓存 runtime.mcentral 中获取新的内存单元,中心缓存属于全局的堆结构体 runtime.mheap,它会从操作系统中申请内存。

在 amd64 的 Linux 操作系统上,runtime.mheap 会持有 4,194,304=4*1024*1024 runtime.heapArena,每一个 runtime.heapArena 都会管理 64MB 的内存,单个 Go 语言程序的内存上限也就是 256TB。

内存管理单元

runtime.mspan 是 Go 语言内存管理的基本单元,该结构体中包含 next 和 prev 两个字段,它们分别指向了前一个和后一个 runtime.mspan

type mspan struct {
    next *mspan
    prev *mspan
    ...
}

串联后的上述结构体会构成如下双向链表,运行时会使用 runtime.mSpanList 存储双向链表的头结点和尾节点并在线程缓存以及中心缓存中使用。

 

 

页和内存

每个 runtime.mspan 都管理 npages 个大小为 8KB 的页,这里的页不是操作系统中的内存页,它们是操作系统内存页的整数倍,该结构体会使用下面的这些字段来管理内存页的分配和回收:

type mspan struct {
    startAddr uintptr // 起始地址
    npages    uintptr // 页数
    freeindex uintptr
    allocBits  *gcBits
    gcmarkBits *gcBits
    allocCache uint64
    ...
}
  • startAddr 和 npages — 确定该结构体管理的多个页所在的内存,每个页的大小都是 8KB;
  • freeindex — 扫描页中空闲对象的初始索引;
  • allocBits 和 gcmarkBits — 分别用于标记内存的占用和回收情况;
  • allocCache — allocBits 的补码,可以用于快速查找内存中未被使用的内存;

runtime.mspan 会以两种不同的视角看待管理的内存,当结构体管理的内存不足时,运行时会以页为单位向堆申请内存:

 

 

图 7-12 内存管理单元与页

当用户程序或者线程向 runtime.mspan 申请内存时,该结构会使用 allocCache 字段以对象为单位在管理的内存中快速查找待分配的空间:

 

 如果我们能在内存中找到空闲的内存单元,就会直接返回,当内存中不包含空闲的内存时,上一级的组件 runtime.mcache 可能会为该结构体添加更多的内存页以满足为更多对象分配内存的需求。

跨度类

runtime.spanClass 是 runtime.mspan 结构体的跨度类,它决定了内存管理单元中存储的对象大小和个数:

type mspan struct {
    ...
    spanclass   spanClass
    ...
}

Go 语言的内存管理模块中一共包含 67 种跨度类,每一个跨度类都会存储特定大小的对象并且包含特定数量的页数以及对象,所有的数据都会被预选计算好并存储在 runtime.class_to_size 和 runtime.class_to_allocnpages 等变量中:

class bytes/obj bytes/span objects tail waste max waste
1 8 8192 1024 0 87.50%
2 16 8192 512 0 43.75%
3 32 8192 256 0 46.88%
4 48 8192 170 32 31.52%
5 64 8192 128 0 23.44%
6 80 8192 102 32 19.07%
66 32768 32768 1 0 12.50%

 跨度类的数据

上表展示了对象大小从 8B 到 32KB,总共 66 种跨度类的大小、存储的对象数以及浪费的内存空间

除了上述 66 个跨度类之外,运行时中还包含 ID 为 0 的特殊跨度类,它能够管理大于 32KB 的特殊对象

线程缓存

runtime.mcache 是 Go 语言中的线程缓存,它会与线程上的处理器一一绑定,主要用来缓存用户程序申请的微小对象。每一个线程缓存都持有 67 * 2 个 runtime.mspan,这些内存管理单元都存储在结构体的 alloc 字段中

 

 

线程缓存与内存管理单元

线程缓存在刚刚被初始化时是不包含 runtime.mspan 的,只有当用户程序申请内存时才会从上一级组件获取新的 runtime.mspan 满足内存分配的需求。

微分配器

线程缓存中还包含几个用于分配微对象的字段,下面的这三个字段组成了微对象分配器,专门为 16 字节以下的对象申请和管理内存:

type mcache struct {
    tiny             uintptr
    tinyoffset       uintptr
    local_tinyallocs uintptr
}

微分配器只会用于分配非指针类型的内存,上述三个字段中 tiny 会指向堆中的一篇内存,tinyOffset 是下一个空闲内存所在的偏移量,最后的 local_tinyallocs 会记录内存分配器中分配的对象个数。

中心缓存

runtime.mcentral 是内存分配器的中心缓存,与线程缓存不同,访问中心缓存中的内存管理单元需要使用互斥锁:

type mcentral struct {
    lock      mutex
    spanclass spanClass
    nonempty  mSpanList
    empty     mSpanList
    nmalloc uint64
}

每一个中心缓存都会管理某个跨度类的内存管理单元,它会同时持有两个 runtime.mSpanList,分别存储包含空闲对象的列表和不包含空闲对象的链表:

 

 中心缓存和内存管理单元

该结构体在初始化时,两个链表都不包含任何内存,程序运行时会扩容结构体持有的两个链表,nmalloc 字段也记录了该结构体中分配的对象个数。

内存管理单元

线程缓存会通过中心缓存的 runtime.mcentral.cacheSpan 方法获取新的内存管理单元,该方法的实现比较复杂,我们可以将其分成以下几个部分:

  1. 从有空闲对象的 runtime.mspan 链表中查找可以使用的内存管理单元;
  2. 从没有空闲对象的 runtime.mspan 链表中查找可以使用的内存管理单元;
  3. 调用 runtime.mcentral.grow 从堆中申请新的内存管理单元;
  4. 更新内存管理单元的 allocCache 等字段帮助快速分配内存;

页堆

runtime.mheap 是内存分配的核心结构体,Go 语言程序只会存在一个全局的结构,而堆上初始化的所有对象都由该结构体统一管理,该结构体中包含两组非常重要的字段,其中一个是全局的中心缓存列表 central,另一个是管理堆区内存区域的 arenas 以及相关字段。

页堆中包含一个长度为 134 的 runtime.mcentral 数组,其中 67 个为跨度类需要 scan 的中心缓存,另外的 67 个是 noscan 的中心缓存:

 

 

go语言设计与实现书中提到的东西远不止这些。还有很多更详细更深奥的东西。如果有兴趣可以买书或者看电子书。

问题:为什么分成67类对象?微对象,小对象,大对象分类的标准是什么,为什么这么分?


鲜花

握手

雷人

路过

鸡蛋
该文章已有0人参与评论

请发表评论

全部评论

专题导读
上一篇:
Go如何顺利导入依赖发布时间:2022-07-12
下一篇:
Go语言求水仙花数(for循环)发布时间:2022-07-12
热门推荐
热门话题
阅读排行榜

扫描微信二维码

查看手机版网站

随时了解更新最新资讯

139-2527-9053

在线客服(服务时间 9:00~18:00)

在线QQ客服
地址:深圳市南山区西丽大学城创智工业园
电邮:jeky_zhao#qq.com
移动电话:139-2527-9053

Powered by 互联科技 X3.4© 2001-2213 极客世界.|Sitemap