小林coding 《图解系统:内存管理》笔记

参考:

The Linux Kernel Linux :Concepts overview

兰新宇 :  Linux中的mmap映射 [一]

Chris Terman:L17: Virtualizing the Processor

 

虚拟内存分段分页

为了在多进程环境下,使得进程之间的内存地址不受影响,相互隔离,于是操作系统就为每个进程独立分配一套虚拟地址空间,每个程序只关心自己的虚拟地址就可以,实际上大家的虚拟地址都是一样的,但分布到物理地址内存是不一样的。作为程序,也不用关心物理地址的事情。

那既然有了虚拟地址空间,那必然要把虚拟地址「映射」到物理地址,这个事情通常由操作系统来维护。

那么对于虚拟地址与物理地址的映射关系,可以有分段和分页的方式,同时两者结合都是可以的。

  1. 内存分段是根据程序的逻辑角度,分成了栈段、堆段、数据段、代码段等,这样可以分离出不同属性的段,同时是一块连续的空间。但是每个段的大小都不是统一的,这就会导致外部内存碎片内存交换效率低的问题。
  2. 于是,就出现了内存分页虚拟空间和物理空间分成大小固定的页如在 Linux 系统中,每一页的大小为 4KB。由于分了页后,就不会产生细小的内存碎片,解决了内存分段的外部内存碎片问题。同时在内存交换的时候,写入硬盘也就一个页或几个页,这就大大提高了内存交换的效率。
    • 在分页机制下,虚拟地址分为两部分,页号和页内偏移。页号作为页表的索引,页表包含物理页每页所在物理内存的基地址,这个基地址与页内偏移的组合就形成了物理内存地址。
  3. 再来,为了解决简单分页产生的页表过大的问题,就有了多级页表,它解决了空间上的问题,但这就会导致 CPU 在寻址的过程中,需要有多层表参与,加大了时间上的开销。
    • 分了二级表,映射 4GB 地址空间就需要 4KB(一级页表)+ 4MB(二级页表)的内存,这样占用空间不是更大了吗?
    • 对于已分配的页表项,如果存在最近一定时间未访问的页表,在物理内存紧张的情况下,操作系统会将页面换出到硬盘,也就是说不会占用物理内存。
    • 如果某个一级页表的页表项没有被用到,也就不需要创建这个页表项对应的二级页表了,即可以在需要时才创建二级页表。
    • 在 32 位的环境下,虚拟地址空间共有 4GB,假设一个页的大小是 4KB(2^12),那么就需要大约 100 万 (2^20) 个页,每个「页表项」需要 4 个字节大小来存储,那么整个 4GB 空间的映射在单页表时,就需要有 4MB 的内存来存储页表。
    • 假设只有 20% 的一级页表项被用到了,那么页表占用的内存空间就只有 4KB(一级页表) + 20% * 4MB(二级页表)= 0.804MB,这对比单级页表的 4MB 是不是一个巨大的节约?
  4. 于是根据程序的局部性原理,在 CPU 芯片中加入了 TLB,负责 缓存最近常被访问的页表项,大大提高了地址的转换速度。CPU 在寻址时,会先查 TLB,如果没找到,才会继续查常规的页表。

 

    

CPU 芯片里面,封装了内存管理单元MMUMemory Management Unit)芯片,它的作用是讲 虚拟内存地址 转换为 物理内存地址,以及和 TLB 进行交互。

下图是 MMU 转换 虚拟内存地址的过程:

  • 首先,我们检查 TLB 中是否缓存了所需的 虚拟内存地址 到 物理内存地址 的映射。
  • 如果没有,我们必须访问多级页表以查看该页面是否已分配了物理内存,如果是,则查找其物理页号。
  • 如果我们发现该页面未分配物理内存,则会向 CPU 发出缺页中断,以便它可以运行处理程序从物理内存分配该页面

Linux 系统主要采用了分页管理,但是由于 Intel 处理器的发展史,Linux 系统无法避免分段管理。于是 Linux 就把所有段的基地址设为 0,也就意味着所有程序的地址空间都是线性地址空间(虚拟地址),相当于屏蔽了 CPU 逻辑地址的概念,所以段只被用于访问控制和内存保护。

另外,Linux 系统中虚拟空间分布可分为用户态和内核态两部分,其中用户态的分布:代码段、全局变量、BSS、函数栈、堆内存、映射区。

 

malloc 是如何分配内存?

虚拟地址空间的内部又被分为内核空间用户空间两部分

内核空间与用户空间的区别:

  • 在用户空间运行时被称为进程的用户态,而陷入内核空间的时候,被称为进程的内核态。
  • 进程在用户态时,只能访问用户空间内存;
  • 只有进入内核态后,才可以访问内核空间的内存;
  • 从用户态到内核态的转变,需要通过系统调用来完成。一次系统调用,要发生两次用户态和内核态的切换(切过去再切回来)

虽然每个进程都各自有独立的虚拟内存,但是每个虚拟内存中的内核地址,其实关联的都是相同的物理内存。

  • 比如进程 a,进程 b,进程 c 分别在各自的用户态虚拟内存空间中访问虚拟地址 x 。由于进程之间的用户态虚拟内存空间相互隔离相互独立的,虽然在进程a,进程b,进程c 访问的都是虚拟地址 x 但是看到的内容却是不一样的(背后可能映射到不同的物理内存中)。
  • 但是当进程 a,进程 b,进程 c 进入到内核态之后情况就不一样了,由于内核虚拟内存空间各个进程共享的,所以它们在内核空间中看到的内容全部是一样的,比如进程 a,进程 b,进程 c 在内核态都去访问虚拟地址 y。这时它们看到的内容就是一样的了。

 

用户空间内存从低到高分别是 6 种不同的内存段:

  • 代码段,包括二进制可执行代码;
  • 数据段,包括已初始化的静态常量和全局变量;
  • BSS 段,包括未初始化的静态变量和全局变量;
  • 堆段,包括动态分配的内存,从低地址开始向上增长;
  • 文件映射段,包括动态库、共享内存等,从低地址开始向上增长(跟硬件和内核版本有关 (opens new window));
  • 栈段,包括局部变量和函数调用的上下文等。栈的大小是固定的,一般是 8 MB。当然系统也提供了参数,以便我们自定义大小;

在这 6 个内存段中,文件映射段的内存是动态分配的。比如说,使用 C 标准库的 malloc() 或者 mmap() ,就可以分别在堆和文件映射段动态分配内存。

C 语言 malloc 函数就是对系统调用 sbrk 和 mmap 的封装。malloc() 源码里默认定义了一个阈值:

  • 如果用户分配的内存小于 128 KB,则通过 brk() 系统调用从分配内存;
  • 如果用户分配的内存大于 128 KB,则通过 mmap() 匿名映射系统调用在文件映射区域(匿名映射区也在其中)分配内存;

malloc 分配的是虚拟内存。

如果分配后的虚拟内存没有被访问的话,虚拟内存是不会映射到物理内存的,这样就不会占用物理内存了。

只有在访问已分配的虚拟地址空间的时候,操作系统通过查找页表,发现虚拟内存对应的页没有在物理内存中,就会触发缺页中断,然后操作系统会建立虚拟内存和物理内存之间的映射关系。

  • malloc 通过 brk() 方式申请的内存,free 释放内存的时候,并不会把内存归还给操作系统,而是缓存在 malloc 的内存池中,待下次使用;
  • malloc 通过 mmap() 方式申请的内存(匿名映射),free 释放内存的时候,会把内存归还给操作系统,内存得到真正的释放。

brk() 优缺点:

  • malloc 通过 brk() 系统调用在堆空间申请内存的时候,由于堆空间是连续的,所以直接预分配更大的内存来作为内存池(malloc(1) malloc(10) 等实际上都是分配 132kb),当内存释放的时候,就不真正释放而是缓存在内存池中。等下次在申请内存的时候,就直接从内存池取出对应的内存块就行了,而且可能这个内存块的虚拟地址与物理地址的映射关系还存在,这样不仅减少了系统调用的次数(可以直接从之前的内存池取,不用真正brk()系统调用),也减少了缺页中断的次数,这将大大降低 CPU 的消耗。
  • 如果我们连续申请了 10k,20k,30k 这三片内存,如果 10k 和 20k 这两片释放了,变为了空闲内存空间,如果下次申请的内存小于 30k,那么就可以重用这个空闲内存空间。但是如果下次申请的内存大于 30k,没有可用的空闲内存空间,必须向 OS 申请,实际使用内存继续增大。因此,随着系统频繁地 malloc 和 free ,尤其对于小块内存,堆内将产生越来越多不可用的碎片,导致“内存泄露”。而这种“泄露”现象使用 valgrind 是无法检测出来的。

mmap() 匿名映射优缺点:

与 brk() 相对地,

  • mmap 分配的内存每次释放的时候,都会归还给操作系统,于是每次 mmap 分配的虚拟地址都是缺页状态的,所以在第一次访问该虚拟地址的时候,就会触发缺页中断。也就是说不仅每次都会发生运行态的切换,还会发生缺页中断(在第一次访问虚拟地址后),这样会导致 CPU 消耗较大。
  • 而因为每次 free 都会真正释放内存,没有不可用的内存碎片问题。

free() 函数只传入一个内存地址,为什么能知道要释放多大的内存?

malloc 返回给用户态的内存起始地址比进程的堆空间起始地址多了 16 字节。这个多出来的 16 字节就是保存了该内存块的描述信息,比如有该内存块的大小。

这样当执行 free() 函数时,free 会对传入进来的内存地址向左偏移 16 字节,然后从这个 16 字节的分析出当前的内存块的大小,自然就知道要释放多大的内存了。

 

 

文件映射(file-backed)与匿名映射(anonymous mapping)

内核中, 我们所说的进程控制块 PCB 就是上图的 task_struct ,里面有一个叫做内存描述符的 mm_struct 结构体 来表示进程虚拟内存空间的全部信息。

上图中的结构体 vm_area_struct 描述了用户空间不同的虚拟内存区域。内存映射区可以有多个  vm_area_struct,其它都是一个。这些 vm_area_struct 通过链表或红黑树链接起来。

Page Cache

Page Cache 是由内核虚拟内存空间管理的一块物理内存, 每当从磁盘读取文件时,数据都会放入物理内存Page Cache中,以避免后续读取时昂贵的磁盘访问。类似地,当写入文件时,数据被放置在页面缓存中并最终进入后备存储设备。写入的页面被标记为脏页。

读取文件时的两种方式:

  • 用户进程调用read():比如要读取磁盘上某个文件的8192个字节数据,那么这8192个字节会首先拷贝到物理内存中作为page cache(方便以后快速读取),它们是由内核空间管理,所以用户进程需要通过内核态从page cache拷贝到用户空间指定的buffer中。
    • 也就是说,在数据已经加载到page cache后,还需要一次内存拷贝操作和一次系统调用。
  • 使用mmap():在磁盘数据加载到page cache后,本来由内核空间管理mmap映射后用户进程可以过指针直接读写物理内存中的 page cache,不再需要系统调用和内存拷贝。
    • 但是前面说过,mmap() 是懒加载,会触发大量缺页中断。另一方面,随着硬件性能的发展,内存拷贝消耗的时间已经大大降低了。所以啊,很多情况下,mmap()的性能反倒是比不过read()和write()的。

文件映射和匿名映射

文件映射:映射物理内存中 Page Cache 中的 page(本来由内核空间管理,用户空间映射到这块物理内存后,可由用户进程直接读写),对应于磁盘上的若干数据块(文件);对于这些页最大的问题是脏页回盘;

vm_file 属性就用来关联被映射的文件,vm_pgoff 则表示映射进虚拟内存中的文件内容,在文件中的偏移。

匿名映射映射在磁盘上没有对应文件的物理内存,没有实际载体。此类映射是为用户空间的栈和堆隐式创建的(可以由上面的图看出,栈、堆、BSS 段都是隐式的匿名映射区),或者如 malloc 通过显式调用 mmap(2) 系统调用来创建匿名映射区

在匿名映射中,vm_area_struct 结构中的 vm_file 就为 null,vm_pgoff 也就没有了意义。

私有映射和共享映射

  • 共享文件映射
  • 私有文件映射:对于私有映射,进程A的修改对进程B是不可见的,利用的是 Copy On Write(COW)机制。当进程A试图修改某个page上的数据时,内核会将这个page的内容拷贝一份,之后A的写操作实际是在这个拷贝的page上进行的(进程A中对应这个page的页表项也需要被修改,以指向新拷贝的page),这样进程B看到的这个page还是原来未经改动的。这种修改只会存在于内存中,不会同步到外部的磁盘文件上(事实上也没法同步,因为不同进程所做的修改是不同的)。
  • 共享匿名映射:一个典型应用是作为进程间通信机制的POSIX共享内存。在Linux中,POSIX共享内存是通过挂载在/dev/shm下的tmpfs内存文件系统实现的,创建的每一个共享内存都对应tmpfs中的一个文件,因此POSIX共享内存也可视为共享文件映射。
  • 私有匿名映射:最常见的用途是进程的堆段和栈段。以及glibc中的 malloc() 大于128k 时的内存分配。

 

内存满了会发生什么?

内核在给应用程序分配物理内存的时候,如果空闲物理内存不够,那么就会进行内存回收的工作,主要有两种方式:

  • 后台内存回收:物理内存紧张的时候,会唤醒 kswapd 内核线程来回收内存,这个回收内存的过程异步的,不会阻塞进程的执行。
  • 直接内存回收:如果后台异步回收跟不上进程内存申请的速度,就会开始直接回收,这个回收内存的过程是同步的,会阻塞进程的执行。
  • OOM 机制:在经历完直接内存回收后,空闲的物理内存大小依然不够,那么就会触发 OOM 机制,OOM killer 选择一个占用物理内存较高的进程,然后将其杀死,以便释放内存资源。如果物理内存依然不足,OOM Killer 会继续杀死占用物理内存较高的进程,直到释放足够的内存位置。

主要有两类内存可以被回收,而且它们的回收方式也不同。

  • 文件页(File-backed Page):内核缓存的磁盘数据(Buffer)和内核缓存的文件数据(Cache)都叫作文件页。大部分文件页,都可以直接释放内存,以后有需要时,再从磁盘重新读取就可以了。而那些被应用程序修改过,并且暂时还没写入磁盘的数据(也就是脏页),就得先写入磁盘然后才能进行内存释放。所以,回收干净页的方式是直接释放内存,回收脏页的方式是先写回磁盘后再释放内存。
  • 匿名页(Anonymous Page):这部分内存没有实际载体,不像文件缓存有硬盘文件这样一个载体(malloc 大于 128k 时用 mmap 分配的),比如堆、栈数据等。这部分内存很可能还要再次被访问,所以不能直接释放内存,它们回收的方式是通过 Linux 的 Swap 机制,Swap 会把不常访问的内存先写到磁盘中,然后释放这些内存,给其他更需要的进程使用。再次访问这些内存时,重新从磁盘读入内存就可以了。

Swap 就是把一块磁盘空间或者本地文件,当成内存来使用,它包含换出和换入两个过程:

  • 换出(Swap Out) ,是把进程暂时不用的内存数据存储到磁盘中,并释放这些数据占用的内存;
  • 换入(Swap In),是在进程再次访问这些内存的时候,把它们从磁盘读到内存中来;

文件页和匿名页的回收都是基于 LRU 算法,也就是优先回收不常访问的内存。回收内存的操作基本都会发生磁盘 I/O 的,如果回收内存的操作很频繁,意味着磁盘 I/O 次数会很多,这个过程势必会影响系统的性能。

LRU 回收算法,实际上维护着 active 和 inactive 两个双向链表,其中:

  • active_list 活跃内存页链表,这里存放的是最近被访问过(活跃)的内存页;
  • inactive_list 不活跃内存页链表,这里存放的是很少被访问(非活跃)的内存页;

针对回收内存导致的性能影响,常见的解决方式。

  • 设置 /proc/sys/vm/swappiness,调整文件页和匿名页的回收倾向,尽量倾向于回收文件页;(文件页对于干净页回收是不会发生磁盘 I/O 的,脏页才会发生。因此性能影响相对较小)
  • 设置 /proc/sys/vm/min_free_kbytes,调整 kswapd 内核线程异步回收内存的时机;(尽早的触发「后台内存回收」来避免应用程序进行直接内存回收)
  • 设置 /proc/sys/vm/zone_reclaim_mode,调整 NUMA 架构下内存回收策略,建议设置为 0,这样在回收本地内存之前,会在其他 Node 寻找空闲内存,从而避免在系统还有很多空闲内存的情况下,因本地 Node 的本地内存不足,发生频繁直接内存回收导致性能下降的问题;
  • 可以通过调整进程的 /proc/[pid]/oom_score_adj 值,来降低被 OOM killer 杀掉的概率。

什么条件下才能触发 kswapd 内核线程回收内存呢?

内核定义了三个内存阈值(watermark,也称为水位),用来衡量当前剩余内存(pages_free)是否充裕或者紧张:

  • 图中橙色部分:kswapd0 会执行后台异步内存回收,直到剩余内存大于高阈值(pages_high)为止。

  • 图中红色部分:说明用户可用内存都耗尽了,此时就会触发直接同步内存回收,这时应用程序就会被阻塞。

min_free_kbytes 虽然设置的是页最小阈值(pages_min),但是页高阈值(pages_high)和页低阈值(pages_low)都是根据页最小阈值(pages_min)计算生成的,它们之间的计算关系如

  • pages_min = min_free_kbytes
  • pages_low = pages_min*5/4
  • pages_high = pages_min*3/2

 

在 4GB 物理内存的机器上,申请 8G 内存会怎么样?

  • 32 位操作系统,因为进程理论上最大能申请 3 GB 大小的虚拟内存,所以直接申请 8G 内存,会申请失败。
  • 64位 位操作系统,因为进程理论上最大能申请 128 TB 大小的虚拟内存,即使物理内存只有 4GB,申请 8G 内存也是没问题,因为申请的内存是虚拟内存。如果没有被使用,它是不会占用物理空间的。当访问这块虚拟内存后,操作系统才会进行物理内存分配。如果申请物理内存大小超过了空闲物理内存大小,就要看操作系统有没有开启 Swap 机制:
    • 如果没有 Swap 分区,因为物理空间不够,进程会被操作系统杀掉,原因是 OOM(内存溢出);
    • 如果有 Swap 分区即使物理内存只有 4GB,程序也能正常使用 8GB 的内存,进程可以正常运行;

 

最终总结:虚拟内存有什么作用?

  • 第一,虚拟内存可以使得进程对运行内存超过物理内存大小,因为程序运行符合局部性原理,CPU 访问内存会有很明显的重复访问的倾向性,对于那些没有被经常使用到的内存,我们可以把它换出到物理内存之外,比如硬盘上的 swap 区域。
  • 第二,由于每个进程都有自己的页表,所以每个进程的虚拟内存空间就是相互独立的。进程也没有办法访问其他进程的页表,所以这些页表是私有的,这就解决了多进程之间地址冲突的问题
  • 第三,页表里的页表项中除了物理地址之外,还有一些标记属性的比特,比如控制一个页的读写权限,标记该页是否存在等。在内存访问方面,操作系统提供了更好的安全性。

 

如何避免预读失效和缓存污染的问题?

本质上都是 如何改进 LRU 算法

传统的 LRU 算法存在这两个问题:

  • 「预读失效」导致缓存命中率下降
  • 「缓存污染」导致缓存命中率下降

Redis 的缓存淘汰算法则是通过实现 LFU 算法来避免「缓存污染」而导致缓存命中率下降的问题(Redis 没有预读机制)。

MySQL BufferPool 和 Linux PageCache 是通过改进 LRU 算法来避免「预读失效和缓存污染」而导致缓存命中率下降的问题。

预读失效

  • 预读机制:根据局部性原理(靠近当前被访问数据的数据,在未来很大概率会被访问到),从磁盘加载页时,会提前把它相邻的页一并加载进来,目的是为了减少磁盘 IO。
  • 预读失效:预读页加载进来,可能并不会被访问。而这些不会被访问的预读页却占用了 LRU 链表前排的位置,而末尾淘汰的页,可能是热点数据,这样就大大降低了缓存命中率 。

Linux PageCache 避免预读失效

Linux 操作系统实现两个了 LRU 链表:活跃 LRU 链表(active_list)和非活跃 LRU 链表(inactive_list)。

有了这两个 LRU 链表后,预读页就只需要加入到 inactive list 区域的头部,当页被真正访问的时候,才将页插入 active list 的头部。如果预读的页一直没有被访问,就会从 inactive list 移除,这样就不会影响 active list 中的热点数据。

MySql BufferPool 避免预读失效

MySQL 的 Innodb 存储引擎是在一个 LRU 链表上划分来 2 个区域,young 区域 和 old 区域。

young 区域在 LRU 链表的前半部分,old 区域则是在后半部分,这两个区域都有各自的头和尾节点。

young 区域与 old 区域在 LRU 链表中的占比关系并不是一比一的关系,而是 63:37(默认比例)的关系。

划分这两个区域后,预读的页就只需要加入到 old 区域的头部,当页被真正访问的时候,才将页插入 young 区域的头部。如果预读的页一直没有被访问,就会从 old 区域移除,这样就不会影响 young 区域中的热点数据。

缓存污染

  • 缓存污染:某一个 SQL 语句扫描了大量的数据(不是结果集大小,而是扫描了大量比如全表扫描了),在 Buffer Pool 空间比较有限的情况下,可能会将 Buffer Pool 里的所有页都替换出去,导致大量热数据被淘汰了,等这些热数据又被再次访问的时候,由于缓存未命中,就会产生大量的磁盘 I/O,MySQL 性能就会急剧下降。

解决

前面的 LRU 算法只要数据被访问一次,就将数据加入活跃 LRU 链表(或者 young 区域),这种 LRU 算法进入活跃 LRU 链表的门槛太低了

所以,只要我们提高进入到活跃 LRU 链表(或者 young 区域)的门槛,就能有效地保证活跃 LRU 链表(或者 young 区域)里的热点数据不会被轻易替换掉。

Linux 操作系统和 MySQL Innodb 存储引擎分别是这样提高门槛的:

  • Linux 操作系统:在内存页被访问第二次的时候,才将页从 inactive list 升级到 active list 里。
  • MySQL Innodb:在内存页被访问第二次的时候,并不会马上将该页从 old 区域升级到 young 区域,因为还要进行停留在 old 区域的时间判断:
    • 如果第二次的访问时间与第一次访问的时间在 1 秒内(默认值),那么该页就不会被从 old 区域升级到 young 区域;
    • 如果第二次的访问时间与第一次访问的时间超过 1 秒那么该页就会从 old 区域升级到 young 区域;

 

 

内核虚拟内存

内核态虚拟内存空间的前 896M 区域是直接映射到物理内存中的前 896M 区域中的,直接映射区中的映射关系是一比一映射。映射关系是固定的不会改变。

虽然这块区域中的虚拟地址是直接映射到物理地址上,但是内核在访问这段区域的时候还是走的虚拟内存地址,内核也会为这块空间建立映射页表。

  • ZONE_DMA:直接映射区的前 16M 专门让内核用来为 DMA 分配内存,这块 16M 大小的内存区域我们称之为 ZONE_DMA。用于 DMA 的内存必须从 ZONE_DMA 区域中分配。
  • ZONE_NORMAL:直接映射区中剩下的部分也就是从 16M 到 896M(不包含 896M)这段区域,我们称之为 ZONE_NORMAL。从字面意义上我们可以了解到,这块区域包含的就是正常的页框(使用没有任何限制)。
  • ZONE_HIGHMEM:物理内存 896M 以上的区域被内核划分为 ZONE_HIGHMEM 区域,我们称之为高端内存。
    • vmalloc动态映射区:采用动态映射的方式映射物理内存中的高端内存。和用户态进程使用 malloc 申请内存一样,在这块动态映射区内核是使用 vmalloc 进行内存分配。
    • 永久映射区:内核通过 alloc_pages() 函数在物理内存的高端内存中申请获取到的物理内存页,这些物理内存页可以通过调用 kmap 映射到永久映射区中。
    • 固定映射区:与动态映射区以及永久映射区不同的是,在固定映射区中虚拟地址是固定的,而被映射的物理地址是可以改变的。也就是说,有些虚拟地址在编译的时候就固定下来了,是在内核启动过程中被确定的,而这些虚拟地址对应的物理地址不是固定的。采用固定虚拟地址的好处是它相当于一个指针常量(常量的值在编译时确定),指向物理地址,如果虚拟地址不固定,则相当于一个指针变量。
    • 临时映射区: