内存管理
内存管理
操作系统的内存管理主要负责下面这些事情
- 内存的分配与回收 - 对进程所需的内存进行分配和释放
- 地址转换 - 将程序中的虚拟地址转换成内存中的物理地址
- 内存扩充 - 当系统没有足够的内存时,利用虚拟内存技术或自动覆盖技术,从逻辑上扩充内存
- 内存映射 - 将一个文件直接映射到进程的进程空间中,这样可以通过内存指针用读写内存的办法直接存取文件内容,速度更快
- 内存优化 - 通过调整内存分配策略和回收算法来优化内存使用效率
- 内存安全 - 保证进程之间使用内存互不干扰,避免一些恶意程序通过修改内存来破坏系统的安全性
- …
虚拟内存
操作系统提供虚拟内存机制,将不同进程的虚拟地址和内存中不同的物理地址进行映射,以避免进程同时运行写入内存时引发冲突
CPU 通过内存管理单元(MMU) 将进程持有的虚拟地址按映射关系转变成物理地址,然后再通过物理地址访问内存
作用
- 使进程的运行内存超过物理内存大小 - 因为程序运行符合局部性原理,CPU 访问内存会有很明显的重复访问的倾向性,对于那些没有被经常使用到的内存,我们可以把它换出到物理内存之外,比如硬盘上的 swap 区域
- 解决多进程之间地址冲突的问题 - 每个进程的虚拟内存空间相互独立,实际写入的物理地址由操作系统保证不冲突
- 简化内存管理 :进程都有一个一致且私有的虚拟地址空间,程序不用和真正的物理内存打交道,而是借助虚拟地址空间访问物理内存,从而简化了内存管理
- 在内存访问方面的安全性更好 - 控制进程对物理内存的访问,隔离不同进程的访问权限,提高系统的安全性
- 共享物理内存 - 进程在运行过程中会加载许多操作系统的动态库,这些库对于每个进程而言都是公用的,它们在内存中实际只会加载一份,这部分称为共享内存
物理地址是真正的物理内存中地址,更具体点来说是内存地址寄存器中的地址
程序中访问的内存地址不是物理地址,而是虚拟地址
操作系统一般通过 CPU 中的一个重要组件 内存管理单元 MMU 将虚拟地址转换为物理地址,这个过程被称为地址翻译/地址转换
常见的虚拟内存管理方式包括内存分段管理,内存分页管理和段页式管理
内存分段
分段机制(Segmentation)以段(—段连续的物理内存)的形式管理/分配物理内存。应用程序的虚拟地址空间被分为大小不等的段,段是有实际意义的,每个段定义了一组逻辑信息,例如有主程序段 MAIN、子程序段 X、数据段 D 及栈段 S 等
分段管理通过段表映射虚拟地址和物理地址,虚拟地址由段选择因子和段内偏移量两部分组成
- 段选择因子保存在段寄存器里,包括用作段表的索引的段号,段表里面保存的段基地址、段界限和特权等级等
- 虚拟地址中的段内偏移量位于 0 和段界限之间,段内偏移量合法时,将段内偏移量加上段基地址即可得到物理内存地址
段表项可能不存在
- 被删除 - 软件错误、软件恶意行为等情况可能会导致段表项被删除
- 还未创建 - 如果系统内存不足或者无法分配到连续的物理内存块,就会导致段表项无法被创建
分段机制通常会把程序的虚拟地址分成 4 个段(栈,堆,数据,代码),每个段在段表中有一个项,包含该段的段基地址

内存分段存在外部内存碎片和内存交换效率低两大问题
- 多个不连续的小物理内存导致在空闲内存足够的情况下新的程序无法被装载,进而导致外部内存碎片
- 通过内存交换,可将小物理内存写入硬盘,再次读入时紧密排列,进而空缺出连续空闲内存空间,但对一个占内存空间很大的程序进行内存交换时会严重影响性能
内存分页
内存分页用于解决内存分段存在的问题,把整个虚拟内存空间和物理内存空间分成固定尺寸的页(Page)
在 Linux 下,每一页的大小为 4KB
虚拟地址与物理地址之间通过页表来映射,页表存储在内存中
操作系统负责维护页表,为每个进程提供一个独立的页表,即一个独立的虚拟地址空间
分页机制下的虚拟地址由页号和页内偏移量两部分组成
- 页号作为页表的索引,页表包含物理页每页所在物理内存的基地址
- 页内偏移量加上物理内存的基地址形成实际物理内存地址
内存分页机制分配内存的最小单位是一页,页与页之间紧密排列,所以不会有外部内存碎片,但页内可能会出现内存浪费,所以内存分页机制会有内部内存碎片
换页机制指内存空间不够时,操作系统换出其他正在运行的进程中最近没被使用的内存页面至硬盘,需要的时候再重新换入,仅操作少数页,内存交换效率高,同时加载程序时可延迟加载内存页至物理内存,仅在实际使用时进行加载
页缺失
当进程访问的虚拟地址在页表中查不到时,系统产生一个缺页异常,进入内核空间分配物理内存、更新进程页表,最后再返回用户空间,恢复进程的运行
- 硬性页缺失(Hard Page Fault) - 物理内存中没有对应的物理页,Page Fault Hander 指示 CPU 从已经打开的磁盘文件中读取相应的内容到物理内存,而后交由 MMU 建立相应的虚拟页和物理页的映射关系
- 软性页缺失(Soft Page Fault) - 物理内存中有对应的物理页,但虚拟页还未和物理页建立映射,Page Fault Hander 指示 MMU 建立相应的虚拟页和物理页的映射关系
发生上面这两种缺页错误的时候,应用程序访问的是有效的物理内存,只是出现了物理页缺失或者虚拟页和物理页的映射关系未建立的问题
如果应用程序访问的是无效的物理内存的话,还会出现无效缺页错误(Invalid Page Fault)
多级页表
简单分页下由于每个进程需要有虚拟的独属完整内存空间,也即有自己的页表,因此需要庞大的内存空间存储所有进程的页表
在 32 位的环境下,虚拟地址空间共有 4GB,假设一个页的大小是 4KB(2^12),那么就需要大约 100 万 (2^20) 个页,每个「页表项」需要 4 个字节大小来存储,那么整个 4GB 空间的映射就需要有
4MB的内存来存储页表
100个进程的话,就需要400MB的内存来存储页表
多级页表中对应多个页表,每个页表与前一个页表相关联
32 位系统一般为二级页表,一级页表覆盖完整的虚拟地址空间,但其中仅存储一级页号和二级页表地址,二级表中存储实际物理页号
大多数程序使用空间远小于实际物理内存,因此可以延迟到实际访问时真正创建二级页表,进而节约内存空间
对于 64 位系统,采用四级目录来进一步节省空间
- 全局页目录项 PGD(Page Global Directory)
- 上层页目录项 PUD(Page Upper Directory)
- 中间页目录项 PMD(Page Middle Directory)
- 一级页表(Page Table) - 页表项 PTE(Page Table Entry)
页目录并不是一开始就准备好的,而是在缺页处理中,内核一点一点补齐的
PGD 是一开始内核就为进程准备好的,在 fork()创建进程的时候,内核会为进程创建 PGD,更加严谨的说,内核其实提供了一个配置项,默认是只为进程创建 PGD,但是我们可以通过配置来让内核创建多少个 PUD、PMD(这一点只做了解就好),一般情况下,你可以认为内核只会为刚刚创建出来的进程准备 PGD 顶级页表
- 一张 page table 其实本质上是一个物理内存页,占用 4K,page table 中的元素叫做页表项 PTE,一个 PTE 可以映射 4K 大小的物理内存,64 位中一个 PTE 占用 8 字节,一张 page table 里包含 512 个 PTE ,所以一张 page table 可以映射 2M 的物理内存
- PMD 页目录中的一个页目录项 pmd 是指向一张 page table 的所以一个 pmd 就可以映射 2M 物理内存,这个就是内存大页,由 PMD 页目录中的页目录项负责映射,同样的道理,一张 PMD 由 512 个页目录项,一个 pmd 页目录项可以映射 2M,那么一张 PMD 页目录表就可以映射 1G 的物理内存
- PUD 里的页目录项是负责指向 PMD 的,所以 PUD 里的页目录项就可以映射 1G 物理大页,这个就是大页映射的本质,64位 x86 架构的 CPU 支持的物理大页尺寸为 2M 和 1G 它们分别被 PMD 页目录项映射,PUD 页目录项映射
页表项
页表项通常有如下图的字段:
- 页号
- 物理页号
- 状态位 - 用于表示该页是否有效,也就是说是否在物理内存中,供程序访问时参考
- 访问字段 - 用于记录该页在一段时间被访问的次数,供页面置换算法选择出页面时参考
- 修改位 - 表示该页在调入内存后是否有被修改过,由于内存中的每一页都在磁盘上保留一份副本,因此,如果没有修改,在置换该页时就不需要将该页写回到磁盘上,以减少系统的开销;如果已经被修改,则将该页重写到磁盘上,以保证磁盘中所保留的始终是最新的副本
- 硬盘地址 - 用于指出该页在硬盘上的地址,通常是物理块号,供调入该页时使用
写时复制
- 创建子进程时,将父进程的虚拟内存与物理内存映射关系(页表)复制到子进程中,并将内存设置为只读(设为了当对内存进行写操作时触发缺页异常)
- 当父进程或者子进程对内存数据进行修改时,便会触发写时复制机制,CPU 就会触发写保护中断,然后操作系统会在「写保护中断处理函数」里进行相应物理内存的复制,并重新设置其内存映射关系,将父子进程的内存读写权限设置为可读写,最后才会对复制出的新内存数据进行写操作
父子进程通过页表的复制共享一片物理内存,当父进程或者子进程向这个内存发起写操作时,CPU 就会触发写保护中断,然后操作系统会在「写保护中断处理函数」里进行相应物理内存的复制,并重新设置其内存映射关系,将父子进程的内存读写权限设置为可读写,最后才会对复制出的内存进行写操作,这个过程被称为写时复制(Copy On Write)
分页 VS 分段
共同点:
- 都是非连续内存管理的方式
- 都采用了地址映射的方法,将虚拟地址映射到物理地址,以实现对内存的管理和保护
区别 :
- 分页机制以页面为单位进行内存管理,而分段机制以段为单位进行内存管理
- 页的大小是固定的,由操作系统决定,通常为 2 的幂次方。而段的大小不固定,取决于我们当前运行的程序
- 页是物理单位,即操作系统将物理内存划分成固定大小的页面,每个页面的大小通常是 2 的幂次方,例如 4KB、8KB 等等,而段是逻辑单位,是为了满足程序对内存空间的逻辑需求而设计的,通常根据程序中数据和代码的逻辑结构来划分
- 分段机制容易出现外部内存碎片,即在段与段之间留下碎片空间(不足以映射给虚拟地址空间中的段),分页机制解决了外部内存碎片的问题,但仍然可能会出现内部内存碎片
- 分页机制采用了页表来完成虚拟地址到物理地址的映射,页表通过一级页表和二级页表来实现多级映射,而分段机制则采用了段表来完成虚拟地址到物理地址的映射,每个段表项中记录了该段的起始地址和长度信息
- 分页机制对程序没有任何要求,程序只需要按照虚拟地址进行访问即可,分段机制需要程序员将程序分为多个段,并且显式地使用段寄存器来访问不同的段
段页式内存管理
段页式内存管理为内存分段和内存分页的组合
实现方式:先将程序划分为多个有逻辑意义的段,再把每个段划分为多个固定大小的页
该机制下地址结构由段号、段内页号和页内位移三部分组成
段页式地址变换中经过三次内存访问得到物理地址:
- 访问段表,得到页表起始地址
- 访问页表,得到物理页号
- 将物理页号与页内位移组合,得到物理地址
Linux 内存管理
Intel CPU
intel 的 80386 CPU在由段式内存管理所映射而成的地址上再加上一层地址映射进行页式内存管理
段式内存管理先将逻辑地址映射成线性地址,然后再由页式内存管理将线性地址映射成物理地址
TLB
根据局部性原理,在一段时间内,程序倾向于访问同一块或相邻的内存地址
因此在 CPU 中存在一个存放程序最常访问的页表项的缓存 TLB,又称页表缓存、转址旁路缓存、快表等
局部性原理指的是,在计算机程序运行过程中,访问内存的数据和指令往往呈现出一定的局部性特征,即在一段时间内,程序倾向于访问同一块或相邻的内存地址。局部性原理主要包括以下三种类型:
- 时间局部性:指程序在某个时间点访问某个内存地址时,很可能在不久的将来再次访问该地址
- 空间局部性:指程序在访问某个内存地址时,很可能在不久的将来访问该地址相邻的内存地址
- 块局部性:指程序在访问某个内存地址时,很可能在不久的将来访问该地址所在的内存块中的其他地址
TLB 同样由 CPU 通过 MMU 访问
Linux 中,开启大内存页能减少页表项的数量,降低 TLB MISS
假设我们只有一级页表 page table,page table 中的页表项只能映射 4K 大小的物理内存,那么如果用 pte 来映射 2M 的内存大页,就需要 512 个 pte,而 TLB 使用来缓存热点 pte 的,但 TLB 并不一定可以一下放下这 512 个 pte,这样你访问内存的时候就会有很大概率发生 miss
现在引入了大页,比如 2M 的物理大页,它在进程页表中只需要一个 pmd(PMD 页目录项)就可以映射了,大小和 pte 一样都是 8 字节,TLB 中就只需要缓存这一个 pmd ,对比一下,同样是访问 2m 物理内存,大页情况下 TLB 中有一个 pmd 就可以了,而普通页的情况下 TLB 需要缓存 512 个,而 TLB 容量有限,是不是用大页 TLB miss 的概率就会大大降低了?
Linux 内存管理机制
Linux 内存主要采用页式内存管理,但由于依托于 Intel 的 CPU,同时也涉及了段机制
Linux 中每个段都是从 0 地址开始的整个虚拟空间,Linux 系统中的代码,包括操作系统本身的代码和应用程序代码,所面对的地址空间都是线性地址空间(虚拟地址),这种做法相当于屏蔽了处理器中的逻辑地址概念,段只被用于访问控制和内存保护
Linux 操作系统中虚拟地址空间的内部又被分为内核空间和用户空间两部分,不同位数的系统,地址空间的范围也不同
进程在用户态时,只能访问用户空间内存,只有进入内核态后,才可以访问内核空间的内存
每个虚拟内存中的内核地址关联的都是相同的物理内存
对于一个完整的段式内存,32 位系统又将用户空间内存从低到高分为 7 种内存段
- 保留区,不可访问,在大多数的系统里较小数值的地址不是一个合法地址
- 代码段,包括二进制可执行代码
- 数据段,包括已初始化的静态常量和全局变量
- BSS 段,包括未初始化的静态变量和全局变量
- 堆段,包括动态分配的内存,从低地址开始向上增长
- 文件映射段,包括动态库、共享内存等,从低地址开始向上增长(跟硬件和内核版本有关)
- 栈段,包括局部变量和函数调用的上下文等(栈的大小是固定的,一般是 8 MB)
内存分配
malloc
malloc 是 C 语言库中用于动态分配内存的函数,其申请内存时存在两种向操作系统申请堆内存的方式
- 通过
brk()从堆分配内存- 将指向堆顶的指针向高地址移动,获得新的内存空间
- free 释放内存的时候,并不会把内存归还给操作系统,而是缓存在 malloc 的内存池中,待下次使用
- 缺点:缓存导致堆内产生众多不可用小碎片
- 通过
mmap()系统调用从文件映射分配内存- 以匿名映射的方式从文件映射区分配的匿名内存
- free 释放内存的时候,会把内存归还给操作系统,内存得到真正的释放
- 缺点:每次都会发生运行态的切换,第一次访问分配的虚拟地址时会发生缺页中断(mmap 没有缓存,分配的内存每次释放时都会归还给操作系统)
malloc 分配的是虚拟内存而非物理内存
malloc 分配内存时会多申请 16 byte 用于保存该内存块的描述信息(e.g. 内存块的大小)
内存回收
当 CPU 访问没有映射到物理内存的虚拟内存时,就会产生缺页中断,进程会从用户态切换到内核态,并将缺页中断交给内核的 Page Fault Handler (缺页中断函数)处理
如果有空闲物理内存直接分配并建立映射,没有则触发内存回收
- 后台内存回收(kswapd):唤醒 kswapd 内核线程异步回收内存,不会阻塞进程的执行
- 直接内存回收(direct reclaim):后台异步回收跟不上进程内存申请的速度时进行直接内存回收,阻塞进程执行同步回收内存
主要有两类内存可以被回收:
- 文件页(File-backed Page):内核缓存的磁盘数据(Buffer)和内核缓存的文件数据(Cache)
- 对于文件页,回收干净页的方式是直接释放内存,回收脏页的方式是先写回磁盘后再释放内存
- 匿名页(Anonymous Page):堆、栈数据等,这部分内存没有真实文件载体,且很可能还要再次被访问,所以不能直接释放内存
- 通过 Linux 的 Swap 机制回收,Swap 把不常访问的内存先写到磁盘中,然后释放内存空间,再次访问这些内存时重新从磁盘读入
文件页和匿名页的回收都是基于 LRU 算法(优先回收不常访问),维护 active 和 inactive 两个双向链表
回收内存时脏页写回磁盘和 swap 换入磁盘都会发生磁盘 IO,产生性能开销
回收内存带来的性能影响
调整文件页和匿名页的回收倾向
尽早触发 kswapd 内核线程异步回收内存
NUMA 架构下的内存回收策略
内存置换 SWAP
SWAP 机制将内存数据换出磁盘,又从磁盘中恢复数据到内存
SWAP 会在内存不足和内存闲置的场景下触发:
- 内存不足:当系统需要的内存超过了可用的物理内存时,内核会使用直接内存回收将内存中不常使用的内存页交换到磁盘上为当前进程让出内存
- 内存闲置:应用程序在启动阶段使用的大量内存之后往往都不会使用,内核通过后台运行的守护进程 kSwapd 将这部分只使用一次的内存交换到磁盘上为其他内存的申请预留空间
Linux 提供了Swap 分区(Swap Partition)和 Swap 文件(Swapfile)两种方法启用 Swap
- Swap 分区是硬盘上的独立区域,该区域只会用于交换分区,其他的文件不能存储在该区域上,可以使用
swapon -s命令查看当前系统上的交换分区 - Swap 文件是文件系统中的特殊文件,与文件系统中的其他文件没有太大区别
mmap
mmap 是一种内存映射文件的方法,即将一个文件或者其它对象映射到进程的地址空间,实现文件磁盘地址和进程虚拟地址空间中一段虚拟地址的一一对映关系
实现这样的映射关系后,进程就可以采用指针的方式读写操作这一段内存,而系统会自动回写脏页面到对应的文件磁盘上,即完成了对文件的操作而不必再调用 read,write 等系统调用函数
同时,内核空间对这段区域的修改也直接反映用户空间,从而可以实现不同进程间的文件共享
内存溢出
直接内存回收后仍无法满足内存申请时触发 OOM(Out of Memory) 机制
OOM Killer 会根据算法循环杀死一个占用物理内存较高的进程以便释放内存资源,直到释放足够的内存位置
Linux 内核使用 oom_badness() 函数扫描系统中可以被杀掉的进程并对每个进程打分,首先杀掉得分最高的进程
// points 代表打分的结果
// process_pages 代表进程已经使用的物理内存页面数
// oom_score_adj 代表 OOM 校准值
// totalpages 代表系统总的可用页面数
points = process_pages + oom_score_adj*totalpages/1000
oom_score_adj 默认为 0,可修改该值以减小进程被杀的可能
- 在 32 位操作系统,因为进程最大只能申请 3 GB 大小的虚拟内存,所以直接申请 8G 内存,会申请失败
- 在 64 位操作系统,因为进程最大能申请 128 TB 大小的虚拟内存,即使物理内存只有 4GB,申请 8G 内存也是没问题,因为申请的内存是虚拟内存
- 如果申请物理内存大小超过了空闲物理内存大小,就要看操作系统有没有开启 Swap 机制:
- 如果没有开启 Swap 机制,程序会直接 OOM
- 如果有开启 Swap 机制,程序可以正常运行








