文章目录
- 物理内存
- 物理内存分配
- 外部碎片
- 内部碎片
- 伙伴系统(buddy system)
- slab分配器
物理内存
在Linux中,内核将物理内存划分为三个区域。
在解释DMA内存区域之前解释一下什么是DMA:
DMA(直接存储器访问) 使用物理地址访问内存,将数据从一个地址空间复制到另外一个地址空间,从而加快磁盘和内存之间数据的交换,不经过MMU(内存管理单元),这时CPU可以去干别的事,大大增加了效率。
- DMA内存区域(ZONE_DMA): 包含
0M~16M
之内的内存页框,该区域的物理页面专门供I/O设备的DMA使用,DMA需要连续的缓冲区,为了能够提供物理上连续的缓冲区,必须从物理地址空间专门划分一段区域用于DMA。 - 普通内存区域(ZONE_NORMAL): 包含
16MB~896M
以上的内存页框,可以直接映射到内核空间中的直接映射区。 - 高端内存区域(ZONE_HIGHMEM): 包含
896M
以上的内存页框,不可以进行直接映射,可以通过高端内存映射区中的永久内存映射区
以及临时内存映射区
(固定内存映射区中的一部分) 来对这块物理内存进行访问。
内存分布如下图:
物理内存分配
在Linux中,通过分段和分页的机制,将物理内存划分为4k
大小的内存页(page),并且将页
作为物理内存分配与回收的基本单位
。通过分页机制我们可以灵活的对内存进行管理。
- 如果用户申请了小块内存,我们可以直接分配一页给它,就可以避免因为频繁的申请、释放小块内存而发起的系统调用带来的消耗。
- 如果用户申请了大块内存,我们可以将多个页框组合成一大块内存后再进行分配,非常的灵活。
但是,这种直接的内存分配非常容易导致内存碎片的出现,下面就分别介绍内部碎片和外部碎片这两种内存碎片。
为了方便接下来的阅读,这里科普一下 页 和 页框 :
- 分页单元认为所有的RAM被分成了固定长度的 页框 ,页框是主存的一部分,是一个实际的存储区域。
- 页 是指一系列的线性地址和包含于其中的数据,每页被视为一个数据块。而存放数据块的物理内存就是 页框 ,也就是说一个 页框 的长度和一个 页 的长度是一样的, 页 可以存放在任何页框或磁盘中。
外部碎片
当我们需要分配大块内存时,操作系统会将连续的页框组合起来,形成大块内存,来将其分配给用户。但是,频繁的申请和释放内存页,就会带来 内存外碎片 的问题,如下图。
假设我们这块内存块中有10个页框,我们一开始先是分配了3个页框给 进程A
,而后又分配了5个页框给 进程B
。当进程A结束后,其释放了申请的3个页框,此时我们剩余空间就是内存块起始位置的3个页框,以及末尾位置的2个页框。
假如此时我们运行了 进程C
,其需要5个页框的内存,此时虽然这块内存中还剩下5个页框,但是由于我们频繁的申请和释放小块空间导致内存碎片化,因此如果我们想申请5个页框的空间,只能到其他的内存块中申请,这块内存的空闲页框就被浪费了。
要想解决 外部碎片
的问题,无非就两种方法:
- 外碎片问题的本质就是
空闲页框不连续
,所以可以将非连续的空闲页框
映射到连续的虚拟地址空间
,如果现存的空闲页框总大小
满足进程的需求,则允许将一个进程分散地分配到许多不相邻的分区中,从而避免直接申请新的内存块; - 记录现存的
连续空闲页框块
的情况,如果有 能满足的小块内存需求 直接从记录中分配 相等或大于 内存需求的连续空闲页框块
,从而避免直接申请新的内存块。
第一种方法就是将上面举例中的 C进程
一部分分配到前面的 3个页框
, 另一部分分配到后面的 2个页框
,如此一来不用申请新的内存块即可满足C进程的需求,详细内容将在分页知识中讲述。
第二种方法就是,虽然 C进程
要申请新的内存块,但是如果接下来 A进程
又开始运行,那我们就将 B进程
所在的内存块中 3块连续空闲页框块 分配给 A进程
而不是直接申请新的 10块连续页框 分配给 A进程
。
Linux选择了第二种方法,引入 伙伴系统算法 ,来解决 外部碎片 的问题。
内部碎片
内部碎片 是 页 的未被利用的空闲区域。一开始的时候也说了,由于页是物理内存分配的基本单位,因此即使我们需求的内存很小,Linux也会至少给我们分配 4k
的内存页,此时会造成内存浪费。
举个例子:当一个进程需要 7K 大小的内存时,我们必须给他分配 2个页框 以满足需求,但是第 2 个页框我们只使用了其中 3K 的内存,因此有 1K 的内存被浪费掉了。
如上图,倘若我们需求的只有几个字节,那该内存页中又有大量的空间未被使用,就造成了内存浪费的问题,而如果我们频繁的进行小块内存的申请,这种浪费现象就会愈发严重。
内碎片问题的本质就是 页内空闲内存
无法被其他进程再次利用。而 SLAB分配器 就可以 对内部碎片进行再利用 ,从而解决内部碎片问题。
伙伴系统(buddy system)
什么是伙伴系统算法呢?其实就是 把相同大小的连续页框块用链表串起来 ,这使页框之间看起来就像是手拉手的伙伴,这也就是其名字的由来。
伙伴系统将所有的空闲页框分组为11块链表,每个块链表分别包含大小为1,2,4,8,16,32,64,128,256,512和1024个连续页框的页框块,即2的0~10次方,最大可以申请 1024
个连续页框,对应 4MB(最大连续页框数 * 每个页的大小 = 1024 * 4k)
大小的连续内存。每个页框块的第一个页框的物理地址是该块大小的整数倍。
因为任何正整数都可以由 2^n
的和组成,所以我们总能通过拆分与合并,来找到合适大小的内存块分配出去,减少了外部碎片产生 。
倘若我们需要分配1MB的空间,即256个页框的块,我们就会去查找在256个页框的链表中是否存在一个空闲块,如果没有,则继续往下查找更大的链表,如查找512个页框的链表。如果存在空闲块,则将其拆分为两个256个页框的块,一个用来进行分配,另一个则放入256个页框的链表中。
释放时也同理,它会将多个连续且空闲的页框块进行合并为一个更大的页框块,放入更大的链表中。
slab分配器
虽然伙伴系统很好的解决了外部碎片的问题,但是它还是以页作为内存分配和释放的单位,而我们在实际的应用中则是以字节为单位,例如我们要申请2个字节的空间,其还是会向我们分配一页,也就是 4096字节(4K)
的内存,因此其还是会存在内部碎片的问题。
为了解决这个问题,slab分配器就应运而生了。其以 字节 为基本单位,专门用于对 小块内存 进行分配。slab分配器并未脱离伙伴系统,而是对伙伴系统的补充,它将伙伴系统分配的大内存进一步细化为小内存分配(对内部碎片的再利用)。
那么它的原理是什么呢?
对于内核对象,生命周期通常是这样的: 分配内存->初始化->释放内存 。而内核中如文件描述符、pcb等小对象又非常多,如果按照伙伴系统按页分配和释放内存,不仅存在大量的空间浪费,还会因为频繁对小对象进行 分配-初始化-释放
这些操作而导致性能的消耗。
所以为了解决这个问题,对于内核中这些需要重复使用的小型数据对象,slab通过一个缓存池来缓存这些常用的已初始化的对象 。
- 当我们需要申请这些小对象时,就会直接从缓存池中的slab列表中分配一个出去。
- 而当我们需要释放时,我们不会将其返回给伙伴系统进行释放,而是将其重新保存在缓存池的slab列表中。
通过这种方法,不仅避免了内部碎片的问题,还大大的提高了内存分配的性能。
PS:这里说的 缓存池
是对真正的缓存—— 硬件缓存(cache)
原理的一种模仿:
- 硬件缓存是为了解决快速的CPU和速度较慢的内存之间速度不匹配的问题,CPU访问cache的速度要快于内存,如果将常用的数据放到硬件缓存中,使用时CPU
直接访问cache而不再访问内存
,从而提升系统速度。 - 而这里的
缓存池
实际上使在内存中预先开辟一块空间,使用时直接从这一块空间中去取所需对象(访问的是内存而不是cache),是SLAB分配器为了便于对小块内存的管理而建立的。
下面就由大到小,来画出底层的数据结构:
slab 分配器把每一个 请求的内存
称之为 对象
,每种 对象
分配一个 高速缓存(kmem_cache)
,所有的 高速缓存
通过双链表组织在一起,形成 高速缓存链表(cache_chain)
,每个 高速缓存
所占内存区被划分为多个 slab
,这些 slab
都属于一个 slab列表
,每个 slab列表
是一段连续的内存块,并包含3种类型的 slabs链表
:
- slabs_full(完全分配的slab)
- slabs_partial(部分分配的slab)
- slabs_empty(空slab,或者没有对象被分配)。
slab
是 slab分配器的 最小单位 ,在具体实现上一个 slab
由一个或者多个连续的物理页组成(通常只有一页)。单个 slab
可以在 slab链表
中进行移动,例如一个 未满的slab节点
,其原本在 slabs_partial
链表中,如果它由于分配对象而变满,就需要从原先的 slabs_partial
中删除,插入到完全分配的链表 slabs_full
中。
举个具象的例子:
slab分配器
将进程描述符和索引节点对象放在一个 cache_chain
,该 cache_chain
下辖两个 kmem_cache
:一个 kmem_cache
用于存放进程描述符,而另一个 kmem_cache
存放索引节点对象,然后这些 kmem_cache
又被划分为多个 slab
,每个 slab
都管辖着若干个对象(进程描述符/索引节点对象),而这些 slab
又根据状态(已满、半满、全空)分布在3个 slabs链表
中,3个 slabs链表
共同构成一个 slab列表
。
举个例子以说明slab的分配过程:
如果在 cache_chain
里有一个名叫 inode_cachep
的 kmem_cache
节点,它存放了一些 inode
对象。当内核请求分配一个新的 inode
对象时,slab分配器
就开始工作了:
- 首先要查看
inode_cachep
的slabs_partial
链表,如果slabs_partial
非空,就从中选中一个slab
, 返回一个指向已分配但未使用的inode结构的指针。 完事之后,如果这个slab
满了,就把它从slabs_partial
中删除,插入到slabs_full
中去,结束; - 如果
slabs_partial
为空,也就是没有半满的slab
,就会到slabs_empty
中寻找。如果slabs_empty
非空,就选中一个slab
, 返回一个指向已分配但未使用的inode结构的指针 ,然后将这个slab
从slabs_empty
中删除,插入到slabs_partial
(或者slab_full
)中去,结束; - 如果
slabs_empty
也为空,那么没办法,cache_chain
内存已经不足,只能新创建一个slab
了。
内核中slab分配对象的全过程:
- 根据对象的类型找到
cache_chain
中对应的高速缓存kmem_cache
- 如果
slabs_partial
链表非空,则选择其中一个slab
,将slab
中一个未分配的对象分配给需求来源。如果分配之后这个slab
已满,则移动这个slab
到slabs_full
链表 - 如果
slabs_partial
链表没有未分配的空间,则去查看slabs_empty
链表 - 如果
slabs_empty
非空,则选择其中一个slab
,将slab
中一个未分配的对象分配给需求来源,同时移动slab
进入slabs_partial
链表中 - 如果
slabs_empty
也没有未分配的空间,则说明此时空间不足,就会请求伙伴系统分页,并创建新的空闲slab
节点放入slabs_empty
链表中,回到步骤3
从上面可以看出,slab分配器的本质其实就是 将内存按使用对象不同再划分成不同大小的空间,即对内核对象的缓存操作 。