不知道大家在运行自己写的程序时,有没有发现一个问题:就是物理机器明明只有8G内存,但是我们运行的程序却可以申请到16G的内存?或者说机器上运行的多个进程,占用的总内存已经远超物理内存了,却还能正常工作。其实,这都要归功于CPU和操作系统设计的虚拟内存的机制。所谓虚拟内存,就是机器上运行的一个个的进程,访问的都是虚拟的内存,比如C语言中的指针指向的内存地址,或者gdb调试工具看到的地址,都是虚拟的,并不是机器上的实际物理内存。
而物理内存,简单说就是那根内存条,是机器真正实际可以访问的物理内存空间。你的内存条是 1G 的,那计算机可用的物理内存就是 1G。这个内存条加电以后就可以存储数据了。在早期的 CPU 指令集里,从内存中加载数据,向内存中写入数据都是直接操作物理内存的。也就是说每一个数据存储在内存的什么位置,都由程序员自己负责。例如,8086 这款 40 年前的 CPU 的 mov 指令就可以直接访问物理内存。
1、为什么要使用虚拟内存如果只有单个进程独享整个物理内存,当然没有什么问题。但是如果有多个进程,必然要求程序员手动对数据进行布局,那么内存不够用怎么办呢?而且,每个进程分配多少内存,如何保证指令中访存地址的正确性,这些问题都全部要程序员来负责。
还有,当两个进程要同时对同一个物理内存地址进行读写时,显然是有冲突的。随着计算机上要运行的程序越来越多,这个问题也越来越突出。
那既然直接访问物理内存效率那么低,现在还有开发人员用这种模式吗?
其实也还是有的。在嵌入式设备中,手动管理内存的操作还是广泛存在的。这是因为在嵌入式开发中,往往没有进程的概念,也就是说整个应用独享全部内存,所以手动管理内存才有可能性。在单进程的系统中,所有的物理资源都是单一进程在管理,直接管理物理内存的操作复杂度还可以接受。
因为直接使用物理地址存在前面所说的问题,所以CPU和操作系统联合设计出了虚拟地址的机制,就是给所有程序可见的都是虚拟地址,这块虚拟地址非常大,并且是连续的,每个程序都可以操作虚拟内存地址,至于说这个虚拟内存对应的是哪块物理内存,交给CPU和操作系统就好了,这样就大大提高了程序的开发效率。
Intel从80286 CPU开始,改变了8086直接访问物理内存的方式,在CPU芯片内部集成了内存管理单元(MMU),进程访问的虚拟内存地址通过MMU,转换成物理地址,然后再通过物理地址访问内存。
2、进程的虚拟内存空间是什么样的计算机的虚拟内存大小是不一样的。虚拟地址空间往往与机器字宽有关系,下面是32位和64位系统下进程的虚拟地址空间:
可以看出:
-
32 位系统上,指向内存的指针是 32 位的,所以它的虚拟地址空间是 2 的 32 次方,也就是 4G。其中,内核空间占用 1G,位于最高处,剩下的 3G 是用户空间。
-
64 位系统上,指向内存的指针是 64 位的,但在 64 位系统里只使用了低 48 位,所以它的虚拟地址空间是 2 的 48 次方,也就是 256T。其中,内核空间和用户空间都是 128T,分别占据整个内存空间的最高和最低处,剩下的中间部分是未定义的。
进程在用户态时,只能访问用户空间内存;只有进入内核态后,才可以访问内核空间内存。虽然每个进程的地址空间都包含了内核空间,但这些内核空间,其实关联的都是相同的物理内存。这样,进程切换到内核态后,就可以很方便地访问内核空间内存。
3、进程申请内存时就会对应到物理内存吗既然每个进程都有一个这么大的地址空间,那么所有进程的虚拟内存加起来,自然要比实际的物理内存大得多。所以,并不是所有的虚拟内存都会分配物理内存,只有那些实际使用的虚拟内存才分配物理内存,并且分配后的物理内存,是通过内存映射来管理的。
虚拟空间页面与物理空间页面的映射关系,如下图:
这个图中,我们需要理解的有这几点:
-
虽然虚拟内存提供了很大的空间,但实际上进程启动之后,这些空间并不是全部都能使用的。
-
开发者必须要使用 malloc 等分配内存的接口才能将内存从未分配状态变成已分配状态。在你得到一块虚拟内存以后,这块内存就是未映射状态,因为它并没有被映射到相应的物理内存;直到对该块内存进行读写时,CPU 就会去访问这个虚拟内存, 这时会发现这个虚拟内存没有映射到物理内存, CPU 就会产生缺页中断,进程会从用户态切换到内核态,并将缺页中断交给内核的 Page Fault Handler (缺页中断函数)处理,这时才会真正地为它分配物理内存。然后这个页面才能成为正常页面。
-
在虚拟内存中连续的页面,在物理内存中不必是连续的。只要维护好从虚拟内存页到物理内存页的映射关系,你就能正确地使用内存了。这种映射关系是操作系统通过页表来自动维护的,不必你操心。
相关视频推荐
Linux内核源码分析之《物理内存与虚拟内存》
剖析linux内核MMU机制详解
庞杂的内存问题,如何理出自己的思路出来,让你开发与面试双丰收
免费学习地址:c/c++ linux服务器开发/后台架构师
需要C/C++ Linux服务器架构师学习资料加qun812855908获取(资料包括C/C++,Linux,golang技术,Nginx,ZeroMQ,MySQL,Redis,fastdfs,MongoDB,ZK,流媒体,CDN,P2P,K8S,Docker,TCP/IP,协程,DPDK,ffmpeg等),免费分享
4、虚拟地址和物理地址是怎么映射的虚拟地址和物理地址的映射机制,经历了从内存分段到分页的过程,我们先来看内存分段。
4.1 内存分段内存分段机制,简单理解就是根据程序申请使用内存的需要,来把物理内存分成一段一段内存来管理,比如程序需要100M的内存,分段机制就给1段100M连续空间的物理内存与之对应。
在地址寻址上,分段机制维护有段表,并且将虚拟地址分为两部分,一部分是段表项的编号,另外一部分是段内偏移量。每个段表项里面有段的起始地址和段的边界长度,其中段内偏移量应该小于段的边界长度。每个段在段表中有一个项,在这一项找到段的基地址,再加上偏移量,于是就能找到物理内存中的地址。
比如要访问的虚拟地址A,段表项编号为2,段内偏移量是400 ,段表项2的起始地址是1000,边界长度是500,我们可以计算出A对应的物理地址为,段表项2起始地址 1000 + 偏移量 400 = 1400。
分段机制解决了程序使用物理地址存在的问题,但是也有一些不足之处:
-
存在外部内存碎片:因为每段大小长度不一样,各个段之间会存在多段大小不一样且不连续的空隙,比如存在两段不连续的100M空间,这个时候即使刚好有个进程(例如下图中的进程B)需要申请200M空间,也是没法直接使用这两段100M空间的,因为它们是不连续的。
-
换入换出效率低:针对两段不连续100M空间不能给需要200M空间的进程B的问题,其实也是可以有办法解决的,就是将其它进程(比如进程A)暂时不用的内存,先通过swap机制写入到磁盘中(换入),等给这个进程B分配好内存后,再从磁盘把进程A回写到内存的另外一段空间(换出)。但是因为磁盘的读写速度比内存读写慢太多了,这个过程会产生性能瓶颈。
4.2 内存分页由于分段存在前面说的问题,所以提出了内存分页机制。内存分页改变了分段这种粗犷的内存管理方式,它将整个虚拟内存和物理内存空间分成一段段固定大小的片,虚拟内存和物理内存的映射以这个片为最小单位进行管理,我们把这个片称为页,在linux系统上,页的大小为4KB。
分段机制用段表来进行虚拟地址和物理地址的映射,分页机制是用页表来进行映射的。在分页机制下,虚拟地址分为两部分,页编号和页偏移。页编号作为页表的索引,页表包含物理页编号,这个物理页编号与页偏移的组合就形成了物理内存地址。
内存分页由于内存空间都是预先划分好的,页与页之间是紧密排列的,不会存在像分段中存在的段与段之间的空隙,所以不会有外部碎片。但是分页最小的管理单位是页,即使需要的内存没有一页,也会分配一个页,所以也会存在内部碎片,有内存浪费的情况。
4.3 多级页表上面的页表映射关系看起来还是比较简单的,不过我们得考虑一下页表的大小问题,因为页表是存储在内存当中的,而内存大小比较有限。
以32位linux系统为例,我们来计算一下:32位系统,一个进程的虚拟内存空间为4GB,linux的页大小为4KB,所以需要4GB/4KB=(1024*1024)页,内存分页每一个页表项大小为4字节,可以对应1个页,所以需要的页表项大小就是1024*1024*4 = 4M字节。
4M大小看起来没多大,但是这只是一个进程需要的页表项大小,如果有100个进程,1000个进程呢?显然页表项占用的内存空间就会非常多,而且这还只是32位系统,如果是64位系统,所需要的页表项就更多了。
为了解决页表项过多的问题,Linux 提供了两种机制,也就是大页(HugePage)和多级页表。
大页,顾名思义,就是比普通页更大的内存块,常见的大小有 2MB 和 1GB。页大小变大了,所需要的页自然就少了,页表项也变少了,页表占用的空间也就变少了。大页通常用在使用大量内存的进程上,比如 Oracle、DPDK 等。
多级页表就是把内存分成区块来管理,将原来的映射关系改成区块索引和区块内的偏移。由于虚拟内存空间通常只用了很少一部分,那么,多级页表就只保存这些使用中的区块,这样就可以大大地减少页表的项数。
32位系统使用的是两级页表,将虚拟地址分为三部分:
-
22~31(10位):一级页号(页目录表)
-
12~21(10位):二级页号
-
0~11(12位): 页内偏移量
10位的一级页号,可以表示0~1023,每一个一级页号可以对应一个内存块号,通过这个内存块号,可以找到一个二级页表,在二级页表中可以找到二级页号对应的内存块,然后再加上12位的页内偏移量,就可以算出对应的物理内存。
所以我们可以知道:
-
一级页表只有1个,一个一级页表有1024个页表项,每个页表项记录一个二级页表号,总共可以记录1024个二级页表,占用内存大小1024*4 = 4KB
-
二级页表有1024个,每个页表有1024个页表项,总共占用内存大小1024*1024*4 = 4M
看起来二级页表总共需要4KB+4M的内存空间来存放所有页表项,比一级页表项占用的空间还要多,但实际并不是这样的。
根据局部性原理可知,很多时候,进程在一段时间内只需要访问某几个页面就可以正常运行了。因此没有必要让整个页面都常驻内存,不用把所有的页表都调入内存,只在需要它时才调入,所以只需要一张索引表来告诉我们第几张页表该上哪里去找,就能解决页表的查询问题。建立多级页表的目的在于建立索引,以便不用浪费主存空间去存储无用的页表项,也不用盲目地顺序式查找页表项。
所以,如果某个一级页表的页表项没有被用到,也就不需要创建这个页表项对应的二级页表了,即可以在需要时才创建二级页表,所以两级页表的方式实际上比一级页表大大节省了页表项的内存空间占用。
上面为了理解简单,是以32位系统为例,在我们现在实际使用的linux 64位系统上,采用的方案是4级页表,分别是:
-
PGD:page Global directory(47-39), 页全局目录
-
PUD:Page Upper Directory(38-30),页上级目录
-
PMD:page middle directory(29-21),页中间目录
-
PTE:page table entry(20-12), 直接页表
4.4 TLBCPU在执行指令时,通过MMU进行虚拟地址到物理地址的转换,这个转换关系是存在页表中的,而页表是存在于内存中的,相对于访问CPU的寄存器或者Cache,访问内存还是要慢得多,所以,根据程序运行的局部性原理以及参照CPU的三级缓存设计思想,我们把页表中的热点表项存到了CPU中叫做TLB(Translation Lookaside Buffer)的硬件里面了。
有了 TLB 后,那么 CPU 在寻址时,会先查 TLB,如果没找到,才会继续查常规的页表。TLB的访问速度非常快,和寄存器相当,比L1访问还快。有了TLB之后,CPU访问某个虚拟内存地址的过程如下:
5、Linux系统中如何查看进程实际占用的内存大小前面说的是CPU和操作系统对于虚拟内存的映射管理机制,由于程序使用的都是虚拟内存,那在Linux系统上,如何查看进程实际占用的内存大小呢?
我们一般都会用top命令去查看单个进程占用的内存大小,但是我们得结合前面的理论知识去理解top命令查看的参数的实际意义:
这些数据,包含了进程最重要的几个内存使用情况,我们来看一下:
-
VIRT 是进程虚拟内存的大小,只要是进程申请过的内存,即便还没有真正分配物理内存,也会计算在内。
-
RES 是常驻内存的大小,也就是进程实际使用的物理内存大小,但不包括 Swap 和共享内存。
-
SHR 是共享内存的大小,比如与其他进程共同使用的共享内存、加载的动态链接库以及程序的代码段等。
-
%MEM 是进程使用物理内存占系统总内存的百分比。
这里我们需要注意两点:
-
虚拟内存通常并不会全部分配物理内存。从上面的输出,你可以发现每个进程的虚拟内存都比常驻内存大得多。
-
共享内存 SHR 并不一定是共享的,比方说,程序的代码段、非共享的动态链接库,也都算在 SHR 里。当然,SHR 也包括了进程间真正共享的内存。所以在计算多个进程的内存使用时,不要把所有进程的 SHR 直接相加得出结果。
好了,虚拟内存机制的内容就先讲到这里了。