Linux内存管理:深度解析与探索

你是否想过,在 Linux 系统中,当你打开一个程序、浏览网页或者处理文件时,这些数据都存放在哪里呢?答案就是内存。Linux 内存管理就像是一个超级大管家,它负责管理着系统中所有数据的 “家”。这个 “家” 的空间有限,却要容纳各种各样的数据,而且要保证每个数据都能被快速准确地找到和使用。它需要智慧地分配房间(内存空间),合理地安排住户(进程),还要及时清理不再需要的杂物(回收内存)。今天,我们就一起深入了解这个神奇的 Linux 内存管理机制。

一、概述


Linux 内存管理是操作系统核心功能之一,涉及虚拟内存、物理内存及多种分配回收机制,确保系统高效稳定运行;在 Linux 系统中,内存被划分为多个区域,每个区域有不同的作用。其中,虚拟内存是一种计算机系统的内存管理技术,它扩展了系统可用内存的容量,并为每个进程提供了独立的地址空间。虚拟内存将物理内存和磁盘空间结合使用,使得进程能够访问超出物理内存限制的数据。

虚拟内存通过分页机制实现,将虚拟内存分割成固定大小的内存块,称为 “页”(page),物理内存也同样划分为与页大小相同的 “页框”(page frame)。当进程需要访问数据时,内核将虚拟地址的页映射到物理内存中的页框。例如,在 32 位的系统中,页的大小通常为 4KB,虚拟地址被分为高位部分(页号)和低位部分(页内偏移)。通过页表来完成虚拟地址到物理地址的映射,页表中的每一项包含了虚拟页到物理页框的映射信息。

现代 Linux 内核使用多级页表来提高查找效率,如二级页表或四级页表,通过分级的方式减少查找开销。同时,还使用页表缓存(TLB,Translation Lookaside Buffer)来加速地址转换。TLB 是一块高速缓存,用于存储最近使用的页表项,减少对页表的访问次数。

Linux 内存管理的目标是提高内存利用率,减少内存碎片,最大限度地利用可用内存,同时保证系统的稳定和可靠性。通过合理的内存管理策略和机制,Linux 系统可以有效地管理系统的内存资源,提高系统的性能和稳定性。

二、关键概念解析


2.1虚拟内存

虚拟内存是 Linux 内存管理的核心概念之一。它通过将物理内存和存储设备(如硬盘)结合起来,为每个进程提供了独立的、连续的内存空间。这极大地简化了内存管理,并提升了系统的安全性和稳定性。

在虚拟内存机制中,进程访问的是虚拟地址,而不是直接访问物理内存地址。内核会将虚拟地址映射到实际的物理地址,这个过程通过页表来完成。虚拟内存使得操作系统可以提供更大的内存空间,即使物理内存不足,虚拟内存可以通过将部分数据存储在硬盘的交换分区(swap)中来扩展内存空间。例如,当物理内存耗尽时,操作系统可以将不常用的页面移到交换分区,为新的页面腾出空间。这样,即使物理内存只有 4GB,一个进程也可以认为它拥有更大的内存空间。

此外,虚拟内存还实现了进程隔离。每个进程都有独立的虚拟地址空间,防止了不同进程之间的内存访问冲突。不同进程的虚拟地址可以相同,但通过页表映射到不同的物理地址,从而保证了进程之间的独立性。据统计,在实际应用中,虚拟内存机制可以使系统同时运行更多的进程,提高了系统的并发性能。

虚拟内存的整体流程可分为 4 步:

  1. CPU 产生一个虚拟地址
  2. MMU 从 TLB 中获取页表,翻译成物理地址
  3. MMU 把物理地址发送给主存
  4. 主存将地址对应的数据返回给 CPU

虚拟地址与物理地址的映射关系如下图所示:

虚拟内存系统将虚拟内存分割成固定大小的块,也被称为虚拟页。类似地,物理内存也被分割成固定大小的块,称为物理页。要实现虚拟页与物理页之间的映射关系,我们需要一种叫作页表的数据结构。页表实际上就是一个页表条目(Page Table Entry,PTE)的数组。

每条 PTE 都由一个有效位和一个 n 位地址组成。如果 PTE 的有效位为 1,则 n 位地址表示相应物理页的起始位置,即虚拟地址能够在物理内存中找到相应的物理页。如果 PTE 的有效位为 0,且后面跟着的地址为空,那么表示该虚拟地址指向的虚拟页还没有被分配。如果 PTE 的有效位为 0,且后面跟着指向虚拟页的地址,表示该虚拟地址在物理内存中没有相对应的物理地址,指向该虚拟页在磁盘上的起始位置,我们通常把这种情况称为缺页。

此时,若出现缺页现象,MMU 会发出一个缺页异常,缺页异常调用内核中的缺页处理异常程序,该程序会选择主存的一个牺牲页,将我们需要的虚拟页替换到原牺牲页的位置。我们目前为止讨论的只是单页表,但在实际的环境中虚拟空间地址都是很大的(一个32位系统的地址空间有 2^32 = 4GB,更别说 64 位系统了)。在这种情况下,使用一个单页表明显是效率低下的。

常用方法是使用层次结构的页表。假设我们的环境为一个 32 位的虚拟地址空间,它有如下形式:虚拟地址空间被分为 4KB 的页,每个 PTE 都是 4 字节。内存的前 2K 个页面分配给了代码和数据.之后的 6K 个页面还未被分配。再接下来的 1023 个页面也未分配,其后的 1 个页面分配给了用户栈。

虚拟地址空间构造的二级页表层次结构(真实情况中多为四级或更多),一级页表( 1024 个 PTE 正好覆盖 4GB 的虚拟地址空间,同时每个 PTE 只有 4 字节,这样一级页表与二级页表的大小也正好与一个页面的大小一致都为 4KB)的每个 PTE 负责映射虚拟地址空间中一个 4MB 的片(chunk),每一片都由 1024 个连续的页面组成。二级页表中的每个PTE负责映射一个 4KB 的虚拟内存页面。

2.2分页机制

我们知道,当一个程序运行时,在某个时间段内,它只是频繁地用到了一小部分数据,也就是说,程序的很多数据其实在一个时间段内都不会被用到。以整个程序为单位进行映射,不仅会将暂时用不到的数据从磁盘中读取到内存,也会将过多的数据一次性写入磁盘,这会严重降低程序的运行效率。

现代计算机都使用分页(Paging)的方式对虚拟地址空间和物理地址空间进行分割和映射,以减小换入换出的粒度,提高程序运行效率。

分页是虚拟内存实现的重要机制之一。它将虚拟内存分割成固定大小的内存块,称为 “页”(page),物理内存也同样划分为与页大小相同的 “页框”(page frame)。在 Linux 系统中,页的大小通常为 4KB。。如此,就能够以页为单位对内存进行换入换出:

  • 当程序运行时,只需要将必要的数据从磁盘读取到内存,暂时用不到的数据先留在磁盘中,什么时候用到什么时候读取。
  • 当物理内存不足时,只需要将原来程序的部分数据写入磁盘,腾出足够的空间即可,不用把整个程序都写入磁盘。

页的大小是固定的,由硬件决定,或硬件支持多种大小的页,由操作系统选择决定页的大小。比如 Intel Pentium 系列处理器支持 4KB 或 4MB 的页大小,那么操作系统可以选择每页大小为 4KB,也可以选择每页大小为 4MB,但是在同一时刻只能选择一种大小,所以对整个系统来说,也就是固定大小的。

目前几乎所有PC上的操作系统都是用 4KB 大小的页。假设我们使用的PC机是32位的,那么虚拟地址空间总共有 4GB,按照 4KB 每页分的话,总共有 2^32 / 2^12 = 2^20 = 1M = 1048576 个页;物理内存也是同样的分法。

分页带来的好处包括提高内存利用率。分页机制允许物理内存与虚拟内存不连续,从而减少了内存碎片。因为页面是固定大小的,操作系统可以更有效地管理内存,将不连续的页框分配给不同的页面。

同时,分页支持按需分配。Linux 支持按需分页,即当某页被访问时,才将它从磁盘加载到物理内存中,这大大提升了内存的使用效率。例如,一个大型程序可能只使用了一部分内存,如果没有按需分页,整个程序可能会在启动时全部加载到物理内存中,浪费大量内存资源。而有了按需分页,只有当程序实际访问到某一页时,才会将该页加载到内存中。

2.3页表

页表是 Linux 内存管理中用于存储虚拟地址到物理地址映射关系的数据结构。每个进程都有自己的页表,内核根据页表来完成虚拟内存到物理内存的转换。页表中的每一项包含了虚拟页到物理页框的映射信息。

为了提高查找效率,现代 Linux 内核使用多级页表(如二级页表或四级页表),通过分级的方式减少查找开销。例如,在 64 位的系统中,通常使用四级目录,分别是全局页目录项 PGD(Page Global Directory)、上层页目录项 PUD(Page Upper Directory)、中间页目录项 PMD(Page Middle Directory)和页表项 PTE(Page Table Entry)。这样可以有效地减少页表项的数量,提高访问速度。

同时,Linux 还使用了页表缓存(TLB,Translation Lookaside Buffer)来加速地址转换。TLB 是一块高速缓存,用于存储最近使用的页表项,减少对页表的访问次数。当进程访问一个虚拟地址时,首先在 TLB 中查找,如果找到相应的页表项,则直接得到物理地址,否则再去访问页表。据测试,使用 TLB 可以大大提高地址转换的速度,减少系统的响应时间。

三、内存分配与释放


3.1分配机制

Linux 使用虚拟内存系统,将进程虚拟内存空间划分为多个分区,如代码段、数据段、堆、栈等。对于不同的分区,Linux 采用不同的内存分配算法和系统调用进行内存分配。

在堆的分配中,通常使用动态分配算法,如malloc函数。当程序请求内存时,内核会根据请求的大小在堆中寻找合适的连续空间进行分配。例如,当一个程序需要分配大量的动态内存时,Linux 会通过遍历堆的空闲链表,找到足够大的连续空间来满足请求。如果找不到合适的连续空间,可能会触发内存碎片整理或者向系统请求更多的物理内存。

对于栈的分配,是自动进行的,当函数被调用时,栈空间会自动增长以存储局部变量和函数调用的上下文。栈的大小通常是有限的,一般为 8MB。如果栈空间耗尽,会导致栈溢出错误。

在系统调用方面,mmap函数可以将文件或设备映射到进程的虚拟地址空间,实现高效的文件访问和内存共享。例如,在加载动态库时,Linux 使用mmap将动态库文件映射到进程的虚拟地址空间,多个进程可以共享同一个动态库的内存映射,节省了内存空间。

3.2释放机制

在内存释放方面,Linux 默认情况下会进行一定程度的自动释放。当内存不再被使用时,系统会自动回收相应的内存空间。例如,当一个进程结束时,其占用的内存会被自动释放。

对于 cache memory,Linux 会自动管理其释放。当系统内存紧张时,会优先释放不活跃的 cache memory,将脏数据写入文件中再释放内存页。而对于活跃的 cache memory,会在适当的时候进行释放,以满足其他进程的内存需求。

用户也可以手动释放不同类型的缓存。例如,可以使用echo 1 > /proc/sys/vm/drop_caches释放页缓存,echo 2 > /proc/sys/vm/drop_caches释放目录和索引节点缓存,echo 3 > /proc/sys/vm/drop_caches同时释放页、目录和索引节点缓存。但需要注意的是,这些操作是无害的,只会释放完全没有使用的内存对象。脏对象将继续被使用直到它们被写入到磁盘中。

在物理内存紧张时,Linux 采用置换算法来选择一些页面置换到辅助存储器上。常用的置换算法包括最近未使用(LRU)算法和最不常用(LFU)算法。这些算法根据页面的访问频率和时间等参数来进行页面置换,以尽量减少对磁盘的访问,提高系统的响应速度。

3.3碎片问题

⑴内存碎片的产生原因

①内存分配与释放的动态性

Linux 系统中的进程在运行过程中不断地申请和释放内存。由于进程的内存需求大小和时间都是随机的,这就导致内存空间被分割成各种大小不一的块。例如,一个进程先申请了一大块内存,使用一段时间后释放了其中的一部分。接着,另一个进程申请一个较小的内存块,它占用了之前释放的部分空间。随着这样的操作不断重复,内存空间逐渐变得碎片化。

就好比在一个仓库中,不同的货物(进程)在不同的时间进出仓库,货物大小各异。一开始仓库空间是整齐的,但随着货物的频繁进出,仓库里就会出现很多大小不一的空位,使得仓库空间难以有效利用。

②内存分配算法的局限性

Linux 常用的内存分配算法虽然能够高效地处理大多数内存分配请求,但在某些情况下,也会导致碎片的产生。例如,伙伴系统(Buddy System)在分配内存时是以固定大小的块(页)为单位进行的。当进程申请的内存大小不是这些固定块大小的整数倍时,可能会剩余一些小的内存碎片。

以分配积木为例,积木有固定的几种大小规格(类似于伙伴系统中的固定页大小)。如果要搭建一个形状不规则的模型(类似于进程的不规则内存需求),在使用积木搭建过程中,就会剩下一些无法再用于搭建该模型的小积木块,这些小积木块就类似于内存碎片。

⑵内存碎片的类型

①内部碎片

内部碎片主要是指在分配给进程的内存块中,有一部分空间没有被有效利用。这通常是由于内存分配单位和进程实际需求之间的差异造成的。例如,在使用某些内存分配器时,它们分配内存是以固定大小的单元进行的。如果进程所需的内存小于这个单元大小,就会产生内部碎片。

还是以积木为例,假设每个积木单元是固定大小为 10 个小方块,而一个搭建任务只需要 7 个小方块,那么分配给这个任务一个积木单元后,就会剩下 3 个小方块的内部碎片。

②外部碎片

外部碎片是指在内存空间中,存在许多小的空闲内存块,但是这些空闲块由于不连续,无法满足一些较大的内存分配请求。比如,系统中有多个小的空闲内存块,每个块的大小都小于一个正在申请内存的大型进程所需要的大小,即使这些小空闲块的总和足够该进程使用,也无法将它们分配给这个进程。

这就好比仓库中有很多分散在不同角落的小空位,但没有一块足够大的连续空位来存放一个大型货物。

⑶内存碎片问题的解决方案

①调整内核参数

通过修改一些内核参数,可以在一定程度上缓解内存碎片问题。例如,调整内存分配器的参数,优化内存分配的策略。对于伙伴系统,可以调整页的大小和伙伴合并的阈值等参数。这样可以根据系统的实际运行情况,使内存分配更加合理,减少碎片的产生。

②定期内存规整

Linux 系统提供了一些工具和机制来进行内存规整。内存规整就像是对仓库进行重新整理,将分散的小空闲内存块合并成较大的连续内存块。通过内存迁移技术,将占用内存的进程从碎片化的区域迁移到连续的空闲区域,从而释放出连续的内存空间,以满足较大的内存分配请求。

③使用伙伴系统和 Slab 分配器

伙伴系统可以有效地管理内存块,它以页为单位进行内存分配,当一个内存块被释放时,它会尝试与相邻的同大小的空闲块合并,形成更大的空闲块,从而减少外部碎片。Slab 分配器则侧重于按对象缓冲管理内存,它针对特定大小的对象进行分配,有助于减少内部碎片。这两种分配器相互配合,能够在一定程度上优化内存的分配和利用。

④监控和优化内存使用

使用系统监控工具,如 sar、vmstat 等,定期监测内存的使用情况和碎片程度。根据监控结果,对系统中的进程进行优化,例如优化进程的内存申请和释放逻辑,避免不必要的小内存块申请和频繁的内存释放操作。

⑤定期重启服务

在一些长期运行的系统中,定期重启某些服务也是一种简单有效的减少内存碎片的方法。当服务重启时,其占用的内存空间会被重新分配,之前产生的碎片会被清除。不过这种方法可能会对系统的可用性产生一定的影响,需要谨慎使用。

四、内存管理的其他方面


4.1内存映射类型

内存映射主要有四种类型:私有匿名映射、共享匿名映射、私有文件映射和共享文件映射。

  • 私有匿名映射:不是具体文件,而是一块普通内存,用完释放,类似于malloc。
  • 共享匿名映射:基于内存的进程间通信,不同进程可共享这块内存区域。
  • 私有文件映射:每个进程对其读写都是私有的,不会写入到磁盘,而是在各自的内存空间。
  • 共享文件映射:基于文件的进程间通信,多个进程可共享对文件的操作。

在 Linux 中,可以使用pmap命令查看进程的内存映射情况。

4.2进程地址空间

进程地址空间由多个区域组成,包括代码段区域、数据段区域、堆区、栈区及mmap开辟的内存映射区域。

⑴代码段区域

这是进程地址空间中存放可执行代码的部分。当一个程序被加载到内存中时,其机器语言指令就存放在代码段。就像是一本书的正文部分,包含了程序运行所需要执行的操作步骤。这个区域通常是只读的,这意味着程序在运行过程中不能随意修改自己的代码(当然,在一些特殊的情况下,如自修改代码的程序,会有特殊的处理机制)。

例如,一个简单的 C 语言程序中的函数定义部分就存放在代码段。像 “int add (int a, int b) { return a + b; }” 这样的函数代码会被加载到代码段区域,供 CPU 读取并执行。

⑵数据段区域

数据段主要用于存储程序中已经初始化的全局变量和静态变量。可以把它想象成一个数据仓库,里面存放着程序在整个生命周期内都需要使用的各种数据。这些数据在程序启动时就被初始化,并且在程序运行过程中可以被修改。

以一个简单的程序为例,定义了全局变量 “int global_variable = 10;”,这个变量就会存储在数据段区域。在程序运行过程中,如果有其他函数修改了这个变量的值,修改操作也是在数据段区域内完成的。

⑶堆区

堆区是进程地址空间中一个非常灵活的部分,用于动态内存分配。程序可以在运行过程中通过系统调用(如 malloc 函数)在堆区申请内存空间,用于存储各种数据结构,如链表、树等。堆区的内存分配和释放是由程序员手动控制的,这就像在一片空地上,根据自己的需求建造各种建筑物,需要的时候就申请一块土地来建造,不需要的时候就拆除(释放内存)。

例如,在一个处理大量数据的程序中,可能会使用链表来存储数据。通过 “struct node new_node = (struct node ) malloc (sizeof (struct node));” 这样的操作,就在堆区申请了一块内存用于存储一个链表节点,并且可以根据数据量的多少,多次申请内存来构建完整的链表。

⑷栈区

栈区主要用于存储函数调用的相关信息,包括函数的参数、局部变量和返回地址等。它的工作方式就像一个栈结构,遵循后进先出(LIFO)的原则。当一个函数被调用时,相关的信息就被压入栈中,函数执行完毕后,这些信息又会按照相反的顺序从栈中弹出。

比如,在一个函数 “void function (int param) { int local_variable; local_variable = param + 1; }” 中,参数 “param” 和局部变量 “local_variable” 都会存储在栈区。每次调用这个函数时,都会在栈区为这些变量开辟新的空间,函数结束后,这些空间就会被释放。

⑸mmap 开辟的内存映射区域

mmap 是一种内存映射机制,它允许进程将文件或者其他对象映射到自己的地址空间中。这个区域可以用于高效地访问文件内容,就好像文件的内容直接在内存中一样。通过这种方式,进程可以以内存访问的方式来读取和修改文件,大大提高了文件访问的效率。

例如,在一个数据库应用程序中,可能会使用 mmap 将数据库文件映射到进程地址空间。这样,在查询或者更新数据库记录时,就可以像访问普通内存数据一样快速方便地进行操作。

⑹堆区和栈区的特性

①生长方向

堆区是从低地址向高地址方向生长的。这意味着随着程序不断地在堆区申请更多的内存,堆区所占用的内存地址会越来越高。而栈区则是从高地址向低地址方向生长。当一个函数调用另一个函数时,新函数的栈帧(包含函数的参数、局部变量等信息)会在栈区的较低地址处开辟,就像在一个向下挖掘的坑中堆放新的物品。

这种相反的生长方向有助于防止堆区和栈区在生长过程中相互冲突,保证了它们在进程地址空间中的独立性。

②brk 地址

在堆区的管理中,brk 地址是一个重要的概念。它代表了堆区的边界,当程序通过系统调用(如 sbrk)来扩展堆区时,实际上是在移动 brk 地址。例如,当调用 “void *ptr = sbrk (1024);” 时,系统会将 brk 地址向上移动 1024 字节(假设字节单位),从而为堆区增加了 1024 字节的可用空间。

理解进程地址空间的各个组成部分及其特性,对于深入理解 Linux 内存管理以及编写高效、稳定的程序至关重要。它就像是一张地图,指导着程序在内存这个复杂的领域中有序地活动。

4.3内存分配方式

⑴小内存分配

当程序需要分配小内存时,通常会使用诸如malloc函数(在 C 语言环境下)。malloc函数的底层实现是通过系统调用与内存分配器进行交互来获取内存。

在 Linux 中,对于小内存分配,内存分配器会采用一种高效的策略。它会从预先分配好的内存池中获取合适大小的内存块。这个内存池就像是一个装满各种规格积木的盒子,当程序需要一块小内存时,就从盒子里挑选合适大小的积木(内存块)。

例如,在 C 语言中编写一个简单的程序,需要动态分配一个小结构体的内存空间:

#include <stdio.h>
#include <stdlib.h>
struct small_struct {
    int num;
};
int main() {
    struct small_struct *ptr = (struct small_struct *)malloc(sizeof(struct small_struct));
    if (ptr == NULL) {
        perror("malloc");
        return 1;
    }
    ptr->num = 10;
    printf("Allocated memory and set value: %dn", ptr->num);
    free(ptr);
    return 0;
}

当用户分配小内存时,通常使用malloc函数,malloc函数会根据struct small_struct的大小从内存池中获取合适的内存块来分配给ptr,malloc在分配一块小型内存(小于或等于 128kb)时,会调用brk函数将堆顶指针向高地址移动,获得新的内存空间。

⑵内存分配

当涉及到大内存分配时,系统会采用不同的策略。通常会直接从操作系统的物理内存中分配连续的内存页。这是因为对于大内存需求,保证内存的连续性可以提高内存访问效率。

以一个处理大型图像数据的程序为例。如果程序需要加载一个高分辨率的图像,可能需要分配一大块连续的内存空间来存储图像的像素数据。此时,系统会从物理内存中划分出连续的页来满足这个需求。

在 Linux 中,分配大内存可能会涉及到系统调用,如mmap(内存映射)或brk/sbrk系统调用的特殊用法。mmap可以将文件或者匿名内存区域映射到进程的地址空间,用于分配大内存块非常有效。

例如,通过mmap分配大内存的简单示例:

#include <stdio.h>
#include <sys/mman.h>
#include <sys/types.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdlib.h>
#define MEM_SIZE (1024 * 1024)  // 1MB的内存大小
int main() {
    void *ptr = mmap(NULL, MEM_SIZE, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
    if (ptr == MAP_FAILED) {
        perror("mmap");
        return 1;
    }
    // 可以在这里使用分配的大内存,比如存储大型数据结构
    // ......
    if (munmap(ptr, MEM_SIZE) == -1) {
        perror("munmap");
        return 1;
    }
    return 0;
}

当用户分配大内存时,malloc会调用mmap以 “私有匿名映射” 的方式,在文件映射区分配一块内存,mmap函数用于分配了 1MB 的匿名内存空间,这个空间可以用于存储大型的数据结构或者文件内容。

⑶malloc的工作原理

malloc函数是用户空间中常用的内存分配函数,它的工作原理较为复杂。

malloc会首先检查是否有足够的空闲内存块可以满足请求。如果有,它会从空闲内存块列表中选择一个合适的块,并将其返回给用户程序。这个空闲内存块列表是由内存分配器维护的,它会记录内存池中各种大小的空闲内存块

如果没有足够的空闲内存块,malloc会向操作系统请求更多的内存。这可能涉及到系统调用,以获取新的内存页或者调整内存池的大小。

内存分配后,malloc会对内存块进行一些标记和组织。它会在内存块的头部存储一些元数据,如内存块的大小、是否被使用等信息。这些元数据对于后续的内存管理操作,如free函数释放内存时非常重要。

例如,当free函数被调用时,它会根据内存块头部的元数据来确定该内存块的大小和状态,然后将其放回空闲内存块列表中,以便下次malloc请求时可以再次使用。

malloc的工作原理是先从预先分配的内存池中寻找合适的内存块,如果没有找到,则通过系统调用进行内存分配。内存块通常以链表的形式组织,根据大小进行分类管理。

⑷内存块的组织方式

在内存分配过程中,内存块的组织方式也很关键。Linux 中的内存分配器通常会采用不同的策略来组织内存块。

一种常见的方式是使用链表来组织空闲内存块。每个空闲内存块都包含一个指向下一个空闲内存块的指针,这样就形成了一个链表结构。当需要分配内存时,内存分配器可以沿着这个链表查找合适大小的内存块。

另外,还会采用一些分区策略。例如,将内存池按照内存块大小进行分区,不同大小范围的内存块存放在不同的分区中。这样可以提高内存分配的效率,当请求特定大小范围的内存时,可以直接在对应的分区中查找。

4.4伙伴系统与 Slab 分配器

在 Linux 内存管理的复杂体系中,伙伴系统(Buddy System)和 Slab 分配器(Slab Allocator)发挥着至关重要的作用。它们就像两个经验丰富的 “管家”,分别从不同角度对内存进行高效的管理和分配。

⑴伙伴系统

伙伴系统是以页(Page)为单位来分配内存的。在 Linux 系统中,物理内存被划分成固定大小的页,伙伴系统利用这些页来构建内存分配的基本框架。它的核心思想是基于一种 “伙伴” 关系。

当一个进程申请内存时,伙伴系统会尝试找到一个大小合适的内存块来满足需求。这个内存块是由连续的页组成的。如果没有合适大小的内存块,它会向上寻找更大的内存块,直到找到一个可用的。例如,如果进程需要 2 个页大小的内存,而系统中没有直接可用的 2 个页大小的内存块,它可能会从 4 个页大小的内存块中划分出 2 个页来满足需求。

当一个内存块被释放时,伙伴系统会检查它的 “伙伴” 内存块是否空闲。如果 “伙伴” 也是空闲的,那么这两个内存块就会合并成一个更大的空闲内存块,这种合并操作可以有效地减少外部碎片。就好像两个相邻的空闲地块,当它们都空着的时候,就会被合并成一个更大的地块,方便后续的分配。

优势

  • 减少外部碎片:通过合并空闲的 “伙伴” 内存块,能够有效避免内存空间被分割成大量无法使用的小碎片,保证有足够大的连续内存块来满足较大的内存分配请求。
  • 简单高效:其基于页的分配方式和固定的合并规则,使得内存分配和回收的操作相对简单,易于实现和理解。

应用场景

内核内存分配:在 Linux 内核中,对于一些需要较大块连续物理内存的情况,如设备驱动程序为硬件设备分配缓冲区等,伙伴系统是一种理想的选择。例如,为网络设备分配用于接收和发送数据包的缓冲区,伙伴系统可以快速提供合适大小的连续内存块。

⑵Slab 分配器

Slab 分配器主要侧重于按对象缓冲(Object – Caching)来管理内存。它针对特定大小的对象进行分配,这些对象可以是内核中的数据结构,如进程描述符、文件描述符等。

Slab 分配器会预先分配一定数量的内存块,这些内存块被组织成 “Slab”。每个 Slab 包含多个相同大小的对象,这些对象的大小是根据系统中常见的内存使用场景预先确定的。当需要分配一个特定大小的对象时,Slab 分配器会从相应大小的 Slab 中获取一个空闲对象。

例如,对于内核中的进程描述符对象,Slab 分配器会创建一个专门用于存储进程描述符的 Slab。当创建一个新进程时,就从这个 Slab 中取出一个进程描述符对象来使用。当进程结束时,这个对象又会被放回 Slab 中,以便下次使用。

优势

  • 减少内部碎片:由于是按照对象大小进行分配,并且对象大小是预先确定的,所以可以有效减少在分配过程中由于内存块大小与对象实际需求不匹配而产生的内部碎片。
  • 提高内存分配速度:通过预先分配和缓存对象,当需要分配相同类型的对象时,可以直接从缓存中获取,避免了频繁地向操作系统申请内存,从而提高了内存分配的速度。

应用场景

内核对象分配:在 Linux 内核中,大量的内核对象需要频繁地创建和销毁。Slab 分配器非常适合这种场景,比如用于分配和管理文件系统中的 inode 对象。当文件系统需要创建一个新的文件或目录时,就从 inode – Slab 中获取一个 inode 对象,提高了文件系统操作的效率。

⑶伙伴系统与 Slab 分配器的配合

在 Linux 内存管理中,伙伴系统和 Slab 分配器并不是相互独立的,而是相互配合的关系。

伙伴系统主要负责为 Slab 分配器提供大块的物理内存。当 Slab 分配器需要更多的内存来创建新的 Slab 时,它会向伙伴系统请求。而 Slab 分配器则专注于对这些内存进行细分,按照对象的大小进行更精细的分配和管理,以满足内核和用户程序对不同大小内存对象的需求。

这种配合使得 Linux 内存管理能够在保证有足够连续物理内存的同时,又能高效地分配和管理各种大小的内存对象,有效地提高了内存的利用率和系统的整体性能。它们共同构成了 Linux 内存管理的坚实基础,确保系统在各种复杂的内存需求场景下都能稳定高效地运行。

4.5虚拟地址与物理地址映射关系

虚拟地址与物理地址不直接一一映射的原因主要是为了提高内存利用率、实现进程隔离和方便内存管理。多级映射的优势在于可以减少页表项的数量,提高查找效率。

TLB(Translation Lookaside Buffer)块表的作用是加速地址转换,它是一块高速缓存,用于存储最近使用的页表项,减少对页表的访问次数。相同虚拟地址在 TLB 中的区分方法是通过不同的进程标识和页表项中的其他信息。

不同进程访问共享内核地址空间时,由于每个虚拟内存中的内核地址关联的都是相同的物理内存,所以进程切换到内核态后,可以很方便地访问内核空间内存,提高了效率。

写时复制的意义在于减少内存的复制操作,当多个进程共享同一块内存区域时,只有在某个进程试图修改该区域的内容时,才会真正复制一份新的内存区域给该进程使用。

在内存访问时,先通过 MMU(Memory Management Unit)进行地址转换,然后再访问 cache。cache 缓存大地址空间的原理是通过将最近访问过的内存区域的内容存储在 cache 中,下次访问时可以直接从 cache 中获取,提高访问速度。

五、全文总结


Linux 内存管理是一个复杂而高效的系统,它通过虚拟内存、分页机制、页表等技术,为系统提供了高效的内存管理方案。同时,通过内存分配与释放、内存映射、伙伴系统与 Slab 分配器等机制,进一步提高了内存的利用率和系统的性能。

然而,Linux 内存管理也面临着一些挑战。例如,内存碎片问题仍然存在,虽然可以通过调整内核参数、定期内存规整等方法来缓解,但在一些高负载的系统中,仍然可能会影响系统的性能。此外,随着系统的不断发展,对内存的需求也在不断增加,如何更好地管理和优化内存,以满足不断增长的需求,也是一个需要持续研究的问题。

展望未来,Linux 内存管理有望在以下几个方面得到进一步的发展和优化。

首先,随着硬件技术的不断进步,如更大容量的内存、更快的存储设备等,Linux 内存管理可以更好地利用这些硬件资源,提高系统的性能和稳定性。例如,可以通过优化页表结构,提高地址转换的速度;可以利用新的存储设备,如 NVM(Non-Volatile Memory),实现更快的内存访问。

其次,随着云计算和容器技术的发展,对内存管理的要求也在不断提高。Linux 内存管理可以更好地支持容器化环境,实现更高效的内存隔离和资源分配。例如,可以通过优化内存分配算法,提高容器的启动速度和内存利用率;可以利用容器的特性,实现更灵活的内存管理策略。

最后,随着人工智能和大数据技术的发展,对内存的需求也在不断增加。Linux 内存管理可以更好地支持这些应用场景,实现更高效的内存管理和资源分配。例如,可以通过优化内存分配算法,提高大数据处理的效率;可以利用人工智能的技术,实现更智能的内存管理策略。

2