持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第 11 天,点击查看活动详情
一、虚拟内存
- 操作系统为每个进程分配独立的一套「虚拟地址」,将不同进程的虚拟地址和不同内存的物理地址映射起来,达到隔离进程地址的目的;
- 进程持有的虚拟地址会通过 CPU 芯片中的内存管理单元(MMU)的映射关系,来转换变成物理地址,然后再通过物理地址访问内存;
- 程序所使用的内存地址叫做虚拟内存地址(Virtual Memory Address);
- 实际存在硬件里面的空间地址叫物理内存地址(Physical Memory Address);
- 虚拟内存可以使得进程对运行内存超过物理内存大小,对于那些没有被经常使用到的内存,可以把它换出到物理内存之外,比如硬盘上的 swap 区域;
- 每个进程的虚拟内存空间就是相互独立的,也没有办法访问其他进程的页表,这就解决了多进程之间地址冲突的问题;
- 页表里的页表项中除了物理地址之外,还有一些标记属性的比特,比如控制一个页的读写权限,标记该页是否存在等;
- 32 位操作系统,进程最多只能申请 3 GB 大小的虚拟内存空间;
二、内存分段
- 可由代码分段、数据分段、栈段、堆段组成;
- 分段虚拟地址组成:
- 段选择因子:保存在段寄存器里面,段选择子里面最重要的是段号,用作段表的索引,段表里面保存的是这个段的基地址、段的界限和特权等级等;
- 段内偏移量:应该位于 0 和段界限之间,如果段内偏移量是合法的,就将段基地址加上段内偏移量得到物理内存地址;
- 外部内存碎片:
- 内存分段管理可以做到段根据实际需求分配内存,所以有多少需求就分配多大的段,所以不会出现内部内存碎片;
- 由于每个段的长度不固定,多个段未必能恰好使用所有的内存空间,内存回收后会产生了多个不连续的小物理内存,导致新的程序无法被装载,所以会出现外部内存碎片的问题;
- 内存交换效率:
- 低,外部内存碎片是很容易产生的,Swap内存区域时,分段的方式下每一次内存交换,都需要把一大段连续的内存数据写到硬盘上,这个过程会产生性能瓶颈,大内存的程序会更明显;
三、内存分页
- 分页是把整个虚拟和物理内存空间切成一段段连续并且尺寸固定的内存空间,在Linux下,每一页的大小为4KB;
- 虚拟地址与物理地址之间通过页表来映射,页表是存储在内存里的,内存管理单元(MMU)就做将虚拟内存地址转换成物理地址的工作;
- 在进行虚拟内存和物理内存的页之间的映射之后,并不真的把页加载到物理内存里,而是只有在程序运行中,需要用到对应虚拟内存页里面的指令和数据时,再加载到物理内存里面去;
- 当进程访问的虚拟地址在页表中查不到时,系统会产生一个缺页异常,进入系统内核空间分配物理内存、更新进程页表,最后再返回用户空间,恢复进程的运行;
- 内部内存碎片:
- 页与页之间是紧密排列的,所以不会有外部碎片;
- 内存分页机制分配内存的最小单位是一页,程序不足一页大小时,页内会出现内存浪费,所以针对内存分页机制会有内部内存碎片的现象;
- 内存交换效率:
- Swap内存区域时,一次性写入磁盘的只有少数的一个页或者几个页,不会花太多时间,内存交换的效率就相对比较高;
- 分页虚拟地址组成:
- 页号:作为页表的索引,根据虚拟页号,从页表里面,查询对应的物理页号;
- 偏移量:直接拿物理页号,加上偏移量,就得到了物理内存地址;
- 简单分页缺陷:
- 如果虚拟地址在页表中找不到对应的页表项,计算机系统就不能工作,所以页表一定要覆盖全部虚拟地址空间;
- 在32位环境下,虚拟地址空间共有4GB,一个页的大小是4KB,那么就需要大约100万个页,每个「页表项」需要4个字节大小来存储,那么一个进程整个4GB虚拟地址空间的映射就需要有4MB的内存来存储页表;
- 操作系统是可以同时运行非常多的进程的,每个进程都是有自己的虚拟地址空间,意味着页表会非常的庞大,64位将会更大;
- 多级页表:
- 组成:一级页号、二级页号、偏移量;
- 将每1024个页表当做一个表项组成一个二级页表,再将该二级页表作为一个页表项组成一个一级页表,形成1024*1024的二级映射;
- 4G虚拟地址只需要先映射到一个一级页表即可完成全部虚拟地址的映射,即4KB;
- 对于大多数程序来说,其使用到的空间远未达到4GB,存在部分对应的页表项都是空的,根本没有分配;
- 如果某个一级页表的页表项没有被用到,也就不需要创建这个页表项对应的二级页表了,即可以在需要时才创建二级页表;
- 64位系统采用四级目录;
- 页表缓存:
- 利用局部性原理,在CPU芯片中,加入了一个专门存放程序最常访问的页表项的Cache,这个Cache就是TLB(Translation Lookaside Buffer),通常称为页表缓存、转址旁路缓存、快表等;
- 在CPU芯片里面,封装了内存管理单元(Memory Management Unit)芯片,用来完成地址转换和TLB的访问与交互,CPU在寻址时,会先查 TLB,如果没找到,才会继续查常规的页表;
四、段页式管理
- 实现方式:
- 先将程序划分为多个有逻辑意义的段,也就是分段机制;
- 接着再把每个段划分为多个页,对分段划分出来的连续空间,再划分固定大小的页;
- 地址结构就由段号、段内页号和页内位移三部分组成;
- 三次内存访问:
- 第一次访问段表,得到页表起始地址;
- 第二次访问页表,得到物理页号;
- 第三次将物理页号与页内位移组合,得到物理地址;
五、Linux内存管理
- 在 Linux 操作系统中,虚拟地址空间的内部被分为内核空间和用户空间两部分;
- 32 位系统的内核空间占用 1G,位于最高处,剩下的 3G 是用户空间;
- 64 位系统的内核空间和用户空间都是 128T,分别占据整个内存空间的最高和最低处,剩下的中间部分是未定义的;
- 进程在用户态时,只能访问用户空间内存;只有进入内核态后,才可以访问内核空间的内存;
- 每个进程都各自有独立的虚拟内存,但是每个虚拟内存中的内核地址,其实关联的都是相同的物理内存,进程切换到内核态后,就可以很方便地访问内核空间内存;
- 用户空间内存,从低到高分别6种不同的内存段:
- 程序文件段(.text),包括二进制可执行代码;
- 已初始化数据段(.data),包括静态常量;
- 未初始化数据段(.bss),包括未初始化的静态变量;
- 堆段,包括动态分配的内存,从低地址开始向上增长;
- 文件映射段,包括动态库、共享内存等,从低地址开始向上增长;
- 栈段,包括局部变量和函数调用的上下文等。栈的大小是固定的,一般是8MB,也可以通过修改系统参数自定义;
- 堆和文件映射段的内存是动态分配的,使用 C 标准库的malloc()或者 mmap(),就可以分别在堆和文件映射段动态分配内存;
六、内存申请与释放
1,malloc()
- malloc()并不是系统调用,而是 C 库里的函数,用于动态分配内存;
- malloc() 分配的是虚拟内存,如果分配后的虚拟内存没有被访问的话,虚拟内存是不会映射到物理内存的;
- malloc 申请内存的时候,会有两种方式向操作系统申请堆内存:
- 方式一:通过 brk() 系统调用从堆分配内存,通过 brk() 函数将「堆顶」指针向高地址移动,获得新的内存空间;
- 方式二:通过 mmap() 系统调用在文件映射区域分配内存;
- malloc() 源码里默认定义了一个阈值:
- 如果用户分配的内存小于 128 KB,则通过 brk() 申请内存;
- 如果用户分配的内存大于 128 KB,则通过 mmap() 申请内存;
2,free()
- 1)brk
- malloc 通过 brk() 方式申请的内存,free 释放内存的时候,并不会把内存归还给操作系统,而是缓存在 malloc 的内存池中;
- 在下次申请内存的时候,就直接从内存池取出对应的内存块就行了,而且可能这个内存块的虚拟地址与物理地址的映射关系还存在,这样不仅减少了系统调用的次数,也减少了缺页中断的次数,将大大降低 CPU 的消耗;
- 2)mmap
- malloc 通过 mmap() 方式申请的内存,free 释放内存的时候,会把内存归还给操作系统,内存得到真正的释放;
- 频繁通过 mmap 分配内存的话,不仅会发生缺页中断(在第一次访问虚拟地址后),还会发生运行态的切换,这样会导致 CPU 消耗较大;
- 3)内存块描述信息
- 用户使用的内存块前有一块内存,保存了该内存块的描述信息,比如有该内存块的大小;
- 当执行 free() 函数时,free 会对传入进来的内存地址向左偏移 16 字节,然后从这个 16 字节的分析出当前的内存块的大小,自然就知道要释放多大的内存了;
3,为什么需要brk和mmp两种方式
- 随着系统频繁地 malloc 和 free ,尤其对于小块内存,堆内将产生越来越多不可用的碎片,导致“内存泄露”。而这种“泄露”现象使用 valgrind 是无法检测出来的;
- 所以,malloc 实现中,充分考虑了 brk 和 mmap 行为上的差异及优缺点,默认分配大块内存 (128KB) 才使用 mmap 分配内存空间;
七、内存回收
1,后台内存回收(kswapd)
- 在物理内存紧张的时候,会唤醒 kswapd 内核线程来回收内存,这个回收内存的过程异步的,不会阻塞进程的执行;
2,直接内存回收(direct reclaim)
- 如果后台异步回收跟不上进程内存申请的速度,就会开始直接回收,这个回收内存的过程是同步的,会阻塞进程的执行;
3,OOM
- 如果直接内存回收后,空闲的物理内存仍然无法满足此次物理内存的申请,那么内核就会触发 OOM (Out of Memory)机制;
- OOM Killer 机制会根据算法选择一个占用物理内存较高的进程,然后将其杀死,以便释放内存资源,如果物理内存依然不足,OOM Killer 会继续杀死占用物理内存较高的进程,直到释放足够的内存位置;
4,回收的内容
- 文件页(File-backed Page)
- 内核缓存的磁盘数据(Buffer)和内核缓存的文件数据(Cache)都叫作文件页,回收干净页的方式是直接释放内存,回收脏页的方式是先写回磁盘后再释放内存;
- 匿名页(Anonymous Page)
- 这部分内存没有实际载体,不像文件缓存有硬盘文件这样一个载体,比如堆、栈数据等,通过 Linux 的 Swap 机制,Swap 会把不常访问的内存先写到磁盘中,然后释放这些内存,给其他更需要的进程使用,再次访问这些内存时,重新从磁盘读入内存就可以;
- 回收内存的操作基本都会发生磁盘 I/O 的,如果回收内存的操作很频繁,意味着磁盘 I/O 次数会很多,这个过程势必会影响系统的性能,整个系统给人的感觉就是很卡;
5,回收策略
1)回收方式
- 文件页和匿名页的回收都是基于 LRU 算法,也就是优先回收不常访问的内存;
- LRU 回收算法,实际上维护着 active 和 inactive 两个双向链表,其中:
- active_list 活跃内存页链表,这里存放的是最近被访问过(活跃)的内存页;
- inactive_list 不活跃内存页链表,这里存放的是很少被访问(非活跃)的内存页;
- 活跃和非活跃的内存页,按照类型的不同,又分别分为文件页和匿名页。可以从 /proc/meminfo 中,查询它们的大小;
2)策略调整-倾向回收匿名页
- Linux 提供了一个 /proc/sys/vm/swappiness 选项,用来调整文件页和匿名页的回收倾向;swappiness 的范围是 0-100,数值越大,越积极使用 Swap,也就是更倾向于回收匿名页;数值越小,越消极使用 Swap,也就是更倾向于回收文件页;
3)策略调整-尽早触发 kswapd 内核线程异步回收内存
- 内核定义了三个阈值(watermark,也称水位),衡量当前剩余内存(pages_free)是否充裕:
- 页高阈值(pages_high)、页低阈值(pages_low)、页最小阈值(pages_min);
- 分别为:pages_min1.5、pages_min1.25、pages_min;
- 三个阈值划分了四种内存状态,分别为:
- 内存充足、内存分配正常、内存压力大、内存基本耗尽;
- 内存回收触发:
- 当内存低于页低阈值即内存压力大状态时,kswapd 会执行异步内存回收,直到剩余内存大于高阈值为止;
- 当内存低于页最小阈值即内存基本耗尽时,触发直接内存回收;
- 页低阈值可以通过内核选项 /proc/sys/vm/min_free_kbytes (该参数代表系统所保留空闲内存的最低限)来间接设置;
4)不同CPU架构下的内存回收
- SMP
- 一种多CPU处理器共享资源的电脑硬件架构,多个CPU都通过一个总线访问内存,每个CPU访问内存所用时间都是相同的,也被称为一致存储访问结构;
- 随着CPU处理器核数的增多,总线的带宽压力会越来越大,同时每个CPU可用带宽会减少,这也就是SMP架构的问题;
- NUMA
- 即非一致存储访问结构(Non-uniform memory access,NUMA);
- 将每个CPU进行了分组,每一组CPU用Node来表示,一个Node可能包含多个CPU;
- 每个Node有自己独立的资源,每个Node之间可以通过互联模块总线进行通信,但是访问远端 Node 的内存比访问本地内存要耗时很多;
- 当某个 Node 内存不足时,系统可以从其他 Node 寻找空闲内存,也可以从本地内存中回收内存。
5)OOM
- 在经历完直接内存回收后,空闲的物理内存大小依然不够,那么就会触发 OOM 机制,OOM killer 就会根据每个进程的内存占用情况和 oom_score_adj 的值进行打分,得分最高的进程就会被首先杀掉;
5,swap机制
- 定义:
- Swap 就是把一块磁盘空间或者本地文件,当成内存来使用,它包含换出和换入两个过程:
- 换出(Swap Out) ,是把进程暂时不用的内存数据存储到磁盘中,并释放这些数据占用的内存;
- 换入(Swap In),是在进程再次访问这些内存的时候,把它们从磁盘读到内存中来;
- 优缺点:
- 优点:应用程序实际可以使用的内存空间将远远超过系统的物理内存;
- 缺点:频繁地读写硬盘,会显著降低操作系统的运行速率;
- 触发场景:
- 内存不足:当系统需要的内存超过了可用的物理内存时,内核会将内存中不常使用的内存页交换到磁盘上为当前进程让出内存,保证正在执行的进程的可用性,这个内存回收的过程是强制的直接内存回收;
- 内存闲置:应用程序在启动阶段使用的大量内存在启动后往往都不会使用,通过后台运行的守护进程(kSwapd),可以将这部分只使用一次的内存交换到磁盘上为其他内存的申请预留空间;
- 启用方式:
- Swap 分区是硬盘上的独立区域,该区域只会用于交换分区,其他的文件不能存储在该区域上,可以使用swapon -s命令查看当前系统上的交换分区;
- Swap 文件是文件系统中的特殊文件,它与文件系统中的其他文件也没有太多的区别;
八、预读失效
1,预读机制
- 含义:
- 应用程序利用 read 系统调动读取 4KB 数据,实际上内核使用预读机制(ReadaHead)机制完成了 16KB 数据的读取,也就是通过一次磁盘顺序读将多个 Page 数据装入 Page Cache;
- 示例:
- 应用程序只想读取磁盘上文件 A 的 offset 为 0-3KB 范围内的数据,由于磁盘的基本读写单位为 block(4KB),于是操作系统至少会读 0-4KB 的内容,这恰好可以在一个 page 中装下;
- 但是操作系统出于空间局部性原理(靠近当前被访问数据的数据,在未来很大概率会被访问到),会选择将磁盘块 offset [4KB,8KB)、[8KB,12KB) 以及 [12KB,16KB) 都加载到内存,于是额外在内存中申请了 3 个 page;
- 优缺点
- 优点:减少了 磁盘 I/O 次数,提高系统磁盘 I/O 吞吐量;
- 缺点:如果这些被提前加载进来的页,并没有被访问,相当于这个预读工作是白做了,这个就是预读失效。
- 预读失效影响:
- 使用传统的 LRU 算法进行缓存淘汰管理时,就会把「预读页」放到 LRU 链表头部,而当内存空间不够的时候,还需要把末尾的页淘汰掉;
- 如果这些「预读页」一直不会被访问到,就会出现不会被访问的预读页却占用了 LRU 链表前排的位置,而末尾淘汰的页,可能是热点数据,这样就大大降低了缓存命中率;
- 传统 LRU 算法问题:
- 预读失效导致缓存命中率下降;
- 缓存污染导致缓存命中率下降;
2,解决方式
- 让预读页停留在内存里的时间要尽可能的短,让真正被访问的页才移动到 LRU 链表的头部,从而保证真正被读取的热数据留在内存里的时间尽可能长;
- Linux 操作系统和 MySQL Innodb 通过改进传统 LRU 链表来避免预读失效带来的影响,具体的改进分别如下:
- Linux 操作系统实现了两个 LRU 链表:活跃 LRU 链表(active_list)和非活跃 LRU 链表(inactive_list);
- MySQL 的 Innodb 存储引擎是在一个 LRU 链表上划分来 2 个区域:young 区域 和 old 区域。
- 这两个改进方式,设计思想都是类似的,都是将数据分为了冷数据和热数据,然后分别进行 LRU 算法;
- Linux 操作系统实现方式:
- active list 活跃内存页链表,这里存放的是最近被访问过(活跃)的内存页;
- inactive list 不活跃内存页链表,这里存放的是很少被访问(非活跃)的内存页;
- 预读页就只需要加入到 inactive list 区域的头部,当页被真正访问的时候,才将页插入 active list 的头部;
- 如果预读的页一直没有被访问,就会从 inactive list 移除,这样就不会影响 active list 中的热点数据;
- MySQL Innodb:
- young 区域与 old 区域在 LRU 链表中的占比关系并不是一比一的关系,而是是 7 比 3 (默认比例)的关系;
- 划分这两个区域后,预读的页就只需要加入到 old 区域的头部,当页被真正访问的时候,才将页插入 young 区域的头部;
- 如果预读的页一直没有被访问,就会从 old 区域移除,这样就不会影响 young 区域中的热点数据;
九、缓存污染
1,含义
- 在批量读取数据的时候,由于数据被访问了一次,这些大量数据都会被加入到「活跃 LRU 链表」里,之前缓存在活跃 LRU 链表里的热点数据全部都被淘汰了,如果这些大量的数据在很长一段时间都不会被访问的话,那么整个活跃 LRU 链表就被污染了;
2,影响
- 等这些热数据又被再次访问的时候,由于缓存未命中,就会产生大量的磁盘 I/O,系统性能就会急剧下降;
3,解决方式
- 只要我们提高进入到活跃 LRU 链表(或者 young 区域)的门槛,就能有效地保证活跃 LRU 链表(或者 young 区域)里的热点数据不会被轻易替换掉;
- Linux:
- 在内存页被访问第二次的时候,才将页从 inactive list 升级到 active list 里;
- MySQL:
- 在内存页被访问第二次的时候,并不会马上将该页从 old 区域升级到 young 区域,因为还要进行停留在 old 区域的时间判断:
- 如果第二次的访问时间与第一次访问的时间在 1 秒内(默认值),那么该页就不会被从 old 区域升级到 young 区域;
- 如果第二次的访问时间与第一次访问的时间超过 1 秒,那么该页就会从 old 区域升级到 young 区域;