# 内存

:Memory ,又称为内部存储器、主存储器、物理内存。

# 存储技术

# ROM

常见技术:

  • 只读存储器(Read Only Memory ,ROM)

    • 只能读取数据,不能写入数据。
    • 断电后数据不会丢失,因此能持久保存数据。
  • 可编程 ROM(Programmable ROM ,PROM)

    • 出厂时,存储的每个 bit 都为 1 ,用户可以擦除为 0 ,从而写入数据,但只能擦除一次。
  • 可擦除可编程 ROM(Erasable Programmable ROM ,EPROM)

    • 通过照射紫外线来擦除,可以多次擦除。
  • 电可擦除可编程 ROM(EEPROM)

    • 可以用电信号多次擦除,但是以 Byte 为单位擦除,效率低。
  • 闪存(FLash Memory)

    • 属于改进型的 EEPROM ,基于浮栅晶体管存储电荷。
    • 主要分类:
      • NOR
        • :基于与或门(NOT-OR)。
        • 容量小,一般为几十 MB 。擦写速度慢,支持随机访问。
        • 常用于 BIOS 等存储数据少的嵌入式设备。
      • NAND
        • :基于与非门(NOT-AND)。
        • 容量大。擦写速度快,以 Block 为单位访问。
        • 常用于 U 盘、SD 卡、SSD 硬盘。
    • 传统的嵌入式设备(比如 BIOS )一般采用 EPROM 存储数据,目前改用 Flash 。

# RAM

:随机存储器(Random Access Memory),泛指可以在任意位置读、写数据的存储器。

常见技术:

  • 动态 RAM(DRAM)

    • :基于电容存储电荷,用电容的充、放电之后的电压高低表示二进制的 1、0 。不过电容的电荷会缓慢耗散,需要定期刷新电容。
    • 结构简单,容易做到高密度、大存储容量。
    • 一个内存条包含几个内存芯片以及外围电路,每个内存芯片中都集成了大量的内存颗粒。
    • 常用于制作计算机的内存,已演变出多代技术:
      • SDRAM :同步 DRAM ,时钟频率与 CPU 一致。
      • DDR SDRAM :提高时钟频率、读写速度。
      • DDR2 SDRAM :读写速度大约为 5 GB/s 。
      • DDR3 SDRAM :读写速度大约为 10 GB/s 。
      • DDR4 SDRAM :读写速度大约为 30 GB/s 。
  • 静态 RAM(SRAM)

    • :用两个 CMOS 晶体管组成非门,在保持供电的情况下会保持电信号。
    • SRAM 比 DRAM 的成本更高,但读写速度快很多。
    • 常用于制作 CPU Cache 。
  • 非易失性 RAM(Non-Volatile ,NVRAM)

    • :基于浮栅晶体管存储电荷,即使断电也可以持久保持电信号。
    • 非易失性是指断电后数据不会丢失,而 DRAM、SRAM 都是易失性,需要保持供电。
  • ECC(Error Checking and Correcting)

    • :一种数据纠错技术。是在内存条中增加一颗 ECC 芯片,用于检查数据并纠正错误的二进制位,从而降低出错率,比如抵抗电磁干扰。
    • ECC 内存比普通内存贵了 1 倍多,常用于服务器,而家用电脑一般不兼容。

# 内存寻址

  • Linux 内核中存在一个内存管理单元(Memory Management Unit,MMU),负责管理内存。

# 虚拟地址空间

  • Linux 系统中,进程不会直接访问物理内存,不会直接对物理内存寻址。而是通过 VAS 间接访问物理内存。

  • 每个进程运行在一个独立的虚拟地址空间(Virtual Address Space,VAS)中,又称为虚拟内存空间。

    • 使用 VAS 时,就像该主机上只运行了这一个进程,独享了物理内存的整个地址空间。
    • VAS 是一个逻辑概念。进程写入 VAS 中的数据,会被 Linux 自动写入物理内存,从而实现真实的存储。
    • 与 VAS 相对的,物理内存的地址空间,称为真实地址空间(Real Address Space)。
    • Windows 系统也有虚拟内存的概念,但用途相当于 Linux 的 Swap 分区。
  • 使用 VAS 的优点:

    • 隔离不同进程的内存地址空间。这样每个进程,不能访问其它进程的内存地址,除非是共享内存。
    • 可以将物理内存的分散地址映射到 VAS 的连续地址,从而方便进程寻址。
    • 延迟分配内存:当进程调用 malloc() 申请分配一块内存时,内核并不会立即分配物理内存空间,而是先分配一块虚拟内存 pages 。

# 内核空间

  • 每个进程的 VAS 空间,分为两半空间:

    • 高地址的部分空间,用作内核空间(Kernel Space),
      • 用于存储内核的代码、数据。
      • Linux 主机中通常只运行一个内核。
        • 因此,所有进程的内核内存空间,都会映射到物理内存中的同一段地址。
        • 因此,不同进程可以通过内核进行进程间通信。
      • 进程平时运行在用户态,只能访问用户空间的内存地址。进程切换到内核态时,才能访问内核空间的内存地址。
    • 剩下的空间,用作用户空间(User Space)。
      • 用于存储该进程的代码、数据。
      • 不同进程的用户内存空间,是相互隔离的。
        • 例如进程 A 可以将自己的虚拟内存地址 0x00001000 映射到任意物理内存地址。进程 B 也可以将自己的虚拟内存地址 0x00001000 映射到任意物理内存地址,与进程 A 互不影响。
  • 例:如果 CPU 地址总线的宽度为 32 位,则最多寻址 2^32=4 GB 的物理内存空间,每个进程独享一个 4GB 的 VAS 。

    • 高地址的 1G 空间用作内核空间。
    • 低地址的 3G 空间用作用户空间。
  • 例:如果 CPU 地址总线的宽度为 64 位,则最多寻址 2^64=16 EB 的物理内存空间,每个进程独享一个 16EB 的 VAS 。

    • 这样的内存空间太大了,因此目前的 64 位 CPU 芯片,通常只用 48 位来寻址,从而减少生产成本。因此物理内存空间最大为 2^48=256 TB 。
    • 一般的 64 位主机,物理内存的容量没有 256TB ,只有几十 GB 。但 VAS 的容量都是 256TB ,毕竟 VAS 是逻辑概念,不需要成本。
    • VAS 中,高地址的 128TB 用作内核空间,低地址的 128TB 用作用户空间。

# 用户空间

  • 用户空间细分为几块用途不同的区域,从高地址到低地址排列如下:

    stack     # 栈区,用于存放局部变量、函数参数、函数返回地址
    <empty>
    mmap      # 包含一组 VMA 区域。进程调用 malloc()->mmap() 会分配该区域的内存
    <empty>
    heap      # 堆区。进程调用 malloc()->brk() 会分配该区域的内存
    data      # 数据段,用于存放已初始化的全局变量、静态变量
    BSS       # 用于存放尚未初始化的全局变量、静态变量
    text      # 代码段,用于存放该进程要执行的程序代码
    
    • stack、mmap、heap 三个区域的体积都不固定,可能在进程运行时增长。
      • 因此 Linux 在这三个区域之间,预留了大块未使用的 empty 虚拟地址。
      • 而且 stack 区域是从高地址向低地址,倒序增长,与 mmap 区域之间的间隔更大。
    • 严格来说,mmap、heap 是两个不同的区域。但有时将两者统称为堆区,因为两者都被 malloc() 函数用于动态分配内存。
  • 关于 stack 。

    • 同一进程中,每个线程会创建一个独立的栈区,ulimit 默认限制了每个 stack 体积最大为 8MB 。
      • stack 区域可以写入多个元素,这些元素组成一个后进先出(LIFO, Last In First Out)的队列。
      • Linux 创建一个线程时,会通过 mmap() 方式分配一块内存,作为 stack 区域。因此 stack 起初不会占用物理内存,直到触发缺页异常。
    • stack 占用的内存量,主要受函数影响。
      • 调用函数时,会新增局部对象,占用更多 stack 内存。函数执行完之后,会自动销毁局部对象,释放 stack 内存。
      • 每次调用一个函数,会将函数调用方的地址记录到 stack 中。这样当程序执行完该函数之后,才知道返回哪处代码继续执行。
      • 每次调用一个函数,会将函数的实参拷贝到 stack 中,作为函数的形参(属于函数内部的局部变量)使用。
      • 如果嵌套调用多层函数,则可能占满 stack 内存。
  • data、BSS、text 区域的体积固定。因此,如果使用同一程序启动多个进程,则这些区域的开始地址相同。

    • 开始地址相同,优点是简化了进程的寻址过程,缺点是容易发生缓冲区溢出攻击。
    • 为了提高安全性,Linux 采用了 ASLR(address space layout randomization,地址空间布局随机化)方案,在每次启动进程时,随机决定各个区域的开始地址。
      • 例如 heap 与 data 两个区域并不直接相邻,而是插入了随机的 offset 间隔,使得 heap 的开始地址难以预测。
      • 攻击者虽然可以反复猜测虚拟内存地址,甚至穷举。但访问无效的虚拟内存地址,可能引发 segment fault 等报错,导致进程崩溃,然后攻击者通常没有权限让进程重启。
    • ASLR 方案只是作用于用户空间。后来 Linux 还采用了 KASLR 方案,作用于内核空间,使得内核代码段的开始地址随机化。
    • 现代的编译器,将程序从源代码编译成可执行文件时,默认会启用 PIE (Position Independent Executable,位置无关的可执行文件)编译选项,从而允许程序各个区域的开始地址不固定。
      • 如果未采用 PIE 编译选项,则程序可能不兼容 ASLR 方案。

# page

  • 管理内存空间时,如果以 byte 为单位,则需要记录每个 byte 的状态(比如是否存储了数据),很繁琐。

  • 因此,现代计算机通常将内存空间划分成很多个小型区块,以区块为单位管理内存。

    • 优点:只需记录每个区块的状态。
    • 缺点:给进程分配内存时,只能分配 n≥1 个区块,不能精确到 byte ,容易超过进程的实际内存用量,造成内存浪费。
    • 管理虚拟内存空间时,每个区块称为一个页面(page),又称为 virtual page 。
    • 管理物理内存空间时,每个区块称为一个页框(frame),又称为 page frame、physical page 。
    • page、frame 的体积必须是 2 的 n 次方,即 2^n bytes 。在 Linux 中通常为 4KB 。
  • 划分 page ,是为了方便管理内存空间。而对内存寻址时,依然可以精确到 byte 单位。

    • 每个 byte 在整个内存空间中,有一个唯一的全局地址:memory_address = (page_number*page_size) + offset
    • 例:0x00001000 内存地址,表示序号 page_number=0 的 page 中,偏移量 offset=1000 的那个 byte 。
  • 对于物理内存中每个 physical page ,Linux 会创建一个结构体来描述它:

    struct page {
        unsigned long flags;
        atomic_t _mapcount;
        atomic_t _refcount;
        ...
    }
    
    • Linux 主机开机时,会扫描物理内存并进行初始化,为每个 physical page 创建一个 struct page 。
      • 一个 struct page 通常占用 64 bytes 内存。
      • 假设 Linux 主机总物理内存为 M ,则存在 M/4KB 个 physical page 。全部 struct page 占用 (M/4KB)*64 bytes 内存,占总内存的 1.56% 。
    • flags 用于记录该 page 的状态,例如:
      PG_locked       # 该 page 是否被锁定。一个进程可以申请锁定某个 page ,以免同时有其它进程读写该 page
      PG_dirty        # 该 page 是否属于脏页
      PG_lru          # 该 page 是否位于 lru 列表。lru 列表分为 active、inactive 两个
      PG_active       # 该 page 是否位于 active lru 列表
      PG_referenced   # 该 page 是否最近被访问过。 lru 算法会优先释放 inactive 且 unreferenced 的 page
      PG_workingset   # 该 page 是否用于某个进程的工作集
      PG_slab         # 该 page 是否用于 slab
      PG_error        # 该 page 是否损坏,不能被访问
      
    • _refcount
      • :page 的引用计数,表示此时该 page 当前被多少个对象引用。
      • 如果新分配一个 page 给进程使用,或者用于 buff/cache ,则 _refcount 等于 1 。
      • 如果等于 0 ,则说明该 page 属于空闲内存。
    • _mapcount
      • :page 的映射计数,表示此时该 page 被多少个进程的 Page Table 映射了。
      • 如果等于 -1 ,则表示没有映射。
      • 如果等于 0 ,则表示只有一个进程映射,是私有内存(private)。
      • 如果大于 0 ,则表示有多个进程映射,是共享内存(share)。

# page table

  • 每个进程,如何通过 VAS 间接访问物理内存?

    • MMU 会为每个进程记录一张页表(Page Table),将该进程的虚拟内存空间中已使用的每个 virtual page ,映射到物理内存的某个 physical page 。
    • 例:将 4GB 容量的虚拟内存空间,映射到 4GB 容量的物理内存空间,每个 4KB 体积的 page 映射到一个 4KB 体积的 frame ,形成一对一的映射关系。
      • 一个 page 可以不映射到 frame 。此时该 page 没有实际存储数据,不能被读写。
      • 多个 page 可以映射到同一个 frame 。比如写时复制。
    • 例:一个进程将 1 byte 数据写入自己的虚拟内存地址 0x00001000 处。至于该数据会被 MMU 写入哪个物理内存地址,该进程不知道,也不必知道。
    • 例:MMU 将虚拟内存地址 0x00001000 映射到物理内存地址的流程:
      1. 解析虚拟地址 0x00001000 ,知道它表示序号 page_number=0 的 page 中,偏移量 offset=1000 的那个 byte 。
      2. 在页表中,找到这个 page_number ,映射到哪个 frame_number 。
      3. 读取目标 frame 中,偏移量 offset=1000 的那个 byte 。
  • 每个进程的页表中,会为每个 page 记录一条映射关系,称为页表项(Page Table Entry,PTE)。

    • PTE 是一个 unsigned long 类型的变量。
      • 在 32 位主机中,每个 PTE 占用 4 bytes 内存空间。
      • 在 64 位主机中,每个 PTE 占用 8 bytes 内存空间。
    • 每个 PTE 中,一部分 bits 用于记录 frame 地址。剩下的 bits 用作标志位,记录该 page 的状态,例如:
      _PAGE_PRESENT   # 该 page 数据是否位于物理内存中
      _PAGE_READ      # 该 page 是否允许当前进程读取
      _PAGE_WRITE     # 该 page 是否允许当前进程写入
      _PAGE_USER      # 该 page 是否允许被当前进程在用户态读写
      _PAGE_DIRTY     # 该 page 是否为脏页
      _PAGE_ACCESSED  # 该 page 是否曾经被访问过
      
  • 对于每个进程,Linux 内核会创建一个 mm_struct 结构体,用于记录该进程的 VAS 内存映射信息(Memory Mapping),又称为内存描述符(Memory Descriptor)。

    • 源代码:
      struct mm_struct {
          unsigned long task_size;  // VAS 的最大体积,取决于 CPU 地址总线的宽度
          pgd_t *pgd;               // Page Global Directory ,它是四级页表的根节点
          int map_count;            // VMA 区域的数量
      
          // 记录该 mm_struct 被当前进程的多少个线程引用了
          // 同一进程的所有线程,使用同一个 mm_struct ,从而共享虚拟内存空间。不同进程使用不同的 mm_struct
          // 如果 mm_users 为 0 ,则说明当前进程没有线程,此时会将 mm_count 减 1
          atomic_t mm_users;
      
          // 记录该 mm_struct 被多少个进程引用了(包括当前进程、内核线程)
          // 如果为 0 ,则说明该 mm_struct 未被使用,可以销毁
          // 例:假设新创建一个进程,包含 4 个线程,则 mm_users 为 4 ,mm_count 为 1
          atomic_t mm_count;
      
          unsigned long total_vm;   // VAS 中已分配的 page 数量。该变量用于统计进程的 Virtual Set Size
          unsigned long stack_vm;   // stack 占用的 page 数量
      
          unsigned long start_code, end_code; // 代码段的开始地址、结束地址
          unsigned long start_data, end_data; // 数据段的开始地址、结束地址
          unsigned long start_brk, brk;       // heap 区域的开始地址、结束地址
          unsigned long mmap_base;            // mmap 区域的开始地址
          unsigned long start_stack;          // stack 区域的开始地址,而结束地址记录在栈顶指针中
      
          ...
      }
      
    • 例:每当 Linux 内核执行一个进程时,会按以下流程访问其内存:
      1. 获取进程描述符 task_struct 。
      2. 通过 task_struct->mm 指针,获取该进程的内存描述符 mm_struct 。
      3. 通过 mm_struct->pgd 指针,获取该进程的页表。
      4. 通过页表,查询该进程某个虚拟内存地址,映射到了哪个物理内存地址。
  • 页表的缺点:

    • 使用页表时,CPU 需要先读取页表,查询到 frame 地址,然后读取该 frame 中的数据。这需要两次读取,虽然页表位于内存而不是磁盘,但依然拖慢了 CPU 运行进程的速度。
      • 对策:使用 TLB 缓存。
    • 页表的体积越大,CPU 读取该页表时,占用的内存越多,耗时越久。
      • 对策:
        • 创建多级页表,减小单个页表的体积。但依然需要先后查询不同级别的页表,这样多次查询,增加了耗时。
        • 创建 huge page ,减少 PTE 数量。
  • 为了加速查询 PTE ,CPU 添加了一个缓存区(Translation Lookaside Buffer,TLB),用于缓存最近访问的一些 page 的 PTE 。

    • TLB 不存储在物理内存中,而是位于 CPU 芯片内的高速缓存。
    • 当 CPU 想知道一个 page 的 PTE 时,会先查询 TLB ,如果命中缓存,则不必查询页表。
    • 一个占用大量内存的进程,可能经常访问不同地址的 page ,导致经常不命中 TLB 缓存,需要查询页表。
    • 为了提高 TLB 缓存的命中率,需要减少进程的 PTE 数量,例如:
      • 减少进程使用的内存量。
      • 让进程使用 Huge Page 。
  • 多级页表(Multi-Level Page Tables)

    • :是指划分多级、多个页表,组成树形关系,使得每个页表的体积不超过 1 page 。
    • 例如:
      • 创建大量的低级页表,用于记录 PTE 。
      • 创建少量的高级页表,用于索引各个低级页表。
      • CPU 查询虚拟地址 0x00001000 的 PTE 时,需要先读取高级页表,找出该 PTE 位于哪个低级页表,然后读取这个低级页表中的 PTE 。
    • Linux v2.6.11 开始,创建一个进程时,默认采用四级页表:
      • PGD(Page Global Directory)
      • PUD(Page Upper Directory)
      • PMD(Page Middle Directory)
      • PTE

# huge page

  • Huge Page

    • :泛指体积大于 4KB 的内存页。
    • 原理:修改 mm_struct ,将多个地址连续的普通 physical page 串联在一起,组成一个体积更大的复合页(compound page)。
    • Redhat 系统支持创建 2MB、1GB 两种体积的 Huge Page ,默认采用 2MB 。
    • 优点:
      • 对于同一块内存空间,划分的 page size 越大,记录的 PTE 就越少,从而减少页表的内存开销、查询耗时。
    • 目前 Linux 定义了两种 Huge Page :
      • HugeTLB pages
      • THP
  • HugeTLB pages

    • :普通的 Huge Page ,又称为静态大页。
    • 如何创建?
      • Linux 主机一般启用了 HugeTLB pages 功能,但是默认数量为 0 。root 用户可以执行 sysctl -w vm.nr_hugepages=xx 创建指定数量个 Huge Page 。
      • 建议修改 Linux 的开机参数,在开机时创建一些 Huge Page ,因为此时空闲内存多。
      • 可以挂载一个 hugetlbfs 文件系统,例如执行 mount -t hugetlbfs nodev /mnt/hugetlbfs
        • 在 hugetlbfs 中创建的每个文件都会以 Huge Page 为单位存储。
        • 如果进程想写入数据到这些文件,则只能用 mmap() 映射这些文件。
    • 如何使用?
      • 进程调用 mmap() 申请一块匿名内存时,在 flags 中添加 MAP_HUGETLB ,就会让内核用一组 Huge Page 组成这块内存空间。
      • 进程调用 shmget() 申请一块共享内存时,在 flags 中添加 SHM_HUGETLB ,就会让内核用一组 Huge Page 组成这块共享内存。
    • 缺点:
      • 一般进程只会使用普通 page ,需要按特殊方式才能使用 Huge Page 。因此,如果 Huge Page 一直未被进程使用,则会导致这些空闲内存一直浪费。
  • THP(Transparent Huge Page)

    • :又称为透明大页。
    • 如何创建?
      • 如果进程将多个 physical page ,映射到虚拟内存空间的连续地址,则 Linux 可能自动将它们合并为一个 Huge Page 。
      • HugeTLB pages 是在进程使用内存之前,事先创建 Huge Page 。而 THP 是在进程使用内存时,自动创建 Huge Page 。
    • 缺点:
      • 自动合并可能有几秒的耗时,导致进程运行突然变慢。例如 Redis 建议用户禁用 THP 功能。
    • 例:查看本机是否启用了 THP 功能
      [root@CentOS ~]# cat /sys/kernel/mm/transparent_hugepage/enabled
      [always] madvise never
      
      这表示,只有 madvise() 会启用 THP ,其它情况下禁用 THP 。

# page fault

  • 缺页异常(page fault)

    • 又称为缺页中断、页面错误。
    • 什么时候发生?
      • 当进程读写某个 virtual page 的数据时,内核会到 Page Table 中查找它映射到哪个物理内存地址,从而读写物理内存中实际存储的数据。
      • 如果该 page 没有映射到物理内存地址,则抛出缺页异常(page fault),并执行缺页异常处理程序。
  • 缺页异常分为几种:

    • 主要缺页异常(major page fault)

      • 含义:目标 page 数据,在物理内存中不存在,而是位于磁盘中。需要将磁盘中该 page 数据拷贝到物理内存中,存在一些磁盘 IO 耗时。
      • 原因举例:
        • 程序启动,首次读取其二进制代码时,会触发一次 major page fault ,将磁盘中的程序代码拷贝到物理内存中。
        • 使用 swap 分区时,每次 swap in ,会触发一次 major page fault ,将磁盘 swap 分区中的某些数据拷贝回物理内存。
    • 次要缺页异常(minor page fault)

      • 含义:目标 page 数据,在物理内存中存在,但没有被当前进程的 Page Table 映射。
      • 原因举例:
        • 进程 A 尝试将磁盘中一个库文件拷贝到物理内存时,发现进程 B 已经将该库文件载入内存,则可通过共享内存的方式访问。
        • 将一个程序启动多次,运行多个进程。第一个进程会触发 major page fault ,将磁盘中的程序代码拷贝到物理内存中。之后的每个进程会触发 minor page fault ,共享物理内存中的程序代码。
    • invalid page fault

      • 含义:当前进程访问了一个无效的 page 。
      • 此时,内核会发出 segment fault 报错给当前进程。
        • page fault 是一种合理的异常,经常会在 Linux 主机中发生。而 segment fault 是一种严重的报错,通常会导致进程异常终止。
      • 原因举例:
        • 进程尝试读取一个虚拟内存地址,但该地址在 Page Table 中不存在。例如:
          • 访问一个数组时,索引越界。
          • 访问一个尚未初始化的内存指针,其指向的内存地址为 NULL 或随机值。
          • 访问一个已经初始化的内存指针,但其指向的内存已被 free() 释放。
        • 进程尝试读取一个虚拟内存地址,该地址在 Page Table 中存在,但是不允许该进程读写。
          • 例如用户态进程,无权访问虚拟内存空间中的内核空间。
  • Linux 基于 page fault 实现了一些重要功能:

    • 延迟分配内存:

    • 当进程调用 malloc() 申请分配一块内存时,内核并不会立即分配物理内存空间,而是先分配一块虚拟内存 pages ,并返回内存指针给该进程。

    • 等进程首次读写该虚拟内存 pages 时,在 Page Table 中找不到映射的物理内存地址,会触发 minor page fault 。此时内核才修改 Page Table ,选取一些空闲的物理内存空间,映射到虚拟内存 pages 。如果空闲内存不足,则可能触发 OOM 。

    • 优点:这样可以节省物理内存。毕竟有的进程申请内存之后,不会立即使用,甚至永远不用。

    • 多个进程可以同时映射物理内存中的同一个 page ,称为共享内存。但如何避免一个进程修改共享内存时,影响到其它进程?通常采用写时复制(Copy On Write ,COW)的策略:

      • 当一个进程读取该 page 时,会顺利读取。
      • 当一个进程写入该 page 时,会发现没有写入权限,触发 minor page fault 。然后内核在物理内存中,将该 page 拷贝一份,专门供该进程映射、读写。
      • 优点:如果多个进程只是读取共享内存,则不必拷贝,从而节省物理内存、拷贝耗时。
  • page fault 是一种合理的异常,经常会在 Linux 主机中发生。但如果太频繁,则会明显增加 CPU 负载,此时需要排查原因,比如主机空闲内存不足。

# 内存开销

  • 当 Linux 主机刚开机时,物理内存的大部分空间是空闲的,只有内核占用了少部分内存空间。

  • 当 Linux 主机运行一些程序之后,物理内存通常分成几种用途:

    • 被内核占用的内存
    • 被各个进程占用的内存
    • buff/cache :用于缓存一些数据,可以释放,变成空闲内存。
    • free 空闲内存:没有存储数据,可以随时用于存储数据。
  • 当主机的空闲内存不足时,就不能创建新进程,旧进程也不能申请新内存空间。此时,MMU 可能采取三种措施:

    • 回收一些 buff/cache 缓存:根据 LRU(Least Recently Used,最近最少使用)算法,将一些缓存转换成空闲内存。
    • 将物理内存中一些 used 内存,移到磁盘 swap 分区。
    • 通过 OOM 杀死某些进程,释放其占用的物理内存空间。

# free

通常用 free 命令查看 Linux 主机的内存开销:

free            # 显示主机内存的使用情况
    -k          # 采用 KB 作为显示单位(默认)
    -m          # 采用 MB 作为显示单位
    -h          # --human ,自动调整显示单位
    -w          # 拉宽显示,将 buffers 与 cache 列分别显示
    -s 1 -c 10  # 每隔一秒显示一次,最多显示 10 次
  • 例:

    [root@CentOS ~]# free -wh
                  total        used        free      shared  buff/cache   available
    Mem:           7.6G        5.9G        187M        496K        1.6G        673M
    Swap:          4.0G        1.8G        2.2G
    
    • total :内存的总可用量。
    • used :已被占用的内存。
      • free 命令会先从 /proc/meminfo 读取 MemTotal、MemFree、Buffers、Cached、Slab 信息,然后按 used = total - free - buffer - cache 计算出已用内存。
    • free :空闲内存。表示既没有被进程、内核占用,也没有被用作缓存的内存。
    • shared :共享内存,属于 used 内存。
    • buffers :指 Buffer Cache 。
    • cache :包括 Page Cache、Reclaimable Slab、tmpfs 。
    • available :可用内存,等于 free + buff/cache 内存。该值是估算的,并不准确。
      • free 内存较少并不代表内存紧张,因为内核会自动回收 buff/cache 内存,分配给进程使用,成为 used 内存。
      • 当 available 内存较少、甚至 Swap 分区被使用时,才说明内存紧张,需要增加主机的物理内存。
  • /proc/meminfo 记录的 MemTotal 表示内存的总可用量,比实际容量大概少 2% ,因为要减去 absent、reserved 内存。

    • 例:查看内存设备
      [root@CentOS ~]# lsmem
      ...
      Memory block size:       128M
      Total online memory:       8G
      Total offline memory:      0B
      
    • 例:查看内核保留的内存
      [root@CentOS ~]# dmesg | grep reserved
      [    0.000000] Memory: 4972572k/9437184k available (7788k kernel code, 1049104k absent, 402384k reserved, 5954k data, 1984k init)
      
      • absent :不受内核管理的内存,比如被 BIOS 占用。
      • reserved :内核 Boot 阶段会保留一些内存,不会分配出去。
        • 这包括 kernel code、data、init 占用的内存。
        • 内核 Boot 完成之后,会释放少量 reserved 内存。

# 内核内存

  • 内核占用的内存举例:

    Kernel Modules    # 内核模块,可用 lsmod 命令查看
    struct page       # 占总内存的 1.56%
    Page Cache        # 用于内核读写磁盘时的缓存区、缓冲区。通常体积较大,但是当主机 free 内存不足时会被自动回收
    Slab
    Page Tables       # MMU 会为每个进程记录一张页表
    Kernel Stack      # 同一进程中,每个线程会创建一个独立的栈区,ulimit 默认限制了每个 stack 体积最大为 8MB
    Sockets           # 每个 Socket 的接收缓冲区加发送缓冲区,可能占用 4KB~10MB 内存
    
  • 可执行 cat /proc/meminfo 查看主机内存的使用情况。

    • 但它不会统计以下内存:
      • Socket 内存
      • 通过 alloc_pages() 分配的内存

# 进程内存

  • 一个进程会占用哪些内存?

    • 静态分配内存:一个进程在启动时,必须申请少量内存空间,用于存储代码段、数据段等内容。
    • 动态分配内存:一个进程在运行时,可以调用 malloc() 函数,申请自定义大小的内存空间,供自己存储数据。然后调用 free() 释放内存。
  • Linux 内核会准确记录每个进程占用的 CPU 时长,但不能准确记录每个进程占用的内存量。

    • 一个进程申请了一块内存,可能并没有实际使用。如何统计该进程的内存开销?
    • 物理内存中的一个 Page ,可能被多个进程共享。如何统计这些进程的内存开销?
    • 为了统计每个进程占用的内存量,通常采用 RSS、WSS 算法,但存在误差。
  • 虚拟集(Virtual Set Size,VSZ)

    • :进程申请分配的内存量。
    • 例如进程多次调用 malloc() ,申请分配多块内存空间。它们的体积之和就是 VSZ 。
  • 常驻集(Resident Set Size ,RSS)

    • :进程长期驻留的内存量。是指进程的 Page Table 中,触发过缺页异常的 page 数量。
    • 假设进程 A 申请分配了 10MB 内存空间,实际只在 2MB 内存空间中写入了数据。则 VSZ 为 10MB , RSS 为 2MB 。
      • 如果进程释放一些已用内存,则统计的 RSS 不会减少。因此 RSS 可能比进程实际占用的内存虚高。
    • RSS = 进程占用的非共享内存 + SHR。
      • RSS 包括堆、栈、共享内存,不包括 Swap ,也不包括 page tables、huge page、kernel stack、struct thread_info、struct task_struct 等。
      • 用如下命令可统计所有进程的 RSS 内存之和,但这样会重复累计 SHR 内存,因此计算结果比所有进程实际占用的内存量虚高。为了减少误差,应该统计所有进程的 PSS 内存之和。
        ps -eo rss | awk 'NR>1' | awk '{sum+=$1} END {print sum/1024}'
        
  • SHR

    • :Shared Memory ,进程占用的共享内存。
    • 多个进程可能导入同一个共享库,例如 glibc ,此时使用共享内存就不必重复占用内存空间。
  • 比例集(Proportional Set Size,PSS)

    • :按比例估算出进程的常驻内存。
    • PSS = 进程独占的非共享内存 + 进程平均占用的 SHR
    • 假设进程 A 的 RSS 为 10MB ,其中 8MB 为非共享内存,2MB 为与其它 N 个进程共享的内存。则进程 A 的 PSS = 8 + 2/(N+1) MB 。
  • 工作集(Working Set Size ,WSS)

    • :进程保持工作所需的内存。是估算进程最近访问过的 pages 数,包括物理内存、内核内存、脏页。
    • 一个进程的 RSS 总是大于等于 WSS ,因为 RSS 可能虚高。

# Cache

  • Linux 内核读写磁盘时,会自动分配一些内存空间,用作缓存区、缓冲区,称为 Cache 。

    • 用途:缓存从磁盘读取到内存的数据。
      • 优点:
        • 重复读取同一段数据时,可减少耗时。
    • 用途:缓冲从内存写入磁盘的数据。
      • 优点:
        • 累计一定数据再写入磁盘,提高写磁盘的效率。
        • 让内存与磁盘异步工作,减少等待磁盘 IO 的耗时。
      • 缺点:
        • 进程读取磁盘时,所有数据都要先被内核拷贝到 Cache ,再拷贝到进程内存空间。写磁盘的流程反之。
        • 写数据时,需要保证将脏页同步到磁盘。
  • 按数据单位的不同,将 Cache 分为两类:

    • Page Cache
      • :在读写文件时使用。
      • 以 page 为单位分配内存,又称为页缓存。
    • Buffer Cache
      • :在读写块设备时使用。
      • 以 block 为单位分配内存,又称为块缓存。
  • 日常说到 Cache 时,可能指 Page Cache 和 Buffer Cache ,也可能单指 Page Cache 。

    • 它与 CPU Cache 的功能相似,但它位于物理内存芯片中,而 CPU Cache 位于 CPU 芯片中。
    • Linux 内核最初只设计了 Buffer Cache 。后来增加了 Page Cache ,但一个文件数据可能同时被 Buffer Cache、Page Cache 缓存,降低了效率。
    • Linux v2.4 开始,Buffer Cache 合并到 Page Cache 中,共享一片 pages 内存空间。
      • 每个 struct page 中,有一个 private 变量,表示该 page 是否属于 Buffer Cache 。
  • Linux 主机运行时,会自动将一些 free 内存转换成 Cache 。

    • 这样提高了内存的利用率,通过 Cache 加速了主机的运行,是好事。
    • 当 free 内存紧张时,内核会自动回收一些 Cache (不会回收脏页),转换成 free 内存。
    • 用户可以执行以下命令,主动让内核回收一次 Cache ,不过之后内核依然会重新建立 Cache :
      echo 1 > /proc/sys/vm/drop_caches   # 清除 Cache ,包括 Page Cache、Buffer Cache
      echo 2 > /proc/sys/vm/drop_caches   # 清除 Reclaimable slab ,包括 dentries、inodes
      echo 3 > /proc/sys/vm/drop_caches   # 清除 Cache + Reclaimable slab
      
    • 例:
      [root@CentOS ~]# echo 3 > /proc/sys/vm/drop_caches          # 回收一次缓存
      [root@CentOS ~]# free -wh                                   # 此时 buffer 很少,Cache 有一些
                    total        used        free      shared     buffers       cache   available
      Mem:           2.0G        300M        1.5G        688K        4.0M        124M        1.5G
      Swap:            0B          0B          0B
      [root@CentOS ~]# dd if=/dev/urandom of=f1 bs=1M count=1024  # 拷贝数据,写入文件 f1
      [root@CentOS ~]# free -wh                                   # 此时 buffers 不变, cache 增加了 1G
                    total        used        free      shared     buffers       cache   available
      Mem:           2.0G        300M        525M        688K        4.0M        1.1G        1.5G
      Swap:            0B          0B          0B
      
      • 如果执行 dd if=/dev/vda1 of=/dev/null bs=1M count=1024 ,则可见 buffers 增加 1G ,cache 不变 。
  • 修改 Cache 中的数据时,需要保证缓存与磁盘的数据一致性,有几种策略:

    • 不缓存(no-cache)
      • :删除缓存中的原数据,直接将数据写入磁盘。
    • 写穿透缓存(write through cache)
      • :修改缓存中的数据,并立即同步到磁盘。
    • 写回(write back)
      • :修改缓存中的数据,并将被修改的页面标记为脏页。内核会每隔一定时间,自动将脏页同步到磁盘。
      • 如果从磁盘拷贝一段数据到缓存中,修改缓存数据之后没有同步写入磁盘,则称为脏页(dirty page)。
      • 优点:与写透缓存相比,减少了磁盘 IO 次数,效率更高。
      • 缺点:实现难度更大。比如主机突然宕机,脏页可能来不及同步到磁盘,导致数据丢失。
      • Linux 的 Cache 默认采用该策略。

# OOM

  • Linux 内核的 OOM killer(Out of Memory)用于在空闲内存不足时,自动杀死某些进程,从而腾出空闲内存,给其它进程使用。

    • 优点:
      • 可以快速腾出大量空闲内存。
    • 缺点:
      • 可能杀死某些重要进程。建议用户为重要进程添加自动重启的措施。
      • 通过 SIGKILL 信号强制杀死进程。进程会非正常终止,可能来不及保存数据到磁盘,导致部分数据丢失。
  • 每个进程会被评价一个 oom_score 分数,取值范围为 0~1000 ,取值越大则越可能被 OOM 杀死。

    • oom_score 的取值等于以下两项之和:
      • 系统评分:主要与进程占用的内存量呈正比,默认为 0 。
      • 用户评分:称为 oom_score_adj ,取值范围为 -1000~1000 ,默认为 0 。
    • 用户可以将某些进程的 oom_score_adj 设置为负数,从而降低其 oom_score ,但 oom_score 的值最低为 0 。
  • 例:

    [root@CentOS ~]# cat /proc/self/oom_score   /proc/self/oom_score_adj    # 查看进程的 oom 分数
    0
    0
    [root@CentOS ~]# echo -10 > /proc/self/oom_score_adj                    # 修改 oom_score_adj
    [root@CentOS ~]# cat /proc/self/oom_score   /proc/self/oom_score_adj
    0
    -10
    
  • Linux 每次触发 OOM killer 时,会记录内核日志。如下:

    [root@CentOS ~]# grep -i 'out of memory' /var/log/messages
    Jan 17 19:48:48 CentOS kernel: Out of memory: Kill process 8120 (java) score 313 or sacrifice child
    Jan 19 21:10:21 CentOS kernel: Out of memory: Kill process 20607 (java) score 372 or sacrifice child
    Jan 24 05:11:08 CentOS kernel: Memory cgroup out of memory: Kill process 15230 (run.py) score 251 or sacrifice child
    
    • 示例中的 Memory cgroup 通常是 Docker 容器的 OOM 日志。
  • 分析源代码:

    • Linux 内核经常会调用 alloc_pages() 来分配 physical pages ,如果发现当前的 available 内存不足以分配,则调用一次 out_of_memory() 函数。
    • out_of_memory() 的主要流程:
      1. 调用 select_bad_process() 函数。遍历所有进程,调用 oom_badness() 函数给每个进程评价一个分数 badness ,表示进程应该被杀死的程度。然后选出 badness 最大的一个进程。
      2. 调用 oom_kill_process() 函数,发送 SIGKILL 信号来杀死上述进程。
    • oom_badness() 的代码如下:
      long oom_badness(struct task_struct *p, unsigned long totalpages)
      {
        long points;
        long adj;
      
        // 如果进程不应该被 OOM 杀死(包括主机的 init 进程、kernel thread ),则返回最小的评分,表示排除对该进程的 OOM
        if (oom_unkillable_task(p))
          return LONG_MIN;
      
        // 确保进程的 task->mm 内存映射已被锁定
        p = find_lock_task_mm(p);
        if (!p)
          return LONG_MIN;
      
        // 获取进程的 oom_score_adj 。如果等于最小值 -1000 ,则返回最小的评分
        adj = (long)p->signal->oom_score_adj;
        if (adj == OOM_SCORE_ADJ_MIN ||
            test_bit(MMF_OOM_SKIP, &p->mm->flags) ||    // 如果进程被标记为已被 OOM 杀死,则不重复杀死
            in_vfork(p)) {
          task_unlock(p);
          return LONG_MIN;
        }
      
        // 计算 oom_score 中的系统评分。占用内存越多,则分数越大,该进程越可能被杀死
        points = get_mm_rss(p->mm) + get_mm_counter(p->mm, MM_SWAPENTS) + mm_pgtables_bytes(p->mm) / PAGE_SIZE;
        task_unlock(p);
      
        // 将系统评分加上 oom_score_adj ,得到最终的评分
        adj *= totalpages / 1000;
        points += adj;
      
        return points;
      }
      

# 内存分配

# malloc()

  • Linux 系统中,每个进程需要动态分配内存时,通常是调用 glibc 库的 malloc() 函数。

  • 相关 API :

    #include <stdlib.h>
    
    void *malloc(size_t size);
        // 申请一块内存空间,体积为 size 个 bytes
        // 如果函数执行成功,则返回该内存空间的首地址
    
    void free(void *_Nullable ptr);
        // 传入一块内存空间的首地址,释放该内存空间,将它变成空闲内存
    
    • Linux 默认配置了 sysctl -w vm.overcommit_memory=0 ,允许内核分配给所有进程的内存量,超过物理内存的总量。因为分配的内存不一定会被进程实际使用。
      • 因此主机的空闲内存很少时,调用 malloc() 可能依然成功。
      • 当触发缺页异常时,如果内核找不到足够的空闲内存来映射,则可能通过 OOM 杀死某些进程。
  • 代码示例:

    #include <stdio.h>
    #include <stdlib.h>
    #include <string.h>
    
    int main() {
        // 申请一块内存,体积为 5 个 char 数据类型
        char *m = malloc(5 * sizeof(char));
    
        // 检查 malloc() 是否执行成功。例如主机空闲内存不足时,不能动态分配内存
        if (m == NULL) {
            printf("Failed to allocate memory!\n");
            return 1;
        }
    
        // 读写这块内存
        strcpy(m, "Hello");
        printf("%s\n", m);
    
        // 释放这块内存
        free(m);
    
        return 0;
    }
    
  • malloc() 有两种底层实现方式。

    • 早期版本的 malloc() ,是从进程的 heap 分配一块内存,给进程使用。

      • 原理:
        • 在 malloc() 的底层,调用 brk() 改变 heap 的结束地址,从而增加、减小 heap 体积。
        • 申请一块内存一段时间之后,这块内存可能不再位于 heap 区域的尾部,不能通过移动 heap 的结束地址来释放这块内存。因此调用 free() 时会将 heap 内存标记为 available ,但不会变成主机的空闲内存。
      • 优点:
        • 调用 free() 时,会将 heap 内存保留供当前进程未来使用。当进程下次申请内存时,可以直接复用 heap 内存,不会触发缺页异常。
        • 适合分配小块内存。
      • 缺点:
        • 调用 free() 时,不会将 heap 内存释放给主机。因此容易产生进程内部的内存碎片。
    • 后来的 malloc() 进行了优化:如果 malloc() 申请的内存大于等于 MMAP_THRESHOLD=128KB ,则不从 heap 分配内存,而是通过 mmap() 映射匿名内存。

      • 原理:
        • 在 malloc() 的底层,调用 mmap() 将某段物理内存地址(由一组地址连续的 frame 组成),映射到某段虚拟内存地址(由一组地址连续的 page 组成)。
      • 优点:
        • 适合分配大块内存。
      • 缺点:
        • 调用 free() 时会立即释放内存,因此每次申请内存都会触发缺页异常。

# mmap()

#include <sys/mman.h>

void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);
    // 将某段文件地址,映射到从 addr 开始的一段虚拟内存地址
    // 如果函数执行成功,则返回 addr 内存指针
    // addr   :映射区域的首地址,由用户指定。如果赋值为 Null ,则由内核自动分配地址
    // length :映射区域的长度
    // fd     :要映射的目标文件
    // offset :从文件的指定偏移量开始映射

int munmap(void *addr, size_t length);
    // 取消映射
  • mmap() 用于将某段文件地址,映射到当前进程 VAS 中的,某个虚拟内存区域(Virtual Memory Area,VMA)。

    • 每次调用 mmap() ,就会在当前进程的 VAS 中,新建一个 VMA 区域。
    • 一个进程通常同时存在多个 VMA 区域。每个 VMA 由一组地址连续的 page 组成,而多个 VMA 之间的地址不一定连续。
  • 一个进程调用 mmap() ,主要有两种用途:

    • 匿名映射(Anonymous Mapping)

      • :将某段物理内存地址,映射到某段虚拟内存地址。又称为匿名内存。
        • 此时 flags 包含 MAP_ANONYMOUS ,表示忽略 fd 参数,不映射文件。
      • 刚调用 mmap() 之后,该 VMA 区域中每个 virtual page 的页表项为空。因为 Linux 采用延迟分配内存的策略。
        • 等进程首次读写一个 virtual page 时,会发现它属于 Anonymous Mapping ,于是触发 minor page fault 。
        • 触发 minor page fault 时,内核会选取空闲的 physical page ,映射到 virtual page 。此时进程读写 virtual page ,实际上是读写 physical page 中存储的数据。
      • 例:
        • 进程调用 malloc() 申请内存时,底层会调用 mmap() 来映射内存。
    • 内存映射文件(Memory Mapped File)

      • :将某段磁盘文件地址,映射到某段虚拟内存地址。
      • 刚调用 mmap() 之后,该 VMA 区域中每个 virtual page 的页表项为空。因为 CPU 可以对内存直接寻址,但不能对磁盘直接寻址。
        • 等进程首次读写一个 virtual page 时,会发现它属于 Memory Mapped File ,于是触发 major page fault 。
        • 触发 major page fault 时,DMA 会将磁盘地址中存储的数据,拷贝到物理内存 Page Cache 中。并将 physical page 映射到当前的 virtual page ,供 CPU 读写。
      • 当进程写入数据到 virtual page 时,实际上写入的数据存储在 Page Cache 中。
        • 此时,Page Cache 中的这些文件数据,与磁盘中存储的文件数据不同,属于脏页。
        • 为了保证 Page Cache 缓存与磁盘的数据一致性,内核会每隔一定时间,自动将脏页同步写入磁盘。
        • 用户也可以手动执行命令 sync ,将脏页立即同步到磁盘,这可能有几秒耗时。
      • 优点:
        • 用 read()、write() 多次读写文件时,需要多次调用函数,每次都发生 CPU 上下文切换。而调用一次 mmap() 之后,就可以通过指针读写文件的任意地址。
        • 用 read()、write() 读写文件时,需要先后拷贝数据到 Page Cache、进程内存空间。而用 mmap() 时,只需拷贝到 Page Cache ,属于零拷贝技术,读写大文件时速度更快。
        • 多个进程可以同时映射同一个文件,类似共享内存。例如 Linux 很多进程启动时,会以 MMAP 方式加载 glibc 动态链接库。
  • 创建 VMA 之后,如何取消映射?

    • 关闭文件描述符 fd ,并不会自动取消 VMA 。
    • 每次调用 munmap() ,可以取消一个 VMA 。
    • 进程终止时,会自动取消所有 VMA 。
  • 执行 cat /proc/$PID/maps 可以查看指定进程的 VMA 列表。也可以用 pmap 命令:

    pmap  <pid>     # 显示一个进程的所有 VMA 列表
          -x        # 增加显示 RSS、Dirty Page 列
    
    • 例:
      [root@CentOS ~]# pmap 1 -x
      1:   /usr/lib/systemd/systemd --switched-root --system --deserialize 22
      Address           Kbytes     RSS   Dirty    Mode    Mapping
      0000557a0dbf2000    1424    1208       0    r-x--   systemd
      0000557a0df56000     140     132     132    r----   systemd
      0000557a0df79000       4       4       4    rw---   systemd
      0000557a0f80b000    1224    1124    1124    rw---     [ anon ]
      00007f1878000000     164      12      12    rw---     [ anon ]
      00007f1878029000   65372       0       0    -----     [ anon ]
      
      • 表中每行描述一块 VMA 区域。
      • Address 列表示该 VMA 的首地址。
      • Mapping 列表示该 VMA 的用途。
        • 取值为 systemd 表示映射了一个名为 systemd 的文件。
        • 取值为 [ anon ] 表示匿名内存。
        • 取值为 [ heap ] 表示堆区。
        • 取值为 [ stack ] 表示栈区。

# 内存碎片

  • 内存碎片(memory fragmentation)

    • :指物理内存中,一些空闲内存块的体积太小,以至于不能被分配使用。
    • 分为两类:外部碎片、内部碎片。
  • 外部碎片

    • :没有分配给进程的内存碎片。
    • 产生原因:主机刚启动时,物理内存空间几乎全是空闲内存。但主机运行一段时间之后,进程们多次申请内存、释放内存,可能只有一些零散的内存空间属于空闲内存。
      • 每块空闲内存由一组地址连续的 frames 组成,但多块空闲内存的地址不连续。
      • 外部碎片比较多时,一个特征是:buddy system 的小型内存块很多,大型内存块很少。
    • 对策:当外部碎片导致不足以分配内存时,内核线程 kcompactd 会自动压缩内存:将多块小型空闲内存,移动到连续的地址空间,从而合并成一块大型空闲内存。
      • 用户也可以手动触发:
        echo 3 > /proc/sys/vm/drop_caches     # 删除缓存,有助于减少碎片
        echo 1 > /proc/sys/vm/compact_memory  # 压缩内存,需要几秒耗时
        
  • 内部碎片

    • :已经分配给进程的内存碎片。
    • 产生原因:MMU 以 page 为单位分配内存。如果一个进程申请一块内存空间,但体积不是 page 的整数倍,则依然会分配 n≥1 个 page ,导致最后一个 page 不会被完全使用,也不能分配给其它进程。
    • 例:一个进程申请 1 byte 的内存空间,则 MMU 会分配 1 page 即 4KB 的内存空间,造成内存浪费。
    • 缺点:
      • 如果进程不停产生内部碎片,则会占用越来越多 RSS 内存,看起来像内存泄漏。
    • 对策:
      • 修改进程的程序代码,事先申请一块内存作为内存池,每次需要使用几 bytes 的小内存时,就复用该内存池,而不是申请新内存。

# 内存分配器

  • Linux 物理内存主要被内核、进程占用,两者使用内存的习惯不同,因此通过不同方式分配内存。

    • 用户空间中,由 malloc()、mmap() 等函数分配内存。
    • 内核空间中,由 alloc_pages()、kmem_cache_create() 等函数分配内存。
  • 用户空间的内存分配器(memory allocator)有多种,如下,都提供了 malloc() 等 API 供用户调用。

    • ptmalloc
      • :由 glibc 库提供,被 Linux 默认采用。
    • jemalloc
      • :由 FreeBSD libc 库提供。
    • tcmalloc
      • :由 Google 公司开发。
  • buddy system

    • :Linux 的一个内存分配器。将空闲内存划分为多个内存块,等待使用。每个内存块的体积为 2^n 个 pages 。
    • 内核函数 alloc_pages() 是基于 buddy system 分配内存。
    • 例:假设内核需要一块体积为 5KB 的内存空间,则 4KB 大小的 buddy system 内存块不满足需求,至少要分配一个 8KB 大小的内存块。
    • 优点:可以快速分配大块内存空间。
    • 缺点:分配的内存块,可能超过实际需要的内存量,产生内存碎片。
  • 例:查看本机的 buddy system 内存块数量

    [root@CentOS ~]# cat /proc/buddyinfo
    Node 0, zone      DMA      1      0      1      0      2      1      1      0      1      1      3
    Node 0, zone    DMA32    372    808    446    358    113    170     54     12      7      0      0
    Node 0, zone   Normal    286    123    324    348    137     94     16     20     79      0      0
    
    • Linux 支持 NUMA 架构,可以划分多个节点(node),每个节点划分多个内存区域(zone)。
    • 常见的几种 zone :
      • DMA :直接内存访问(Direct Memory Access)。
      • DMA32 :用于在 64bits 操作系统中,按 32bits 模式访问内存。
      • Normal :普通内存。
    • 右侧有 11 列数字,列编号为 0~10 。第 n 列数字,表示体积为 2^n 个 pages 的内存块的数量。
      • buddy system 内存块属于空闲内存。一旦某个内存块被占用,则数量减一。
  • slab system

    • :Linux 的一个内存分配器。为一些经常分配的、占内存小的对象,事先分配一些内存空间。
    • 内核函数 kmem_cache_create() 是基于 slab system 分配内存。
    • 例如:主机经常会新建进程,而新进程需要创建 task_struct 。因此可以事先分配几百份 task_struct 内存空间,每份空间称为一个 Slab 。等新建进程时,就快速分配一个 slab 来存储 task_struct 。
      • 每种对象需要定义一个结构体 struct kmem_cache ,描述应该为它分配多大体积的 slab 空间。
      • 当某种对象的 slab 空间全部用完时,Linux 会消耗一个 buddy system 内存块,分配更多 slab 空间。
    • slab 内存空间分为两种:
      • Unreclaim :不可回收的。
      • Reclaimable :可回收的。
    • 优点:可以快速分配小块内存空间,并避免内存碎片。
    • 缺点:需要事先分配一些 slab 内存空间,减少了主机的空闲内存。
    • 可执行命令 cat /proc/slabinfoslabtop, 查看主机的 slab 总数、使用率。