内存管理
单片机是没有操作系统的,单片机的 CPU 是直接操作内存的「物理地址」。在这种情况下,要想在内存中同时运行两个程序是不可能的。如果第一个程序在 2000 的位置写入一个新的值,将会擦掉第二个程序存放在相同位置上的所有内容。
操作系统会提供一种机制,把进程所使用的地址「隔离」开来,即让操作系统为每个进程分配独立的一套「虚拟地址」。将不同进程的虚拟地址和不同内存的物理地址映射起来。
如果程序要访问虚拟地址的时候,由操作系统转换成不同的物理地址,这样不同的进程运行的时候,写入的是不同的物理地址,这样就不会冲突了。
- 我们程序所使用的内存地址叫做虚拟内存地址(Virtual Memory Address)
- 实际存在硬件里面的空间地址叫物理内存地址(Physical Memory Address)。
Linux 系统提供的虚拟内存管理机制,使每一个进程认为其独占内存空间,因此所有进程的内存空间之和远远大于物理内存。所有进程的内存空间之和超过物理内存的部分就需要交换到磁盘上。这是Swap 机制存在的本质原因。Swap 机制指的是当物理内存不够用,内存管理单元(Memory Mangament Unit,MMU)需要提供调度算法来回收相关内存空间,然后将清理出来的内存空间给当前内存申请方。
缓冲区(BUFFER)是为了协调IO和CPU之间的速度不一致而存在的一种设计,以减少CPU中断次数,提高程序效率;
缓存技术(cache)则是通过将CPU最为频繁访问的数据保存在特定区域,并具有优先被访问权,这样提高CPU对数据的获取效率来提高程序运行效率;
缓冲区和内存缓存区都属于内存的一部分,但是在底层设计上,缓存一般具有比普通内存更快的访问速度。
缓冲(BUFFER)、缓存(CACHE)和内存(RAM)
内存功能分段
程序是由若干个逻辑分段组成的,如可由代码分段、数据分段、栈段、堆段组成。不同的段是有不同的属性的,所以就用分段(Segmentation)的形式把这些段分离出来。
分段的办法很好,解决了程序本身不需要关心具体的物理内存地址的问题,但它也有一些不足之处:
第一个就是内存碎片的问题。
第二个就是内存交换的效率低的问题。
打开一二三号程序,结束掉二号程序时,内存占用会变得不连续。可以把零碎程序占用的那 几MB 内存写到硬盘上,然后再从硬盘上读回来到内存里。不过再读回的时候,我们不能装载回原来的位置,而是紧紧跟着那已经被占用了的 512MB 内存后面。就是靠挪出去再挪回来,整合出连续的空间,于是新的程序就可以装载进来。
这个内存交换空间,在 Linux 系统里,也就是我们常看到的 Swap 空间,这块空间是从硬盘划分出来的,用于内存与硬盘的空间交换。
对于多进程的系统来说,用分段的方式,内存碎片是很容易产生的,产生了内存碎片,那不得不重新 Swap 内存区域,而硬盘的访问速度要比内存慢太多了,这个过程会产生性能瓶颈。
内存分页 整片取用
为了解决内存分段的内存碎片和内存交换效率低的问题,就出现了内存分页。
分页是把整个虚拟和物理内存空间切成一段段固定尺寸的大小。这样一个连续并且尺寸固定的内存空间,我们叫页(Page)。在 Linux 下,每一页的大小为 4KB。
由于内存空间都是预先划分好的,也就不会像分段会产生间隙非常小的内存,这正是分段会产生内存碎片的原因。而采用了分页,那么释放的内存都是以页为单位释放的,也就不会产生无法给进程使用的小内存。如果内存空间不够,操作系统会把其他正在运行的进程中的「最近没被使用」的内存页面给释放掉,也就是暂时写在硬盘上,称为换出(Swap Out)。一旦需要的时候,再加载进来,称为换入(Swap In)。
在 32 位的环境下,虚拟地址空间共有 4GB,假设一个页的大小是 4KB(2^12),那么就需要大约 100 万 (2^20) 个页,每个「页表项」需要 4 个字节大小来存储,那么整个 4GB 空间的映射就需要有 4MB 的内存来存储页表。
这 4MB 大小的页表,看起来也不是很大。但是要知道每个进程都是有自己的虚拟地址空间的,也就说都有自己的页表。那这时候这个转换记录页数用的表就太占空间了。
多级页表 改良索引
要解决上面的问题,就需要采用一种叫作多级页表(Multi-Level Page Table)的解决方案。
我们把这个 100 多万个「页表项」的单级页表再分页,将页表(一级页表)分为 1024 个页表(二级页表),每个表(二级页表)中包含 1024 个「页表项」,形成二级分页。
如果某个一级页表的页表项没有被用到,也就不需要创建这个页表项对应的二级页表了,即可以在需要时才创建二级页表。做个简单的计算,假设只有 20% 的一级页表项被用到了,那么页表占用的内存空间就只有 4KB(一级页表) + 20% * 4MB(二级页表)= 0.804MB,这对比单级页表的 4MB 是不是一个巨大的节约?
那么为什么不分级的页表就做不到这样节约内存呢?
我们从页表的性质来看,保存在内存中的页表承担的职责是将虚拟地址翻译成物理地址。假如虚拟地址在页表中找不到对应的页表项,计算机系统就不能工作了。所以页表一定要覆盖全部虚拟地址空间,不分级的页表就需要有 100 多万个页表项来映射,而二级分页则只需要 1024 个页表项(此时一级页表覆盖到了全部虚拟地址空间,二级页表在需要时创建)。
对于 64 位的系统,两级分页肯定不够了,就变成了四级目录,分别是:
全局页目录项 PGD(Page Global Directory);
上层页目录项 PUD(Page Upper Directory);
中间页目录项 PMD(Page Middle Directory);
页表项 PTE(Page Table Entry);
多级页表虽然解决了空间上的问题,但是虚拟地址到物理地址的转换就多了几道转换的工序,这显然就降低了这俩地址转换的速度,也就是带来了时间上的开销。【真多事啊,鱼和熊掌咋都想要!】
“固定到快速访问”
程序是有局部性的,即在一段时间内,整个程序的执行仅限于程序中的某一部分。相应地,执行所访问的存储空间也局限于某个内存区域。
我们就可以利用这一特性,把最常访问的几个页表项存储到访问速度更快的硬件,于是计算机科学家们,就在 CPU 芯片中,加入了一个专门存放程序最常访问的页表项的 Cache,这个 Cache 就是 TLB(Translation Lookaside Buffer) ,通常称为页表缓存、转址旁路缓存、快表等。
有了 TLB 后,那么 CPU 在寻址时,会先查 TLB,如果没找到,才会继续查常规的页表。TLB 的命中率其实是很高的,因为程序最常访问的页就那么几个。
好了,没别的花样了。综上:
段页式地址变换中要得到物理地址须经过三次内存访问:
第一次访问段表,得到页表起始地址;
第二次访问TLB或页表,得到物理页号;
第三次将物理页号与页内位移组合,得到物理地址。
Linux的策略
Linux不想分段,但他用的Intel的处理器里面的硬件流程又非要有分段的步骤。因此Linux 系统中的每个段都是从 0 地址开始的整个 4GB 虚拟空间(32 位环境下),也就是所有的段的起始地址都是一样的。这意味着,Linux 系统中的代码,包括操作系统本身的代码和应用程序代码,所面对的地址空间都是线性地址空间(虚拟地址),这种做法相当于屏蔽了处理器中的逻辑地址概念,段只被用于访问控制和内存保护。
在 Linux 操作系统中,虚拟地址空间的内部又被分为内核空间和用户空间两部分,不同位数的系统,地址空间的范围也不同
进程在用户态时,只能访问用户空间内存;
只有进入内核态后,才可以访问内核空间的内存。
虽然每个进程都各自有独立的虚拟内存,但是每个虚拟内存中的内核地址,其实关联的都是相同的物理内存。这样,进程切换到内核态后,就可以很方便地访问内核空间内存。【艹 文章开头不是说为了避免相同的物理地址才搞的这一套虚拟地址吗】
用户空间内存,从低到高分别是 6 种不同的内存段:
- 程序文件段(.text),包括二进制可执行代码;
- 已初始化数据段(.data),包括静态常量;
- 未初始化数据段(.bss),包括未初始化的静态变量;
- 堆段,包括动态分配的内存,从低地址开始向上增长;
- 文件映射段,包括动态库、共享内存等,从低地址开始向上增长(跟硬件和内核版本有关 (opens new window));
- 栈段,包括局部变量和函数调用的上下文等。栈的大小是固定的,一般是 8 MB。当然系统也提供了参数,以便我们自定义大小;
在这 7 个内存段中,堆和文件映射段的内存是动态分配的。比如说,使用 C 标准库的 malloc() 或者 mmap() ,就可以分别在堆和文件映射段动态分配内存。
Linux 系统上供用户可访问的内存分为两个类型,即: File-backed pages:文件备份页也就是 Page Cache 中的
page,对应于磁盘上的若干数据块;对于这些页最大的问题是脏页回盘; Anonymous
pages:匿名页不对应磁盘上的任何磁盘数据块,它们是进程的运行是内存空间(例如方法栈、局部变量表等属性);
Linux 的 Page Cache【文件管理用的缓存区】
malloc
两种分配途径
malloc() 分配的是虚拟内存,在分配内存的时候会预分配比需求大一些的空间作为内存池。malloc(1) 实际上预分配 132K 字节的内存。
malloc获取内存有两种方式向操作系统申请堆内存:
如果用户分配的内存小于 128 KB,则通过 brk() 申请内存;
如果用户分配的内存大于 128 KB,则通过 mmap() 申请内存;
通过 free 释放内存后,堆内存还是存在的,并没有归还给操作系统。
这是因为与其把这 1 字节释放给操作系统,不如先缓存着放进 malloc 的内存池里,当进程再次申请 1 字节的内存时就可以直接复用,这样速度快了很多。
当然,当进程退出后,操作系统就会回收进程的所有资源。
上面说的 free 内存后堆内存还存在,是针对 malloc 通过 brk() 方式申请的内存的情况。
如果 malloc 通过 mmap 方式申请的内存,free 释放内存后就会归归还给操作系统。
为什么不全部使用 mmap 来分配内存?
因为向操作系统申请内存,是要通过系统调用的,执行系统调用是要进入内核态的,然后在回到用户态,运行态的切换会耗费不少时间。
为什么不全部使用 brk 来分配?
如果我们连续申请了 10k,20k,30k 这三片内存,如果 10k 和 20k 这两片释放了,变为了空闲内存空间,如果下次申请的内存小于 30k,那么就可以重用这个空闲内存空间。但如果大于30k,那就得另外申请内存,而且那30k 已经free了的内存不会还给系统,久而久之就会造成内存泄露。
嗯?不矛盾吗?malloc(1)直接就超过了阈值?
申请了内存,但没完全申请
应用程序通过 malloc 函数申请内存的时候,实际上申请的是虚拟内存,此时并不会分配物理内存。
当应用程序读写了这块虚拟内存,CPU 就会去访问这个虚拟内存, 这时会发现这个虚拟内存没有映射到物理内存, CPU 就会产生缺页中断,进程会从用户态切换到内核态,并将缺页中断交给内核的 Page Fault Handler (缺页中断函数)处理。
缺页中断处理函数会看是否有空闲的物理内存,如果有,就直接分配物理内存,并建立虚拟内存与物理内存之间的映射关系。
如果没有空闲的物理内存,那么内核就会开始进行回收内存的工作
【这一段是牛客网上的题,没看懂,先记这儿】
在linux系统中,内存对匿名页和文件缓存一共用了四条链表进行组织,回收过程主要是针对这四条链表进行扫描和操作。整个扫描的过程包括
首先扫描每个zone上的cgroup组;
然后再以cgroup的内存为单元进行page链表的扫描;
内核会先扫描anon的active链表,将不频繁的放进inactive链表中,然后扫描inactive链表,将里面活跃的移回active中;
进行swap的时候,先对inactive的页进行换出;
如果是file的文件映射page页,则判断其是否为脏数据,如果是脏数据就写回,不是脏数据可以直接释放。
内存回收的激活阈值
图中橙色部分:如果剩余内存(pages_free)在页低阈值(pages_low)和页最小阈值(pages_min)之间,说明内存压力比较大,剩余内存不多了。这时 kswapd0 会执行内存回收,直到剩余内存大于高阈值(pages_high)为止。虽然会触发内存回收,但是不会阻塞应用程序,因为两者关系是异步的。
图中红色部分:如果剩余内存(pages_free)小于页最小阈值(pages_min),说明用户可用内存都耗尽了,此时就会触发直接内存回收,这时应用程序就会被阻塞,因为两者关系是同步的。
如果把最小阈值设置得太高,会使得系统预留过多的空闲内存,从而在一定程度上降低了应用程序可使用的内存量,这在一定程度上浪费了内存。极端情况下设置 min_free_kbytes 接近实际物理内存大小时,留给应用程序的内存就会太少而可能会频繁地导致 OOM 的发生。
所以在调整 min_free_kbytes 之前,需要先思考一下,应用程序更加关注什么,如果关注延迟那就适当地增大 min_free_kbytes,如果关注内存的使用量那就适当地调小 min_free_kbytes。
直接内存回收 和 后台内存回收
回收的方式主要是两种:直接内存回收和后台内存回收。
- 后台内存回收(kswapd):在物理内存紧张的时候,会唤醒 kswapd
内核线程来回收内存,这个回收内存的过程异步的,不会阻塞进程的执行。 - 直接内存回收(direct reclaim):如果后台异步回收跟不上进程内存申请的速度,就会开始直接回收,这个回收内存的过程是同步的,会阻塞进程的执行。【啊,程序未响应是因为在回收内存吗?
页面置换算法
【优先回收不常访问的内存页数据。而且回收之前确保磁盘里有备份,如果没存到磁盘就先去存一份然后再删掉内存里的。】
这里提一下,页表项通常有如下图的字段:
- 状态位:用于表示该页是否有效,也就是说是否在物理内存中,供程序访问时参考。
- 访问字段:用于记录该页在一段时间被访问的次数,供页面置换算法选择出页面时参考。
- 修改位:表示该页在调入内存后是否有被修改过,由于内存中的每一页都在磁盘上保留一份副本,因此,如果没有修改,在置换该页时就不需要将该页写回到磁盘上,以减少系统的开销;如果已经被修改,则将该页重写到磁盘上,以保证磁盘中所保留的始终是最新的副本。
- 硬盘地址:用于指出该页在硬盘上的地址,通常是物理块号,供调入该页时使用。
缺页中断处理找不到空闲页的话,就说明此时内存已满了,这时候,就需要「页面置换算法」选择一个物理页,如果该物理页有被修改过(脏页),则把它换出到磁盘,然后把该被置换出去的页表项的状态改成「无效的」,最后把正在访问的页面装入到这个物理页中。【不是很懂】
页面置换算法的功能是,当出现缺页异常,需调入新页面而内存已满时,选择被置换的物理页面。也就是说选择一个物理页面换出到磁盘,然后把需要访问的页面换入到物理页。
那其算法目标则是,尽可能减少页面的换入换出的次数
常见的页面置换算法有如下几种:
最不常用置换算法(LFU)选择「访问次数」最少的那个页面
最佳页面置换算法(OPT)置换在「未来」最长时间不访问的页面
先进先出置换算法(FIFO)顾名思义
最近最久未使用的置换算法(LRU)记录活跃度,费事
时钟页面置换算法(Lock)按旧-新的顺序去查找访问数为0的表,不为0就顺手先-1再找下一个。
最不常用置换算法(LFU)
选择「访问次数」最少的那个页面,并将其淘汰。
要增加一个计数器来实现,这个硬件成本是比较高的,另外如果要对这个计数器查找哪个页面访问次数最小,查找链表本身,如果链表长度很大,是非常耗时的,效率不高。【啊?页表项里不是有访问次数吗?怎么要加个计数器】
但还有个问题,LFU 算法只考虑了频率问题,没考虑时间的问题,比如有些页面在过去时间里访问的频率很高,但是现在已经没有访问了。而且新来的页面的访问次数也不会高。
那这个问题的解决的办法还是有的,可以定期减少访问的次数,比如当发生时间中断时,把过去时间访问的页面的访问次数除以 2。
最佳页面置换算法(OPT)
该算法计算内存中每个逻辑页面的「下一次」访问时间,然后比较,选择未来最长时间不访问的页面。
不过你肯定想问,怎么知道未来会先用谁再用谁?答案是,确实不知道。所以全靠程序员写算法猜呗,猜得准 这个算法就好,猜不准就是个憨憨。
最近最久未使用的置换算法(LRU)
选择最长时间没有被访问的页面进行置换
也就是说,该算法假设已经很久没有使用的页面很有可能在未来较长的一段时间内仍然不会被使用。
为了完全实现 LRU,需要在内存中维护一个所有页面的链表,最近最多使用的页面在表头,最近最少使用的页面在表尾。而且在每次访问内存时都必须要更新「整个链表」。在链表中找到一个页面,删除它,然后把它移动到表头是一个非常费时的操作。代价太高,实际应用中比较少使用。
时钟页面置换算法(Lock)
把所有的页面都保存在一个类似钟面的「环形链表」中,一个表针指向最老的页面
当发生缺页中断时,算法首先检查表针指向的页面:
如果它的访问位是 0 就淘汰该页面,并把新的页面插入这个位置,然后把表针前移一个位置;
如果访问位是 1 就清除访问位,并把表针前移一个位置,重复这个过程直到找到了一个访问位为 0 的页面为止;
OOM
如果直接内存回收后,空闲的物理内存仍然无法满足此次物理内存的申请,那么内核就会根据算法选择一个占用物理内存较高的进程,然后将其杀死,以便释放内存资源,如果物理内存依然不足,那么就会触发 OOM 机制。内核的OOM Killer会把系统中可以被杀掉的进程扫描一遍,并对每个进程打分,得分最高的进程就会被首先杀掉,直到释放足够的内存位置。【杀死?是那个程序崩溃闪退?不会是删掉别的正在运行的程序吧?】
// points 代表打分的结果
// process_pages 代表进程已经使用的物理内存页面数
// oom_score_adj 代表 OOM 校准值
// totalpages 代表系统总的可用页面数
points = process_pages + oom_score_adj*totalpages/1000
每个进程的 oom_score_adj 默认值都为 0,所以消耗的内存越大越容易被杀掉。如果你想某个进程无论如何都不能被杀掉,那你可以将 oom_score_adj 配置为 -1000。
我们最好将一些很重要的系统服务的 oom_score_adj 配置为 -1000,比如 sshd,因为这些系统服务一旦被杀掉,我们就很难再登陆进系统了。
但是,不建议将我们自己的业务程序的 oom_score_adj 设置为 -1000,因为业务程序一旦发生了内存泄漏,而它又不能被杀掉,这就会导致随着它的内存开销变大,OOM killer 不停地被唤醒,从而把其他进程一个个给杀掉。
在 32 位/64 位操作系统环境下,申请的虚拟内存超过物理内存后会怎么样?
在 32 位操作系统,因为进程最大只能申请 3 GB 大小的虚拟内存,所以直接申请 8G 内存,会申请失败。
在 64 位操作系统,因为进程最大只能申请 128 TB 大小的虚拟内存,即使物理内存只有 4GB,申请 8G 内存也是没问题,因为申请的内存是虚拟内存。
程序申请的虚拟内存,如果没有被使用,它是不会占用物理空间的。当访问这块虚拟内存后,操作系统才会进行物理内存分配。
如果申请物理内存大小超过了空闲物理内存大小,就要看操作系统有没有开启 Swap 机制:当内存使用存在压力的时候,会开始触发内存回收行为,会把这些不常访问的内存先写到磁盘中,然后释放这些内存,给其他更需要的进程使用。再次访问这些内存时,重新从磁盘读入内存就可以了。【这个机制令应用程序实际可以使用的内存空间将远远超过系统的物理内存。不过用着会很卡】
如果没有开启 Swap 机制,程序就会直接 OOM;
如果有开启 Swap 机制,程序可以正常运行。
磁盘调度算法
为了提高磁盘的访问性能,算法一般是通过优化磁盘的访问请求顺序来提高效率。寻道的时间是磁盘访问最耗时的部分,如果请求顺序优化的得当,必然可以节省一些不必要的寻道时间
假设有下面一个请求序列,每个数字代表磁道的位置:
98,183,37,122,14,124,65,67
初始磁头当前的位置是在第 53 磁道。
接下来,分别对以上的序列,作为每个调度算法的例子
常见的磁盘调度算法有:
先来先服务算法【效率不高
最短寻道时间优先算法【太远的可能根本没机会轮到
扫描算法算法【不患寡而患不均
循环扫描算法【行,就是没必要跑到头而已
LOOK 与 C-LOOK 算法【看起来是最好的算法了?
先来先服务算法
简单粗暴 性能差
最短寻道时间优先算法
磁头移动的总距离是 236 磁道。但如果后续来的请求都是小于 183 磁道的,那么 183 磁道可能永远不会被响应
扫描算法
磁头固定一个方向移动,访问途中所有未完成的请求,直到磁头到达该方向上的最后的磁道,才调换方向,这就是扫描(Scan)算法。但是中间部分相比其他部分响应的频率会比较多。
循环扫描算法
磁道只响应一个方向上的请求,跑到最边缘的磁道之后复位磁头,这个过程是很快的,并且返回中途不处理任何请求。
LOOK 与 C-LOOK 算法
这俩是对(循环)扫描算法的改进。区别就是磁头在移动到「最远的请求」位置就反向移动,而不必跑到最边缘去。
LOOK 算法在反向移动的途中会响应请求,对应于扫描算法。
C-LOOK 在反向移动的途中不会响应请求,对应于循环扫描算法。