内存: linux中的内存是如何工作的?



0. 文章背景

记录这篇文章的目的是为了linux的性能调优,所以并不会记录详细的理论细节,只会将必要的过程进行个人语言的简单概要。

1. 内存映射

linux中,只有内核可以直接访问物理内存。进程无法直接访问物理内存,linux内核给每个进程都分配了一个独立的虚拟内存地址空间。这样进程就可以通过访问虚拟内存来达到使用内存的目的。

虚拟内存地址空间分为两部分,内核空间和地址空间。如果我们把内存地址由高到低排列,那么以64位系统举例,低位的128T被分配为用户空间,而高位的128T被分配为内核空间,高位和低位中间的部分,为未定义的部分。

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

系统为每一个进程都分配了这么大的地址空间,那么所有进程的虚拟内存加起来,会比实际的物理内存大特别多。所以,并不是所有的虚拟空间都会分配到实际的物理内存,只有被实际使用的虚拟内存才会分配到物理内存。这个过程是通过内存映射管理的。

所谓内存映射,其实就是虚拟内存地址映射到物理内存地址。内核会为每一个进程维护一张页表,记录虚拟内存地址和物理内存地址的映射关系。这些页表实际上存储在CPU的内存管理单元MMU中,CPU就可以直接通过硬件,找出要访问的内存。

缺页异常,指的是进程访问的虚拟地址在页表中查不到其与物理地址的映射,进而进入内核空间分配物理内存给这个虚拟地址,然后更新进程页表,最后再返回用户空间,恢复进程的运行。

MMU的单位是,页的大小通常为4KB大小。如果每个页是4KB,那么会导致页表非常巨大。为了解决这个问题,linux提供了两种机制,多级页表大页。多级页表就是把内存分成区块管理;大页就是比普通也更大的内存块,常见的大小有2MB和1GB。大页通常用在使用大量内存的进程上,比如Oracle等

2. 虚拟内存空间分布

虚拟内存空间地址由高到低顺序排布,由上至下分别为内核空间、栈、文件映射、堆、数据段、只读段。

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

上面五个内存段中,堆和文件映射段的内存是动态分布的。C标准库中的malloc()或者mmap(),就分别可以在堆和文件映射段中动态分配内存

3.1 内存分配

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

对小块内存(小于128K),会使用系统调用blk(),通过移动堆顶的方式在堆上分配。这些内存释放后并不会立刻归还系统,而是被缓存起来,这样可以重复利用。

对于大块内存(大于128K),会使用内存映射mmap()在文件映射段分配空闲内存。

通过mmap()的参数,在文件关联性和映射区域是否共享两个维度

分为以下四种类型:

  • 私有匿名映射,常用于内存分配
  • 私有文件映射,常用于加载动态库
  • 共享匿名映射,常用于进程间共享内存
  • 共享文件映射,常用于内存映射IO,进程间通信

可通过fd=-1flags=MAP_ANONYMOUS | MAP_PRIVATE来分配私有匿名内存

可通过fd=-1flags=MAP_ANONYMOUS | MAP_SHARED 来分配共享匿名内存

以上两种方式各有优缺点,brk()方式的缓存,可以减少缺页异常的发生,提高内存访问率。不过由于内存并未归还系统,在内存工作繁忙时,频繁的内存分配和释放会造成内存碎片。而mmap()方式分配的内存,会在释放的时候直接归还系统,所以每次mmap必然会发生缺页异常。在内存工作繁忙时,频繁的内存分配和释放会导致大量的缺页异常,使内核的管理负担增大。这也是malloc()只对大块内存使用mmap()的原因。

此外,需要注意的是,当两种调用发生后,并未真正的分配内存。只有在内存被首次访问时,才会通过触发缺页异常进入内核中,再由内核真正的分配内存。

如果遇到比页更小的对象,比如不到1K的时候,该怎么分配内存呢?

实际系统运行中,确实有大量比页还小的对象,如果为它们分配单独的页,那么就存在大量的系统浪费。所以在用户空间,malloc()通过brk()分配的内存,在释放时并不立即归还系统,而是缓存起来重复利用。在内核空间,linux则通过slab分配器来管理小内存,其主要作用就是分配并释放内核中的小对象。

3.2 内存回收

对内存来说,如果一直分配却不释放,就会造成内存泄漏,甚至会耗尽系统内存。所以,在进程用完内存后,需要调用free()或unmap()来释放不用的内存。

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

  1. 回收缓存,比如使用LRU(Least Recently Used)算法,回收最近使用最少的内存页;

  2. 回收不常访问的内存,把不常用的内存通过交换分区直接写到磁盘中。也就是使用所谓的swap(交换分区),swap其实就是把一块磁盘空间当做内存来用,可以把暂时用不到的内存数据存入到磁盘中(swap out),当进程需要访问这部分数据时,再将数据从磁盘读取到内存中(swap in)。使用swap分区,虽然可以增大系统的可用内存,但是会造成严重的性能问题,因为磁盘的读写速度远低于内存的写入速度。

  3. 杀死进程,内存紧张时系统还会通过OOM(Out of Memory),直接杀掉占用大量内存的进程。这是内核的一种保护机制,它监控各个进程的内存使用情况,并且使用oom_score为每个进程的内存使用情况进行评分,评分的规则如下:

    • 进程消耗的内存越大,oom_score就越大
    • 进程占用的cpu越多,oom_score就越小 这样,进程的oom_score越大,就越容易被内核通过OOM杀死,从而可以更好的保证系统的运行

可以通过/proc/<pid>/oom_adj来手动调整OOM,取值范围为[-17,15],数值越大,表示越容易被OOM杀死,其中-17代表禁止OOM。