小林coding阅读笔记:操作系统篇之内存分配与回收
Posted adventure.Li
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了小林coding阅读笔记:操作系统篇之内存分配与回收相关的知识,希望对你有一定的参考价值。
前言
- 学习/导流:
小林coding - 4.2 malloc 是如何分配内存的?
小林coding - 4.3 内存满了,会发生什么?? - 学习意义
- 理解linux操作系统如何设计内存分配\\回收的过程
- 为学习JVM的内存管理奠定基础(JVM的内存布局及垃圾回收也是重点!!)
- 为编码时合理分配、使用内存提供理论指导
- 相关说明
该篇博文是个人阅读的重要梳理,仅做简单参考,详细请阅读小林coding的原文!
malloc 是如何分配内存的?
背景-问题切入
- malloc 是如何分配内存的?
- malloc 分配的是物理内存吗?
- malloc(1) 会分配多大的内存?
- free 释放内存,会归还给操作系统吗?
- free() 函数只传入一个内存地址,为什么能知道要释放多大的内存?
→ 内存管理的基本任务:
- 内存分配
- 内存回收、释放
- 越界保护
一、Linux进程的内存布局
在 Linux 操作系统中,虚拟地址空间的内部又被分为内核空间和用户空间两部分,不同位数的系统,地址空间的范围也不同。比如最常见的 32 位和 64 位系统,如下所示:
虽然每个进程都各自有独立的虚拟内存,但是每个虚拟内存中的内核地址,其实关联的都是相同的物理内存。这样,进程切换到内核态后,就可以很方便地访问内核空间内存。
用户空间布局
- 程序文件段,包括二进制可执行代码;
- 已初始化数据段,包括静态常量;
- 未初始化数据段,包括未初始化的静态变量;
- 堆段,包括动态分配的内存,从低地址开始向上增长;
- 文件映射段,包括动态库、共享内存等,从低地址开始向上增长(跟硬件和内核版本有关 );
- 栈段,包括局部变量和函数调用的上下文等。栈的大小是固定的,一般是
8 MB
。当然系统也提供了参数,以便我们自定义大小;
在这 6 个内存段中,堆和文件映射段的内存是动态分配的。比如说,使用 C 标准库的 malloc()
【可能到右brk,也可能调用mmap,根据阀值】或者 mmap()
,就可以分别在堆和文件映射段动态分配内存。
注:结合JVM的内存布局去分析,内存的设计思路、理念
二、malloc如何分配内存的?
实际上,malloc() 并不是系统调用,而是 C 库里的函数,用于动态分配内存。malloc 申请内存的时候,会有两种方式向操作系统申请堆内存。
- 方式一:通过 brk() 系统调用从堆分配内存。
- 方式二:通过 mmap() 系统调用在文件映射区域分配内存;
brk()方式
通过 brk() 函数将**「堆顶」指针**向高地址移动,获得新的内存空间。
mmap()方式
mmap() 系统调用中**「私有匿名映射」**的方式,在文件映射区分配一块内存,也就是从文件映射区“偷”了一块内存。
malloc() 源码里默认定义了一个阈值(不同的 glibc 版本定义的阈值也是不同的):
- 如果用户分配的内存小于 128 KB,则通过 brk() 申请内存;
- 如果用户分配的内存大于 128 KB,则通过 mmap() 申请内存;
关于brk()和mmap()的更多说明:https://blog.csdn.net/yusiguyuan/article/details/39496057
三、malloc(1) 会分配多大的虚拟内存?
malloc() 在分配内存的时候,并不是老老实实按用户预期申请的字节数来分配内存空间大小,而是会预分配更大的空间作为内存池。【有点 读 CPU读数据 一次性读 CacheLine和分支预测的思想【提前预测,提升效率】?】
具体会预分配多大的空间,跟 malloc 使用的内存管理器有关系,我们就以 malloc 默认的内存管理器(Ptmalloc2)来分析。
接下里,我们做个实验,用下面这个代码,通过 malloc 申请 1 字节的内存时,看看操作系统实际分配了多大的内存空间。
// —
这个例子分配的内存小于 128 KB,所以是通过 brk() 系统调用向堆空间申请的内存,因此可以看到最右边有 [heap] 的标识。
可以看到,堆空间的内存地址范围是 00d73000-00d94000,这个范围大小是 132KB,也就说明了 malloc(1) 实际上预分配 132K 字节的内存。
可能有的同学注意到了,程序里打印的内存起始地址是 d73010
,而 maps 文件显示堆内存空间的起始地址是 d73000
,为什么会多出来 0x10
(16字节)呢?这个问题,我们先放着,后面会说
掌握指令:cat /proc/pid/maps 查看 内存分配情况
四、free 释放内存,会归还给操作系统吗?
通过 free 释放内存后,堆内存还是存在的,并没有归还给操作系统。
这是因为与其把这 1 字节释放给操作系统,不如先缓存着放进 malloc 的内存池里,当进程再次申请 1 字节的内存时就可以直接复用,这样速度快了很多。
当然,当进程退出后,操作系统就会回收进程的所有资源。
上面说的 free 内存后堆内存还存在,是针对 malloc 通过 brk() 方式申请的内存的情况。
如果 malloc 通过 mmap 方式申请的内存,free 释放内存后就会归归还给操作系统。
我们做个实验验证下, 通过 malloc 申请 128 KB 字节的内存,来使得 malloc 通过 mmap 方式来分配内存。
对于 「malloc 申请的内存,free 释放内存会归还给操作系统吗?」这个问题,我们可以做个总结了:
- malloc 通过 brk() 方式申请的内存,free 释放内存的时候,并不会把内存归还给操作系统,而是缓存在 malloc 的内存池中,待下次使用;
- malloc 通过 mmap() 方式申请的内存,free 释放内存的时候,会把内存归还给操作系统,内存得到真正的释放。
五、为什么不全部使用 mmap 来分配内存
- 避免频繁调用、增大CPU的消耗
因为向操作系统申请内存,是要通过系统调用的,执行系统调用是要进入内核态的,然后在回到用户态,运行态的切换会耗费不少时间。
所以,申请内存的操作应该避免频繁的系统调用,如果都用 mmap 来分配内存,等于每次都要执行系统调用。
另外,因为 mmap 分配的内存每次释放的时候,都会归还给操作系统,于是每次 mmap 分配的虚拟地址都是缺页状态的,然后在第一次访问该虚拟地址的时候,就会触发缺页中断。
也就是说,频繁通过 mmap 分配的内存话,不仅每次都会发生运行态的切换,还会发生缺页中断(在第一次访问虚拟地址后),这样会导致 CPU 消耗较大。
为了改进这两个问题,malloc 通过 brk() 系统调用在堆空间申请内存的时候,由于堆空间是连续的,所以直接预分配更大的内存来作为内存池,当内存释放的时候,就缓存在内存池中。
等下次在申请内存的时候,就直接从内存池取出对应的内存块就行了,而且可能这个内存块的虚拟地址与物理地址的映射关系还存在,这样不仅减少了系统调用的次数,也减少了缺页中断的次数,这将大大降低 CPU 的消耗。
六、为什么不全部使用 brk 来分配?
- 内存碎片问题→内存泄露
前面我们提到通过 brk 从堆空间分配的内存,并不会归还给操作系统,那么我们那考虑这样一个场景。
如果我们连续申请了 10k,20k,30k 这三片内存,如果 10k 和 20k 这两片释放了,变为了空闲内存空间,如果下次申请的内存小于 30k,那么就可以重用这个空闲内存空间。
但是如果下次申请的内存大于 30k,没有可用的空闲内存空间,必须向 OS 申请,实际使用内存继续增大。
因此,随着系统频繁地 malloc 和 free ,尤其对于小块内存,堆内将产生越来越多不可用的碎片,导致“内存泄露”。而这种“泄露”现象使用 valgrind 是无法检测出来的。
所以,malloc 实现中,充分考虑了 brk 和 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 次数会很多,这个过程势必会影响系统的性能,整个系统给人的感觉就是很卡。
解决办法
- 调整文件页和匿名页的回收倾向【减少IO开销】
- 尽早触发 kswapd 内核线程异步回收内存【减少阻塞】
调整文件页和匿名页的回收倾向
从文件页和匿名页的回收操作来看,文件页的回收操作对系统的影响相比匿名页的回收操作会少一点,因为文件页对于干净页回收是不会发生磁盘 I/O 的,而匿名页的 Swap 换入换出这两个操作都会发生磁盘 I/O。
Linux 提供了一个 /proc/sys/vm/swappiness
选项,用来调整文件页和匿名页的回收倾向。
swappiness 的范围是 0-100,数值越大,越积极使用 Swap,也就是更倾向于回收匿名页;数值越小,越消极使用 Swap,也就是更倾向于回收文件页。
[root@xiaolin ~]# cat /proc/sys/vm/swappiness0
一般建议 swappiness 设置为 0(默认值是 60),这样在回收内存的时候,会更倾向于文件页的回收,但是并不代表不会回收匿名页。
尽早触发 kswapd 内核线程异步回收内存
查看系统的直接内存回收和后台内存回收的指标
我们可以使用 sar -B 1
命令来观察:
图中红色框住的就是后台内存回收和直接内存回收的指标,它们分别表示:
- pgscank/s : kswapd(后台回收线程) 每秒扫描的 page 个数。【page scan kswapd】
- pgscand/s: 应用程序在内存申请过程中每秒直接扫描的 page 个数【page scan directly?】。
- pgsteal/s: 扫描的 page 中每秒被回收的个数(pgscank+pgscand)。
记住 pgscank反应后台回收,pgscand反应直接回收
如果系统时不时发生抖动,并且在抖动的时间段里如果通过 sar -B 观察到 pgscand 数值很大,那大概率是因为「直接内存回收」导致的。
针对这个问题,解决的办法就是,可以通过尽早的触发「后台内存回收」来避免应用程序进行直接内存回收。
kswapd 内核线程回收内存的触发条件
内核定义了三个内存阈值(watermark,也称为水位),用来衡量当前剩余内存(pages_free)是否充裕或者紧张,分别是:
- 页最小阈值(pages_min);
- 页低阈值(pages_low);
- 页高阈值(pages_high);
该图关键!!
这三个内存阈值会划分为四种内存使用情况,如下图:
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),说明用户可用内存都耗尽了,此时就会触发直接内存回收,这时应用程序就会被阻塞,因为两者关系是同步的。
页低阈值(pages_low)可以通过内核选项 /proc/sys/vm/min_free_kbytes
(该参数代表系统所保留空闲内存的最低限)来间接设置。【min_pages = min_free_kbytes】
67584 kb = 66MB【默认预留 66MB是不是有点低?】
如果系统时不时发生抖动,并且通过 sar -B 观察到 pgscand 数值很大,那大概率是因为直接内存回收导致的,这时可以增大 min_free_kbytes 这个配置选项来及早地触发后台回收,然后继续观察 pgscand 是否会降为 0。
增大了 min_free_kbytes 配置后,这会使得系统预留过多的空闲内存,从而在一定程度上降低了应用程序可使用的内存量,这在一定程度上浪费了内存**。极端情况下设置 min_free_kbytes 接近实际物理内存大小时,留给应用程序的内存就会太少而可能会频繁地导致 OOM 的发生【】**。
所以在调整 min_free_kbytes 之前,需要先思考一下,应用程序更加关注什么,如果关注延迟那就适当地增大 min_free_kbytes,如果关注内存的使用量那就适当地调小 min_free_kbytes。
四、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 架构下,当某个 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 杀掉呢?
在系统空闲内存不足的情况**,进程申请了一个很大的内存,如果直接内存回收都无法回收出足够大的空闲内存,那么就会触发 OOM 机制**,内核就会根据算法选择一个进程杀掉。
Linux 到底是根据什么标准来选择被杀的进程呢?这就要提到一个在 Linux 内核里有一个 oom_badness()
函数,它会把系统中可以被杀掉的进程扫描一遍,并对每个进程打分,得分最高的进程就会被首先杀掉。
进程得分的结果受下面这两个方面影响:
- 第一,进程已经使用的物理内存页面数。
- 第二,每个进程的 OOM 校准值 oom_score_adj。它是可以通过
/proc/[pid]/oom_score_adj
来配置的。我们可以在设置 -1000 到 1000 之间的任意一个数值,调整进程被 OOM Kill 的几率。
函数 oom_badness() 里的最终计算方法是这样的:
// points 代表打分的结果
// process_pages 代表进程已经使用的物理内存页面数
// oom_score_adj 代表 OOM 校准值
// totalpages 代表系统总的可用页面数
points = process_pages + oom_score_adj*totalpages/1000
每个进程的 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 不停地被唤醒,从而把其他进程一个个给杀掉。
以上是关于小林coding阅读笔记:操作系统篇之内存分配与回收的主要内容,如果未能解决你的问题,请参考以下文章
小林coding阅读笔记:操作系统篇之内存管理基础,虚拟内存分段分页