本文仅自己阅读笔记,详细请阅读原文draveness-内存分配器
一、概述
程序中的数据和变量都会被分配到程序所在的虚拟内存中,内存空间包含两个重要区域:栈区(Stack)
和堆区(Heap)。
不同的编程语言会选择不同的方式管理内存,而在Go语言中管理方式如下:
- 栈区由编译器管理:其中函数调用的参数、返回值以及局部变量大都会被分配到栈上,这部分内存会由编译器进行管理;
- 堆中的对象由内存分配器分配并由垃圾收集器回收。
二、内存分配器
内存管理一般包含三个不同的组件:用户程序
、分配器
和收集器
,而当用户程序申请内存时,它会通过内存分配器
申请新内存,而分配器会负责从堆中初始化相应的内存区域。
1、内存分配器——分配方法
内存分配器一般包含两种分配方法:线性分配器
、空闲链表分配器
。
①线性分配器
当我们使用线性分配器时,只需要在内存中维护一个指向内存特定位置的指针,如果用户程序向分配器申请内存,分配器只需要检查剩余的空闲内存、返回分配的内存区域并修改指针在内存中的位置。
- 优点:
- 较快的执行速度
- 较低的实现复杂度
- 缺点:
- 无法在内存被释放时重用内存(如下图):
- 无法在内存被释放时重用内存(如下图):
- 线性分配器与垃圾回收算法的配合使用:
由于线性分配器无法在内存被释放时重用内存,所以需要与合适的垃圾回收算法(eg:标记压缩、复制回收等)配合使用,通过拷贝的方式整理存活对象的碎片,整理存活对象的碎片,将空闲内存定期合并,使得可以充分发挥线性分配器的效率,提升内存分配器的性能。(注:线性分配器需要与具有拷贝特性的垃圾回收算法配合。)
②空闲链表分配器
空闲链表分配器会在内部会维护一个类似链表的数据结构,使得已经被释放的内存可以被重用。
-
原理:不同的内存块通过指针构成了链表,当用户程序申请内存时,空闲链表分配器会依次遍历空闲的内存块,找到足够大的内存,然后申请新的资源并修改链表。
-
四种内存分配策略
使用空闲链表分配器,分配内存时需要遍历链表。但它可以选择不同的策略在链表中的内存块中进行选择:- 首次适应:从链表头开始遍历,选择第一个大小大于申请内存的内存块;
- 循环首次适应:从上次遍历的结束位置开始遍历,选择第一个大小大于申请内存的内存块;
- 最优适应:从链表头遍历整个链表,选择最合适的内存块;
隔离适应
:将内存分割成多个链表,每个链表中的内存块大小相同,申请内存时先找到满足条件的链表,再从链表中选择合适的内存块;
注:Go 语言使用的内存分配策略与隔离适应策略相似
隔离适应策略的原理
该策略会将内存分割成由 4、8、16、32 字节的内存块组成的链表,当我们向内存分配器申请 8 字节的内存时,它会在上图中找到满足条件的空闲内存块并返回。隔离适应的分配策略减少了需要遍历的内存块数量,提高了内存分配的效率。
2、内存分配器——分级分配
线程缓存分配(Thread-Caching Malloc,TCMalloc)
线程缓存分配
是用于分配内存的机制,它的核心理念是使用多级缓存将对象根据大小分类,并按照类别实施不同的分配策略。
TCMalloc
比 glibc 中的malloc
还要快很多。Go 语言的内存分配器就借鉴了 TCMalloc 的设计实现高速的内存分配。
①根据对象大小分类
Go 语言的内存分配器会根据申请分配的内存大小选择不同的处理逻辑,运行时根据对象的大小将对象分成微对象、小对象和大对象三种:
- 微对象:(0, 16B)
- 小对象:[16B, 32KB]
- 大对象:(32KB, +∞)
为什么要将对象分类?
因为申请的内存大小影响 Go 语言运行时分配内存的过程和开销,所以分别处理大对象和小对象有利于提高内存分配器的性能。而且程序中的绝大多数对象的大小都在 32KB 以下。
②多级缓存
内存分配器会将内存分成不同的级别分别管理,Go 运行时分配器由三个组件分级管理内存:
线程缓存(Thread Cache)
中心缓存(Central Cache)
页堆(Page Heap)
多级缓存管理内存的原则:
- 线程缓存属于每一个独立的线程,它能够满足线程上绝大多数的内存分配需求,因为不涉及多线程,所以也不需要使用互斥锁来保护内存,这能够减少锁竞争带来的性能损耗。
- 当线程缓存不能满足需求时,运行时会使用中心缓存作为补充解决小对象的内存分配,在遇到 32KB 以上的对象时,内存分配器会选择页堆直接分配大内存。
3、虚拟内存布局
①线性内存
- 在 Go 语言 1.10 以前的版本,堆区的内存空间都是连续的。
- 1.10版本会在启动时初始化整片虚拟内存区域,而这些虚拟内存分为三个区域:
spans(512MB)
、bitmap(16GB)
、arena(512GB)
。
spans
区域存储了指向内存管理单元runtime.mspan
的指针 ,每个内存单元会管理几页的内存空间,每页大小为 8KB;
(mspan
:Go中内存管理的基本单元,是由一片连续的8KB的页组成的大块内存。)bitmap
用于标识 arena 区域中的哪些地址保存了对象,并且用4bit标志位表示对象是否包含指针、GC标记信息。bitmap中一个byte大小的内存对应arena区域中4个指针大小(指针大小为 8B )的内存,所以bitmap区域的大小是512GB/(4*8B)=16GB。;arena
区域是真正的堆区,运行时会将 8KB 看做一页,这些内存页中存储了所有在堆上初始化的对象;
如何找到管理对象的runtime.mspan
- 对于任意一个地址,我们都可以根据 arena 的基地址计算该地址所在的页数并通过 spans 数组获得管理该片内存的管理单元 runtime.mspan,spans 数组中多个连续的位置可能对应同一个 runtime.mspan 结构。(Go 语言在垃圾回收时会根据指针的地址判断对象是否在堆中,并通过上一段中介绍的过程找到管理该对象的 runtime.mspan。)
线性内存存在的问题
以上虚拟内存的分布都是建立在堆区的内存是连续的这一假设上,这种设计虽然简单并且方便,但是在 C 和 Go 混合使用时会导致程序崩溃:
- 分配的内存地址会发生冲突,导致堆的初始化和扩容失败;
- 没有被预留的大块内存可能会被分配给 C 语言的二进制,导致扩容后的堆不连续;
(线性的堆内存需要预留大块的内存空间,但是申请大块的内存空间而不使用是不切实际的,不预留内存空间却会在特殊场景下造成程序崩溃。)
②稀疏内存
在 1.11 版本,Go 团队使用稀疏的堆内存空间替代了连续的内存。
- 优点:使用稀疏的内存布局不仅能移除堆大小的上限,还解决了 C 和 Go 混合使用时的地址空间冲突问题。
- 缺点:失去了内存的连续性,使内存管理变得更加复杂。
4、地址空间
因为所有的内存最终都是要从操作系统中申请的,所以 Go 语言的运行时构建了操作系统的内存管理抽象层,该抽象层将运行时管理的地址空间分成以下四种状态:
- None:内存没有被保留或者映射,是地址空间的默认状态
- Reserved:运行时持有该地址空间,但是访问该内存会导致错误
- Prepared:内存被保留,一般没有对应的物理内存访问该片内存的行为是未定义的可以快速转换到 Ready 状态
- Ready:可以被安全访问