Linux 的内存是怎么工作的

内存映射

Linux 内核给每个进程都提供了一个独立的虚拟地址空间,并且这个地址是连续的,这样进程就可以很方便的访问内存,更确切的说是访问虚拟内存。

虚拟地址空间的内部又被分为:

  • 内核空间
  • 用户空间

不同字长的处理器,地址空间范围不同,下图是32位和64位系统的虚拟地址空间

34位系统内核占用1g,用户空间占用3g,64位系统内核空间和用户空间占用128T。

进程的用户态和内核态

进程进入用户态时,只能访问用户空间内存,只有进入内核态后,才可以访问内核空间地址。虽然每个进程的地址空间都包含了内核空间,但是这些内核空间,其实关联的都是相同的物理内存,这样,进程切换到内核态后,就可以很方便的访问内核空间内存。

并不是所有的虚拟内存都会分配物理内存,只有那些实际使用的虚拟内存才会被分配物理内存,并且分配后的物理内存,是通过内存映射来管理的。

内存映射

内存映射试试将虚拟内存地址映射到物理内存地址,为了完成映射,内核为每个进程都维护了一张页表,记录虚拟地址和物理地址的映射关系。

页表

页表实际上存储在 CPU 的内存管理单元 MMU 中,这样,正常情况下,处理器就可以直接通过硬件,找出要访问的内存。

当进程访问的虚拟地址在页表中查找不到,系统会产生一个缺页异常,进入内核空间分配物理内存、更新进程页表,最后在返回给用户空间,恢复进程的运行。

TLB 实际上就是 MMU 中页表的告诉缓存。由于进程的虚拟地址 空间是独立的,TLB的访问速度又比MMU 快,所以,通过减少进程的上下文切换,减少TLB的刷新次数,就可以提高TLB 缓存的使用率,进程提高CPU 的内存访问性能。

注意,MMU 并不是以字节为单位来管理内存,而是规定了一个内存映射的最小单位,也就是页,通常是4KB大小,这样,每一次内存映射,都需要关联 4KB或者4KB 整数倍的内存空间。

页的大小只有4KB,因此导致整个页表会变得非常大。一个32位系统就需要100多万个页表项,才能实现整个地址空间的映射,为了解决这个问题,Linux 提供了多级页表大页

什么是多级页表

多级页表就是把内存分为区块来管理,将原来的映射关系改成区块索引和区块内的偏移。由于虚拟内存空间通常只用了很少一部分。那么多级页表就只保存这些使用中的取款,这样就可以大大减少列表的项数。

Linux 用的正是四级页表来管理内存页,虚拟地址被分为五个部分,前四个表项用于选择页,最后一个索引表是页内偏移。

什么是大页

大页就是比普通页更大的内存块。常见的有2MB以及1GB。大页通常在使用大量内存的进程上,比如oracle、DPDK等。

通过这些机制,在页表的映射下。进程就可以通过虚拟地址来访问物理内存了。

虚拟内存空间分布

下图,最上方的是内核空间,下方是用户空间。用户空间其实又被分成了多个不同的段。

通过上面这张图,你可以看到用户空间内存从低到高分别分成五个不同的内存块:

  • 只读段,包括代码和常量。
  • 数据段,包括全局变量。
  • 堆,包括动态内存的分配、从低地址开始向上增长。
  • 文件映射段,包括动态库、共享内存等,从高地震向下增长。
  • 栈,包括局部变量和函数调用的上下文等。栈一般大小是固定的,一般是 8MB。

堆和文件映射段的内存是动态分配的。

内存如何分配和回收

malloc()是C标准库提供的内存分配函数,对应到系统调用上,有两种实现方式,即brk()和mmap()。

对小块内存(小于128K),C标准库使用brk() 来分配,也就是通过移动堆顶的位置来分配内存。这些内存释放后并不会立刻归还系统,而是被缓存起来,这样就可以重复使用。

而大块内存(大于128K),则直接使用内存映射mmap()来分配,也就是在文件映射段找一块空闲内存分配出去。

这两种方式,自然各有优缺点。

brk()方式的缓存,可以减少缺页异常的发生,提高内存访问效率。不过,由于这些内存没有归还系统,在内存工作繁忙时,频繁的内存分配和释放会造成内存碎片。

而mmap()方式分配的内存,会在释放时直接归还系统,所以每次mmap都会发生缺页异常。在内存工作繁忙时,频繁的内存分配会导致大量的缺页异常,使内核的管理负担增大。这也是malloc只对大块内存使用mmap的原因。

当这两种调用发生后,其实并没有真正分配内存。这些内存,都只在首次访问时才分配,也就是通过缺页异常进入内核中,再由内核来分配内存。

整体来说,Linux 使用伙伴系统来管理内存分配。这些内存在MMU中以页为单位进行管理,伙伴系统也一样,以页为单位来管理内存,并且会通过相邻页的合并,减少内存碎片化(比如brk方式造成的内存碎片)。

你可能会想到一个问题,如果遇到比页更小的对象,比如不到1K的时候,该怎么分配内存呢?

在用户空间,malloc 通过brk()分配的内存,在释放时并不立即归还系统,而是缓存起来重复利用。在内核空间,Linux 则通过slab分配器来管理小内存。你可以把slab看成构建在伙伴系统_上的一个缓存,主要作用就是分配并释放内核中的小对象。

对内存来说,如果只分配而不释放,就会造成内存泄漏,甚至会耗尽系统内存。所以,在应用程序用完内存后,还需要调用free() 或unmap(),来释放这些不用的内存。

当然,系统也不会任由某个进程用完所有内存。在发现内存紧张时,系统就会通过一系列机制来回收内存,比如下面这三种方式:

  • 回收缓存,比如使用L RU (Least Recently Used) 算法,回收最近使用最少的内存页面;

  • 回收不常访问的内存,把不常用的内存通过交换分区直接写到磁盘中;

  • 杀死进程,内存紧张时系统还会通过00M (Out of Memory),直接杀掉占用大量内存的进程。

其中,第二种方式回收不常访问的内存时,会用到交换分区(以下简称Swap)。Swap其实就是把一块磁盘空间当成内存来用。它可以把进程暂时不用的数据存储到磁盘中(这个过程称为换出),当进程访问这些内存时,再从磁盘读取这些数据到内存中(这个过程称为换入)。

所以,你可以发现,Swap把系统的可用内存变大了。不过要注意,通常只在内存不足时,才会发生Swap交换。并且由于磁盘读写的速度远比内存慢,Swap 会导致严重的内存性能问题。

第三种方式提到的OOM (Out of Memory),其实是内核的- -种保护机制。它监控进程的内存使用情况,并且使用oom_ score 为每个进程的内存使用情况进行评分:

  • 一个进程消耗的内存越大,oom_ score就越大;

  • 一个进程运行占用的CPU越多,oom_ score就越小。

这样,进程的oom_ score 越大,代表消耗的内存越多,也就越容易被00M杀死,从而可以更好保护系统。

当然,为了实际工作的需要,管理员可以通过/proc文件系统,手动设置进程的oom_ adj ,从而调整进程的oom_ score。

oom_ adj 的范围是[-17, 15],数值越大,表示进程越容易被00M杀死;数值越小,表示进程越不容易被OOM杀死,其中-17表示禁止0OM。

比如用下面的命令,你就可以把sshd进程的oom_ adj 调小为-16,这样,sshd 进程就不容易被OOM杀死。

1
echo -16 > /proc/$(pidof sshd)/oom_adj

如何查看内存使用情况

1
2
3
4
# free
total used free shared buff/cache available
Mem: 1008936 113468 646612 748 248856 746168
Swap: 0 0 0

你可以看到,free 输出的是一个表格,其中的数值都默认以字节为单位。表格总共有两行六列,这两行分别是物理内存Mem和交换分区Swap的使用情况,而六列中,每列数据的含义分别为:

  • 第一列,total是总内存大小;

  • 第二列,used是已使用内存的大小,包含了共享内存;

  • 第三列,free是未使用内存的大小;

  • 第四列,shared是共享内存的大小;

  • 第五列,buff/cache是缓存和缓冲区的大小;

  • 最后一列,available是新进程可用内存的大小。

注意一下,最后一列的可用内存available。available 不仅包含未使用内存,还包括了可回收的缓存,所以一般会比未使用内存更大。不过,并不是所有缓存都可以回收,因为有些缓存可能正在使用中。

top 按下M 查看内存排序

跟内存相关的几列数据,比如VIRT、RES、SHR以及%MEM等,这进程最重要的几个内存使用情况:

  • VIRT是进程虚拟内存的大小,只要是进程申请过的内存,即便还没有真正分配物理内存,也会计算在内。

  • RES是常驻内存的大小,也就是进程实际使用的物理内存大小,但不包括Swap和共享内存。

  • SHR是共享内存的大小,比如与其他进程共同使用的共享内存、加载的动态链接库以及程序的代码段等。

  • %MEM是进程使用物理内存占系统总内存的百分比。

使用top 查看内存需要注意:

  • 虚拟内存通常并不会全部分配给物理内存
  • 共享内存 SHR 并不一定是共享,例如程序的代码段、非共享的动态链接库,也都算在 SHR 里。当然,SHR 也包括了进程间真正共享的内存。所以在计算多个进程的内存使用时,不要把所有进程的 SHR 直接相加得出结果。