Linux操作系统面试——虚拟内存45问
你是否好奇,为何在老旧设备上运行大型程序时,系统不会瞬间崩溃,而是勉力维持?为何多任务并行时,各个进程看似都能拥有充足内存空间?这背后,Linux 虚拟内存技术功不可没。它让每个进程都仿佛拥有一块独立且足够大的内存空间,不必担忧实际物理内存的局促与复杂布局。想象一下,你有一间堆满书籍的小书房,空间有限,但又想存放更多知识。虚拟内存就像一个智能书架系统,它能巧妙规划,将常用书籍摆在触手可及之处,不常用的暂时存放到地下室,需要时再快速调取。
Linux 虚拟内存正是如此,通过页表这一 “翻译官”,将进程使用的虚拟地址精准转换为实际物理地址,实现内存地址的灵活转换与管理。当进程访问的数据不在物理内存中,系统便会触发缺页异常,如同从地下室取书,从磁盘交换区或文件系统中加载相应数据到内存,更新页表后,进程便能顺利访问。今天,就让我们一同深入 Linux 虚拟内存的神秘世界,从原理、机制到实践应用,全方位解析这一解决物理内存限制的关键技术,探寻它如何在有限资源下,为程序运行构建出无限可能的舞台。
Part1.虚拟内存是什么?
1.1虚拟内存概述
虚拟内存,简单来说,是一种内存管理技术,它让操作系统能将硬盘空间当作额外的内存来使用。你可以把它想象成一个 “内存扩充器”,当计算机的物理内存(也就是我们常说的内存条提供的内存)不够用时,虚拟内存就会把暂时用不到的数据从物理内存转移到硬盘上的特定区域,这个区域就像是内存的 “仓库”,我们称之为交换空间(swap space) 。当程序需要这些数据时,再从交换空间把数据调回到物理内存。
图片
打个比方,物理内存就像是你办公桌上的桌面,空间有限,只能放一些当下马上要处理的文件。而虚拟内存则像是办公室里的文件柜,当桌面上堆满了文件,你就可以把一些暂时不需要的文件放到文件柜里。当你需要这些文件时,再从文件柜中取出来放回桌面。这样,即使你的桌面空间有限,也能处理更多的文件,就像计算机即使物理内存有限,也能运行更多的程序。
对于进程来说,虚拟内存提供了独立的地址空间,每个进程都认为自己独占了系统的所有内存资源,这样可以避免不同进程之间的内存冲突,提高了系统的稳定性和安全性。就好比每个租客都觉得自己租下了一整套房子,有独立的空间放置自己的物品,不用担心和其他租客的物品混在一起。
1.2为什么需要使用虚拟内存
进程需要使用的代码和数据都放在内存中,比放在外存中要快很多。问题是内存空间太小了,不能满足进程的需求,而且现在都是多进程,情况更加糟糕。所以提出了虚拟内存,使得每个进程用于3G的独立用户内存空间和共享的1G内核内存空间。(每个进程都有自己的页表,才使得3G用户空间的独立)这样进程运行的速度必然很快了。而且虚拟内存机制还解决了内存碎片和内存不连续的问题。为什么可以在有限的物理内存上达到这样的效果呢?
例如:对于程序计数器位数为32位的处理器来说,他的地址发生器所能发出的地址数目为2^32=4G个,于是这个处理器所能访问的最大内存空间就是4G。在计算机技术中,这个值就叫做处理器的寻址空间或寻址能力。
照理说,为了充分利用处理器的寻址空间,就应按照处理器的最大寻址来为其分配系统的内存。如果处理器具有32位程序计数器,那么就应该按照下图的方式,为其配备4G的内存:
图片
这样,处理器所发出的每一个地址都会有一个真实的物理存储单元与之对应;同时,每一个物理存储单元都有唯一的地址与之对应。这显然是一种最理想的情况。
但遗憾的是,实际上计算机所配置内存的实际空间常常小于处理器的寻址范围,这是就会因处理器的一部分寻址空间没有对应的物理存储单元,从而导致处理器寻址能力的浪费。例如:如下图的系统中,具有32位寻址能力的处理器只配置了256M的内存储器,这就会造成大量的浪费:
图片
另外,还有一些处理器因外部地址线的根数小于处理器程序计数器的位数,而使地址总线的根数不满足处理器的寻址范围,从而处理器的其余寻址能力也就被浪费了。例如:Intel8086处理器的程序计数器位32位,而处理器芯片的外部地址总线只有20根,所以它所能配置的最大内存为1MB:
图片
在实际的应用中,如果需要运行的应用程序比较小,所需内存容量小于计算机实际所配置的内存空间,自然不会出什么问题。但是,目前很多的应用程序都比较大,计算机实际所配置的内存空间无法满足。
实践和研究都证明:一个应用程序总是逐段被运行的,而且在一段时间内会稳定运行在某一段程序里。
这也就出现了一个方法:如下图所示,把要运行的那一段程序自辅存复制到内存中来运行,而其他暂时不运行的程序段就让它仍然留在辅存。
图片
当需要执行另一端尚未在内存的程序段(如程序段2),如下图所示,就可以把内存中程序段1的副本复制回辅存,在内存腾出必要的空间后,再把辅存中的程序段2复制到内存空间来执行即可:
图片
在计算机技术中,把内存中的程序段复制回辅存的做法叫做“换出”,而把辅存中程序段映射到内存的做法叫做“换入”。经过不断有目的的换入和换出,处理器就可以运行一个大于实际物理内存的应用程序了。或者说,处理器似乎是拥有了一个大于实际物理内存的内存空间。于是,这个存储空间叫做虚拟内存空间,而把真正的内存叫做实际物理内存,或简称为物理内存。
那么对于一台真实的计算机来说,它的虚拟内存空间又有多大呢?计算机虚拟内存空间的大小是由程序计数器的寻址能力来决定的。例如:在程序计数器的位数为32的处理器中,它的虚拟内存空间就为4GB。
可见,如果一个系统采用了虚拟内存技术,那么它就存在着两个内存空间:虚拟内存空间和物理内存空间。虚拟内存空间中的地址叫做“虚拟地址”;而实际物理内存空间中的地址叫做“实际物理地址”或“物理地址”。处理器运算器和应用程序设计人员看到的只是虚拟内存空间和虚拟地址,而处理器片外的地址总线看到的只是物理地址空间和物理地址。
由于存在两个内存地址,因此一个应用程序从编写到被执行,需要进行两次映射。第一次是映射到虚拟内存空间,第二次时映射到物理内存空间。在计算机系统中,第两次映射的工作是由硬件和软件共同来完成的。承担这个任务的硬件部分叫做存储管理单元MMU,软件部分就是操作系统的内存管理模块了。
在映射工作中,为了记录程序段占用物理内存的情况,操作系统的内存管理模块需要建立一个表格,该表格以虚拟地址为索引,记录了程序段所占用的物理内存的物理地址。这个虚拟地址/物理地址记录表便是存储管理单元MMU把虚拟地址转化为实际物理地址的依据,记录表与存储管理单元MMU的作用如下图所示:
图片
综上所述,虚拟内存技术的实现,是建立在应用程序可以分成段,并且具有“在任何时候正在使用的信息总是所有存储信息的一小部分”的局部特性基础上的。它是通过用辅存空间模拟RAM来实现的一种使机器的作业地址空间大于实际内存的技术。
从处理器运算装置和程序设计人员的角度来看,它面对的是一个用MMU、映射记录表和物理内存封装起来的一个虚拟内存空间,这个存储空间的大小取决于处理器程序计数器的寻址空间。
可见,程序映射表是实现虚拟内存的技术关键,它可给系统带来如下特点:
系统中每一个程序各自都有一个大小与处理器寻址空间相等的虚拟内存空间;在一个具体时刻,处理器只能使用其中一个程序的映射记录表,因此它只看到多个程序虚存空间中的一个,这样就保证了各个程序的虚存空间时互不相扰、各自独立的;使用程序映射表可方便地实现物理内存的共享。Part2.虚拟内存与物理内存
2.1两者区别
物理内存是实实在在插在计算机主板内存槽上的内存条所提供的内存,是计算机硬件的一部分,由半导体芯片组成 ,CPU 可以直接进行寻址,用于存放正在运行的程序和临时的数据。一旦电脑关闭或重启,物理内存中的内容就会丢失。它的容量是固定的,取决于你安装的内存条数量和容量大小。例如,你的电脑安装了两根 8GB 的内存条,那么物理内存就是 16GB。
而虚拟内存是一种内存管理技术,并不是真实的物理硬件。它通过将硬盘空间的一部分模拟为内存来扩展物理内存的容量,是操作系统为了扩大可用内存而创造的一种 “模拟” 扩展 。当物理内存不足时,操作系统会自动利用虚拟内存来存储暂时不需要立即访问的数据,把部分长期不用的数据从物理内存移动到硬盘上的虚拟内存区域(也就是交换空间) 。虚拟内存对于那些需要大量数据但仍受限于物理内存的应用特别有用,但其读取速度远低于物理内存,因为硬盘的读写速度要比内存慢得多。
2.2相互关系
在系统运行时,虚拟内存和物理内存紧密协作。当一个程序启动时,操作系统会为其分配虚拟内存空间,程序在运行过程中访问的都是虚拟地址。当程序需要访问某个数据时,首先会通过虚拟地址去查找。CPU 会将虚拟地址发送给内存管理单元(MMU) ,MMU 通过查询页表(一种记录虚拟地址和物理地址映射关系的数据结构),将虚拟地址转换为对应的物理地址。如果所需的数据就在物理内存中,那么 CPU 就可以直接从物理内存中读取数据,这就好比你在办公桌上能直接找到需要的文件,速度很快。
但如果数据不在物理内存中,而是在虚拟内存(硬盘的交换空间)里,就会发生缺页异常。这时,操作系统会从物理内存中选择一个暂时不用的页面(如果物理内存已满的话),将其数据写回到硬盘的交换空间,然后把程序需要的数据从交换空间读取到物理内存中,并更新页表中的映射关系。之后,CPU 就可以通过新的物理地址访问数据了。这个过程就像是你在办公桌上找不到文件,需要去文件柜(虚拟内存)里找,找到后把文件拿出来放在桌面上(物理内存),方便下次使用 。
可以说,虚拟内存是物理内存的补充和延伸,它们共同为程序的运行提供内存支持,使得计算机系统能够更高效地运行多个程序,处理各种复杂的任务。
Part3.虚拟内存技术
3.1分页机制
分页是虚拟内存管理中的一种重要机制,它将内存空间划分为固定大小的块,这些块就被称为页(page) 。在 Linux 系统中,常见的页大小是 4KB(2^12 字节),不过在某些架构下,也支持如 64KB 或 2MB 的大页(Huge Pages) 。之所以采用固定大小的页,是为了简化内存管理和提高内存分配的效率。
图片
页表(Page Table)是分页机制的核心数据结构,它就像是一本 “地址字典”,记录了虚拟页与物理页之间的映射关系 。每个进程都拥有自己独立的页表,当进程访问内存时,CPU 会将虚拟地址发送给内存管理单元(MMU) ,MMU 通过查询页表,把虚拟地址转换为对应的物理地址。例如,在 x86_64 架构中,Linux 使用四级页表结构,分别为页全局目录(PGD)、页上级目录(PUD)、页中间目录(PMD)和页表项(PTE) 。当 CPU 接收到一个虚拟地址时,首先会根据虚拟地址的最高几位在 PGD 中找到对应的 PUD;然后依据虚拟地址的次高几位在 PUD 中找到对应的 PMD;接着根据虚拟地址的再次高几位在 PMD 中找到对应的 PTE;最后,PTE 中记录了该虚拟页对应的物理页框地址,从而实现了虚拟地址到物理地址的转换。
以一个简单的例子来说明,假设我们有一个进程需要访问虚拟地址 0x12345678。在四级页表结构下,MMU 会先提取虚拟地址的高几位(比如高 9 位,具体位数根据架构和页表设计而定),通过这几位索引 PGD,找到对应的 PUD;再从虚拟地址中提取接下来的几位(同样根据设计而定),在 PUD 中找到对应的 PMD;然后继续提取相应位在 PMD 中找到 PTE;最终,PTE 中保存了物理页框地址,再结合虚拟地址中剩下的偏移部分,就可以得到实际的物理地址,进而访问到所需的数据 。
3.2分段机制
分段机制是另一种内存管理方式,它将程序的地址空间划分为多个逻辑段,比如代码段(存放程序的指令)、数据段(存放程序的全局变量和静态变量等)、堆栈段(存放函数调用时的局部变量、返回地址等) 。每个段都有自己的起始地址和长度,通过段寄存器来标识和访问。
图片
与分页机制不同,分页是将内存划分为固定大小的页,主要目的是为了实现虚拟内存和内存管理的高效性;而分段是基于程序的逻辑结构进行划分,更注重程序的模块化和保护 。例如,代码段可以设置为只读,防止程序运行时被意外修改;数据段可以根据需要设置读写权限,以保证数据的安全性。
分页机制消除了外部碎片,因为内存空间是预先划分好的,页与页之间是紧密排列的。但分页机制可能产生内部碎片,即当分配的页面大小大于实际需要的内存大小时,剩余的空间将被浪费。
现代操作系统一般都采用段页式存储的方式来实现虚拟内存和物理内存的映射。段页式存储,顾名思义是一种结合了段式存储管理和页式存储管理优点的内存管理技术。在段页式存储中,程序的逻辑地址空间被划分为若干个段,每个段再被划分为若干个固定大小的页。同时,物理内存也被划分为与页面大小相同的物理块。
图片
在 Linux 内核中,虽然 x86 架构支持分段机制,但 Linux 对分段机制的使用进行了简化和弱化 。在 32 位的 x86 架构中,Linux 通常只使用了两个段:一个是用户数据段,用于存放用户进程的数据;另一个是用户代码段,用于存放用户进程的代码。对于内核空间,也类似地使用两个段。这种简化的分段方式,使得 Linux 的内存管理更加简单高效,同时也借助分页机制来实现更强大的内存管理功能 。
虚拟内存区域(VMA)是 Linux 内核中用于管理进程虚拟内存的一种数据结构 。每个进程的虚拟地址空间被划分为多个 VMA,每个 VMA 对应一个连续的虚拟地址范围,并且具有相同的访问权限和属性 。例如,一个进程的代码段、数据段、堆栈段等都可以分别对应一个 VMA。分段机制与 VMA 的关系在于,VMA 可以看作是对分段概念的一种扩展和细化,它更灵活地管理进程的虚拟内存,并且与分页机制相结合,共同实现了 Linux 高效的内存管理 。
3.3内存对齐
内存对齐是指数据在内存中存储时,按照一定的规则排列,使得数据的起始地址是特定值的倍数 。这个特定值通常是数据类型大小的倍数,比如在 32 位系统中,int 类型通常占 4 字节,那么 int 类型数据的起始地址通常会被对齐到 4 的倍数;在 64 位系统中,指针类型通常占 8 字节,指针数据的起始地址会被对齐到 8 的倍数 。
基本变量类型所占大小如下图,不同的系统的区别在于long和point类型的大小:
图片
内存对齐的重要性主要体现在以下几个方面:一是提高访问速度,现代处理器在访问内存时,通常是以一定的块大小(如 4 字节、8 字节等)进行读取的。如果数据是对齐的,处理器可以一次读取到完整的数据,而不需要进行额外的处理。相反,如果数据未对齐,可能需要多次读取内存,并进行数据拼接,这会大大降低访问速度 。二是硬件要求,某些处理器架构对数据的对齐有严格要求,如果数据未对齐,可能会导致硬件异常或性能下降 。三是优化存储空间,合理的内存对齐可以减少内存碎片的产生,提高内存的利用率 。例如,在结构体中,如果各个成员按照其自身的对齐要求进行排列,可以减少结构体整体的大小,节省内存空间 。
Part4.页面置换算法
当物理内存已满,而又需要加载新的页面时,操作系统就需要决定将哪个页面从内存中置换出去,这就用到了页面置换算法 。页面置换算法的目标是尽可能减少缺页中断的次数,提高系统性能。
图片
4.1 LRU(最近最少使用)算法
LRU 算法的核心思想是:如果一个页面在最近一段时间内没有被访问,那么在未来它被访问的概率也较低 ,所以当内存已满需要置换页面时,就选择最近最少使用的页面淘汰出去 。
假设内存中最多能容纳 3 个页面,页面访问序列为 1, 2, 3, 4, 2, 1, 5, 6, 2, 1, 2, 3, 7, 6, 3, 2, 1, 2, 3, 6 。
最初,内存为空,依次访问页面 1, 2, 3,此时内存中页面为 1, 2, 3。当访问页面 4 时,内存已满,需要置换页面。根据 LRU 算法,最近最少使用的页面是 1(因为 1 最早进入内存且之后未被访问),所以将 1 置换出去,内存中页面变为 4, 2, 3 。接着访问页面 2,2 在内存中,不需要置换,更新 2 的访问时间,使其成为最近使用的页面,内存中页面顺序变为 2, 4, 3 。访问页面 1 时,内存中没有 1,需要置换页面。此时最近最少使用的是 3,将 3 置换出去,把 1 放入内存,内存中页面变为 2, 4, 1 。以此类推,随着页面的不断访问,LRU 算法会根据页面的使用情况动态地置换页面,以保证内存中始终是最近最常使用的页面 。在实际实现 LRU 算法时,常用的数据结构是双向链表和哈希表 。双向链表用于维护页面的访问顺序,链表头部是最近使用的页面,链表尾部是最近最少使用的页面 。哈希表用于快速查找某个页面是否在内存中以及获取其在双向链表中的位置 。当访问一个页面时,如果页面在内存中,通过哈希表找到其在链表中的位置,将其移动到链表头部;如果页面不在内存中,先从链表尾部删除最近最少使用的页面(同时更新哈希表),再将新页面插入到链表头部并更新哈希表 。
4.2其他常见算法
FIFO(先进先出)算法:这种算法非常直观,它按照页面进入内存的先后顺序进行置换 。即最早进入内存的页面最先被置换出去 。还是以上面的页面访问序列为例,最初内存为空,依次放入1, 2, 3 。当访问 4 时,由于内存已满,根据 FIFO 算法,最早进入的 1 被置换出去,内存变为 2, 3, 4 。FIFO 算法的优点是实现简单,但它没有考虑页面的使用频率,可能会把一些仍然被频繁访问的页面置换出去,导致缺页率较高 。例如,如果有一个程序需要频繁访问最早进入内存的某个页面,FIFO 算法就会不断地将其置换出去又换进来,增加了系统开销 。
LFU(最不经常使用)算法:LFU 算法根据页面的访问频率来决定置换哪个页面 。它为每个页面设置一个计数器,每当页面被访问时,计数器加 1 。当内存已满需要置换页面时,选择计数器值最小(即访问频率最低)的页面淘汰 。假设内存中已有页面 1, 2, 3,它们的访问次数分别为 3, 2, 1 。当需要置换页面时,LFU 算法会选择访问次数为 1 的页面 3 进行置换 。LFU 算法能较好地反映页面的实际使用情况,但它需要维护每个页面的访问次数,实现相对复杂一些 。
Part5.虚拟内存与进程
5.1进程的虚拟地址空间布局
图片
在 Linux 系统中,每个进程都拥有自己独立的虚拟地址空间,就像每个租客都有自己独立的房间,互不干扰 。以 32 位系统为例,这个虚拟地址空间的大小为 4GB(2^32 字节),它被划分为不同的区域,每个区域都有特定的用途和特点 :
代码段(Text Segment):这是程序的只读部分,存放着程序的机器指令(也就是我们编写的代码被编译后的二进制形式)和只读数据,如字符串常量 。它的特点是只读,这就像是一本被锁起来的书,只能读取内容,不能修改,这样可以防止程序在运行时意外修改自身的代码,保证了程序执行的稳定性和安全性 。每个进程只有一个代码段,并且在内存中是共享的,例如多个进程同时运行同一个可执行文件,它们共享的就是同一段代码段 。数据段(Data Segment):用于存放已初始化的全局变量和静态变量 。这些变量在程序编译时就已经确定了初始值,并且在程序运行期间一直存在 。数据段属于静态内存分配,一旦程序加载到内存中,数据段的大小就基本固定下来了 。比如在C语言中定义的int global_variable = 10;,这个global_variable就存放在数据段中 。BSS 段(Block Started by Symbol Segment):主要存放未初始化的全局变量和静态变量 。与数据段不同,BSS 段在可执行文件中并不占用实际的磁盘空间,只是记录了这些变量所需的空间大小 。在程序开始执行前,系统会自动将 BSS 段中的变量初始化为 0 。例如,在 C 语言中定义的int uninitialized_global_variable;,它就位于 BSS 段 。BSS 段属于静态内存分配,它的存在可以节省可执行文件的大小,因为不需要为未初始化的变量在磁盘上存储初始值 。堆(Heap Segment):是进程运行时动态分配内存的区域,通过malloc、calloc、realloc等函数进行内存分配,使用free函数释放内存 。堆的大小是动态变化的,可以根据程序的需求进行扩张或缩减 。它从低地址向高地址增长,就像一个可以不断向上堆叠物品的货架 。在 C语言中,使用malloc函数分配内存时,例如int *p = (int *)malloc(10 * sizeof(int));,这 10 个int类型大小的内存空间就是从堆中分配出来的 。不过,如果在使用堆内存时,忘记释放不再使用的内存,就会导致内存泄漏 。栈(Stack Segment):用于存放函数的局部变量、函数调用的参数、返回地址等信息 。它是一种后进先出(LIFO,Last In First Out)的数据结构,就像一个放盘子的栈,最后放上去的盘子最先被拿走 。栈由操作系统自动管理,当函数被调用时,相关的局部变量和参数等会被压入栈中;函数执行结束后,这些数据会从栈中弹出,自动释放内存 。栈的大小通常是固定的,在Linux系统中,一般默认的栈大小为 8MB 。如果在函数中定义了过多的局部变量或者递归调用层数过深,导致栈空间不够用,就会发生栈溢出(Stack Overflow)错误 。文件映射段(Memory - Mapped Segment):用于映射文件、共享内存、动态链接库等 。通过mmap系统调用可以将文件的一部分或全部映射到进程的虚拟地址空间中,使得进程可以像访问内存一样访问文件,提高了文件 I/O 的效率 。同时,共享内存也利用了这个区域,多个进程可以通过映射同一个共享内存区域来实现数据共享和进程间通信 。动态链接库在加载时也会被映射到这个区域,实现代码和数据的共享 。5.2进程内存分配
进程在运行时,经常需要动态分配内存来存储各种数据 。在 C 语言中,最常用的内存分配函数就是 malloc 。当我们调用 malloc 函数时,它会在堆上为我们分配一块指定大小的内存空间 。例如, int *p = (malloc(10 * sizeof(int))); 这行代码就会在堆上分配 10 个 int 类型大小的内存空间,并返回一个指向这块内存起始地址的指针p 。
那么,malloc函数是如何在堆上分配内存的呢?实际上,malloc并不是直接与操作系统的内存管理机制打交道,而是通过 glibc(GNU C Library)来实现的 。在 glibc 中,维护了一个内存池,当我们调用malloc时,它首先会在内存池中查找是否有足够的空闲内存来满足请求 。如果内存池中有足够的空闲内存,就直接从内存池中分配内存,并返回相应的指针 。这样可以减少系统调用的开销,提高内存分配的效率 。因为系统调用涉及到用户态和内核态的切换,这种切换会带来一定的性能损耗 。
然而,如果内存池中的空闲内存不足以满足请求,malloc函数就会借助系统调用与操作系统进行交互 。在 Linux 系统中,主要涉及到两个系统调用:brk和mmap 。
brk 系统调用:brk系统调用通过移动程序数据段的结束地址(也就是 “堆顶” 指针)来增加堆的大小,从而分配新的内存 。例如,假设当前堆的大小为 100 字节,当调用brk函数并传入一个大于当前堆顶地址的值,如 150 字节时,堆就会扩展到 150 字节,新增加的 50 字节内存就可以用于分配 。brk分配的内存是连续的,适合小块内存的频繁分配和释放 。但是,由于brk分配的内存是基于堆的连续扩展,如果频繁地分配和释放小块内存,可能会导致堆内存碎片化,即堆中出现很多不连续的空闲小内存块,这些小内存块可能无法满足后续较大内存块的分配请求 。例如,先分配了一个 10 字节的内存块,再释放它,然后又分配一个 20 字节的内存块,这样在堆中就可能会产生一个 10 字节的空闲小内存块,而如果后续需要分配一个 30 字节的内存块,由于这个 10 字节的空闲块无法满足需求,且与其他空闲块不连续,就可能导致分配失败 。
mmap 系统调用:mmap系统调用则是通过在文件映射区域分配一块内存来满足请求 。它可以将文件的全部或部分内容映射到进程的虚拟内存中,进程可以像访问内存一样读写文件的内容,而不需要显式地进行文件 I/O 操作 。同时,mmap也可以创建匿名映射,即不与任何文件关联的内存映射,用于在进程间共享内存或作为大块内存的分配器 。通常情况下,当请求的内存大小小于一定阈值(在大多数系统中,这个阈值通常为 128KB)时,malloc函数会优先使用brk系统调用来分配内存;当请求的内存大小大于这个阈值时,则会使用mmap系统调用 。这是因为mmap分配内存的开销相对较大,对于小块内存的分配不太划算,而对于大块内存的分配,mmap可以避免堆内存碎片化的问题,并且能更好地管理和释放内存 。例如,当需要分配一个 1MB 的内存块时,使用mmap可以直接在文件映射区域分配一块连续的 1MB 内存,而如果使用brk,可能需要多次扩展堆,并且容易导致堆内存碎片化 。
Part6.交换空间
6.1交换空间的作用
交换空间(swap space)在虚拟内存中扮演着至关重要的角色,它就像是一个 “内存储备仓库” 。当系统的物理内存(RAM)不足以满足所有正在运行的进程和应用程序的内存需求时,交换空间就会发挥作用 。
具体来说,Linux 内核会将那些暂时不活跃(也就是很长时间没有被访问)的内存页(pages)从物理内存转移到交换空间(通常是硬盘上的特定区域) ,这个过程被称为 “换出”(swapping out)或 “页面置换”(paging out) 。通过这种方式,物理内存中就腾出了空间,可供那些更活跃、更急需内存的进程使用 。
例如,当你同时打开了多个大型应用程序,如浏览器、视频编辑软件、音乐播放器等,物理内存可能会被迅速耗尽 。此时,系统会将一些暂时不需要访问的内存数据,比如音乐播放器当前没有播放的音频数据、浏览器中暂时未显示的网页缓存数据等,转移到交换空间中 。这样,其他更需要内存的操作,如视频编辑软件的实时预览、浏览器加载新的网页,就可以在有限的物理内存中顺利进行 。
而当进程需要访问已经被交换到磁盘上的内存页时,就会发生 “缺页中断”(page fault) 。这时,操作系统会从交换空间中把相应的内存页读取回物理内存,这个过程被称为 “换入”(swapping in)或 “页面调入”(paging in) 。
虽然交换空间能够在物理内存不足时,提供额外的内存支持,防止系统因内存耗尽而崩溃,让系统仍然可以继续运行 。但是,由于硬盘的读写速度远远低于内存的读写速度,频繁地使用交换空间会导致系统性能明显下降 。所以,交换空间只是物理内存的一种补充手段,理想情况下,系统应该有足够的物理内存,尽量减少对交换空间的依赖 。
6.2交换空间的类型
交换空间主要有两种类型:交换分区和交换文件,它们各自有不同的特点。
①交换分区:交换分区是硬盘上专门划分出来用于交换空间的独立分区,在系统安装过程中就可以进行设置 。比如在安装 Linux 系统时,通过分区工具(如 fdisk、parted 等)将硬盘的一部分空间指定为交换分区,其分区类型一般为 “Linux swap” 。它独立于系统的主文件系统运行,就像一个独立的小仓库,专门用来存放从物理内存中换出的内存页 。
优点:交换分区的效率相对较高,因为它在硬盘上是连续的空间,没有文件系统的额外开销 。而且在安装阶段创建时,通常会被放置在硬盘驱动器的较快区域(更靠近外边缘),这使得数据的访问和写入速度更快 。同时,它与主文件系统分开,能有效防止碎片化,减少对系统文件的干扰 ,就像一个独立的小房间,不会和其他杂物混在一起 。缺点:一旦创建,交换分区的大小就相对固定,如果想要更改其大小,就需要对磁盘进行重新分区 。这是一个比较复杂且有风险的操作,可能会导致数据丢失或系统故障 ,就好比你要扩大一个房间的面积,需要对整个房子的结构进行大改造,很容易出问题 。另外,如果交换分区设置得过大,而实际使用量很少,就会浪费磁盘空间;反之,如果设置得过小,在内存需求高峰期可能无法满足系统的需求,限制系统性能 。②交换文件:交换文件是在系统现有文件系统中的一种特殊文件,其作用和交换分区相同 。可以通过命令(如dd命令创建文件,再用mkswap命令将其设置为交换文件)在需要时创建 。例如,使用dd if=/dev/zero of=/swapfile bs=1024 count=8192命令创建一个大小约为 8MB 的交换文件/swapfile ,然后使用mkswap /swapfile将其初始化为交换文件 。
优点:交换文件具有很高的灵活性 。它可以根据系统的实际需求随时调整大小、删除或移动 。比如,当系统内存需求突然增加时,可以增大交换文件的大小;当内存需求减少时,又可以减小或删除交换文件,这使得它非常适合内存需求不断变化的系统 ,就像一个可以随时调整大小的收纳箱 。此外,交换文件使用现有文件系统中的空间,在不使用时不会浪费磁盘空间,并且可以根据内存需求动态增长 。缺点:由于交换文件存在于文件系统中,文件系统的管理和维护会带来一些额外的开销,而且可能会产生碎片化问题 。在传统的文件系统中,交换文件的性能通常比交换分区慢 。不过,随着现代文件系统(如 ext4、Btrfs 等)的发展,这些问题得到了很大程度的缓解,现在交换文件和交换分区的性能差异已经不是很明显 。但在高负载情况下,大量的交换操作仍可能对文件系统的正常文件操作产生干扰 。6.3 Swappiness 参数
Swappiness 是 Linux 操作系统中一个非常重要的参数,它控制着内核将内存页交换到磁盘(也就是使用交换空间)的倾向程度 。其取值范围是 0 - 100,代表的是一个百分比 。
当 Swappiness 的值为 0 时,意味着内核尽可能地避免使用交换空间,即使物理内存非常紧张,也会优先尝试其他方式来满足内存需求,比如回收缓存等 。而当 Swappiness 的值为 100 时,则表示内核总是倾向于使用交换空间,即使物理内存还有较多的空闲空间,也可能会将内存页交换到磁盘 。Swappiness 参数的调整对于系统性能有着直接且显著的影响 。
在系统内存紧张时,合理设置 Swappiness 值可以帮助优化系统的反应速度和整体性能 。例如,对于高负载的数据库服务器,由于数据库操作对内存的读写速度要求极高,频繁的磁盘 I/O 操作会严重影响性能,因此通常会将 Swappiness 值设置得很低(如 10 - 20) ,以减少交换的使用频率,避免因频繁访问交换空间(磁盘)导致的性能瓶颈 。而在一些内存较大且对内存使用不太敏感的系统中,适当提高 Swappiness 值(如设置为 30 - 50) ,可以有效利用系统资源,将一些暂时不用的内存页交换到磁盘,减少因内存资源闲置而造成的浪费 。
调整方法:查看当前系统的 Swappiness 值,可以使用命令cat /proc/sys/vm/swappiness 。如果想要临时修改 Swappiness 值(重启后失效) ,可以使用sysctl命令,例如将 Swappiness 值临时设置为 10 ,命令为sysctl vm.swappiness=10 。如果希望永久修改 Swappiness 值,则需要编辑/etc/sysctl.conf文件,在文件中添加或修改vm.swappiness = 10这一行,然后执行sysctl -p使修改生效 。在调整 Swappiness 参数时,需要谨慎操作,因为不合适的设置可能会带来一些问题 。
如果设置得过小,虽然减少了交换空间的使用,但可能会导致物理内存被过度使用,当物理内存耗尽时,系统可能会触发 “内存不足(OOM,Out - Of - Memory)” 杀手机制,强制杀掉一些进程来释放内存,这可能会影响系统的正常运行 。而如果设置得过大,系统会频繁地进行内存页的交换操作,由于磁盘 I/O 速度远低于内存速度,会导致系统性能大幅下降,用户会明显感觉到系统变得卡顿 。所以,在调整 Swappiness 参数之前,需要对系统的内存使用情况、应用程序的特点等进行充分的了解和分析,并且在调整后密切监控系统的性能指标,如内存使用率、磁盘 I/O 情况、系统响应时间等,以确保调整后的参数能够使系统达到最佳的性能状态 。
Part7.内存管理单元(MMU)
7.1 MMU的功能
内存管理单元(Memory Management Unit,MMU)是计算机硬件中负责处理中央处理器(CPU)内存访问请求的关键组件 ,在虚拟内存管理中扮演着核心角色。
图片
它的首要功能是地址转换,这是实现虚拟内存机制的基础。在现代操作系统中,每个进程都拥有自己独立的虚拟地址空间,程序在运行时使用的是虚拟地址 。而 MMU 的职责就是将这些虚拟地址转换为实际的物理地址,以便 CPU 能够正确访问内存中的数据 。例如,在 x86 架构的计算机中,当一个进程尝试访问虚拟地址 0x12345678 时,MMU 会通过查询页表(Page Table),将这个虚拟地址映射到对应的物理地址上,如 0x87654321 ,从而实现进程对内存的访问 。
MMU 还承担着内存保护的重要任务 。它通过硬件机制来确保进程只能访问被授权的内存区域,防止进程间的非法内存访问 。比如,MMU 可以为每个内存页面设置访问权限,如只读、读写、执行等 。当一个进程试图以不被允许的方式访问内存时,MMU 会触发异常,通知操作系统进行处理 。假设一个进程尝试写入一个被设置为只读的内存页面,MMU 就会检测到这个非法操作,并产生一个内存访问错误异常,操作系统可以根据这个异常来采取相应的措施,如终止该进程,以保护系统的稳定性和安全性 。这种内存保护机制对于多任务操作系统来说至关重要,它使得多个进程能够在同一台计算机上安全、稳定地运行,避免了因一个进程的错误而导致整个系统崩溃的情况 。
mmu开启以后会有以下特点:
多个程序独立运行虚拟地址是连续的(物理内存可以有碎片)允许操作系统管理内存下图显示的系统说明了内存的虚拟和物理视图。单个系统中的不同处理器和设备可能具有不同的虚拟地址映射和物理地址映射。操作系统编写程序,使MMU在这两个内存视图之间进行转换:
图片
要做到这一点,虚拟内存系统中的硬件必须提供地址转换,即将处理器发出的虚拟地址转换为主内存中的物理地址。MMU使用虚拟地址中最重要的位来索引转换表中的条目,并确定正在访问哪个块。MMU将代码和数据的虚拟地址转换为实际系统中的物理地址。该转换将在硬件中自动执行,并且对应用程序是透明的。除了地址转换之外,MMU还可以控制每个内存区域的内存访问权限、内存顺序和缓存策略。
MMU对执行的任务或应用程序可以不了解系统的物理内存映射,也可以不了解同时运行的其他程序。每个程序可以使用相同的虚拟内存地址空间。即使物理内存是碎片化的,还可以使用一个连续的虚拟内存映射。此虚拟地址空间与系统中内存的实际物理映射分开的。应用程序被编写、编译和链接,以在虚拟内存空间中运行。
图片
如上图所示,TLB是MMU中最近访问的页面翻译的缓存。对于处理器执行的每个内存访问,MMU将检查转换是否缓存在TLB中。如果所请求的地址转换在TLB中导致命中,则该地址的翻译立即可用。TLB本质是一块高速缓存。数据cache缓存地址(虚拟地址或者物理地址)和数据。TLB缓存虚拟地址和其映射的物理地址。TLB根据虚拟地址查找cache,它没得选,只能根据虚拟地址查找。所以TLB是一个虚拟高速缓存。
每个TLB entry通常不仅包含物理地址和虚拟地址,还包含诸如内存类型、缓存策略、访问权限、地址空间ID(ASID)和虚拟机ID(VMID)等属性。如果TLB不包含处理器发出的虚拟地址的有效转换,称为TLB Miss,则将执行外部转换页表查找。MMU内的专用硬件使它能够读取内存中的转换表。
然后,如果翻译页表没有导致页面故障,则可以将新加载的翻译缓存在TLB中,以便进行后续的重用。简单概括一下就是:硬件存在TLB后,虚拟地址到物理地址的转换过程发生了变化。虚拟地址首先发往TLB确认是否命中cache,如果cache hit直接可以得到物理地址。否则,一级一级查找页表获取物理地址。并将虚拟地址和物理地址的映射关系缓存到TLB中。
如果操作系统修改了可能已经缓存在TLB中的转换的entry,那么操作系统就有责任使这些未更新的TLB entry invaild。当执行A64代码时,有一个TLBI,它是一个TLB无效的指令:
TLB可以保存固定数量的entry。可以通过由转换页表遍历引起的外部内存访问次数和获得高TLB命中率来获得最佳性能。ARMv8-A体系结构提供了一个被称为连续块entry的特性,以有效地利用TLB空间。转换表每个entry都包含一个连续的位。当设置时,这个位向TLB发出信号,表明它可以缓存一个覆盖多个块转换的单个entry。查找可以索引到连续块所覆盖的地址范围中的任何位置。因此,TLB可以为已定义的地址范围缓存一个entry从而可以在TLB中存储更大范围的虚拟地址。
7.2 MMU 与 TLB(转译后备缓冲器)
转译后备缓冲器(Translation Lookaside Buffer,TLB)是 MMU 中的一个高速缓存 ,它在虚拟内存管理中与 MMU 协同工作,极大地提高了地址转换的效率 。
TLB 中存储了最近使用的页表项(Page Table Entry,PTE) ,这些页表项记录了虚拟地址到物理地址的映射关系 。当 CPU 需要进行地址转换时,MMU 首先会在 TLB 中查找对应的虚拟地址 。如果在 TLB 中找到了匹配的页表项(即 TLB 命中) ,MMU 就可以直接获取到对应的物理地址,而无需访问内存中的页表 ,这大大缩短了地址转换的时间 。例如,假设 CPU 需要访问虚拟地址 0x12345678,MMU 会先在 TLB 中查找这个虚拟地址 。如果 TLB 中已经缓存了该虚拟地址对应的页表项,MMU 就能立即得到物理地址,直接从内存中读取数据,整个过程非常快速 。
然而,如果在 TLB 中没有找到匹配的页表项(即 TLB 未命中) ,MMU 就需要从内存中的页表中读取相应的页表项 。这个过程相对较慢,因为内存访问的速度远低于 TLB 的访问速度 。在从内存中读取到页表项后,MMU 会将其存入 TLB 中,以便下次访问相同的虚拟地址时能够快速命中 。假设在上述例子中,TLB 未命中,MMU 就会访问内存中的页表,找到虚拟地址 0x12345678 对应的物理地址 。然后,MMU 会把这个页表项存入 TLB 中,当下次 CPU 再次访问这个虚拟地址时,就可以在 TLB 中快速找到对应的物理地址,提高了地址转换的效率 。
LB的原理如下:
当CPU访问一个虚拟地址时,首先检查TLB中是否有对应的页表项。如果TLB中有对应的页表项(即命中),则直接从TLB获取物理地址。如果TLB中没有对应的页表项(即未命中),则需要访问内存来获取正确的页表项。在未命中情况下,操作系统会进行相应处理,从主存中获取正确的页表项,并将其加载到TLB中以供后续使用。一旦正确的页表项加载到TLB中,CPU再次访问相同虚拟地址时就可以直接在TLB中找到映射关系,提高了转换效率。TLB具有快速查找和高效缓存机制,能够极大地减少查询页表所需的时间。然而,由于TLB是有限容量的,在大型程序或多任务环境下可能无法完全覆盖所有需要转换的页面。当发生TLB未命中时,则会导致额外的内存访问开销;操作系统会负责管理和维护TLB,包括缓存策略、TLB的刷新机制等。常见的缓存策略有全相联、组相联和直接映射等。可以说,TLB 就像是 MMU 的 “高速助手”,通过缓存常用的页表项,减少了 MMU 访问内存中页表的次数,从而显著提高了虚拟地址到物理地址的转换速度,进而提升了整个系统的性能 。
Part8.内存相关工具与命令
8.1查看内存使用情况的命令
①free:这是一个非常基础且常用的命令,用于快速查看系统内存的使用情况,它的输出结果是对/proc/meminfo文件信息的一个简洁概述 。执行free命令后,会得到如下格式的输出(以字节为单位):
其中,total表示总内存大小;used表示已使用的内存大小;free表示空闲内存大小;shared表示共享内存大小;buff/cache表示缓冲区和缓存所占用的内存大小;available表示系统可用于分配给新进程的内存大小 。通过这些信息,我们可以快速了解系统内存的整体使用状况,判断是否存在内存不足或内存使用不合理的情况 。如果used接近或超过total,且available较小,可能意味着系统内存紧张,需要进一步排查和优化 。
②top:是一个动态显示系统资源使用情况的命令,类似于 Windows 系统中的任务管理器 。它不仅能实时展示内存使用情况,还能查看 CPU 使用率、每个进程的资源占用等详细信息 。在终端中输入top命令后,会进入一个交互式界面,不断实时更新系统状态 。界面的主要部分包括:
③htop:是top命令的增强版本,提供了更直观、更丰富的信息展示 。它以彩色界面显示,并且支持鼠标操作,使得用户交互更加便捷 。在htop界面中,不仅可以看到每个进程的内存实时使用率,还能详细了解每个进程的常驻内存大小(RES)、程序总内存大小(VIRT)、共享库大小(SHR)等信息 。与top相比,htop的进程列表可以水平及垂直滚动,方便查看更多进程的详细信息 。例如,在处理大量进程的服务器环境中,htop能够更轻松地定位到需要关注的进程,并且其直观的界面设计使得内存使用情况一目了然,对于系统管理员来说是一个非常实用的工具 。
8.2内存分析工具
Valgrind:是一款功能强大的内存调试、内存泄漏检测以及性能分析工具 。它主要用于检测 C 和 C++ 程序中的内存错误,包括内存泄漏、非法内存访问(如越界访问、使用未初始化的内存等) 。例如,在一个 C 语言程序中,如果使用malloc分配了内存,但在程序结束时没有调用free释放内存,就会导致内存泄漏 。使用 Valgrind 可以很容易地检测到这种问题 。假设我们有一个简单的 C 程序test.c:
编译该程序后,使用 Valgrind 运行:valgrind./test ,Valgrind 会输出详细的错误信息,指出内存泄漏的位置和大小,类似如下内容:
通过这些信息,我们可以清楚地知道在test.c的第 6 行分配的 40 字节内存没有被释放,从而能够及时修复代码中的内存泄漏问题 。除了检测内存泄漏,Valgrind 还能检测其他内存错误,如数组越界访问:
使用 Valgrind 运行该程序,它会输出类似如下的错误提示:
提示在test.c的第5行发生了无效的写操作,因为访问的数组下标超出了数组的范围 。Valgrind的这些功能对于提高程序稳定性和可靠性非常重要,特别是在开发大型项目时,能够帮助开发者及时发现和解决内存相关的问题 。
Part9.实际问题与案例分析
9.1内存泄漏问题
内存泄漏是指程序在申请内存后,无法释放已申请的内存空间 ,导致这些内存被持续占用,无法被其他程序或进程使用 。从进程的角度来看,当一个进程不断地分配内存,但没有及时释放不再使用的内存,随着时间的推移,进程占用的内存会越来越多,而系统中可用于分配的内存则会逐渐减少 。例如,在一个长时间运行的服务器程序中,如果存在内存泄漏问题,可能会导致服务器的内存被逐渐耗尽,最终影响整个系统的稳定性和性能 。
在 Linux 系统中,可以使用 Valgrind 工具来检测内存泄漏 。假设我们有一个简单的 C 程序leak.c:
编译该程序后,使用 Valgrind 运行:valgrind --leak-check=full./leak ,Valgrind 会输出详细的内存泄漏信息,如下:
从输出中可以看出,在leak.c的第 6 行分配了 40 字节的内存,但没有释放,导致内存泄漏 。
一旦检测到内存泄漏,就需要及时处理 。对于简单的内存泄漏问题,如上述例子,只需要在合适的位置添加内存释放的代码即可 。在上述程序中,我们可以在return 0;之前添加free(p);来释放分配的内存 。但在实际的大型项目中,内存泄漏的排查和处理可能会比较复杂,需要仔细分析代码逻辑,找出内存分配和释放的不合理之处 。有时候,内存泄漏可能是由于复杂的数据结构或函数调用关系导致的,这就需要借助调试工具和技术,逐步跟踪内存的分配和使用情况,以定位和解决内存泄漏问题 。
9.2 OOM(Out Of Memory)问题
OOM 即内存溢出,是指程序在申请内存时,没有足够的内存空间供其使用 。在 Linux 系统中,当系统内存严重不足时,内核有两种主要的应对策略:一是直接触发系统崩溃(panic) ,这是一种极端情况,通常只有在系统内存极度匮乏且无法通过其他方式解决时才会发生;二是启动 OOM Killer(内存不足杀手)机制 ,内核会根据一定的算法选择并杀掉一些占用内存较大的进程,以释放内存,保证系统的基本运行 。
OOM Killer 在选择要杀掉的进程时,会为每个进程计算一个 oom_score 值 。oom_score 的计算涉及多个因素,包括进程占用的物理内存页数、交换区页数以及页表(Page Table)数量等 。例如,一个进程占用了大量的物理内存,且频繁地进行内存交换操作,它的 oom_score 值就会相对较高,也就更容易被 OOM Killer 选中杀掉 。此外,每个进程还有一个 oom_score_adj 参数,用户可以通过调整这个参数来改变进程被 OOM Killer 杀掉的优先级 。当 oom_score_adj 的值为 - 1000 时,表示该进程不会被 OOM Killer 杀掉 ;而值越大,进程被 OOM Killer 杀掉的可能性就越高 。
为了避免 OOM 问题的发生,可以采取以下措施:
优化程序内存使用:仔细检查程序代码,避免内存泄漏问题,及时释放不再使用的内存 。合理设计数据结构和算法,减少不必要的内存占用 。例如,在处理大数据集时,可以采用分块处理的方式,避免一次性加载过多数据到内存中 。监控内存使用情况:使用如 top、htop、free 等命令实时监控系统和进程的内存使用情况 。通过监控数据,可以及时发现内存使用异常的进程,提前采取措施,如调整进程的内存分配或优化其算法 。合理设置系统参数:根据系统的实际需求,合理调整 Swappiness 参数,平衡物理内存和交换空间的使用 。同时,也可以根据需要调整其他与内存管理相关的系统参数,如/proc/sys/vm/overcommit_memory 。该参数用于控制内存分配的策略,取值为 0 时,内核会尽量检查是否有足够的内存可供分配,只有在确定有足够内存时才会分配;取值为 1 时,内核允许分配超过实际物理内存大小的内存,这可能会导致内存不足的风险增加,但在某些情况下可以提高系统的性能;取值为 2 时,内核会严格按照系统的物理内存和交换空间大小来分配内存,不允许超过这个范围 。在实际应用中,需要根据系统的负载和内存需求,谨慎选择合适的overcommit_memory值 。