欢迎访问 生活随笔!

生活随笔

当前位置: 首页 > 运维知识 > linux >内容正文

linux

Linux 内存管理 | 物理内存、内存碎片、伙伴系统、SLAB分配器

发布时间:2023/12/13 linux 90 豆豆
生活随笔 收集整理的这篇文章主要介绍了 Linux 内存管理 | 物理内存、内存碎片、伙伴系统、SLAB分配器 小编觉得挺不错的,现在分享给大家,帮大家做个参考.

文章目录

  • 物理内存
  • 物理内存分配
    • 外部碎片
    • 内部碎片
    • 伙伴系统(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分配器的本质其实就是 将内存按使用对象不同再划分成不同大小的空间,即对内核对象的缓存操作

    总结

    以上是生活随笔为你收集整理的Linux 内存管理 | 物理内存、内存碎片、伙伴系统、SLAB分配器的全部内容,希望文章能够帮你解决所遇到的问题。

    如果觉得生活随笔网站内容还不错,欢迎将生活随笔推荐给好友。