Linux ——内存管理

 1 虚拟内存

1.1 虚拟内存的作用

  • 第一,虚拟内存可以使得进程的运行内存超过物理内存大小。因为程序运行符合局部性原理,对于那些没有被经常使用到的内存,我们可以使用内存交换技术,把它换出到物理内存之外,比如硬盘上的 swap 区域。
  • 第二,由于每个进程都有自己的页表,所以每个进程的虚拟内存空间就是相互独立的。进程也没有办法访问其他进程的页表,所以这些页表是私有的,这就解决了多进程之间地址冲突的问题。
  • 第三,页表里的页表项中除了物理地址之外,还有一些标记属性的比特,比如控制一个页的读写权限,标记该页是否存在等。在内存访问方面,操作系统提供了更好的安全性

1.2 局部性原理

时间局部性:当前访问的数据可能很快再次被访问

空间局部性:当前访问数据的附近数据可能很快会被用到

1.3 分段和分页

        虚拟地址「映射」到物理地址,这个事情通常由操作系统来维护(内存管理单元 (MMU)。

        虚拟地址与物理地址的映射关系,可以有分段分页的方式,同时两者结合都是可以的。

1.3.1 分段

        内存分段是根据程序的逻辑角度,分成了栈段、堆段、数据段、代码段等,这样可以分离出不同属性的段,同时是一块连续的空间。

        但是每个段的大小都不是统一的,这就会导致外部内存碎片内存交换效率低的问题。

  • 外部内存碎片:内存经过分配和释放,会留下一些不能使用的小内存块,导致外部内存碎片,而解决内存碎片的方式就是内存交换。
  • 内存交换效率低:采用分段的方式,每次交换都要把一大段的数据写入磁盘,速度非常慢,效率低。
32位机器进程的虚拟地址空间
  • 保留区:我们认为比较小数值的地址不是一个合法地址,例如,我们通常在 C 的代码里会将无效的指针赋值为 NULL。因此,这里会出现一段不可访问的内存保留区,防止程序因为出现 bug,导致读或写了一些小内存地址的数据,而使得程序跑飞。 
  • 代码段,包括二进制可执行代码;
  • 数据段,包括已初始化的静态常量和全局变量;
  • BSS 段,包括未初始化的静态变量和全局变量;在程序运行之前,代码段被加载进内存,全局变量和静态变量也被存入数据段和BSS段。
  • 堆段,包括动态分配的内存,从低地址开始向上增长;
  • 文件映射段,包括动态库、共享内存等,从低地址开始向上增长(跟硬件和内核版本有关 (opens new window));
  • 栈段,包括局部变量和函数调用的上下文等。栈的大小是固定的,一般是 8 MB。当然系统也提供了参数,以便我们自定义大小;

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

1.3.2 分页

        于是,就出现了内存分页。把虚拟空间和物理空间分成大小固定的页,如在 Linux 系统中,每一页的大小为 4KB

        分了页后,就不会产生细小的内存碎片,解决了内存分段的外部内存碎片问题。同时在内存交换的时候,写入硬盘也就一个页或几个页,这就大大提高了内存交换的效率。但是会产生内部碎片。

        分页需要页表,每个进程都有自己的页表,进程数量较多时,页表会占用大量存储空间。

        为了解决简单分页产生的页表过大的问题,就有了多级页表,它解决了空间上的问题,但这就会导致 CPU 在寻址的过程中,需要有很多层表参与,加大了时间上的开销。

        于是根据程序的局部性原理,在 CPU 芯片中加入了 TLB,负责缓存最近常被访问的页表项,大大提高了地址的转换速度。首先查询TLB,未命中再查询页表。

1.4 Linux 系统内存管理

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

1.4.1 Linux 的虚拟地址空间

  • 32 位系统的内核空间占用 1G,位于最高处,剩下的 3G 是用户空间;
  • 64 位系统的内核空间和用户空间都是 128T,分别占据整个内存空间的最高和最低处,剩下的中间部分是未定义的。

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

        虽然每个进程都各自有独立的虚拟内存,但是每个虚拟内存中的内核地址,其实关联的都是相同的物理内存

2 malloc分配内存

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

2.1 malloc 是如何分配内存的

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

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

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

方式一:通过 brk() 函数将「堆顶」指针向高地址移动,获得新的内存空间。

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

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

        不是的,malloc() 分配的是虚拟内存

        如果分配后的虚拟内存没有被访问的话,虚拟内存是不会映射到物理内存的,这样就不会占用物理内存了。

        只有在访问已分配的内存的时候,操作系统通过查找页表,发现虚拟内存对应的页没有在物理内存中,就会触发缺页中断,然后操作系统会建立虚拟内存和物理内存之间的映射关系。

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

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


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

  • malloc 通过 brk() 方式申请的内存,free 释放内存的时候,并不会把内存归还给操作系统,而是缓存在 malloc 的内存池中,待下次使用,从而减少申请次数,提高效率;
  • malloc 通过 mmap() 方式申请的内存,free 释放内存的时候,会把内存归还给操作系统,内存得到真正的释放

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

        向操作系统申请内存(brk 和 mmap),都是要通过系统调用的。执行系统调用是要进入内核态的,然后在回到用户态,运行态的切换会耗费不少时间。

       mmap 分配的内存每次释放的时候,都会归还给操作系统,于是每次 mmap 分配的虚拟地址都是缺页状态的,然后在第一次访问该虚拟地址的时候,就会触发缺页中断这样会导致 CPU 消耗较大。

        malloc 通过 brk() 在堆空间申请的内存,当内存释放的时候,就缓存在内存池中。 等下次在申请内存的时候,就直接从内存池取出对应的内存块就行了,不需要进行系统调用。而且可能这个内存块的虚拟地址与物理地址的映射关系还存在,减少了缺页中断的次数,这将大大降低 CPU 的消耗。

2.6 既然 brk 那么牛逼,为什么不全部使用 brk 来分配

        因为随着系统频繁地 malloc 和 free ,尤其对于小块内存,堆内将产生越来越多不可用的碎片,导致“内存泄露”。而这种“泄露”现象使用 valgrind 是无法检测出来的。

        所以,malloc 实现中,充分考虑了 brk 和 mmap 行为上的差异及优缺点,默认分配大块内存 (128KB) 才使用 mmap 分配内存空间。

2.7 free() 函数只传入一个内存地址,为什么能知道要释放多大的内存

         malloc 返回给用户的内存起始地址比进程的堆空间起始地址多了 16 字节,这个多出来的 16 字节就是保存了该内存块的描述信息,比如有该内存块的大小。

        这样当执行 free() 函数时,free 会对传入进来的内存地址向左偏移 16 字节,然后从这个 16 字节的分析出当前的内存块的大小,自然就知道要释放多大的内存了。

3 内存满了,会发生什么? 

3.1 内存分配的过程 

malloc申请虚拟内存,此时并不会分配物理内存;

程序访问分配的虚拟内存时,如果虚拟内存没有映射到物理内存则出发缺页中断,并将缺页中断交给内核的 Page Fault Handler (缺页中断函数)处理;

如果有空闲内存,则直接分配

如果没有空闲内存,内核就会进行内存回收,回收方式有两种:

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

如果直接内存回收后,空闲的物理内存仍然无法满足此次物理内存的申请,那么就会触发 OOM (Out of Memory)机制。OOM Killer 机制会选择一个占用物理内存较高的进程,然后将其杀死,释放内存资源,直到物理内存满足需求。

3.2 哪些内存可以被回收

主要有两类内存可以被回收:

  • 文件页(File-backed Page):内核缓存的磁盘数据(Buffer)和文件数据(Cache)都叫作文件页。未修改的文件页,都可以直接释放内存;被修改过,并且暂时还没写入磁盘的数据(也就是脏页),得先写入磁盘,然后才能进行内存释放。
  • 匿名页(Anonymous Page):这部分内存没有实际载体,比如堆、栈数据等。通过 Linux 的 Swap 机制,把不常访问的内存先写到磁盘中(写入swap区域),然后释放这些内存。再次访问这些内存时,重新从磁盘读入内存就可以了。

内存回收基于 LRU 算法,也就是优先回收不常访问的内存

LRU 回收算法,实际上维护着 active 和 inactive 两个双向链表,越接近链表尾部,就表示内存页越不常访问。其中:

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

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

异步回收,不阻塞进程,没有影响

同步回收,阻塞线程,造成延迟。

文件页的回收:干净的页直接释放,脏页需要写入磁盘,发生IO

匿名页的回收:一定会发生磁盘IO

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

  • 倾向于回收文件页,这样磁盘IO较少
  • 尽早触发kswapd内核线程异步回收内存,避免同步回收内存,造成延迟
  • 在NUMA架构下,设置zone_reclaim_mode=0,在回收本地内存之前,先在其它Node找空闲内存(NUMA架构,cpu分组为Node,每组都有各自的资源,也可访问其它组的资源,但耗时更长)

3.4 怎样避免进程被OOM

申请内存很大,内存回收无法满足需求,就会触发OOM,根据算法,杀死得分高的进程,回收内存资源。

得分受两个方面的影响:

  • 进程使用的物理内存页数
  • 每个进程的oom_score_adj参数
points = process_pages + oom_score_adj*totalpages/1000

我们可以通过调整进程的 oom_score_adj 值,来降低被 OOM killer 杀掉的概率。重要系统程序的得分应尽可能的低。

4 在4GB物理内存的机器上,申请8GB的内存

4.1 32 位操作系统

因为进程理论上最大能申请 3 GB 大小的虚拟内存,所以直接申请 8G 内存,会申请失败。

4.2 64位 位操作系统

因为进程理论上最大能申请 128 TB 大小的虚拟内存,即使物理内存只有 4GB,申请 8G 内存也是没问题,因为申请的内存是虚拟内存。

如果这块虚拟内存被访问了,要看系统有没有开启 Swap 机制:

  • 如果没有 Swap 分区,因为物理空间不够,进程会被操作系统杀掉,原因是 OOM(内存溢出);
  • 如果有 Swap 分区,即使物理内存只有 4GB,程序也能正常使用 8GB 的内存,进程可以正常运行;

4.3 swap机制

4.3.1 两个方面

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

4.3.2 触发条件

申请内存太大,当前物理内存无法满足,强制内存回收。这是同步回收;

物理内存的使用有一定的压力,触发kSwapd 线程异步回收。

4.3.3 Swap 换入换出的是什么类型的内存

进程的堆、栈数据等,它们是没有实际载体,这部分内存被称为匿名页。

而且这部分内存很可能还要再次被访问,所以不能直接释放内存,于是就需要有一个能保存匿名页的磁盘载体,这个载体就是 Swap 分区

Swap 会把不常访问的内存先写到磁盘中,然后释放这些内存,给其他更需要的进程使用。再次访问这些内存时,重新从磁盘读入内存就可以了。

5 LRU算法

5.1 Linux 和 MySQL 的缓存

5.1.1 Linux 操作系统的缓存

在应用程序读取文件的数据的时候,Linux 操作系统会将读取的文件数据缓存在文件系统的 

Page Cache

Page Cache 属于内存空间,下一次访问数据如果能命中缓存就直接返回数据即可,不用进行磁盘IO.

5.1.2 MySQL 的缓存

MySQL 的数据是存储在磁盘里的,为了提升数据库的读写性能,Innodb 存储引擎设计了一个缓冲池(Buffer Pool),Buffer Pool 属于内存空间

有了缓冲池后:

  • 当读取数据时,如果数据存在于 Buffer Pool 中,客户端就会直接读取 Buffer Pool 中的数据,否则再去磁盘中读取。
  • 当修改数据时,首先是修改 Buffer Pool 中数据所在的页,然后将其页设置为脏页,最后由后台线程将脏页写入到磁盘。

5.2 传统 LRU算法

一般使用「链表」作为数据结构来实现,链表头部的数据是最近使用的,而链表末尾的数据是最久没被使用的。那么,当空间不够了,就淘汰链表末尾的数据,从而腾出内存空间。

传统的 LRU 算法的实现思路是这样的:

  • 当访问的页在内存里,就直接把该页对应的 LRU 链表节点移动到链表的头部。
  • 当访问的页不在内存里,除了要把该页放入到 LRU 链表的头部,还要淘汰 LRU 链表末尾的页。

问题:

  • 预读失效导致缓存命中率下降;
  • 缓存污染导致缓存命中率下降

5.3 预读机制

5.3.1 定义

操作系统基于空间局部性原理(当前访问的数据附近的数据,在不久的将来很大概率会被访问到),读取数据时,会把附近的数据也读取进缓存;

这样,在访问附近的数据的时候,可以直接在Page Cache中读取,减少了磁盘IO次数。

5.3.2 预读失效

如果被预读进来的页,没有被访问,相当于白读了,这就是预读失效。

如果使用传统LRU算法,不会被访问的预读页占据链表前排位置,而链表后面的热点数据则被淘汰,这样会降低缓存的命中效率。

5.3.3 怎样避免预读失效的影响

Linux

Linux维护两个链表,分别是活跃 LRU 链表(active_list)和非活跃 LRU 链(inactive_list)。将数据分为了冷数据和热数据,然后分别进行 LRU 算法

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

预读页首先加入inactive list头部,当其真正被访问的时候,再放入active list的头部(此时,active list中被淘汰的数据降级到inactive list )。如果预读页一直没有被访问,则会从inactive ist移除。

MySQL

将 LRU 划分了 2 个区域:old 区域 和 young 区域。young 区域在 LRU 链表的前半部分,old 区域则是在后半部分。

划分这两个区域后,预读的页就只需要加入到 old 区域的头部,当页被真正访问的时候,才将页插入 young 区域的头部。如果预读的页一直没有被访问,就会从 old 区域移除,这样就不会影响 young 区域中的热点数据。

5.4 缓存污染

5.4.1 定义

批量读取数据的时候,数据被访问一次,就会被从inactive list移动到active list头部,从而淘汰active list原有的数据。如果这些大量数据长时间不被访问,那么active list就被污染了。

如果再次读取热数据,就需要访问磁盘,系统效率急剧下降。

举例:

select语句查询数据,如果需要查询大量的页,那么这些页会全部进入活跃区,原本的热点数据会被淘汰,导致缓存命中率大大下降。

5.4.2 避免缓存污染

提高进入active list的门槛:

  • Linux 操作系统:在内存页被访问第二次的时候,才将页从 inactive list 升级到 active list 里。
  • MySQL Innodb:在内存页被访问第二次的时候,并不会马上将该页从 old 区域升级到 young 区域,因为还要根据停留在 old 区域的时间判断
    • 如果第二次的访问时间与第一次访问的时间在 1 秒内(默认值),那么该页就不会被从 old 区域升级到 young 区域;
    • 如果第二次的访问时间与第一次访问的时间超过 1 秒,那么该页就从 old 区域升级到 young 区域;
  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值