前言
本文为 《图解系统》系列文章的个人学习笔记,对具体知识点与示例进行了归纳整理,详细内容参考小林coding 。
一、为什么要有虚拟内存?
1.1 虚拟内存
为了在多进程环境下,使得进程之间的内存地址不受影响,相互隔离,于是操作系统就为每个进程独立分配一套「虚拟地址空间」,每个程序只关心自己的虚拟地址就可以,实际上大家的虚拟地址都是一样的,但分布到物理地址内存是不一样的。作为程序,也不用关心物理地址的事情。
每个进程都有自己的虚拟空间,而物理内存只有一个,所以当启用了大量的进程,物理内存必然会很紧张,于是操作系统会通过「内存交换」技术,把不常使用的内存暂时存放到硬盘(换出,Swap Out),在需要的时候再装载回物理内存(换入,Swap In)。
操作系统引入了虚拟内存,进程持有的虚拟地址会通过 CPU 芯片中的内存管理单元(MMU)的映射关系,来转换变成物理地址,然后再通过物理地址访问内存,如下图所示:
1.2 虚拟物理与物理地址映射
对于虚拟地址与物理地址的映射关系,可以有分段和分页的方式,同时两者结合都是可以的。
内存分段
内存分段是根据程序的逻辑角度,分成了栈段、堆段、数据段、代码段等,这样可以分离出不同属性的段,同时是一块连续的空间。但是每个段的大小都不是统一的,这就会导致外部内存碎片和内存交换效率低的问题。
(1)外部内存碎片:内存分段是根据程序的逻辑角度,分成了栈段、堆段、数据段、代码段等,这样可以分离出不同属性的段,同时是一块连续的空间。但是每个段的大小都不是统一的,这就会导致外部内存碎片和内存交换效率低的问题。
(2)内存交换效率低:为解决外部内存碎片问题,需要频繁进行内存交换。内存与硬盘间的空间交换过程会产生性能瓶颈。
为了解决内存分段的「外部内存碎片和内存交换效率低」的问题,就出现了内存分页。
内存分页
内存分页把虚拟空间和物理空间分成大小固定的页,如在Linux 系统中,每一页的大小为 4kB
。由于分了页后,就不会产生细小的内存碎片,解决了内存分段的外部内存碎片问题。同时在内存交换的时候,写入硬盘也就一个页或几个页,这就大大提高了内存交换的效率。
此外,为了解决简单分页产生的页表过大的问题,就有了多级页表,它解决了空间上的问题,但这就会导致 CPU 在寻址的过程中,需要有很多层表参与,加大了时间上的开销。于是根据程序的局部性原理,在 CPU 芯片中加入了 TLB,负责缓存最近常被访问的页表项,大大提高了地址的转换速度。
段页式内存管理
段页式内存管理实现的方式:
- 先将程序划分为多个有逻辑意义的段,也就是前面提到的分段机制;
- 接着再把每个段划分为多个页,也就是对分段划分出来的连续空间,再划分固定大小的页。
1.3Linux内存布局
Linux内存管理方式
Linux 系统主要采用了分页管理,但是由于 Intel处理器的发展史,Linux 系统无法避免分段管理。于是 Linux 就把所有段的基地址设为 0
,也就意味着所有程序的地址空间都是线性地址空间(虚拟地址),相当于屏蔽了 CPU 逻辑地址的概念,所以段只被用于访问控制和内存保护。
Linux虚拟内存空间分布
在 Linux 操作系统中,虚拟地址空间的内部又被分为内核空间和用户空间两部分,不同位数的系统,地址空间的范围也不同。比如最常见的 32 位和 64 位系统,如下所示:
虽然每个进程都各自有独立的虚拟内存,但是每个虚拟内存中的内核地址,其实关联的都是相同的物理内存。这样,进程切换到内核态后,就可以很方便地访问内核空间内存。
Linux 系统中虚拟空间分布可分为用户态和内核态两部分,其中用户态的分布:代码段、全局变量、BSS、函数栈、堆内存、映射区。
通过下图你可以看到,用户空间内存,从低到高分别是6种不同的内存段:
- 代码段,包括二进制可执行代码
- 数据段,包括已初始化的静态常量和全局变量
- BSS 段,包括未初始化的静态变量和全局变量
- 堆段,包括动态分配的内存,从低地址开始向上增长
- 文件映射段,包括动态库、共享内存等,从低地址开始向上增长(跟硬件和内核版本有关)
- 栈段,包括局部变量和函数调用的上下文等。栈的大小是固定的,一般是
8MB
。当然系统也提供了参数,以便我们自定义大小
上图中的内存布局可以看到,代码段下面还有一段内存空间的(灰色部分),这一块区域是「保留区」,之所以要有保留区这是因为在大多数的系统里,我们认为比较小数值的地址不是一个合法地址,例如,我们通常在C的代码里会将无效的指针赋值为 NULL。因此,这里会出现一段不可访问的内存保留区,防止程序因为出现bug,导致读或写了一些小内存地址的数据,而使得程序跑飞。
虚拟内存空间分布的更多概念辨析参考文章:【Linux C基础复习】虚拟内存分布。
二、malloc 是如何分配内存的?
2.1 malloc的内存分配方式
实际上,malloc() 并不是系统调用,而是 C 库里的函数,用于动态分配内存。
malloc 申请内存的时候,会有两种方式向操作系统申请堆内存。
- 方式一:通过
brk()
系统调用从堆分配内存 - 方式二:通过
mmap()
系统调用在文件映射区域分配内存
方式一实现的方式很简单,就是通过 brk() 函数将「堆顶」指针向高地址移动,获得新的内存空间。如下图:
方式二通过 mmap() 系统调用中「私有匿名映射」的方式,在文件映射区分配一块内存(在用户进程地址空间与内核空间之间建立内存映射),也就是从文件映射区“偷”了一块内存。如下图:
malloc() 源码里默认定义了一个阈值:
- 如果用户分配的内存小于 128 KB,则通过
brk()
申请内存; - 如果用户分配的内存大于 128 KB,则通过
mmap()
申请内存;
注意,不同的 glibc 版本定义的阈值也是不同的。
2.2 malloc() 分配的是物理内存吗?
不是的,malloc()分配的是虚拟内存。
如果分配后的虚拟内存没有被访问的话,虚拟内存是不会映射到物理内存的,这样就不会占用物理内存了。
只有在访问已分配的虚拟地址空间的时候,操作系统通过查找页表,发现虚拟内存对应的页没有在物理内存中,就会触发缺页中断,然后操作系统会建立虚拟内存和物理内存之间的映射关系。
2.3 malloc(1) 会分配多大的虚拟内存?
malloc() 在分配内存的时候,并不是老老实实按用户预期申请的字节数来分配内存空间大小,而是会预分配更大的空间作为内存池。
2.4 free 释放内存,会归还给操作系统吗?
- malloc 通过
brk()
方式申请的内存,free 释放内存的时候,并不会把内存归还给操作系统,而是缓存在 malloc 的内存池中,待下次使用。 - malloc 通过
mmap()
方式申请的内存,free 释放内存的时候,会把内存归还给操作系统,内存得到真正的释放。
2.5 为什么不全用 mmap() 来分配内存?
brk()内存池能减少页中断的次数,降低CPU消耗。
频繁通过 mmap()
分配的内存话,不仅每次都会发生运行态的切换(用户态->内核态),还会发生缺页中断(在第一次访问虚拟地址后),这样会导致 CPU 消耗较大。
为了改进这个问题,malloc 通过 brk()
系统调用在堆空间申请内存的时候,由于堆空间是连续的,所以直接预分配更大的内存来作为内存池,当内存释放的时候,就缓存在内存池中。等下次在申请内存的时候,就直接从内存池取出对应的内存块就行了,而且可能这个内存块的虚拟地址与物理地址的映射关系还存在,这样不仅减少了系统调用的次数,也减少了缺页中断的次数这将大大降低 CPU 的消耗。
2.6 为什么不全用 brk() 来分配内存?
堆内容易出现不可用的内存碎片。
如果我们通过 brk()
连续申请了 10k,20k,30k 这三片内存,如果 10k和 20k这两片释放了,变为了空闲内存空间,如果下次申请的内存小于30k,那么就可以重用这个空闲内存空间。
但是如果下次申请的内存大于 30k,没有可用的空闲内存空间,必须向 OS 申请,实际使用内存继续增大。
因此,随着系统频繁地 malloc和 free ,尤其对于小块内存,堆内将产生越来越多不可用的碎片,导致“内存泄露”。而这种“泄露”现象使用 valgrind 是无法检测出来的。
所以,malloc 实现中,充分考虑了 brk 和 mmap 行为上的差异及优缺点,默认分配大块内存 (128KB) 才使用 mmap 分配内存空间。
2.7 free() 函数只传入一个内存地址,为什么能知道要释放多大的内存?
malloc申请内存时会预留16字节保存内存块的描述信息,free时根据此信息完成释放。
实际上,malloc 返回给用户态的内存起始地址比实际进程的堆空间起始地址多了 16 字节。这个多出来的 16 字节就是保存了该内存块的描述信息,比如有该内存块的大小。
这样当执行 free() 函数时,free 会对传入进来的内存地址向左偏移16 字节,然后从这个 16 字节的分析出当前的内存块的大小,自然就知道要释放多大的内存了。
三、内存满了会发生什么?
内存申请 -> 查询页表 -> 缺页中断 -> 没有空闲物理内存 -> 内存回收 ->触发 OOM 机制
3.1 内存回收
内核在给应用程序分配物理内存的时候,如果空闲物理内存不够那么就会进行内存回收的工作,主要有两种方式:
- 后台内存回收:在物理内存紧张的时候,会唤醒 kswapd 内核线程来回收内存,这个回收内存的过程异步的,不会阻塞进程的执行。
- 直接内存回收:如果后台异步回收跟不上进程内存申请的速度就会开始直接回收,这个回收内存的过程是同步的,会阻塞进程的执行。
可被回收的内存类型有文件页和匿名页:
- 文件页的回收:对于干净页是直接释放内存,这个操作不会影响性能;而对于脏页会先写回到磁盘再释放内存,这个操作会发生磁盘 IO 的,这个操作是会影响系统性能的。
- 匿名页的回收:如果开启了 Swap 机制,那么 Swap 机制会将不常访问的匿名页换出到磁盘中,下次访问时,再从磁盘换入到内存中,这个操作是会影响系统性能的。
文件页和匿名页的回收都是基于 LRU
算法,也就是优先回收不常访问的内存。回收内存的操作基本都会发生磁盘 IO 的,如果回收内存的操作很频繁,意味着磁盘 IO 次数会很多,这个过程势必会影响系统的性能。
3.2 OOM机制
在经历完直接内存回收后,空闲的物理内存大小依然不够,那么就会触发 OOM(Out of Memory) 机制,OOM killer 就会根据每个进程的内存占用情况和 oom_score_adj的值进行打分,得分最高的进程就会被首先杀掉。
我们可以通过调整进程的 /proc/[pid]/oom_score_adj值,来降低被OOM killer 杀掉的概率。
四、在 4GB 物理内存的机器上,申请 8G 内存会怎么样?
32位系统直接失败,64位系统申请能成功,访问后是否崩溃取决于系统是否有
Swap
分区(是否打开Swap
机制)
- 在 32 位操作系统,因为进程理论上最大能申请3GB 大小的虚拟内存,所以直接申请 8G 内存,会申请失败。
- 在 64位 位操作系统,因为进程理论上最大能申请 128 TB 大小的虚拟内存,即使物理内存只有 4GB,申请 8G 内存也是没问题因为申请的内存是虚拟内存。如果这块虚拟内存被访问了,要看系统有没有 Swap 分区:
- 如果没有 Swap 分区,因为物理空间不够,进程会被操作系统杀掉,原因是 OOM(内存溢出)
- 如果有 Swap 分区,即使物理内存只有 4GB,程序也能正常使用 8GB 的内存,进程可以正常运行。(磁盘的Swap分区大小默认1G,但会动态变化,若申请内存过大也会失败)
相关问题
虚拟内存有什么作用?
- 第一,虚拟内存可以使得进程对运行内存超过物理内存大小,因为程序运行符合局部性原理,CPU 访问内存会有很明显的重复访问的倾向性,对于那些没有被经常使用到的内存,我们可以把它换出到物理内存之外,比如硬盘上的 swap 区域。
- 第二,由于每个进程都有自己的页表,所以每个进程的虚拟内存空间就是相互独立的。进程也没有办法访问其他进程的页表,所以这些页表是私有的,这就解决了多进程之间地址冲突的问题。
- 第三,页表里的页表项中除了物理地址之外,还有一些标记属性的比特,比如控制一个页的读写权限,标记该页是否存在等。在内存访问方面,操作系统提供了更好的安全性。