• 图解系统之 内存管理 —— 摘自小林coding


    图解系统之 内存管理

    图解系统之 内存管理

    虚拟内存是什么?为什么会有?

    单片机的 CPU 是直接操作内存的「物理地址」。在这种情况下,要想在内存中同时运行两个程序是不可能的。如果第一个程序在 2000 的位置写入一个新的值,将会擦掉第二个程序存放在相同位置上的所有内容,所以同时运行两个程序是根本行不通的。这里关键的问题是这两个程序都引用了绝对物理地址,而操作系统为每个进程分配独立的一套**「虚拟地址」,将不同进程的虚拟地址和不同内存的物理地址映射起来。**

    • 我们程序所使用的内存地址叫做虚拟内存地址Virtual Memory Address
    • 实际存在硬件里面的空间地址叫物理内存地址Physical Memory Address)。

    操作系统引入了虚拟内存,进程持有的虚拟地址会通过 CPU 芯片中的内存管理单元(MMU)的映射关系,来转换变成物理地址,然后再通过物理地址访问内存,如下图所示:

    img

    操作系统是如何管理虚拟地址与物理地址之间的关系?

    主要有两种方式,分别是内存分段和内存分页

    内存分段

    img img
    分段的不足之处

    分段的办法很好,解决了程序本身不需要关心具体的物理内存地址的问题,但它也有一些不足之处:

    • 第一个就是内存碎片的问题。
    • 第二个就是内存交换的效率低的问题。
    内存碎片
    img

    这里的内存碎片的问题共有两处地方:

    • 外部内存碎片,也就是产生了多个不连续的小物理内存,导致新的程序无法被装载;

      • 解决外部内存碎片的问题就是内存交换

        可以把音乐程序占用的那 256MB 内存写到硬盘上,然后再从硬盘上读回来到内存里。不过再读回的时候,我们不能装载回原来的位置,而是紧紧跟着那已经被占用了的 512MB 内存后面。这样就能空缺出连续的 256MB 空间,于是新的 200MB 程序就可以装载进来。

        这个内存交换空间,在 Linux 系统里,也就是我们常看到的 Swap 空间,这块空间是从硬盘划分出来的,用于内存与硬盘的空间交换。

    • 内部内存碎片,程序所有的内存都被装载到了物理内存,但是这个程序有部分的内存可能并不是很常使用,这也会导致内存的浪费;

      • 内存分页
    内存交换的效率低

    对于多进程的系统来说,用分段的方式,内存碎片是很容易产生的,产生了内存碎片,那不得不重新 Swap 内存区域,这个过程会产生性能瓶颈。

    因为硬盘的访问速度要比内存慢太多了,每一次内存交换,我们都需要把一大段连续的内存数据写到硬盘上。所以,如果内存交换的时候,交换的是一个占内存空间很大的程序,这样整个机器都会显得卡顿。

    • 为了解决内存分段的内存碎片和内存交换效率低的问题,就出现了内存分页。

    内存分页

    分页是把整个虚拟和物理内存空间切成一段段固定尺寸的大小。这样一个连续并且尺寸固定的内存空间,我们叫Page)。在 Linux 下,每一页的大小为 4KB。虚拟地址与物理地址之间通过页表来映射,如下图:

    img

    页表是存储在内存里的,内存管理单元MMU)就做将虚拟内存地址转换成物理地址的工作。而当进程访问的虚拟地址在页表中查不到时,系统会产生一个缺页异常,进入系统内核空间分配物理内存、更新进程页表,最后再返回用户空间,恢复进程的运行。

    分页是怎么解决分段的内存碎片、内存交换效率低的问题?

    由于内存空间都是预先划分好的,也就不会像分段会产生间隙非常小的内存(这正是分段会产生内存碎片的原因),采用了分页,那么释放的内存都是以页为单位释放的,也就不会产生无法给进程使用的小内存。

    如果内存空间不够,操作系统会把其他正在运行的进程中的「最近没被使用」的内存页面给释放掉,也就是暂时写在硬盘上,称为换出Swap Out)。一旦需要的时候,再加载进来,称为换入Swap In)。所以,一次性写入磁盘的也只有少数的一个页或者几个页,不会花太多时间,内存交换的效率就相对比较高。

    img

    更进一步地,分页的方式使得我们在加载程序的时候,不再需要一次性都把程序加载到物理内存中。我们完全可以在进行虚拟内存和物理内存的页之间的映射之后,并不真的把页加载到物理内存里,而是只有在程序运行中,需要用到对应虚拟内存页里面的指令和数据时,再加载到物理内存里面去。


    分页机制下,虚拟地址和物理地址是如何映射的?
    img img
    实际操作系统中,如何分页?

    放到实际中操作系统,前文的简单的分页是肯定是会有问题的。

    有空间上的缺陷。因为操作系统是可以同时运行非常多的进程的,这就意味着页表会非常的庞大。

    在 32 位的环境下,虚拟地址空间共有 4GB,假设一个页的大小是 4KB( 2 12 2^{12} 212),那么就需要大约 100 万 ( 2 20 2^{20} 220) 个页,每个「页表项」需要 4 个字节大小来存储,那么整个 4GB 空间的映射就需要有 4MB 的内存来存储页表。这 4MB 大小的页表,看起来也不是很大。但是要知道每个进程都是有自己的虚拟地址空间的,也就说都有自己的页表。那么,100 个进程的话,就需要 400MB 的内存来存储页表,这是非常大的内存了,更别说 64 位的环境了。


    多级页表

    为了解决简单分页产生的页表过大的问题,就有了多级页表Multi-Level Page Table)的解决方案。

    img
    分了二级表,映射 4GB 地址空间就需要 4KB(一级页表)+ 4MB(二级页表)的内存,占用空间更大了?

    如果 4GB 的虚拟地址全部都映射到了物理内存上的话,二级分页占用空间确实是更大了,但是,我们往往不会为一个进程分配那么多内存。

    换个角度来看问题,计算机组成原理里的局部性原理

    每个进程都有 4GB 的虚拟地址空间,而显然对于大多数程序来说,其使用到的空间远未达到 4GB,因为会存在部分对应的页表项都是空的,根本没有分配,对于已分配的页表项,如果存在最近一定时间未访问的页表,在物理内存紧张的情况下,操作系统会将页面换出到硬盘,也就是说不会占用物理内存。

    如果使用了二级分页,一级页表就可以覆盖整个 4GB 虚拟地址空间,但如果某个一级页表的页表项没有被用到,也就不需要创建这个页表项对应的二级页表了,即可以在需要时才创建二级页表。做个简单的计算,假设只有 20% 的一级页表项被用到了,那么页表占用的内存空间就只有 4KB(一级页表) + 20% * 4MB(二级页表)= 0.804MB,这对比单级页表的 4MB 是不是一个巨大的节约?

    为什么不分级的页表就做不到这样节约内存呢?

    从页表的性质来看,保存在内存中的页表承担的职责是将虚拟地址翻译成物理地址。假如虚拟地址在页表中找不到对应的页表项,计算机系统就不能工作了。所以页表一定要覆盖全部虚拟地址空间,不分级的页表就需要有 100 多万个页表项来映射,而二级分页则只需要 1024 个页表项(此时一级页表覆盖到了全部虚拟地址空间,二级页表在需要时创建)。

    我们把二级分页再推广到多级页表,会发现页表占用的内存空间更少,这就是对对局部性原理的充分应用。 64 位的系统,两级分页肯定不够了,就变成了四级目录:

    img
    TLB(Translation Lookaside Buffer

    多级页表虽然解决了空间上的问题,但是虚拟地址到物理地址的转换就多了几道转换的工序,这显然就降低了这俩地址转换的速度,也就是带来了时间上的开销。

    程序是有局部性的,即在一段时间内,整个程序的执行仅限于程序中的某一部分。相应地,执行所访问的存储空间也局限于某个内存区域。就可以利用这一特性,把最常访问的几个页表项存储到访问速度更快的硬件,于是计算机科学家们,就在 CPU 芯片中,加入了一个专门存放程序最常访问的页表项的 Cache,这个 Cache 就是 TLB ,通常称为页表缓存、转址旁路缓存、快表等。

    img

    在 CPU 芯片里面,封装了内存管理单元(Memory Management Unit)芯片,它用来完成地址转换和 TLB 的访问与交互有了 TLB 后,那么 CPU 在寻址时,会先查 TLB,如果没找到,才会继续查常规的页表。TLB 的命中率其实是很高的,因为程序最常访问的页就那么几个。


    段页式内存管理

    段页式内存管理实现的方式:

    • 先将程序划分为多个有逻辑意义的段,也就是前面提到的分段机制;
    • 接着再把每个段划分为多个页,也就是对分段划分出来的连续空间,再划分固定大小的页;
    img

    段页式地址变换中要得到物理地址须经过三次内存访问:

    • 第一次访问段表,得到页表起始地址;
    • 第二次访问页表,得到物理页号;
    • 第三次将物理页号与页内位移组合,得到物理地址。

    可用软、硬件相结合的方法实现段页式地址变换,这样虽然增加了硬件成本和系统开销,但提高了内存的利用率。


    Linux 操作系统采用了哪种方式来管理内存呢?

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

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

    详细说明

    80386 的页式内存管理设计时,没有绕开段式内存管理,而是建立在段式内存管理的基础上,这就意味着,页式内存管理的作用是在由段式内存管理所映射而成的地址上再加上一层地址映射。

    img
    • 程序所使用的地址,通常是没被段式内存管理映射的地址,称为逻辑地址;
    • 通过段式内存管理映射的地址,称为线性地址,也叫虚拟地址;

    逻辑地址是「段式内存管理」转换前的地址,线性地址则是「页式内存管理」转换前的地址。

    Linux 内存主要采用的是页式内存管理,但同时也不可避免地涉及了段机制

    这主要是上面 Intel 处理器发展历史导致的,因为 Intel X86 CPU 一律对程序中使用的地址先进行段式映射,然后才能进行页式映射。既然 CPU 的硬件结构是这样,Linux 内核也只好服从 Intel 的选择。

    但是事实上,Linux 内核所采取的办法是使段式映射的过程实际上不起什么作用。也就是说,“上有政策,下有对策”,若惹不起就躲着走。

    Linux 系统中的每个段都是从 0 地址开始的整个 4GB 虚拟空间(32 位环境下),也就是所有的段的起始地址都是一样的。这意味着,Linux 系统中的代码,包括操作系统本身的代码和应用程序代码,所面对的地址空间都是线性地址空间(虚拟地址),这种做法相当于屏蔽了处理器中的逻辑地址概念,段只被用于访问控制和内存保护。

    Linux 进程的内存分布长什么样?

    在 Linux 操作系统中,虚拟地址空间的内部又被分为内核空间和用户空间两部分,不同位数的系统,地址空间的范围也不同。内核空间与用户空间的区别:

    • 进程在用户态时,只能访问用户空间内存;
    • 只有进入内核态后,才可以访问内核空间的内存;

    虽然每个进程都各自有独立的虚拟内存,但是每个虚拟内存中的内核地址,其实关联的都是相同的物理内存。这样,进程切换到内核态后,就可以很方便地访问内核空间内存。

    img

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

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

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


    malloc 是如何分配内存的?

    实际上,malloc() 并不是系统调用,而是 C 库里的函数,用于动态分配内存。

    malloc 申请内存的时候,会有两种方式向操作系统申请堆内存。

    • 方式一:通过 brk() 系统调用从堆分配内存
    • 方式二:通过 mmap() 系统调用在文件映射区域分配内存;

    方式一实现的方式很简单,就是通过 brk() 函数将「堆顶」指针向高地址移动,获得新的内存空间。如下图:

    图片

    方式二通过 mmap() 系统调用中「私有匿名映射」的方式,在文件映射区分配一块内存,也就是从文件映射区“偷”了一块内存。如下图:

    图片
    什么场景下 malloc() 会通过 brk() 分配内存?又是什么场景下通过 mmap() 分配内存?

    malloc() 源码里默认定义了一个阈值:

    • 如果用户分配的内存小于 128 KB,则通过 brk() 申请内存;
    • 如果用户分配的内存大于 128 KB,则通过 mmap() 申请内存;

    注意,不同的 glibc 版本定义的阈值也是不同的。


    malloc() 分配的是物理内存吗?

    不是的,malloc() 分配的是虚拟内存。如果分配后的虚拟内存没有被访问的话,是不会将虚拟内存不会映射到物理内存,这样就不会占用物理内存了。只有在访问已分配的虚拟地址空间的时候,操作系统通过查找页表,发现虚拟内存对应的页没有在物理内存中,会触发缺页中断,然后操作系统建立虚拟内存和物理内存之间的映射关系。

    malloc() 会分配多大的虚拟内存?

    malloc() 在分配内存的时候,并不是老老实实按用户预期申请的字节数来分配内存空间大小,而是会预分配更大的空间作为内存池。具体会预分配多大的空间,跟 malloc 使用的内存管理器有关系。

    free 释放内存,会归还给操作系统吗?

    • malloc 通过 brk() 方式申请的内存,free 释放内存的时候,并不会把内存归还给操作系统,而是缓存在 malloc 的内存池中,待下次使用
      • 与其把这空间释放给操作系统,不如先缓存着放进 malloc 的内存池里,当进程再次申请 1 字节的内存时就可以直接复用,这样速度快了很多。当然,当进程退出后,操作系统就会回收进程的所有资源。
    • malloc 通过 mmap() 方式申请的内存,free 释放内存的时候,会把内存归还给操作系统,内存得到真正的释放

    为什么不全部使用 mmap 来分配内存?

    因为向操作系统申请内存,是要通过系统调用的,执行系统调用是要进入内核态的,然后在回到用户态,运行态的切换会耗费不少时间。所以,申请内存的操作应该避免频繁的系统调用,如果都用 mmap 来分配内存,等于每次都要执行系统调用。另外,因为 mmap 分配的内存每次释放的时候,都会归还给操作系统,于是每次 mmap 分配的虚拟地址都是缺页状态的,然后在第一次访问该虚拟地址的时候,就会触发缺页中断。

    也就是说,频繁通过 mmap 分配的内存话,不仅每次都会发生运行态的切换,还会发生缺页中断(在第一次访问虚拟地址后),这样会导致 CPU 消耗较大

    为了改进这两个问题,malloc 通过 brk() 系统调用在堆空间申请内存的时候,由于堆空间是连续的,所以直接预分配更大的内存来作为内存池,当内存释放的时候,就缓存在内存池中。等下次在申请内存的时候,就直接从内存池取出对应的内存块就行了,而且可能这个内存块的虚拟地址与物理地址的映射关系还存在,这样不仅减少了系统调用的次数,也减少了缺页中断的次数,这将大大降低 CPU 的消耗

    为什么不全部使用 brk 来分配内存?

    通过 brk 从堆空间分配的内存,并不会归还给操作系统,但考虑这样一个场景:如果连续申请10k,20k,30k三片内存,其中10k 和 20k 释放变为了空闲内存空间,下次申请的内存小于 30k,则可以重用这个空闲内存空间。但如果下次申请的内存大于 30k,则没有可用的空闲内存空间,必须向 OS 申请,实际使用内存继续增大。

    **因此,随着系统频繁地 malloc 和 free ,尤其对于小块内存,堆内将产生越来越多不可用的碎片,导致“内存泄露”。而这种“泄露”现象使用 valgrind 是无法检测出来的。**所以,malloc 实现中,充分考虑了 sbrk 和 mmap 行为上的差异及优缺点,默认分配大块内存 (128KB) 使用 mmap 分配内存空间。

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

    实际上,malloc 返回给用户态的内存起始地址比进程的堆空间起始地址多了 16 字节,这个多出来的 16 字节就是保存了该内存块的描述信息,比如有该内存块的大小。这样当执行 free() 函数时,free 会对传入进来的内存地址向左偏移 16 字节,然后从这个 16 字节的分析出当前的内存块的大小,自然就知道要释放多大的内存了。

    图片

    虚拟内存有什么作用?

    • 第一,由于每个进程都有自己的页表,所以每个进程的虚拟内存空间就是相互独立的。进程也没有办法访问其他进程的页表,所以这些页表是私有的。这就解决了多进程之间地址冲突的问题。
    • 第二,页表里的页表项中除了物理地址之外,还有一些标记属性的比特,比如控制一个页的读写权限,标记该页是否存在等。在内存访问方面,操作系统提供了更好的安全性。

    内存分配的过程是怎样的?

    应用程序通过 malloc 函数申请内存的时候,实际上申请的是虚拟内存,此时并不会分配物理内存。

    当应用程序读写了这块虚拟内存,CPU 就会去访问这个虚拟内存, 这时会发现这个虚拟内存没有映射到物理内存, CPU 就会产生缺页中断,进程会从用户态切换到内核态,并将缺页中断交给内核的 Page Fault Handler (缺页中断函数)处理。

    缺页中断处理函数会看是否有空闲的物理内存,如果有,就直接分配物理内存,并建立虚拟内存与物理内存之间的映射关系。

    如果没有空闲的物理内存,那么内核就会开始进行回收内存的工作,回收的方式主要是两种:直接内存回收和后台内存回收。

    • 后台内存回收(kswapd):在物理内存紧张的时候,会唤醒 kswapd 内核线程来回收内存,这个回收内存的过程异步的,不会阻塞进程的执行。
    • 直接内存回收(direct reclaim):如果后台异步回收跟不上进程内存申请的速度,就会开始直接回收,这个回收内存的过程是同步的,会阻塞进程的执行。

    如果直接内存回收后,空闲的物理内存仍然无法满足此次物理内存的申请,那么内核就会放最后的大招了 ——触发 OOM (Out of Memory)机制

    OOM Killer 机制会根据算法选择一个占用物理内存较高的进程,然后将其杀死,以便释放内存资源,如果物理内存依然不足,OOM Killer 会继续杀死占用物理内存较高的进程,直到释放足够的内存位置。


    哪些内存可以被回收?

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

    文件页和匿名页的回收都是基于 LRU 算法,也就是优先回收不常访问的内存。LRU 回收算法,实际上维护着 active 和 inactive 两个双向链表,越接近链表尾部,就表示内存页越不常访问。这样,在回收内存时,系统就可以根据活跃程度,优先回收不活跃的内存。

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

    回收内存带来的性能影响?

    在前面我们知道了回收内存有两种方式。

    • 一种是后台内存回收,也就是唤醒 kswapd 内核线程,这种方式是异步回收的,不会阻塞进程
    • 一种是直接内存回收,这种方式是同步回收的,会阻塞进程,这样就会造成很长时间的延迟,以及系统的 CPU 利用率会升高,最终引起系统负荷飙高

    可被回收的内存类型有文件页和匿名页:

    • 文件页的回收
      • 对于干净页是直接释放内存,不影响性能;
      • 对于脏页会先写回到磁盘再释放内存,这个操作会发生磁盘 I/O ,影响系统性能的。
    • 匿名页的回收:
      • 如果开启了 Swap 机制,那么 Swap 机制会将不常访问的匿名页换出到磁盘中,下次访问时,再从磁盘换入到内存中,这个操作是会影响系统性能的。

    可以看到,回收内存的操作基本都会发生磁盘 I/O 的,如果回收内存的操作很频繁,意味着磁盘 I/O 次数会很多,这个过程势必会影响系统的性能,整个系统给人的感觉就是很卡。


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

    • 设置 /proc/sys/vm/swappiness,调整文件页和匿名页的回收倾向,尽量倾向于回收文件页;

      • swappiness 的范围是 0-100,数值越大,越积极使用 Swap,也就是更倾向于回收匿名页;数值越小,越消极使用 Swap,也就是更倾向于回收文件页。

        一般建议 swappiness 设置为 0(默认值是 60),这样在回收内存的时候,会更倾向于文件页的回收,但是并不代表不会回收匿名页。

    • 设置 /proc/sys/vm/min_free_kbytes,调整 kswapd 内核线程异步回收内存的时机;

      • img

        kswapd 会定期扫描内存的使用情况,根据剩余内存(pages_free)的情况来进行内存回收的工作。

        • 图中绿色部分:如果剩余内存(pages_free)大于 页高阈值(pages_high),说明剩余内存是充足的;
        • 图中蓝色部分:如果剩余内存(pages_free)在页高阈值(pages_high)和页低阈值(pages_low)之间,说明内存有一定压力,但还可以满足应用程序申请内存的请求;
        • 图中橙色部分:如果剩余内存(pages_free)在页低阈值(pages_low)和页最小阈值(pages_min)之间,说明内存压力比较大,剩余内存不多了。这时 kswapd0 会执行内存回收,直到剩余内存大于高阈值(pages_high)为止。虽然会触发内存回收,但是不会阻塞应用程序,因为两者关系是异步的。
        • 图中红色部分:如果剩余内存(pages_free)小于页最小阈值(pages_min),说明用户可用内存都耗尽了,此时就会触发直接内存回收,这时应用程序就会被阻塞,因为两者关系是同步的。
      • min_free_kbytes 虽然设置的是页最小阈值(pages_min),但是页高阈值(pages_high)和页低阈值(pages_low)都是根据页最小阈值(pages_min)计算生成的。

      • 增大了 min_free_kbytes 配置后,这会使得系统预留过多的空闲内存,从而在一定程度上降低了应用程序可使用的内存量,这在一定程度上浪费了内存。极端情况下设置 min_free_kbytes 接近实际物理内存大小时,留给应用程序的内存就会太少而可能会频繁地导致 OOM 的发生。

        所以在调整 min_free_kbytes 之前,需要先思考一下,应用程序更加关注什么,如果关注延迟那就适当地增大 min_free_kbytes,如果关注内存的使用量那就适当地调小 min_free_kbytes。

    • 设置 /proc/sys/vm/zone_reclaim_mode,调整 NUMA 架构下内存回收策略,建议设置为 0,这样在回收本地内存之前,会在其他 Node 寻找空闲内存,从而避免在系统还有很多空闲内存的情况下,因本地 Node 的本地内存不足,发生频繁直接内存回收导致性能下降的问题;

      • SMP 与 NUMA 架构
      • SMP 指的是一种多个 CPU 处理器共享资源的电脑硬件架构,也就是说每个 CPU 地位平等,它们共享相同的物理资源,包括总线、内存、IO、操作系统等。每个 CPU 访问内存所用时间都是相同的,因此,这种系统也被称为一致存储访问结构(UMA,Uniform Memory Access)。

        随着 CPU 处理器核数的增多,多个 CPU 都通过一个总线访问内存,这样总线的带宽压力会越来越大,同时每个 CPU 可用带宽会减少,这也就是 SMP 架构的问题。

      • NUMA 结构,即非一致存储访问结构(Non-uniform memory access,NUMA)。NUMA 架构将每个 CPU 进行了分组,每一组 CPU 用 Node 来表示,一个 Node 可能包含多个 CPU 。

        每个 Node 有自己独立的资源,包括内存、IO 等,每个 Node 之间可以通过互联模块总线(QPI)进行通信,所以,也就意味着每个 Node 上的 CPU 都可以访问到整个系统中的所有内存。但是,访问远端 Node 的内存比访问本地内存要耗时很多。

      • NUMA 架构跟回收内存有什么关系?

        在 NUMA 架构下,当某个 Node 内存不足时,系统可以从其他 Node 寻找空闲内存,也可以从本地内存中回收内存。

        具体选哪种模式,可以通过 /proc/sys/vm/zone_reclaim_mode 来控制。它支持以下几个选项:

        • 0 (默认值):在回收本地内存之前,在其他 Node 寻找空闲内存;
        • 1:只回收本地内存;
        • 2:只回收本地内存,在本地回收内存时,可以将文件页中的脏页写回硬盘,以回收内存。
        • 4:只回收本地内存,在本地回收内存时,可以用 swap 方式回收内存。

        在使用 NUMA 架构的服务器,如果系统出现还有一半内存的时候,却发现系统频繁触发「直接内存回收」,导致了影响了系统性能,那么大概率是因为 zone_reclaim_mode 没有设置为 0 ,导致当本地内存不足的时候,只选择回收本地内存的方式,而不去使用其他 Node 的空闲内存。

        虽然说访问远端 Node 的内存比访问本地内存要耗时很多,但是相比内存回收的危害而言,访问远端 Node 的内存带来的性能影响还是比较小的。因此,zone_reclaim_mode 一般建议设置为 0。


    如何保护一个进程不被 OOM 杀掉?

    在 Linux 内核里有一个 oom_badness() 函数,它会把系统中可以被杀掉的进程扫描一遍,并对每个进程打分,得分最高的进程就会被首先杀掉。

    进程得分的结果受下面这两个方面影响:

    • 第一,进程已经使用的物理内存页面数。
    • 第二,每个进程的 OOM 校准值 oom_score_adj。它是可以通过 /proc/[pid]/oom_score_adj 来配置的。我们可以在设置 -1000 到 1000 之间的任意一个数值,调整进程被 OOM Kill 的几率。

    用「系统总的可用页面数」乘以 「OOM 校准值 oom_score_adj」再除以 1000,最后再加上进程已经使用的物理页面数,计算出来的值越大,那么这个进程被 OOM Kill 的几率也就越大

    每个进程的 oom_score_adj 默认值都为 0,所以最终得分跟进程自身消耗的内存有关,消耗的内存越大越容易被杀掉。我们可以通过调整 oom_score_adj 的数值,来改成进程的得分结果:

    • 如果你不想某个进程被首先杀掉,那你可以调整该进程的 oom_score_adj,从而改变这个进程的得分结果,降低该进程被 OOM 杀死的概率。
    • 如果你想某个进程无论如何都不能被杀掉,那你可以将 oom_score_adj 配置为 -1000。

    我们最好将一些很重要的系统服务的 oom_score_adj 配置为 -1000,比如 sshd,因为这些系统服务一旦被杀掉,我们就很难再登陆进系统了。但是,不建议将我们自己的业务程序的 oom_score_adj 设置为 -1000,因为业务程序一旦发生了内存泄漏,而它又不能被杀掉,这就会导致随着它的内存开销变大,OOM killer 不停地被唤醒,从而把其他进程一个个给杀掉。


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

    这个问题要考虑三个前置条件:

    • 操作系统是 32 位的,还是 64 位的?
    • 申请完 8G 内存后会不会被使用?
    • 操作系统有没有使用 Swap 机制?

    总结

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

    什么是Swap机制?

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

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

    Swap 换入换出的过程如下图:

    img

    使用 Swap 机制优点是,应用程序实际可以使用的内存空间将远远超过系统的物理内存。由于硬盘空间的价格远比内存要低,因此这种方式无疑是经济实惠的。当然,频繁地读写硬盘,会显著降低操作系统的运行速率,这也是 Swap 的弊端。

    Linux 中的 Swap 机制会在内存不足和内存闲置的场景下触发:

    • 内存不足:当系统需要的内存超过了可用的物理内存时,内核会将内存中不常使用的内存页交换到磁盘上为当前进程让出内存,保证正在执行的进程的可用性,这个内存回收的过程是强制的直接内存回收(Direct Page Reclaim)。直接内存回收是同步的过程,会阻塞当前申请内存的进程。
    • 内存闲置:应用程序在启动阶段使用的大量内存在启动后往往都不会使用,通过后台运行的守护进程kSwapd可以将这部分只使用一次的内存交换到磁盘上为其他内存的申请预留空间。kSwapd 是 Linux 负责页面置换(Page replacement)的守护进程,它也是负责交换闲置内存的主要进程,它会在空闲内存低于一定水位时,回收内存页中的空闲内存保证系统中的其他进程可以尽快获得申请的内存。kSwapd 是后台进程,所以回收内存的过程是异步的,不会阻塞当前申请内存的进程。

    Linux 提供了两种不同的方法启用 Swap,分别是 Swap 分区(Swap Partition)和 Swap 文件(Swapfile)。

  • 相关阅读:
    python搭建沙箱环境
    VSCode使用简介
    Redis_03_Redis发布订阅(Pub/Sub)
    Vant可靠的移动端组件库
    【java养成】:main函数中String[] args的作用、商品入库案例、猜数字游戏、随机点名器
    Java的引用(Reference)数据类型概述
    【图像去雾】基于颜色衰减先验的图像去雾附matlab代码
    Win11自动更新怎么永久关闭?
    3.Docker 镜像及镜像分层
    电力电缆故障技术与解决
  • 原文地址:https://blog.csdn.net/h0327x/article/details/125624172