1 虚拟内存
1.1 虚拟内存的作用
- 第一,虚拟内存可以使得进程的运行内存超过物理内存大小。因为程序运行符合局部性原理,对于那些没有被经常使用到的内存,我们可以使用内存交换技术,把它换出到物理内存之外,比如硬盘上的 swap 区域。
- 第二,由于每个进程都有自己的页表,所以每个进程的虚拟内存空间就是相互独立的。进程也没有办法访问其他进程的页表,所以这些页表是私有的,这就解决了多进程之间地址冲突的问题。
- 第三,页表里的页表项中除了物理地址之外,还有一些标记属性的比特,比如控制一个页的读写权限,标记该页是否存在等。在内存访问方面,操作系统提供了更好的安全性。
1.2 局部性原理
时间局部性:当前访问的数据可能很快再次被访问
空间局部性:当前访问数据的附近数据可能很快会被用到
1.3 分段和分页
虚拟地址「映射」到物理地址,这个事情通常由操作系统来维护(内存管理单元 (MMU)。
虚拟地址与物理地址的映射关系,可以有分段和分页的方式,同时两者结合都是可以的。
1.3.1 分段
内存分段是根据程序的逻辑角度,分成了栈段、堆段、数据段、代码段等,这样可以分离出不同属性的段,同时是一块连续的空间。
但是每个段的大小都不是统一的,这就会导致外部内存碎片和内存交换效率低的问题。
- 外部内存碎片:内存经过分配和释放,会留下一些不能使用的小内存块,导致外部内存碎片,而解决内存碎片的方式就是内存交换。
- 内存交换效率低:采用分段的方式,每次交换都要把一大段的数据写入磁盘,速度非常慢,效率低。
- 保留区:我们认为比较小数值的地址不是一个合法地址,例如,我们通常在 C 的代码里会将无效的指针赋值为 NULL。因此,这里会出现一段不可访问的内存保留区,防止程序因为出现 bug,导致读或写了一些小内存地址的数据,而使得程序跑飞。
- 代码段,包括二进制可执行代码;
- 数据段,包括已初始化的静态常量和全局变量;
- BSS 段,包括未初始化的静态变量和全局变量;在程序运行之前,代码段被加载进内存,全局变量和静态变量也被存入数据段和BSS段。
- 堆段,包括动态分配的内存,从低地址开始向上增长;
- 文件映射段,包括动态库、共享内存等,从低地址开始向上增长(跟硬件和内核版本有关 (opens new window));
- 栈段,包括局部变量和函数调用的上下文等。栈的大小是固定的,一般是
8 MB
。当然系统也提供了参数,以便我们自定义大小;
使用 C 标准库的 malloc()
或者 mmap()
,就可以分别在堆和文件映射段动态分配内存。
1.3.2 分页
于是,就出现了内存分页。把虚拟空间和物理空间分成大小固定的页,如在 Linux 系统中,每一页的大小为 4KB
。
分了页后,就不会产生细小的内存碎片,解决了内存分段的外部内存碎片问题。同时在内存交换的时候,写入硬盘也就一个页或几个页,这就大大提高了内存交换的效率。但是会产生内部碎片。
分页需要页表,每个进程都有自己的页表,进程数量较多时,页表会占用大量存储空间。
为了解决简单分页产生的页表过大的问题,就有了多级页表,它解决了空间上的问题,但这就会导致 CPU 在寻址的过程中,需要有很多层表参与,加大了时间上的开销。
于是根据程序的局部性原理,在 CPU 芯片中加入了 TLB,负责缓存最近常被访问的页表项,大大提高了地址的转换速度。首先查询TLB,未命中再查询页表。
1.4 Linux 系统内存管理
Linux 系统主要采用了分页管理,但是由于 Intel 处理器的发展史,Linux 系统无法避免分段管理。于是 Linux 就把所有段的基地址设为 0
,也就意味着所有程序的地址空间都是线性地址空间(虚拟地址),相当于屏蔽了 CPU 逻辑地址的概念,所以段只被用于访问控制和内存保护。
1.4.1 Linux 的虚拟地址空间
32
位系统的内核空间占用1G
,位于最高处,剩下的3G
是用户空间;64
位系统的内核空间和用户空间都是128T
,分别占据整个内存空间的最高和最低处,剩下的中间部分是未定义的。
- 进程在用户态时,只能访问用户空间内存;
- 只有进入内核态后,才可以访问内核空间的内存;
虽然每个进程都各自有独立的虚拟内存,但是每个虚拟内存中的内核地址,其实关联的都是相同的物理内存。
2 malloc分配内存
实际上,malloc() 并不是系统调用,而是 C 库里的函数,用于动态分配内存
2.1 malloc 是如何分配内存的
- 方式一:通过 brk() 系统调用从堆分配内存
- 方式二:通过 mmap() 系统调用在文件映射区域分配内存;
如果用户分配的内存小于 128 KB,则通过 brk() 申请内存;如果用户分配的内存大于 128 KB,则通过 mmap() 申请内存;
注意,不同的 glibc 版本定义的阈值也是不同的。
方式一:通过 brk() 函数将「堆顶」指针向高地址移动,获得新的内存空间。
方式二: 通过 mmap() 系统调用中「私有匿名映射」的方式,在文件映射区分配一块内存,也就是从文件映射区“偷”了一块内存。
2.2 malloc() 分配的是物理内存吗
不是的,malloc() 分配的是虚拟内存。
如果分配后的虚拟内存没有被访问的话,虚拟内存是不会映射到物理内存的,这样就不会占用物理内存了。
只有在访问已分配的内存的时候,操作系统通过查找页表,发现虚拟内存对应的页没有在物理内存中,就会触发缺页中断,然后操作系统会建立虚拟内存和物理内存之间的映射关系。
2.3 malloc(1) 会分配多大的虚拟内存
malloc() 在分配内存的时候,并不是按用户申请的字节数来分配,而是会预分配更大的空间。具体会预分配多大的空间,跟 malloc 使用的内存管理器有关系。
2.4 free 释放内存,会归还给操作系统吗
- malloc 通过 brk() 方式申请的内存,free 释放内存的时候,并不会把内存归还给操作系统,而是缓存在 malloc 的内存池中,待下次使用,从而减少申请次数,提高效率;
- malloc 通过 mmap() 方式申请的内存,free 释放内存的时候,会把内存归还给操作系统,内存得到真正的释放。
2.5 为什么不全部使用 mmap 来分配内存
向操作系统申请内存(brk 和 mmap),都是要通过系统调用的。执行系统调用是要进入内核态的,然后在回到用户态,运行态的切换会耗费不少时间。
mmap 分配的内存每次释放的时候,都会归还给操作系统,于是每次 mmap 分配的虚拟地址都是缺页状态的,然后在第一次访问该虚拟地址的时候,就会触发缺页中断,这样会导致 CPU 消耗较大。
malloc 通过 brk() 在堆空间申请的内存,当内存释放的时候,就缓存在内存池中。 等下次在申请内存的时候,就直接从内存池取出对应的内存块就行了,不需要进行系统调用。而且可能这个内存块的虚拟地址与物理地址的映射关系还存在,减少了缺页中断的次数,这将大大降低 CPU 的消耗。
2.6 既然 brk 那么牛逼,为什么不全部使用 brk 来分配
因为随着系统频繁地 malloc 和 free ,尤其对于小块内存,堆内将产生越来越多不可用的碎片,导致“内存泄露”。而这种“泄露”现象使用 valgrind 是无法检测出来的。
所以,malloc 实现中,充分考虑了 brk 和 mmap 行为上的差异及优缺点,默认分配大块内存 (128KB) 才使用 mmap 分配内存空间。
2.7 free() 函数只传入一个内存地址,为什么能知道要释放多大的内存
malloc 返回给用户的内存起始地址比进程的堆空间起始地址多了 16 字节,这个多出来的 16 字节就是保存了该内存块的描述信息,比如有该内存块的大小。
这样当执行 free() 函数时,free 会对传入进来的内存地址向左偏移 16 字节,然后从这个 16 字节的分析出当前的内存块的大小,自然就知道要释放多大的内存了。
3 内存满了,会发生什么?
3.1 内存分配的过程
malloc申请虚拟内存,此时并不会分配物理内存;
程序访问分配的虚拟内存时,如果虚拟内存没有映射到物理内存则出发缺页中断,并将缺页中断交给内核的 Page Fault Handler (缺页中断函数)处理;
如果有空闲内存,则直接分配;
如果没有空闲内存,内核就会进行内存回收,回收方式有两种:
- 后台内存回收(kswapd):在物理内存紧张的时候,会唤醒 kswapd 内核线程来回收内存,这个回收内存的过程异步的,不会阻塞进程的执行。
- 直接内存回收(direct reclaim):如果后台异步回收跟不上进程内存申请的速度,就会开始直接回收,这个回收内存的过程是同步的,会阻塞进程的执行。
如果直接内存回收后,空闲的物理内存仍然无法满足此次物理内存的申请,那么就会触发 OOM (Out of Memory)机制。OOM Killer 机制会选择一个占用物理内存较高的进程,然后将其杀死,释放内存资源,直到物理内存满足需求。
3.2 哪些内存可以被回收
主要有两类内存可以被回收:
- 文件页(File-backed Page):内核缓存的磁盘数据(Buffer)和文件数据(Cache)都叫作文件页。未修改的文件页,都可以直接释放内存;被修改过,并且暂时还没写入磁盘的数据(也就是脏页),得先写入磁盘,然后才能进行内存释放。
- 匿名页(Anonymous Page):这部分内存没有实际载体,比如堆、栈数据等。通过 Linux 的 Swap 机制,把不常访问的内存先写到磁盘中(写入swap区域),然后释放这些内存。再次访问这些内存时,重新从磁盘读入内存就可以了。
内存回收基于 LRU 算法,也就是优先回收不常访问的内存。
LRU 回收算法,实际上维护着 active 和 inactive 两个双向链表,越接近链表尾部,就表示内存页越不常访问。其中:
- active_list 活跃内存页链表,存放最近被访问过(活跃)的内存页;
- inactive_list 不活跃内存页链表,存放很少被访问(非活跃)的内存页;
3.3 回收内存带来的性能影响
异步回收,不阻塞进程,没有影响
同步回收,阻塞线程,造成延迟。
文件页的回收:干净的页直接释放,脏页需要写入磁盘,发生IO
匿名页的回收:一定会发生磁盘IO
针对回收内存导致的性能影响,常见的解决方式:
- 倾向于回收文件页,这样磁盘IO较少
- 尽早触发kswapd内核线程异步回收内存,避免同步回收内存,造成延迟
- 在NUMA架构下,设置zone_reclaim_mode=0,在回收本地内存之前,先在其它Node找空闲内存(NUMA架构,cpu分组为Node,每组都有各自的资源,也可访问其它组的资源,但耗时更长)
3.4 怎样避免进程被OOM
申请内存很大,内存回收无法满足需求,就会触发OOM,根据算法,杀死得分高的进程,回收内存资源。
得分受两个方面的影响:
- 进程使用的物理内存页数
- 每个进程的oom_score_adj参数
points = process_pages + oom_score_adj*totalpages/1000
我们可以通过调整进程的 oom_score_adj 值,来降低被 OOM killer 杀掉的概率。重要系统程序的得分应尽可能的低。
4 在4GB物理内存的机器上,申请8GB的内存
4.1 32 位操作系统
因为进程理论上最大能申请 3 GB 大小的虚拟内存,所以直接申请 8G 内存,会申请失败。
4.2 64位 位操作系统
因为进程理论上最大能申请 128 TB 大小的虚拟内存,即使物理内存只有 4GB,申请 8G 内存也是没问题,因为申请的内存是虚拟内存。
如果这块虚拟内存被访问了,要看系统有没有开启 Swap 机制:
- 如果没有 Swap 分区,因为物理空间不够,进程会被操作系统杀掉,原因是 OOM(内存溢出);
- 如果有 Swap 分区,即使物理内存只有 4GB,程序也能正常使用 8GB 的内存,进程可以正常运行;
4.3 swap机制
4.3.1 两个方面
- 换出(Swap Out) ,是把进程暂时不用的内存数据存储到磁盘中,并释放这些数据占用的内存;
- 换入(Swap In),是在进程再次访问这些内存的时候,把它们从磁盘读到内存中来;
4.3.2 触发条件
申请内存太大,当前物理内存无法满足,强制内存回收。这是同步回收;
物理内存的使用有一定的压力,触发kSwapd 线程异步回收。
4.3.3 Swap 换入换出的是什么类型的内存
进程的堆、栈数据等,它们是没有实际载体,这部分内存被称为匿名页。
而且这部分内存很可能还要再次被访问,所以不能直接释放内存,于是就需要有一个能保存匿名页的磁盘载体,这个载体就是 Swap 分区。
Swap 会把不常访问的内存先写到磁盘中,然后释放这些内存,给其他更需要的进程使用。再次访问这些内存时,重新从磁盘读入内存就可以了。
5 LRU算法
5.1 Linux 和 MySQL 的缓存
5.1.1 Linux 操作系统的缓存
在应用程序读取文件的数据的时候,Linux 操作系统会将读取的文件数据缓存在文件系统的
Page Cache。
Page Cache 属于内存空间,下一次访问数据如果能命中缓存就直接返回数据即可,不用进行磁盘IO.
5.1.2 MySQL 的缓存
MySQL 的数据是存储在磁盘里的,为了提升数据库的读写性能,Innodb 存储引擎设计了一个缓冲池(Buffer Pool),Buffer Pool 属于内存空间。
有了缓冲池后:
- 当读取数据时,如果数据存在于 Buffer Pool 中,客户端就会直接读取 Buffer Pool 中的数据,否则再去磁盘中读取。
- 当修改数据时,首先是修改 Buffer Pool 中数据所在的页,然后将其页设置为脏页,最后由后台线程将脏页写入到磁盘。
5.2 传统 LRU算法
一般使用「链表」作为数据结构来实现,链表头部的数据是最近使用的,而链表末尾的数据是最久没被使用的。那么,当空间不够了,就淘汰链表末尾的数据,从而腾出内存空间。
传统的 LRU 算法的实现思路是这样的:
- 当访问的页在内存里,就直接把该页对应的 LRU 链表节点移动到链表的头部。
- 当访问的页不在内存里,除了要把该页放入到 LRU 链表的头部,还要淘汰 LRU 链表末尾的页。
问题:
- 预读失效导致缓存命中率下降;
- 缓存污染导致缓存命中率下降
5.3 预读机制
5.3.1 定义
操作系统基于空间局部性原理(当前访问的数据附近的数据,在不久的将来很大概率会被访问到),读取数据时,会把附近的数据也读取进缓存;
这样,在访问附近的数据的时候,可以直接在Page Cache中读取,减少了磁盘IO次数。
5.3.2 预读失效
如果被预读进来的页,没有被访问,相当于白读了,这就是预读失效。
如果使用传统LRU算法,不会被访问的预读页占据链表前排位置,而链表后面的热点数据则被淘汰,这样会降低缓存的命中效率。
5.3.3 怎样避免预读失效的影响
Linux
Linux维护两个链表,分别是活跃 LRU 链表(active_list)和非活跃 LRU 链(inactive_list)。将数据分为了冷数据和热数据,然后分别进行 LRU 算法。
- active list 活跃内存页链表,这里存放的是最近被访问过(活跃)的内存页;
- inactive list 不活跃内存页链表,这里存放的是很少被访问(非活跃)的内存页;
预读页首先加入inactive list头部,当其真正被访问的时候,再放入active list的头部(此时,active list中被淘汰的数据降级到inactive list )。如果预读页一直没有被访问,则会从inactive ist移除。
MySQL
将 LRU 划分了 2 个区域:old 区域 和 young 区域。young 区域在 LRU 链表的前半部分,old 区域则是在后半部分。
划分这两个区域后,预读的页就只需要加入到 old 区域的头部,当页被真正访问的时候,才将页插入 young 区域的头部。如果预读的页一直没有被访问,就会从 old 区域移除,这样就不会影响 young 区域中的热点数据。
5.4 缓存污染
5.4.1 定义
当批量读取数据的时候,数据被访问一次,就会被从inactive list移动到active list头部,从而淘汰active list原有的数据。如果这些大量数据长时间不被访问,那么active list就被污染了。
如果再次读取热数据,就需要访问磁盘,系统效率急剧下降。
举例:
select语句查询数据,如果需要查询大量的页,那么这些页会全部进入活跃区,原本的热点数据会被淘汰,导致缓存命中率大大下降。
5.4.2 避免缓存污染
提高进入active list的门槛:
- Linux 操作系统:在内存页被访问第二次的时候,才将页从 inactive list 升级到 active list 里。
- MySQL Innodb:在内存页被访问第二次的时候,并不会马上将该页从 old 区域升级到 young 区域,因为还要根据停留在 old 区域的时间判断:
- 如果第二次的访问时间与第一次访问的时间在 1 秒内(默认值),那么该页就不会被从 old 区域升级到 young 区域;
- 如果第二次的访问时间与第一次访问的时间超过 1 秒,那么该页就会从 old 区域升级到 young 区域;