虚拟内存
为什么有虚拟内存?
- CPU是直接操作内存的物理地址。
- 在这种情况下,如果两个程序占用的内存有重叠,要想同时运行两个程序是不可能的。
为啥它会内存有重叠啊?我不理解。难道不是这块内存被这个程序使用之后另外的程序就无法访问吗?为什么他们还会重叠啊?
在计算机系统中,内存是用来存储程序和数据的地方。当一个程序运行时,它会占用一部分内存空间来存储自己的指令、变量和数据。然而,并不是所有的程序都需要占用全部可用的内存空间。
操作系统负责管理内存资源,并为不同的程序分配内存空间。当一个程序请求内存时,操作系统会尝试找到足够的连续空闲内存块来满足该程序的需求。如果有足够的空闲内存可用,操作系统会将其分配给请求的程序。
重叠发生的情况通常是由于程序设计或错误引起的。例如,如果一个程序在使用内存时超出了分配给它的范围,它可能会覆盖其他程序的内存空间,导致内存重叠。这种情况可能会导致程序崩溃、数据损坏或系统不稳定。
操作系统通常会采取措施来防止内存重叠和保护程序之间的内存空间。例如,操作系统可以使用虚拟内存技术将物理内存映射到虚拟地址空间,从而使每个程序都认为它拥有独立的内存空间。此外,操作系统还会对程序的内存访问进行监控和限制,以确保程序不会越界访问其他程序的内存。
总之,内存重叠是一种错误情况,通常是由于程序设计或错误引起的。操作系统负责管理内存资源,并采取措施来防止内存重叠和保护程序之间的内存空间。
看到这我又有一个疑问,啥是程序设计或错误啊?我实在是不理解这个词语。是我不理解中国话吗?
chat是这样说的:
程序设计或错误是指在程序设计过程中出现的错误或缺陷。这些错误可能导致程序无法按照预期工作或产生错误的结果。
以下是一些常见的程序设计错误:
逻辑错误:这种错误发生在程序的逻辑部分,即程序的执行流程或算法中。逻辑错误会导致程序产生错误的结果或无法按预期工作。例如,错误的条件判断、循环错误或算法错误等。
数据类型错误:当程序使用不正确的数据类型时,会导致错误的结果或行为。例如,将字符串数据传递给需要整数的函数,或者使用未初始化的变量。
内存错误:内存错误是指对内存的错误访问或管理。这包括访问已释放的内存、内存泄漏(未释放的内存)或越界访问数组等。
并发错误:并发错误发生在多线程或多进程程序中,当多个线程或进程同时访问共享资源时可能发生冲突。这可能导致数据竞争、死锁或其他并发问题。
输入验证错误:如果程序没有正确验证输入数据的有效性和完整性,可能会导致安全漏洞或意外行为。例如,未正确验证用户输入的数据,导致注入攻击或缓冲区溢出等。
性能问题:程序设计中的性能问题可能导致程序运行缓慢或无法处理大规模数据。这包括算法复杂度过高、低效的数据结构选择或不必要的计算等。
为了减少这些错误,程序员通常会采取一系列的最佳实践,如使用合适的编码规范、进行代码审查、进行单元测试和集成测试、使用调试工具等。此外,良好的软件工程实践和经验也对减少程序设计错误非常重要。
那什么时候会发生内存重叠呢?
chat:两个程序占用的内存会有重叠的情况通常发生在以下几种情况下:
共享库:如果两个程序都使用了相同的共享库,那么这些库的内存区域将被多个程序共享。这样,两个程序的内存使用就会有重叠。
内存映射文件:当两个程序都将同一个文件映射到内存中时,它们的内存区域也会有重叠。这种情况通常发生在多个进程需要访问相同的文件数据时,比如共享内存或内存映射数据库等。
动态链接库(DLL)加载:如果两个程序都使用了相同的动态链接库,并且这些库被加载到相同的内存地址上,那么两个程序的内存使用也会有重叠。
需要注意的是,操作系统负责管理和分配内存,以确保不同程序之间的内存空间不会互相干扰。因此,即使存在内存重叠,操作系统也会通过虚拟内存机制来隔离不同程序的内存空间,以确保它们之间的安全性和稳定性。
- 如果第一个程序在2000的位置写入一个新的值,将会擦掉第二个程序存放在相同位置上的所有内容。
- 所以同时运行两个程序是根本行不通的,这两个程序会立刻崩溃。
- 因此,有了虚拟内存。每个进程分配独立的一套虚拟地址,互不干涉。(虚拟地址由操作系统负责映射到物理内存)
虚拟内存地址和物理内存地址
- 操作系统会提供一种机制,将不同进程的虚拟地址和不同内存的物理地址映射起来。
- 如果程序要访问虚拟地址的时候,由操作系统转换成不同的物理地址,这样不同的进程运行的时候,写入的是不同的物理地址,这样就不会冲突了。
两种地址
我们程序所使用的内存地址叫做虚拟内存地址(Virtual Memory Address)
实际存在硬件里面的空间地址叫物理内存地址(Physical Memory Address)。
操作系统引入了虚拟内存,进程持有的虚拟地址会通过 CPU 芯片中的内存管理单元(MMU)的映射关系,来转换变成物理地址,然后再通过物理地址访问内存
内存分段
- 内存分段是操作管理虚拟地址与物理地址之间关系的方式之一,还有一种是内存分页。
- 程序是由若干个逻辑分段组成的,如可由代码分段、数据分段、栈段、堆段组成。不同的段是有不同的属性的,所以就用分段(Segmentation)的形式把这些段分离出来。
分段机制下,虚拟地址和物理地址是如何映射的?
分段机制下的虚拟地址由两部分组成,段选择子和段内偏移量。
- 段选择子就保存在段寄存器里面。段选择子里面最重要的是段号,用作段表的索引。段表里面保存的是这个段的基地址、段的界限和特权等级等。
- 虚拟地址中的段内偏移量应该位于 0 和段界限之间,如果段内偏移量是合法的,就将段基地址加上段内偏移量得到物理内存地址。
- 虚拟地址是通过段表与物理地址进行映射的,分段机制会把程序的虚拟地址分成 4 个段,每个段在段表中有一个项,在这一项找到段的基地址,再加上偏移量,于是就能找到物理内存中的地址
- 如果要访问段 3 中偏移量 500 的虚拟地址,我们可以计算出物理地址为,段 3 基地址 7000 + 偏移量 500 = 7500。
- 分段的办法很好,解决了程序本身不需要关心具体的物理内存地址的问题,但它也有一些不足之处:
第一个就是内存碎片的问题。
第二个就是内存交换的效率低的问题。
分段为什么会产生内存碎片问题
我们来看看这样一个例子。假设有 1G 的物理内存,用户执行了多个程序,其中:
- 游戏占用了 512MB 内存
- 浏览器占用了 128MB 内存
- 音乐占用了 256 MB 内存。
- 这个时候,如果我们关闭了浏览器,则空闲内存还有 1024 - 512 - 256 = 256MB。
- 如果这个 256MB 不是连续的,被分成了两段 128 MB 内存,这就会导致没有空间再打开一个 200MB 的程序。
内存碎片的问题
- 这里的内存碎片的问题共有两处地方:
- 外部内存碎片,也就是产生了多个不连续的小物理内存,导致新的程序无法被装载;
- 内部内存碎片,程序所有的内存都被装载到了物理内存,但是这个程序有部分的内存可能并不是很常使用,这也会导致内存的浪费;
- 解决外部内存碎片的问题就是内存交换。
- 可以把音乐程序占用的那 256MB 内存写到硬盘上,然后再从硬盘上读回来到内存里。不过再读回的时候,我们不能装载回原来的位置,而是紧紧跟着那已经被占用了的 512MB 内存后面。这样就能空缺出连续的 256MB 空间,于是新的 200MB 程序就可以装载进来。
- 这个内存交换空间,在 Linux 系统里,也就是我们常看到的 Swap 空间,这块空间是从硬盘划分出来的,用于内存与硬盘的空间交换
分段为什么会导致内存交换效率低?
- 对于多进程的系统来说,用分段的方式,内存碎片是很容易产生的,产生了内存碎片,那不得不重新 Swap 内存区域,这个过程会产生性能瓶颈。
- 因为硬盘的访问速度要比内存慢太多了,每一次内存交换,我们都需要把一大段连续的内存数据写到硬盘上。
- 所以,如果内存交换的时候,交换的是一个占内存空间很大的程序,这样整个机器都会显得卡顿。
内存分页
- 分段的好处就是能产生连续的内存空间,但是会出现内存碎片和内存交换的空间太大的问题。
- 要解决这些问题,那么就要想出能少出现一些内存碎片的办法。另外,当需要进行内存交换的时候,让需要交换写入或者从磁盘装载的数据更少一点,这样就可以解决问题了。这个办法,也就是内存分页(Paging)。
- 分页是把整个虚拟和物理内存空间切成一段段固定尺寸的大小。这样一个连续并且尺寸固定的内存空间,我们叫页(Page)。在 Linux 下,每一页的大小为 4KB。
- 虚拟地址与物理地址之间通过页表来映射,如下图:
内存映射
- 页表实际上存储在 CPU 的内存管理单元 (MMU) 中,于是 CPU 就可以直接通过 MMU,找出要实际要访问的物理内存地址。
- 而当进程访问的虚拟地址在页表中查不到时,系统会产生一个缺页异常,进入系统内核空间分配物理内存、更新进程页表,最后再返回用户空间,恢复进程的运行。
分页是怎么解决分段的内存碎片、内存交换效率低的问题?
由于内存空间都是预先划分好的,也就不会像分段会产生间隙非常小的内存,这正是分段会产生内存碎片的原因。而采用了分页,那么释放的内存都是以页为单位释放的,也就不会产生无法给进程使用的小内存。
如果内存空间不够,操作系统会把其他正在运行的进程中的「最近没被使用」的内存页面给释放掉,也就是暂时写在硬盘上,称为换出(Swap Out)。一旦需要的时候,再加载进来,称为换入(Swap In)。所以,一次性写入磁盘的也只有少数的一个页或者几个页,不会花太多时间,内存交换的效率就相对比较高。
更进一步地,分页的方式使得我们在加载程序的时候,不再需要一次性都把程序加载到物理内存中。我们完全可以在进行虚拟内存和物理内存的页之间的映射之后,并不真的把页加载到物理内存里,而是只有在程序运行中,需要用到对应虚拟内存页里面的指令和数据时,再加载到物理内存里面去。
分页机制下,虚拟地址和物理地址是如何映射的?
在分页机制下,虚拟地址分为两部分,页号和页内偏移。页号作为页表的索引,页表包含物理页每页所在物理内存的基地址,这个基地址与页内偏移的组合就形成了物理内存地址,见下图。
内存分页寻址
总结一下,对于一个内存地址转换,其实就是这样三个步骤:
- 把虚拟内存地址,切分成页号和偏移量;
- 根据页号,从页表里面,查询对应的物理页号;
- 直接拿物理页号,加上前面的偏移量,就得到了物理内存地址。
下面举个例子,虚拟内存中的页通过页表映射为了物理内存中的页,如下图:
虚拟页与物理页的映射
这看起来似乎没什么毛病,但是放到实际中操作系统,这种简单的分页是肯定是会有问题的。
简单的分页有什么缺陷?
- 有空间上的缺陷。
- 因为操作系统是可以同时运行非常多的进程的,那这不就意味着页表会非常的庞大。
- 在 32 位的环境下,虚拟地址空间共有 4GB,假设一个页的大小是 4KB(2^12),那么就需要大约 100 万 (2^20) 个页,每个「页表项」需要 4 个字节大小来存储,那么整个 4GB 空间的映射就需要有 4MB 的内存来存储页表。
- 这 4MB 大小的页表,看起来也不是很大。但是要知道每个进程都是有自己的虚拟地址空间的,也就说都有自己的页表。
- 那么,100 个进程的话,就需要 400MB 的内存来存储页表,这是非常大的内存了,更别说 64 位的环境了。
多级页表
- 要解决上面的问题,就需要采用的是一种叫作多级页表(Multi-Level Page Table)的解决方案。
- 在前面我们知道了,对于单页表的实现方式,在 32 位和页大小 4KB 的环境下,一个进程的页表需要装下 100 多万个「页表项」,并且每个页表项是占用 4 字节大小的,于是相当于每个页表需占用 4MB 大小的空间。
- 我们把这个 100 多万个「页表项」的单级页表再分页,将页表(一级页表)分为 1024 个页表(二级页表),每个表(二级页表)中包含 1024 个「页表项」,形成二级分页。如下图所示:
分了二级表,映射 4GB 地址空间就需要 4KB(一级页表)+ 4MB(二级页表)的内存,这样占用空间不是更大了吗?
-
当然如果 4GB 的虚拟地址全部都映射到了物理内上的,二级分页占用空间确实是更大了,但是,我们往往不会为一个进程分配那么多内存。
-
其实我们应该换个角度来看问题,还记得计算机组成原理里面无处不在的局部性原理么?
-
每个进程都有 4GB 的虚拟地址空间,而显然对于大多数程序来说,其使用到的空间远未达到 4GB,因为会存在部分对应的页表项都是空的,根本没有分配,对于已分配的页表项,如果存在最近一定时间未访问的页表,在物理内存紧张的情况下,操作系统会将页面换出到硬盘,也就是说不会占用物理内存。
-
如果使用了二级分页,一级页表就可以覆盖整个 4GB 虚拟地址空间,但如果某个一级页表的页表项没有被用到,也就不需要创建这个页表项对应的二级页表了,即可以在需要时才创建二级页表。做个简单的计算,假设只有 20% 的一级页表项被用到了,那么页表占用的内存空间就只有 4KB(一级页表) + 20% * 4MB(二级页表)= 0.804MB,这对比单级页表的 4MB 是不是一个巨大的节约?
-
那么为什么不分级的页表就做不到这样节约内存呢?我们从页表的性质来看,保存在内存中的页表承担的职责是将虚拟地址翻译成物理地址。假如虚拟地址在页表中找不到对应的页表项,计算机系统就不能工作了。所以页表一定要覆盖全部虚拟地址空间,不分级的页表就需要有 100 多万个页表项来映射,而二级分页则只需要 1024 个页表项(此时一级页表覆盖到了全部虚拟地址空间,二级页表在需要时创建)。
-
我们把二级分页再推广到多级页表,就会发现页表占用的内存空间更少了,这一切都要归功于对局部性原理的充分应用。
-
对于 64 位的系统,两级分页肯定不够了,就变成了四级目录,分别是:
-
全局页目录项 PGD(Page Global Directory);
-
上层页目录项 PUD(Page Upper Directory);
-
中间页目录项 PMD(Page Middle Directory);
-
页表项 PTE(Page Table Entry);
- 多级页表虽然解决了空间上的问题,但是虚拟地址到物理地址的转换就多了几道转换的工序,这显然就降低了这俩地址转换的速度,也就是带来了时间上的开销。
- 程序是有局部性的,即在一段时间内,整个程序的执行仅限于程序中的某一部分。相应地,执行所访问的存储空间也局限于某个内存区域。
程序的局部性
-
我们就可以利用这一特性,把最常访问的几个页表项存储到访问速度更快的硬件,于是计算机科学家们,就在 CPU 芯片中,加入了一个专门存放程序最常访问的页表项的 Cache,这个 Cache 就是 TLB(Translation Lookaside Buffer) ,通常称为页表缓存、转址旁路缓存、快表等。
-
在 CPU 芯片里面,封装了内存管理单元(Memory Management Unit)芯片,它用来完成地址转换和 TLB 的访问与交互。
-
有了 TLB 后,那么 CPU 在寻址时,会先查 TLB,如果没找到,才会继续查常规的页表。
-
TLB 的命中率其实是很高的,因为程序最常访问的页就那么几个。
段页式内存管理
- 内存分段和内存分页并不是对立的,它们是可以组合起来在同一个系统中使用的,那么组合起来后,通常称为段页式内存管理。
段页式地址空间
段页式内存管理实现的方式:
- 先将程序划分为多个有逻辑意义的段,也就是前面提到的分段机制;
- 接着再把每个段划分为多个页,也就是对分段划分出来的连续空间,再划分固定大小的页;
这样,地址结构就由段号、段内页号和页内位移三部分组成。
用于段页式地址变换的数据结构是每一个程序一张段表,每个段又建立一张页表,段表中的地址是页表的起始地址,而页表中的地址则为某页的物理页号,如图所示:
段页式管理中的段表、页表与内存的关系
段页式地址变换中要得到物理地址须经过三次内存访问:
- 第一次访问段表,得到页表起始地址;
- 第二次访问页表,得到物理页号;
- 第三次将物理页号与页内位移组合,得到物理地址。
可用软、硬件相结合的方法实现段页式地址变换,这样虽然增加了硬件成本和系统开销,但提高了内存的利用率。
总结
- 为了在多进程环境下,使得进程之间的内存地址不受影响,相互隔离,于是操作系统就为每个进程独立分配一套的虚拟地址空间,每个程序只关心自己的虚拟地址就可以,实际上大家的虚拟地址都是一样的,但分布到物理地址内存是不一样的。作为程序,也不用关心物理地址的事情。
- 每个进程都有自己的虚拟空间,而物理内存只有一个,所以当启用了大量的进程,物理内存必然会很紧张,于是操作系统会通过内存交换技术,把不常使用的内存暂时存放到硬盘(换出),在需要的时候再装载回物理内存(换入)。
- 那既然有了虚拟地址空间,那必然要把虚拟地址「映射」到物理地址,这个事情通常由操作系统来维护。
- 那么对于虚拟地址与物理地址的映射关系,可以有分段和分页的方式,同时两者结合都是可以的。
- 内存分段是根据程序的逻辑角度,分成了栈段、堆段、数据段、代码段等,这样可以分离出不同属性的段,同时是一块连续的空间。但是每个段的大小都不是统一的,这就会导致内存碎片和内存交换效率低的问题。
- 于是,就出现了内存分页,把虚拟空间和物理空间分成大小固定的页,如在 Linux 系统中,每一页的大小为 4KB。由于分了页后,就不会产生细小的内存碎片。同时在内存交换的时候,写入硬盘也就一个页或几个页,这就大大提高了内存交换的效率。
- 再来,为了解决简单分页产生的页表过大的问题,就有了多级页表,它解决了空间上的问题,但这就会导致 CPU 在寻址的过程中,需要有很多层表参与,加大了时间上的开销。于是根据程序的局部性原理,在 CPU 芯片中加入了 TLB,负责缓存最近常被访问的页表项,大大提高了地址的转换速度。
- Linux 系统主要采用了分页管理,但是由于 Intel 处理器的发展史,Linux 系统无法避免分段管理。于是 Linux 就把所有段的基地址设为 0,也就意味着所有程序的地址空间都是线性地址空间(虚拟地址),相当于屏蔽了 CPU 逻辑地址的概念,所以段只被用于访问控制和内存保护。
- 另外,Linxu 系统中虚拟空间分布可分为用户态和内核态两部分,其中用户态的分布:代码段、全局变量、BSS、函数栈、堆内存、映射区。