操作系统中的内存管理
前言
这是一篇鼠鼠看完小林coding
文章后总结的关于操作系统中的内存管理的一些知识点,并且穿插了几个面试题,我自己是看完了小林的答案才知道的,觉得属于这一块的内容于是就写上去了,gogogo!
内存管理
我们知道,所有的程序最后都是在内存中执行的,准确来说,应该是说所有程序运行的时候,都需要加载到内存中才能被CPU获取从而进行代码的执行
单片机
鼠鼠是电子信息工程专业的,虽然我对这个专业一点兴趣都没有,但是有些知识鼠鼠还是知道的,比如单片机
单片机是没有操作系统的,所以我们想要将单片机上的程序运行起来,就必须通过一些工具将我们写的代码烧入单片机的内存中,这样程序才能执行,而且单片机的CPU是直接操作物理内存的
那么这样做有什么问题呢?
因为单片机是直接操作物理地址的,所以就会出现在同一个地址上可能会有两种代码,比如第一个代码烧入单片机后占据了物理内存的某个位置,当我们烧入第二个代码时,万一也是在相同的物理地址上,第一个程序就没办法继续执行了
在现代的计算机中,操作系统是如何解决上面的问题呢?
虚拟内存
- 因为如果有一个进程在某一块内存上运行,此时该内存上是无法运行第二个人进程的,就无法做到并行地运行多个进程,为了避免多个程序都引用了绝对物理地址,于是就引入了虚拟内存
- 每个进程分配独立的虚拟地址(32位操作系统会有4G的虚拟地址,其中1G是属于内核空间,3G属于用户空间;64位操作系统就大得多,用户空间和内核空间都是128T),互不干涉,有个前提就是每个进程都没办法直接访问物理地址,至于虚拟地址是如何落到物理地址上的,对进程来说是透明的,操作系统已经把这些都安排好了
- 操作系统会提供一种机制,将不同进程的虚拟地址和不同内存的物理地址映射起来
虚拟内存地址
程序使用的内存地址
物理内存地址
实际物理硬件(内存条)里面的空间地址
操作系统引入了虚拟内存。进程持有的虚拟地址会通过CPU芯片中的**内存管理单元(MMU)**的映射关系,来转化为物理地址,然后通过物理地址来访问内存
那么操作系统是如何管理虚拟地址和物理地址的关系的呢?
主要是内存分段和内存分页
内存分段
- 程序是由若干个逻辑分段组成的,可由代码段,数据分段,堆,栈构成,因为不同段有不同的属性,所以就用分段的形式把这些段分离出来
- 分段机制下,虚拟地址分为两部分:段选择子和段内偏移量
段选择子
段选择子保存在寄存器中,段选择子最重要的是段号,可以用作段表的索引
段表包含段基地址,段界限,特权级段内偏移量
段内偏移量位于0和段表中的段界限之间,如果段内偏移量合法,那么物理内存地址=段基地址+段内偏移量
我们知道内存分段会将*虚拟地址分为4个段,每个段在段表中有一个项,在这一项找到段的基地址,再加上偏移量,于是就能找到物理内存中的地址
虽然分段的方法很好,但是它也是有一些问题的,其中之一就是内存碎片
内存碎片
首先我们先来考虑这样一个情况:
- 有一个大小为900MB的物理内存,其中执行了3个进程,分别为
- 进程1,占据了400的物理内存
- 进程2,占据了200的物理内存
- 进程3,占据了100的物理内存
此时空余的物理内存大小为:900-400-200-100=200MB
- 此时我们结束掉大小为200的物理内存,那么我们剩余的物理内存大小就为400MB
- 当我们想要运行大小为300MB的一个进程4,此时是没办法运行的,这是为什么呢?
内存碎片
内存碎片也分为两种
- 外部内存碎片:产生了多个不连续的小物理内存,导致新的程序无法被载入,就例如上面那张图所示
- 内部内存碎片:程序所有的内存都被装载到了物理内存,但这个程序很大一部分内存是不经常使用的,这样会导致内存的浪费
如何解决外部内存碎片问题?
我们可以利用内存交换的方法来解决外部内存碎片的问题,如何做到的呢?
内存交换
将一个程序写到硬盘上,然后再从硬盘上读回到内存中,只不过读回来的时候,不能装载回原来的位置,而是紧接着已被占用的内存
这个内存交换空间,在Linux系统中是Swap空间,这块空间是从硬盘划分出来的,用于内存与硬盘的空间交换
但是因为对于多进程的系统而言,内存碎片是很容易产生的,又因为硬盘的访问速度比内存慢太多了,每一次内存交换,都要把一大段连续的内存数据写到磁盘中,所以,如果内存交换的时候,交换的是一个占内存空间很大的程序,这样整个机器都会显得卡顿
为了解决内存交换效率低下的问题,科学家们有研究出了一个新的方法:内存分页
内存分页
- 为了解决内存碎片的问题,另外,当需要进行内存交换时,让需要交换写入或者从磁盘中装载的数据更少一点,就出现了内存分页技术
- 内存分页是把整个虚拟内存空间和物理内存空间切成一段段固定尺寸的大小。这样一个连续并且尺寸固定的内存空间,我们称之为页(page),在Linux下,每一页的大小也4KB
- 虚拟地址通过页表来映射物理地址
- 页表实际上存储在 CPU 的**内存管理单元(MMU)**中,于是CPU就可以直接通过MMU,找出要实际要访问的物理内存地址
- 当进程访问的虚拟地址在页表中查不到时,系统会产生一个缺页异常,进入系统内核空间分配物理内存、更新进程页表,最后再返回用户空间,恢复进程的运行
内存分页如何解决内存碎片、内存交换效率低的问题?
不会产生内存碎片的原因
由于无论是虚拟内存空间还是物理内存空间都是预先划分好的,也就是说不会像分段那样会产生间隙较小的内存,而采用了分页,那么释放的内存都是以页为单位释放的,也就不会产生无法给进程使用的小内存
解决内存交换的效率低下问题的原因
如果内存不够,操作系统会把其他正在运行的进程中[最近未经使用(LRU算法)]的内存页面给释放掉,称为换出(暂时写在硬盘上)
一旦需要的时候,再加载进物理内存,称之为换入
所以,一次性写入磁盘的也只有少数的一页或者几页,不会花费太多时间,内存交换的效率就比较高
分页技术的好处之一
分页技术使得程序在加载的时候,不需要将所有程序都加载到物理内存中。可以在进行虚拟内和物理内存的页之间的映射之后,并不把全部页都加载到物理内存,而是只有在程序运行时,需要用到对应虚拟内存页里面的指令和数据时,再将该页加载到物理内存中
虚拟地址如何映射到物理地址?
虚拟地址=页号+页内偏移量
页号:页表的索引(页表包含物理页每页所在的物理内存的基地址)
页内偏移量:数据在物理页中的位置
世界上没有完美的人,无论什么事都有两面性,就像我单身的话,我就只有偶尔会烦恼没有女朋友,但如果我有女朋友,我就会有很多烦恼,比如要送什么礼物啊,要去哪里约会啊,要如何当好一个合格的男朋友啊什么什么的,所以我选择单身!,并不是鼠鼠找不到奥,只是鼠鼠不想谈(哭)
所以内存分页也会出现一定的问题的
内存分页出现的问题
因为一个进程对应一个页表(每个页在Linux下为4KB),而在一个系统中可以同时运行非常多的进程,这样页表会非常庞大
举个栗子
一个进程的虚拟空间在32位操作系统中位4GB,而每一页在Linux下为4KB,因为一张页表需要包含所有虚拟空间的地址,所以一张页表大概需要100万个页(4GB/4KB),而每个页的页表项需要4个字节,所以整个4GB空间的映射需要4×100万=4MB的内存来存储页表
虽然单个进程来看好像4MB不是很多,但是当有100个进程呢?1000个进程呢?此时光是页表占用的空间的很大了,这还只是在32位操作系统下,在64位操作系统下我都不敢相信欸!
如何解决内存分页出现的问题
聪明的科学家相处了多级页表的方法来解决这个问题,那么什么是多级页表呢?
我们很容易地可以从字面意思知道,多级页表就是将页表分级,分为一级,两级等等,那么如何分呢?
如下(在32位操作系统下):
- 我们把有着100多万个页表项的页表进行二次分页
- 将一级页表分为1024个二级页表,每个二级页表包含1024个页表项,如下图所示 此时一个进程需要映射的所有页表大小加起来为**4字节×1024(一级页表的大小)+1024×(一张二级页表的大小 1024×4字节)=4KB+4MB
此时很多人会疑惑,这样不是比单级页表所需要的内存空间更大了嘛?但实际上真的是这样吗?不对哦
在这里我们需要知道:
- 大多数进程是没办法使用到4GB的虚拟内存的,也就是说我们不需要把4GB的虚拟地址都有一个映射到的物理地址,只有进程占用的虚拟内存才需要映射
- 这也就意味很多页是用不到的,根本没有分配,对于已分配的页表项,如果存在最近一定时间未访问的页表,在物理内存紧张的情况下,操作系统会将页面换出到硬盘,也就是说不会占用物理内存
- 如果只是单级页表的话,因为页表的职责就是将虚拟地址转化为物理地址,也就是说,我们不能有
虚拟地址没法在叶表上找到对应的物理地址
这种情况出现,所以必须包含所有的虚拟空间地址 - 如果是二级表的话,一级表其实和二级表是类似的,也包含了所有的虚拟地址,只不过一级表中每一页包含了很多个虚拟地址,那样就可以只用1024个页表项来包含整个虚拟地址了
- 当一个进程没有用到某个一级页表包含的虚拟地址时,就不需要去创建二级页表了,只有当需要的时候才创建
做个简单的计算,假设只有20%的一级页表项被用到了,那么页表占用的内存空间就只有4KB(一级页表)+ 20%×4MB(二级页表)= 0.804MB,这远远小于单级页表的4MB大小
当然了,有二级页表,自然也可以有3级、4级等等,在当今的64位操作系统下,2级页表也不够看了,所以如今大多是4级页表,分别是:
- 全局页目录项 PGD(Page Global Directory)
- 上层页目录项 PUD(Page Upper Directory)
- 中间页目录项 PMD(Page Middle Directory)
- 页表项 PTE(Page Table Entry)
此时又出现问题啦,没错,我们总是在不断地寻找问题并解决问题,我们一直在这个道路上前进
多级页表虽然解决了空间上的问题,但是虚拟地址到物理地址的转换就多了几道转换的工序,这显然就降低了这俩地址转换的速度,也就是带来了时间上的开销
我们应该知道:
程序是有局部性的,即在一段时间内,整个程序的执行仅限于程序中的某一部分。相应地,执行所访问的存储空间也局限于某个内存区域
我们就可以利用这一特性,把最常访问的几个页表项存储到访问速度更快的硬件,于是计算机科学家们,就在 CPU 芯片中,加入了一个专门存放程序最常访问的页表项的Cache,这个 Cache 就是 TLB(Translation Lookaside Buffer),通常称为页表缓存、转址旁路缓存、快表等
TLB
- 在 CPU 芯片里面,封装了内存管理单元(Memory Management Unit)芯片,它用来完成地址转换和 TLB 的访问与交互
- 有了 TLB 后,那么 CPU 在寻址时,会先查 TLB,如果没找到,才会继续查常规的页表
- TLB 的命中率其实是很高的,因为程序最常访问的页就那么几个
段页式内存管理
内存分段和内存分页并不是对立的,它们是可以组合起来在同一个系统中使用的,那么组合起来后,通常称为段页式内存管理
实现方式
- 先将程序分为多个具有逻辑意义的段(代码段,数据段,栈,堆等),也就是分段机制
- 再把每个段划分位多个页,这样,虚拟地址结构就由段号,段内页号和页内偏移量三部分组成
段页式地址变换到物理地址
想要将段内式地址变换得到物理地址需要经过三次内存访问:
- 第一次,通过段号访问段表,获取页表的起始地址
- 第二次,通过第一次访问到的页表起始地址访问页表,通过段内页号找到页表中对应的页号,获取对应的物理地址
- 第三次,通过第二次得到的物理地址加上页内偏移量,获得真正的物理地址
优势
提高了内存的利用率
劣势
增加了硬件成本和系统开销
Linux的内存管理
先来看看Intel处理器的发展历史
Intel处理器的发展历史
- 早期 Intel 的处理器从80286开始使用的是段式内存管理。但是很快发现,光有段式内存管理而没有页式内存管理是不够的,这会使它的 X86 系列会失去市场的竞争力。因此,在不久以后的 80386 中就实现了对页式内存管理。也就是说,80386 除了完成并完善从 80286 开始的段式内存管理的同时还实现了页式内存管理
- 但是这个80386的页式内存管理设计时,没有绕开段式内存管理,而是建立在段式内存管理的基础上,这就意味着,页式内存管理的作用是在由段式内存管理所映射而成的的地址上再加上一层地址映射
- 由于此时段式内存管理映射而成的地址不再是“物理地址”了,Intel 就称之为“线性地址”(也称虚拟地址)。于是,段式内存管理先将逻辑地址映射成线性地址,然后再由页式内存管理将线性地址映射成物理地址逻辑地址: 程序所使用的地址,通常是没被段式内存管理映射的地址线性地址: 通过段式内存管理映射的地址,称为线性地址,也叫虚拟地址`逻辑地址是「段式内存管理」转换前的地址,线性地址则是「页式内存管理」转换前的地址`
知道这些后,再来看看Linux的内存管理
Linux 内存主要采用的是页式内存管理,但同时也不可避免地涉及了段机制
这主要是上面 Intel 处理器发展历史导致的,因为 Intel X86 CPU 一律对程序中使用的地址先进行段式映射,然后才能进行页式映射。既然 CPU 的硬件结构是这样,Linux 内核也只好服从 Intel 的选择
但是事实上,Linux 内核所采取的办法是使段式映射的过程实际上不起什么作用。也就是说,“上有政策,下有对策”,若惹不起就躲着走
Linux 系统中的每个段都是从0 地址开始的整个 4GB 虚拟空间(32 位环境下),也就是所有的段的起始地址都是一样的。
这意味着,Linux 系统中的代码,包括操作系统本身的代码和应用程序代码,所面对的地址空间都是线性地址空间(虚拟地址),这种做法相当于屏蔽了处理器中的逻辑地址概念,段只被用于访问控制和内存保护。
关于内存管理的一些问题
在4G物理内存的机器上,申请8G的内存会怎么样?
在回答这个问题之前,让我们先了解一下用户如何申请内存,我们在写代码的时候,如果希望开辟一个新的空间,我们可以使用**malloc()/new()**函数来申请内存空间,那么底层是怎么实现的呢?
Linux下分配虚拟内存(申请内存)的两种方法
brk
申请小于128k的内存时,使用brk分配内存,将数据段.data的最高地址指针_edata向高地址移动,即增加堆的有效区域来申请新的内存空间
mmap
申请大于128k的内存时,使用mmap分配内存,mmap是在进程的文件映射区找一块空闲存储空间,128K限制可由M_MMAP_THRESHOLD选项进行修改
注意:当应用程序使用**malloc**进行申请空间后,实际上申请的是**虚拟空间**,只有当我们使用这块虚拟空间的时候,CPU就会去访问这块虚拟内存,根据页表进行映射,但由于是第一次,会发现页表上并没有该虚拟地址对应的物理地址,此时CPU就会产生一个缺页中断,进程会从用户态变成内核态,并将缺页中断交给Page Fault Handler(缺页中断函数)处理
缺页中断处理函数会看是否有空闲的物理内存:
- 如果有,就直接分配物理内存,并建立虚拟内存与物理内存之间的映射关系
- 如果没有空闲的物理内存,那么内核就会开始进行回收内存的工作,如果回收内存工作结束后,空闲的物理内存仍然无法满足此次物理内存的申请,那么内核就会放最后的大招了触发**OOM(Out of Memory)**机制
在不同的环境下对问题的解答
在32位操作系统下并且物理内存只有4GB的环境下
如果在该环境下进行申请,是会失败的
因为我们知道32位操作系统下,进程的虚拟内存在用户态最大只有3GB,所以是没有办法申请8GB的虚拟内存的,也就是无法申请内存
在64位操作系统下并且物理内存只有4GB的环境下
因为如果只是申请虚拟内存的话,是可以成功的,因为64G的操作系统环境下,用户空间高达128T
但是也有可能在Linux下是会失败的,那是因为Linux有一个参数需要我们注意,那就是overcommit_memory
1 | cat /proc/sys/vm/overcommit_memory |
overcommit_memory参数的值:
- 如果值为0(默认值),代表:Heuristic overcommit handling,它允许overcommit,但过于明目张胆的 overcommit 会被拒绝,比如malloc一次性申请的内存大小就超过了系统总内存。Heuristic的意思是“试探式的”,内核利用某种算法猜测你的内存申请是否合理,大概可以理解为单次申请不能超过free memory + free swap + pagecache的大小 + SLAB中可回收的部分 ,超过了就会拒绝overcommit
- 如果值为 1,代表:Always overcommit. 允许overcommit,对内存申请来者不拒
- 如果值为 2,代表:Don’t overcommit. 禁止overcommit
问题衍生
假设我们在64位操作系统,并且overcommit_memory的值为1的时候,申请128T的虚拟内存就一定可以成功嘛?
答案:不一定,我们还需要根据物理内存的大小来判断。
当我们的物理内存只有2G
实验后发现,当我们还没有申请到128T虚拟内存的时候就被杀死了,而且错误信息为killed,也就是触发OOM了
什么是OOM?
内存溢出(Out Of Memory,简称OOM)是指应用系统中存在无法回收的内存或使用的内存过多,最终使得程序运行要用到的内存大于能提供的最大内存。此时程序就运行不了,系统会提示内存溢出
这是因为即使我们只是申请虚拟内存,但仍旧使用到了物理内存(比如内核保存虚拟内存的数据结构,也是占用物理内存的),所以如果物理内存太小的话,大概率是会出发OOM机制的
那么,2G的物理内存就没有办法申请128T的虚拟内存了吗?
其实这也是要根据情况来说的,是什么情况呢?那就是要看我们的操作系统有没有开启Swap机制
使用swapfile的方式开启了1GB的swap空间之后再做实验,虽然最后出现了Cannot allocate memory,但计算下来我们发现其实以及申请成功了,实际上我们是不可能申请完整个 128T 的用户空间的,因为程序运行本身也需要申请虚拟空间
Swap机制的作用
前面我们说过了,在分段机制下,如果想要解决内存碎片的问题,最开始的解决方法就是swap当系统的物理内存不够用的时候,就需要将物理内存中的一部分空间释放出来,以供当前运行的程序使用。那些被释放的空间可能来自一些很长时间没有什么操作的程序,这些被释放的空间会被临时保存到磁盘,等到那些程序要运行时,再从磁盘中恢复保存的数据到内存中
另外,当内存使用存在压力的时候,会开始触发内存回收行为,会把这些不常访问的内存先写到磁盘中,然后释放这些内存,给其他更需要的进程使用。再次访问这些内存时,重新从磁盘读入内存就可以了
swap的两个过程
换入:是在进程再次访问这些内存的时候,把它们从磁盘读到内存中来
换出:是把进程暂时不用的内存数据存储到磁盘中,并释放这些数据占用的内存
swap的优势
应用程序实际可以使用的内存空间将远远超过系统的物理内存。由于硬盘空间的价格远比内存要低,因此这种方式无疑是经济实惠的
swap的劣势
频繁地读写硬盘,会显著降低操作系统的运行速率
swap机制触发的两种场景
- 内存不足:之前一直说的,物理内存不足的时候,会将(最久未经使用)的物理内存换出
- 内存闲置:
应用程序在启动阶段使用的大量内存在启动后往往都不会使用,通过后台运行的守护进程(kSwapd),我们可以将这部分只使用一次的内存交换到磁盘上为其他内存的申请预留空间。kSwapd 是 Linux 负责页面置换(Page replacement)的守护进程,它也是负责交换闲置内存的主要进程,它会在回收内存页中的空闲内存保证系统中的其他进程可以尽快获得申请的内存。kSwapd 是后台进程,所以回收内存的过程是异步的,不会阻塞当前申请内存的进程
Swap机制换出换入的是什么类型的内存
内核缓存的文件数据,因为都有对应的磁盘文件,所以在回收文件数据的时候, 直接写回到对应的文件就可以了,这部分内存被称为文件页
但是像进程的堆、栈数据等,它们是没有实际载体,这部分内存被称为匿名页。而且这部分内存很可能还要再次被访问,所以不能直接释放内存,于是就需要有一个能保存匿名页的磁盘载体,这个载体就是 Swap 分区
匿名页回收的方式是通过 Linux 的 Swap 机制,Swap 会把不常访问的内存先写到磁盘中,然后释放这些内存,给其他更需要的进程使用。再次访问这些内存时,重新从磁盘读入内存就可以了
有了Swap机制后就可以无上限地申请虚拟内存空间了吗?
岂可休,这肯定是不允许的啊!这样子的话…会坏掉的…,哪里有可能一直换下去啊,到了一定程度就会出发OOM机制了,啊当然我自己是没试过的
- 标题: 操作系统中的内存管理
- 作者: 这题超纲了
- 创建于: 2023-03-07 13:39:46
- 更新于: 2023-06-23 14:39:23
- 链接: https://qx-gg.github.io/2023/03/07/blog10/
- 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。