内存管理也是操作系统最核心的功能之一。内存主要用来存储系统和应用程序的指令、数据、缓存等
内存映射
我们通常所说的内存容量指的是物理内存。物理内存也称为主存,大多数计算机用的主存都是动态随机访问内存(DRAM)。只有内核才可以直接访问物理内存。
进程如何访问内存?
Linux 内核给每个进程都提供了一个独立的虚拟地址空间,并且这个地址空间是连续的。进程访问内存其实访问的虚拟内存。
虚拟地址空间的内部又被分为内核空间和用户空间
不同字长(也就是单个CPU指令可以处理数据的最大长度)的处理器,地址空间的范围也不同。比如最常见的 32 位和 64 位系统
- 32位系统的内核空间占用 1G,位于最高处,剩下的3G是用户空间。
- 64位系统的内核空间和用户空间都是 128T,分别占据整个内存空间的最高和最低处,剩下的中间部分是未定义的
进程的用户态和内核态
进程在用户态时,只能访问用户空间内存;只有进入内核态后,才可以访问内核空间内存
虽然每个进程的地址空间都包含了内核空间,但这些内核空间,其实关联的都是相同的物理内存。这样,进程切换到内核态后,就可以很方便地访问内核空间内存。
每个进程都有一个这么大的地址空间,那么所有进程的虚拟内存加起来,自然要比实际的物理内存大得多。所以,并不是所有的虚拟内存都会分配物理内存,只有那些实际使用的虚拟内存才分配物理内存,并且分配后的物理内存,是通过内存映射来管理的。
内核程序执行在内核态(Kernal Mode),用户程序执行在用户态(User Mode)。当发生系统调用时,用户态的程序发起系统调用。因为系统调用中牵扯特权指令,用户态程序权限不足,因此会中断执行,也就是 Trap(Trap 是一种中断)。
发生中断后,当前 CPU 执行的程序会中断,跳转到中断处理程序。内核程序开始执行,也就是开始处理系统调用。内核处理完成后,主动触发 Trap,这样会再次发生中断,切换回用户态工作。
什么是内存映射?
其实就是将虚拟内存地址映射到物理内存地址。为了完成内存映射,内核为每个进程都维护了一张页表,记录虚拟地址与物理地址的映射关系。
页表实际上存储在 CPU 的内存管理单元 MMU中,这样,正常情况下,处理器就可以直接通过硬件,找出要访问的内存。
什么是缺页异常
当进程访问的虚拟地址在页表中查不到时,系统会产生一个缺页异常,进入内核空间分配物理内存、更新进程页表,最后再返回用户空间,恢复进程的运行。
- malloc: 分配内存、不把内存清零。
- realloc: 重新分配内存,把之前的数据搬到新内存去。
- calloc: 分配内存、把内存清零。
TLB(Translation Lookaside Buffer,转译后备缓冲器)会影响 CPU 的内存访问性能,TLB 其实就是 MMU 中页表的高速缓存。由于进程的虚拟地址空间是独立的,而 TLB 的访问速度又比 MMU 快得多,所以,通过减少进程的上下文切换,减少TLB的刷新次数,就可以提高TLB 缓存的使用率,进而提高CPU的内存访问性能。
CPU首先得到的是虚拟地址,必须将虚拟地址转换为物理地址才能进行数据访问。这个过程先查TLB,若TLB命中,则得到了物理地址,进行步骤2;否则,继续查找主存中的页表。若仍不命中,则引发缺页中断进行调页。调页过程又可能引发驻留集页替换和页回写过程。
2.得到物理地址之后,首先访问cache,若命中,CPU直接从cache中读取相应物理地址中的内容;若不命中,则访问主存读取数据,并且更新cache,此过程又可能引发cache的行替换和回写过程。
MMU全称就是内存管理单元,管理地址映射关系(也就是页表)。但MMU的性能跟CPU比还是不够快,所以又有了TLB。TLB实际上是MMU的一部分,把页表缓存起来以提升性能。
增加TLB前后cpu访问内存比较
对于一个页命中的数据获取过程通常来说,如果没有 TLB 加速是这样的:
- CPU生成一个虚拟地址,并把它传给 MMU;
- MMU生成页表项地址 PTEA,并从高速缓存/主存请求获取页表项 PTE;
- 高速缓存/主存向 MMU 返回 PTE;
- MMU 构造物理地址 PA,并把它传给高速缓存/主存;
- 高速缓存/主存返回所请求的数据给CPU。
加的这一层就是缓存芯片 TLB (Translation Lookaside Buffer),它里面每一行保存着一个由单个 PTE 组成的块。
那么查询数据的过程就变成了:
- CPU 产生一个虚拟地址;
- MMU 从 TLB 中取出相应的 PTE;
- MMU 将这个虚拟地址翻译成一个物理地址,并且将它发送到高速缓存/主存;
- 高速缓存/主存将所请求的数据字返回给 CPU。
MMU 如何管理内存
MMU 并不以字节为单位来管理内存,而是规定了一个内存映射的最小单位,也就是页,通常是 4 KB大小。这样,每一次内存映射,都需要关联 4 KB 或者 4KB 整数倍的内存空间。
多级页表和大页
页的大小只有4 KB ,导致的另一个问题就是,整个页表会变得非常大。比方说,仅 32 位系统就需要 100 多万个页表项(4GB/4KB),才可以实现整个地址空间的映射。为了解决页表项过多的问题,Linux 提供了两种机制,也就是多级页表和大页(HugePage)
- 多级页表
把内存分成区块来管理,将原来的映射关系改成区块索引和区块内的偏移。由于虚拟内存空间通常只用了很少一部分,那么,多级页表就只保存这些使用中的区块,这样就可以大大地减少页表的项数。
Linux 用的正是四级页表来管理内存页,如下图所示,虚拟地址被分为5个部分,前4个表项用于选择页,而最后一个索引表示页内偏移。
- 大页
比普通页更大的内存块,常见的大小有 2MB 和 1GB。大页通常用在使用大量内存的进程上,比如 Oracle、DPDK 等。GP是否可以也使用大页呢?
虚拟内存空间分布
在页表的映射下,进程就可以通过虚拟地址来访问物理内存了。那么具体到一个Linux 进程中,这些内存又是怎么使用的呢?
3.1 虚拟内存空间的分布情况
以32位系统为例
1. 只读段,包括代码和常量等。
2. 数据段,包括全局变量等。
3. 堆,包括动态分配的内存,从低地址开始向上增长。
4. 文件映射段,包括动态库、共享内存等,从高地址开始向下增长。
5. 栈,包括局部变量和函数调用的上下文等。栈的大小是固定的,一般是 8 MB。
堆和文件映射段的内存是动态分配的。比如说,使用 C 标准库的 malloc()或者 mmap() ,就可以分别在堆和文件映射段动态分配内存。64位系统的内存分布也类似,只不过内存空间要大得多。
内存分配与回收
malloc() 是 C 标准库提供的内存分配函数,对应到系统调用上,有两种实现方式,即 brk() 和mmap()。对小块内存(小于128K),C 标准库使用 brk() 来分配,也就是通过移动堆顶的位置来分配内存。这些内存释放后并不会立刻归还系统,而是被缓存起来,这样就可以重复使用。而大块内存(大于 128K),则直接使用内存映射 mmap() 来分配,也就是在文件映射段找一块
空闲内存分配出去。
这两种方式,自然各有优缺点。
- brk() 方式的缓存,可以减少缺页异常的发生,提高内存访问效率。不过,由于这些内存没有归还系统,在内存工作繁忙时,频繁的内存分配和释放会造成内存碎片。
- mmap() 方式分配的内存,会在释放时直接归还系统,所以每次 mmap 都会发生缺页异常。在内存工作繁忙时,频繁的内存分配会导致大量的缺页异常,使内核的管理负担增大。这也是malloc 只对大块内存使用 mmap 的原因。如果太小的内存也使用mmap分配内核的管理负担会更大。
来个例子理解一下
servers]# strace cat /dev/null execve("/bin/cat", ["cat", "/dev/null"], [/* 29 vars */]) = 0 brk(0) = 0x1e0d000 mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f42ef607000 access("/etc/ld.so.preload", R_OK) = -1 ENOENT (No such file or directory) open("/etc/ld.so.cache", O_RDONLY) = 3 fstat(3, {st_mode=S_IFREG|0644, st_size=77682, ...}) = 0 mmap(NULL, 77682, PROT_READ, MAP_PRIVATE, 3, 0) = 0x7f42ef5f4000 close(3) = 0 open("/lib64/libc.so.6", O_RDONLY) = 3 read(3, "\177ELF\2\1\1\3\0\0\0\0\0\0\0\0\3\0>\0\1\0\0\0p\356\341\2323\0\0\0"..., 832) = 832 fstat(3, {st_mode=S_IFREG|0755, st_size=1926760, ...}) = 0 mmap(0x339ae00000, 3750152, PROT_READ|PROT_EXEC, MAP_PRIVATE|MAP_DENYWRITE, 3, 0) = 0x339ae00000 mprotect(0x339af8a000, 2097152, PROT_NONE) = 0 mmap(0x339b18a000, 20480, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_FIXED|MAP_DENYWRITE, 3, 0x18a000) = 0x339b18a000 mmap(0x339b18f000, 18696, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_FIXED|MAP_ANONYMOUS, -1, 0) = 0x339b18f000 close(3) = 0 mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f42ef5f3000 mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f42ef5f2000 mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f42ef5f1000 arch_prctl(ARCH_SET_FS, 0x7f42ef5f2700) = 0 mprotect(0x339b18a000, 16384, PROT_READ) = 0 mprotect(0x339a81f000, 4096, PROT_READ) = 0 munmap(0x7f42ef5f4000, 77682) = 0 brk(0) = 0x1e0d000 brk(0x1e2e000) = 0x1e2e000 open("/usr/lib/locale/locale-archive", O_RDONLY) = 3 fstat(3, {st_mode=S_IFREG|0644, st_size=99158576, ...}) = 0 mmap(NULL, 99158576, PROT_READ, MAP_PRIVATE, 3, 0) = 0x7f42e9760000 close(3) = 0 fstat(1, {st_mode=S_IFCHR|0620, st_rdev=makedev(136, 4), ...}) = 0 open("/dev/null", O_RDONLY) = 3 fstat(3, {st_mode=S_IFCHR|0666, st_rdev=makedev(1, 3), ...}) = 0 read(3, "", 32768) = 0 close(3) = 0 close(1) = 0 close(2) = 0 exit_group(0) = ?
当这两种调用发生后,其实并没有真正分配内存。这些内存,都只在首次访问时才分配,也就是通过缺页异常进入内核中,再由内核来分配内存。
Linux 使用伙伴系统来管理内存分配。前面我们提到过,这些内存在MMU中以页为单位进行管理,伙伴系统也一样,以页为单位来管理内存,并且会通过相邻页的合并,减少内存碎片化(比如brk方式造成的内存碎片)
比页(4KB)还小的内存如何分配?
如果为它们也分配单独的页,那就太浪费内存了
所以
- 在用户空间,malloc 通过 brk() 分配的内存,在释放时并不立即归还系统,而是缓存起来重复利用。
- 在内核空间,Linux 则通过 slab 分配器来管理小内存。你可以把slab 看成构建在伙伴系统上的一个缓存,主要作用就是分配并释放内核中的小对象。(malloc本身不会使用slab只有内核中使用kmalloc才回通过slab分配内存) 因此需要注意对于内核空间才会用slab 分配内存
对内存来说,如果只分配而不释放,就会造成内存泄漏,甚至会耗尽系统内存。所以,在应用程序用完内存后,还需要调用 free() 或 unmap() ,来释放这些不用的内存。
内存紧张时触发内存回收机制
- 回收缓存,比如使用 LRU(Least Recently Used)算法,回收最近使用最少的内存页面;
- 回收不常访问的内存,把不常用的内存通过交换分区直接写到磁盘中;
Swap 其实就是把一块磁盘空间当成内存来用。它可以把进程暂时不用的数据存储到磁盘中(这个过程称为换出),当进程访问这些内存时,再从磁盘读取这些数据到内存中(这个过程称为换入)。
Swap 把系统的可用内存变大了。不过要注意,通常只在内存不足时,才会发生 Swap 交换。并且由于磁盘读写的速度远比内存慢,Swap 会导致严重的内存性能问题
杀死进程,内存紧张时系统还会通过 OOM(Out of Memory),直接杀掉占用大量内存的进程
OOM killer是内核的一种保护机制。它监控进程的内存使用情况,并且使用 oom_score 为每个进程的内存使用情况进行评分:
- 一个进程消耗的内存越大,oom_score 就越大;
- 一个进程运行占用的 CPU 越多,oom_score 就越小。
这样,进程的 oom_score 越大,代表消耗的内存越多,也就越容易被 OOM 杀死,从而可以更好保护系统。
管理员可以通过 /proc 文件系统,手动设置进程的 oom_adj ,从而调整进程的 oom_score。oom_adj 的范围是 [-17, 15],数值越大,表示进程越容易被 OOM 杀死;数值越小,表示进程越不容易被 OOM 杀死,其中 -17 表示禁止 OOM。
echo -16 > /proc/$(pidof watch)/oom_adj
问题:如何统计所有进程的物理内存使用量?
free
第一列,total 是总内存大小;
第二列,used 是已使用内存的大小,包含了共享内存;
第三列,free 是未使用内存的大小;
第四列,shared 是共享内存的大小;
第五列,buff/cache 是缓存和缓冲区的大小;
top
主要列含义:
VIRT 是进程虚拟内存的大小,只要是进程申请过的内存,即便还没有真正分配物理内存,也会计算在内。
RES 是常驻内存的大小,也就是进程实际使用的物理内存大小,但不包括 Swap 和共享内存。
SHR 是共享内存的大小,比如与其他进程共同使用的共享内存、加载的动态链接库以及程序的代码段等。
%MEM 是进程使用物理内存占系统总内存的百分比。
第一,虚拟内存通常并不会全部分配物理内存。
第二,共享内存 SHR 并不一定是共享的,比方说,程序的代码段、非共享的动态链接库,也都算在 SHR 里。当然,SHR 也包括了进程间真正共享的内存。所以在计算多个进程的内存使用时,不要把所有进程的 SHR 直接相加得出结果。
问题:如何统计所有进程的物理内存使用量?
The “proportional set size” (PSS) of a process is the count of pagesit has in memory, where each page is divided by the number ofprocesses sharing it. So if a process has 1000 pages all to itself, and1000 shared with one other process, its PSS will be 1500.
每个进程的 PSS ,是指把共享内存平分到各个进程后,再加上进程本身的非共享内存大小的和。就像文档中的这个例子,一个进程的非共享内存为 1000 页,它和另一个进程的共享进程也是 1000 页,那么它的 PSS=1000/2+1000=1500 页。这样,你就可以直接累加 PSS ,不用担心共享内存重复计算的问题了。
grep Pss /proc/[1-9]*/smaps | awk '{total+=$2}; END {printf "%d kB\n", total }'
问题: 为什么要使用虚拟内存
由于操作虚拟内存实际上就是操作页表,从上面讲解我们知道,页表的大小其实和物理内存没有关系,当物理内存不够用时可以通过页缺失来将需要的数据置换到内存中,内存中只需要存放众多程序中活跃的那部分,不需要将整个程序加载到内存里面,这可以让小内存的机器也可以运行程序。
虚拟内存可以为正在运行的进程提供独立的内存空间,制造一种每个进程的内存都是独立的假象。虚拟内存空间只是操作系统中的逻辑结构,通过多层的页表结构来转换虚拟地址,可以让多个进程可以通过虚拟内存共享物理内存。
并且独立的虚拟内存空间也会简化内存的分配过程,当用户程序向操作系统申请堆内存时,操作系统可以分配几个连续的虚拟页,但是这些虚拟页可以对应到物理内存中不连续的页中。
再来就是提供了内存保护机制。任何现代计算机系统必须为操作系统提供手段来控制对内存系统的访问。虚拟内存中页表中页存放了读权限、写权限和执行权限。内存管理单元可以决定当前进程是否有权限访问目标的物理内存,这样我们就最终将权限管理的功能全部收敛到虚拟内存系统中,减少了可能出现风险的代码路径。
问题: SRAM和DRAM 有什么区别?
SRAM 和 DRAM。主存储器是由 DRAM 实现的,也就是我们常说的内存,在 CPU 里通常会有 L1、L2、L3 这样三层高速缓存是用 SRAM 实现的。
- SRAM 被称为“静态”存储器,是因为只要处在通电状态,里面的数据就可以保持存在。而一旦断电,里面的数据就会丢失了。
- DRAM 中存储单元使用电容保存电荷的方式来存储数据,电容会不断漏电,所以需要定时刷新充电,才能保持数据不丢失,这也是被称为“动态”存储器的原因。由于存储 DRAM 一个 bit 只需要一个晶体管,所以存储的数据也大很多。
- L1 的存取速度:4 个CPU时钟周期
- L2 的存取速度:11 个CPU时钟周期
- L3 的存取速度:39 个CPU时钟周期
- DRAM内存的存取速度:107 个CPU时钟周期
问题: CPU cache的写策略write-back 和write-through区别是什么?
这两种策略都是使cache中写入的数据和主存中的数据保持一致。
write-through可以理解为写穿(官方翻译为写直达)。意思就是往cache中写数据之后立即刷新到主存上。
有人可能会问: cache和主存的速度差异超过1个数量级的cpu时钟周期。如果大量写入不是阻塞了?
解决办法就是加写缓存 write buffer解决速度不匹配的问题。
write-back每次都将数据写如cache中,直到cache中被修改的数据不得不被替换时才会刷新到主存中。 因为cache毕竟是有限的不会让你一直写cache。当不得不淘汰cache的时候才会写主存。
读数据对cache的写策略影响几何?
当write-through时,读数据时直接从内存中读取,而不用从cache中读取,write-back时则需要将内存中不存在的cache从内存中加载到cache中。(基于write no-allocate)
Write-through flowchart: A Write-through cache with No-write allocate
Write-back flowchart: A Write-back cache with Write
问题: cpu如何保证缓存一致性
由于现在都是多核 CPU,并且 cache 分了多级,并且数据存在共享的情况,所以需要一种机制保证在不同的核中看到的 cache 数据必须时一致的。最常用来处理多核 CPU 之间的缓存一致性协议就是 MESI 协议。
MESI 协议,是一种叫作写失效(Write Invalidate)的协议。在写失效协议里,只有一个 CPU 核心负责写入数据,其他的核心,只是同步读取到这个写入。在这个 CPU 核心写入 cache 之后,它会去广播一个“失效”请求告诉所有其他的 CPU 核心。
MESI 协议对应的四个不同的标记,分别是:
- M:代表已修改(Modified)
- E:代表独占(Exclusive)
- S:代表共享(Shared)
- I:代表已失效(Invalidated)
“已修改”和“已失效”比较容易理解,我们来看看 独占”和“共享” 两个状态。
在独占状态下,对应的 cache Line 只加载到了当前 CPU 核所拥有的 cache 里。其他的 CPU 核,并没有加载对应的数据到自己的 cache 里。这个时候,如果要向独占的 cache Block 写入数据,我们可以自由地写入数据,而不需要告知其他 CPU 核。
那么对应的,共享状态就是在多核中同时加载了同一份数据。所以在共享状态下想要修改数据要先向所有的其他 CPU 核心广播一个请求,要求先把其他 CPU 核心里面的 cache ,都变成无效的状态,然后再更新当前 cache 里面的数据。
可以看下这个动图理解一下: