Java程序员自我修养——Linux内存管理

目录

一、虚拟地址和虚拟内存

二、逻辑地址与内存分段管理

三、内存分页管理和TLB

四、用户空间和内核空间

五、内核地址空间映射

六、伙伴系统和每CPU页框高速缓存

七、Slab分配器

八、非连续内存区管理与vmalloc函数


一、虚拟地址和虚拟内存

     物理内存对于CPU而言就相当于一个字节数组,CPU通过数组下标的方式访问物理内存,比如内存大小是4GB,则对应的数组下标范围是0x0000 0000-0xffff ffff, 这个范围就是物理地址空间,其中的0x00001000就是物理地址。虚拟地址与物理地址形式上一样,不过虚拟地址属于虚拟地址空间,虚拟地址空间是相当于单个进程的,不同进程间虚拟地址空间完全隔离,虚拟地址空间通过硬件上的内存管理单元(MMU)和操作系统映射至物理内存空间。虚拟地址空间按照程序本身的需要可能大于物理地址空间,亦或者当前剩余可用内存不能满足应用程序的需要,这时需要把部分硬盘空间当做内存来使用,这部分当做内存使用的硬盘空间叫做虚拟内存,Linux上即Swap分区。

      虚拟地址到物理地址的转换需要MMU和操作系统相互配合,因为这一翻译过程的存在,不同进程间的虚拟地址空间实现完全隔离,即A进程完全不能访问或者修改B进程的数据;同时虚拟地址空间完全摆脱了物理内存的限制,实现两者间的解耦,可以不用考虑物理内存的大小和分配,编译时可以在内存只有目标程序使用且完全满足内存需求的理想前提下提前做好内存的分配,即编译时就确认某个变量或者函数的内存地址,如果是运行时才确认某个变量或者函数的内存地址则需要比较耗时的重定位操作而影响程序性能。

扫描二维码关注公众号,回复: 6206434 查看本文章

二、逻辑地址与内存分段管理

      内存分段机制是8086 CPU为了解决数据总线与地址总线宽度不一致问题,实现16位寄存器能够顺利访问20位地址空间而引入的,20位地址空间对应1Mb的内存,16位地址空间对应64kb的内存,8086CPU将1Mb内存分成16个段,每个段64kb,将原来写死的物理地址拆分成段基地址和段内偏移量,这两个组成逻辑地址,然后增加保存段基地址的段寄存器,段基地址通过段寄存器传递,段内偏移量通过16位数据总线传递,对应的物理地址由段基地址*16+段内偏移地址算出。这种早期的CPU寻址模式成为实模式,因为对对段对应的地址空间缺乏保护,目前只在CPU被复位或者加电的时候使用,当操作系统在此模式下完成必要的初始化就转换到保护模式(IA-32模式)。

      保护模式下引入了段描述符的概念,每一个分段都有对应的段描述符和段选择符,段描述符记录了段的基地址、类型和CPU特权级等属性,通过段描述符来实现对对应段的地址空间的权限控制,段描述符保存在全局段描述符表(GDT)和局部段描述符表(LDT),GDT的物理地址保存在gdtr控制寄存器中,LDT的物理地址保存在ldtr控制寄存器中;段选择符标识段描述符在GDT或者LDT中的位置,段选择符保存在专用的段寄存器,如cs 代码段寄存器,ds数据段寄存器,ss栈段寄存器,每个段寄存器有一个对应的不能被代码改写的寄存器用来保存对应的段描述符,当段选择符被装入段寄存器时,CPU自动将对应的段描述符自动装入对应的非编程寄存器,从而实现快速访问段描述符。多核处理器每个CPU都有对应的GDT,由内核创建并维护,LDT和进程包含的内存分段对应的段选择符由进程创建并维护。早期逻辑地址通过分段单元直接转换成物理地址,后面为了解决内存共享,内存碎片等问题而引入了内存分页机制,在逻辑地址和物理地址中间加了一个线性地址,即虚拟地址。将逻辑地址转换成虚拟地址(又称线性地址)由分段单元完成,转换流程如下图:

      

       内存分段是指为二进制可执行文件中的代码段,数据段等segment指定基地址即对应的段描述符从而将其加载到不同的虚拟地址空间范围内,因为进程维护的段选择符较多,且需要频繁切换段选择符,操作系统内存管理实现会相对复杂。Linux采用的替代方案是在二进制可执行文件加载的过程中由加载器按照规则将各segment加载到不同的虚拟地址空间,同时Linux为了规避这种内存分段机制,所有的用户代码共享用户代码段和用户数据段,所有内核代码共享内核代码段和内核数据段,只在进程在用户态和内核态之间切换时才更改段寄存器,从而减少段选择符的频繁切换,并且四个段的起始地址都是0,最大地址是0xfffff(32位下),即逻辑地址等于了虚拟地址,无论使用哪个段描述符对应的虚拟地址都不变,具体而言Linux没有刻意将可执行文件中的代码段放到用户态代码段,数据段放到用户态数据段,CPU读取代码指令或者数据时无论从用户态代码段或者用户态数据段结果都一样,从而极大简化了内存管理。

     由于其他的RISC架构的CPU对内存分段支持很有限,Linux为了能够兼容其他架构的CPU和简化内存管理而基本放弃了内存分段而选择了内存分页模式,现代的x86 64位CPU本身也是最大限度的削弱了对内存分段的支持而强化了对内存分页模式的支持。      

       参考: 总结一下linux中的分段机制

                   linux深入解析分段机制

                   X86/X64处理器体系结构及寻址模式

三、内存分页管理和TLB

      虚拟地址到物理地址的转换由分页单元完成,通过内存分页管理可以将同一个虚拟地址映射到不同的物理地址上,可以保证进程的虚拟地址空间不受物理内存的地址空间限制,可以限制对非法虚拟地址的访问,可以实现对公共代码的内存共享,以页为单位管理内存也可以大大减少物理内存碎片提供内存的使用效率。所谓的页是指一组连续的虚拟地址,32位下通常是4kb,即4096个连续的虚拟地址,页会映射到一组同样大小的连续的物理地址上,称为页框或者物理页,读取页中某个虚拟地址的数据时只需找到对应的页框地址,即起始的物理地址,再加上该地址在页中的偏移量即是该虚拟地址对应的物理地址,把物理地址和读取的字节数通过总线传给内存控制器即可读取对应的数据。

      页框的物理地址通过页表保存,页表的物理地址通过页目录保存,页表由进程创建并根据二进制文件中segment的大小做初始化,通过进程描述符中的内存描述符来保存页表的物理地址,CPU正在使用的页目录地址放在cr3控制寄存器,由内核设置。页表和页目录都可以理解为一个数组且数组元素的结构相同,每个元素包含页表或者页是否分配对应的物理页,页表或者页框的物理地址,页是否被交换到磁盘上,读写权限,访问特权级等属性。以32位虚拟地址为例,最高10位表示页目录索引,中间10位表示页表索引,最后的12位表示页内的偏移,2^12等于4096,跟页大小想对应,转换时从cr3寄存器读取页目录的物理地址,根据页目录索引找到页表的物理地址,根据页表索引找到页框的物理地址,加上页内偏移得到真实的物理地址,如下图:     

页表和页目录初始化时会根据对应的虚拟地址空间范围初始化该范围内用到的页目录和页表,其他的没有用到的都初始化成0, 页表初始化时对应的页框地址也是0,而是等CPU用到该页框对应的页的数据时,由分页单元触发一个缺页异常,然后由内存控制器分配页框物理地址,最后再初始化页表对应项的页框地址,设置对应的标志位。32位下之所以采用页目录和页表这种两级分页的方式是为了避免页目录或者页表的条数过多,从而占据较多的内存空间。现代CPU支持扩展分页即页的大小不是4KB而是2MB或者4MB,虚拟地址位数也由32位扩展到64位,对应的分页机制都会配套调整,比如Linux中有三级分页和四级分页两种模式,但是基本的分页思想不变。

      x86_64 CPU只在指令层面支持64位地址空间,因为实际内存大小有上限,为了降低制造成本,CPU实际只使用低48位,高16位不参与内存分页,48位对应的地址空间是256T,完全能够满足生产需要,因此64位CPU实际的地址总线是48位。但是代码编译环节使用的虚拟地址依然是64位,不过高16位都是一样的,用户空间下都是0000,内核空间下都是ffff。48位的虚拟地址转换成物理地址需要4级页表,转换过程如下图:

      虚拟地址到物理地址的转换比较耗时,底层硬件为了提高转换效率,引入了转换后援缓冲器(TLB),该缓冲器是CPU里面的一块高速缓存区域,保留虚拟地址和物理地址的对应关系,第一次通过读物物理的页目录和页表转成物理地址保存在TLB后,第二次访问就直接访问对应的物理地址了,从而大幅提供读取虚拟地址的速度。当保存页目录地址的cr3控制寄存器的值改变后,TLB会自动刷新,清空原有的转换数据,从而保存新的转换数据。

      参考: Linux内存寻址之分页机制

                  内存分页机制完全攻略

                  Linux内存地址映射

                  64位Linux下的地址映射

四、用户空间和内核空间

       用户空间是指用户程序可以使用的虚拟地址空间,内核空间是指操作系统内核可以使用的虚拟地址空间,两者在虚拟地址空间是隔离的。32位下地址空间是4GB,内核空间为3-4G,用户空间为0-3G,64位下实际的可用地址空间是256T,内核空间为128-256T,用户空间是0-128T,如下图:

区分隔离用户空间和内核空间的主要好处是避免进程在用户态和内核态来回切换时的地址空间冲突,方便各进程从用户态切换到内核态时,可以正常执行内核代码,执行结果可以正常传递到用户空间。页目录地址是翻译虚拟地址的关键,用户空间的虚拟地址传递到内核时,cr3控制寄存器的页目录地址不变,内核可以获取到正确的对应物理地址,从而读取用户空间传递的数据。当内核执行系统调用完成需要将数据传给进程时,同理,内核可以调用内存分配函数在用户空间分配一块足够的内存,产生的地址为用户空间的地址,然后内核将内核空间的执行结果数据复制到用户空间,将对应的内存地址回传给进程,因为分配内存时页目录地址不变,进程可以正常读取该地址。

       参考:linux内核 64位 X86_64 地址空间

五、内核地址空间映射

       为了避免内核空间虚拟地址和用户空间虚拟地址来回切换时频繁更新cr3寄存器中的页目录地址,提高用户空间虚拟地址的转换效率,需要对内核空间虚拟地址映射做特殊处理。32位下Linux的办法是用虚拟地址-3G得到物理地址,即将1G内核空间映射到最低的1G物理内存,这种办法的不足是内核无法访问大于1G的物理内存,这种方式叫做直接映射,对应的地址空间称为直接内存映射区。为了解决这个问题就在1G的内核空间的最高128M划出来,对这128M地址空间采取正常分页的映射逻辑同1G-4G的物理内存建立映射,因为地址空间有限,实际是用完了立即释放。Linux将大于896M的地址空间称为ZONE_HIGHMEM(高端内存),即无法通过固定映射的方式直接访问的内存区域,中间的16MB~896MB称为ZONE_NORMAL,内存开始的16M称为ZONE_DMA,ZONE_NORMAL和ZONE_DMA合称低端内存区。ZONE_DMA内存是专门给DMA控制器使用,因为该控制器只能对前16MB内存寻址,且直接同内存交互,忽略内存分页。

       高端内存内核映射由三种方式:永久内核映射,临时内核映射和动态内核映射,永久内核映射可能阻塞当前进程,临时内核映射绝不会阻塞。 对应将其划分成三个空间, vmalloc区域,即动态内核映射区,PKMAP_BASE 到 FIXADDR_START之间的区域的为永久内核映射,FIXADDR_START 到 FIXADDR_TOP之间的区域为临时映射区,如下图。直接映射和三种高端内存映射对应的页表统称为内核页表,在操作系统启动的过程中由内核创建并维护。

       64位下因为内核地址空间为128T,远远超过现有的物理内存大小,所以64位下目前不存在高端内存。

       参考:Linux用户空间与内核空间(理解高端内存)

                【Linux】Linux的内核空间(低端内存、高端内存)

                【Linux内核学习笔记二】内存管理-管理区(zone)

六、伙伴系统和每CPU页框高速缓存

     存储管理系统有两个问题必须解决,外碎片和内碎片,外碎片是指存储系统无法直接利用的小碎片,主要是在频繁的申请和释放大小不同的物理上连续的存储块的过程中产生的,内碎片是指分配给请求方的存储块中没有利用的部分,主要是因为分配的存储块容量大于实际使用的容量。Linux解决外碎片的办法是伙伴系统算法(buddy system),解决内碎片的办法是slab机制。

     伙伴系统初始化时把所有的空闲页分组为11个块链表,每个块链表分别包含大小为1,2,4,8,16,32,64,128,256,512和1024个连续页框的页块。最大可以申请1024个连续页,对应4MB大小的连续内存。每个页块的第一个页框的物理地址是该块大小的整数倍,比如大小为16个页框的页块,其起始地址是16*2^12的整数倍,2^12次方表示一个大小为4kb的页框。以请求256个页框的页块的处理过程为例说明算法逻辑,算法先在256个页框的链表中查找是否存在空闲的块,如果存在则返回该页块并从链表中移除,如果不存在则在下一级即512个页框的链表中查找是否存在空闲的页块,如果存在,则将该页块从链表中移除并拆分成两个包含256个页框的页块,其中一个页块返回给请求方,另一个页块插入到256个页框的页块链表中。如果512个页框页块链表中没有空闲的,继续查找下一级,即1024个页框的页块链表,如果存在,则将该页块从链表中移除然后拆分成一个512个页框的页块,两个256个页框的页块,其中一个256个页框的页块返回给请求方,另外两个页块插入到各自的页块链表中;如果512个页框的页块链表中不存在空闲的则继续往下查找,直到所有页块链表查找完毕,还没有则分配失败。释放256个页框的页块的过程与上述请求过程是相反的,首先判断256个页框的页块链表中是否存在与之物理上相邻的页块,即存在伙伴关系的页块,如果存在则将两个页块合并成512个页框的页块,插入到512个页框的页块链表中走相同的合并逻辑;如果不存在则将该页块插入到链表中即可。会不断的往上合并,直到没有满足伙伴关系的页块,直到最高级的页框链表。

      满足以下三个条件的两个页块称为伙伴:1、两个页块大小相同;2、两个页块的物理地址是连续的;3、第一个页块的起始地址满足b*2^12要求,b为页块的页框数。

     伙伴算法能够有效解决一组连续的页框的分配问题,但是依然存在一定程度的外碎片问题,因为页块合并要求比较严格,中间的小页块未释放会导致两边的两个大页块无法合并,Linux的解决办法是记录页块是否可移动,可回收,将可移动的页块通过复制移动到相邻位置,使其能够合并,分配的时候也尽量避免在可移动的页块区域中分配不可移动的页块。Linux根据将已分配页框分成三种类型:

    (1)不可移动页:这些页在内存中有固定的位置,不能够移动,内核占用的内存基本属于该类别,这部分内存是固定映射的。

  (2)可回收页:这些页不能移动,但可以删除,其内容可以从某些源重新生成,内核在回收页占据了太多的内存时或者内存短缺时进行页面回收,如映射自文件的数据属于该类别。

  (3)可移动页:这些页可以任意移动,用户空间应用程序使用的页都属于该类别。它们是通过页表映射的。将它们复制到新位置,并更新对应页表项,应用程序无感知。

      伙伴算法相关的数据结构保存在内存管理区描述符中,请求和释放内存时都需要对该数据结构加锁,为了提高内核请求和释放单个页框的效率,引入了每CPU页框高速缓存,缓存中包含一些预先从伙伴系统分配好的页框,当请求单个页框时,直接从当前cpu的页框高速缓存中获取页框,释放页框时直接将页框归还至页框高速缓存中即可,不用跟伙伴系统交互,当内核监控到缓存中可用的页框不足时会自动从伙伴系统预分配足够的页框,超过缓存的页框上限时也会自动从伙伴系统释放该页框。每CPU页框高速缓存包含两部分,热高速缓存和冷高速缓存,前者包含的页框的内容也能在CPU高速缓存中,后者则不在CPU高速缓存中,做这种区分的目的是避免对页框的操作引起了CPU高速缓存的缓存行替换,比如进程从冷缓存中获取一个页框然后立即写入,就会将该页框的部分缓存行加载到CPU高速缓存,而替换了原来的缓存行。

      伙伴系统和每CPU页框高速缓存一起构成Linux内核的页框分配器。

        参考: 伙伴系统之避免碎片--Linux内存管理(十六)

                    Linux 伙伴算法简介

                   每CPU页框高速缓存

七、Slab分配器

     伙伴系统采用页框作为内存分配的基本单位,适用于对大块内存的请求,如果为了几十字节的内存分配一个页框则会造成严重的内存浪费,即内碎片问题,Linux采用Slab机制来解决该问题。slab以对象为小块内存管理的基本单位,slab本身就是一个已分配页框的对象缓冲区,对象是指内核或者用户进程频繁使用的数据结构,如进程描述符,打开文件对象等,请求对象时从缓冲区中取一个未被分配的对象,释放对象时将其归还到缓冲区,只有当整个slab的对象都长期未使用时才会回收该slab的页框,从而大幅减少频繁分配和释放页框的性能开销。每个slab包含一个或者多个页框,这些页框按照对象大小分成多个对象,slab会记录这些对象是否已分配,据此分为三种slab,一种是所有对象都已分配的slab,一种是所有对象都未分配slab,一种是只有部分对象被分配的slab,这三种slab通过对应的slab链表维护。相同类型的多个slab都放在同一个高速缓存(kmem_cache)中,这里的高速缓存不是硬件的CPU高速缓存,而是逻辑意义上的slab或者对象的缓存区。高速缓存创建时没有任何slab,只有请求新对象或者没有任何空闲对象时才会创建一个新的slab,当高速缓存中有太多的空闲对象或者某个slab完全未被使用则会回收slab。为了提升分配对象的效率,slab分配器同样使用了每CPU高速缓存(array_cache per cpu),即使用一个数组来保存若干个已分配的对象,Linux会定时检查数组中空间对象的个数,如果不足则调用slab分配器分配指定数量的对象。slab,kmem_cache和array_cache的数据结构关系如下图:

其中kmem_bufctl_t表示一个对象描述符,就是一个无符号整数,用来记录下一个空闲的对象序号,不过只有在对象空闲时才有意义,通过这种方式建立一个slab内部的空闲对象链表。

     因为实际的对象大小不一定是2的整数倍,而物理内存大小则是2的整数倍,为了提高分配效率,slab会对对象做对齐,即适当扩大对象的大小,使其是2的整数倍,但是会导致轻微的内碎片。

     因为CPU高速缓存的映射机制,对象大小相同的多个slab内具有相同偏移量的对象A和B很可能映射至同一个缓存行,CPU交替访问A和B会导致该缓存行频繁失效然后从内存重新加载,Linux通过对象着色机制解决该问题,大致原理是利用slab内的空闲空间将起始地址往后偏移,颜色不同起始偏移量不同,从而保证不同slab的起始偏移量不同,如果空闲空间不足则着色机制可能失效。假如对象的大小是32byte,因为对象大小相同所有slab的空闲空间相同,假设是31byte,对象对齐要求起始地址是2的整数倍,则一共有31/2=15中颜色,即第一个slab偏移0,第二个slab偏移2byte,第三个slab偏移4byte,依次类推。

      高速缓存分成两种类型,普通和专用,普通高速缓存是slab分配器通用的缓存,第一个普通高速缓存叫做kmem_cache,用于包含内核使用的其余高速缓存的高速缓存描述符,内核后续创建新的高速缓存的时候直接从这个kmem_cache分配高速缓存描述符,其他的普通高速缓存都是用于kmalloc分配内存使用的,一共26个,slab对象大小分别为32,64,96,128,192等,kmalloc根据传入待分配内存大小来选择合适的普通高速缓存,然后取出一个空闲的slab,适用于对通用对象的内存分配;专用高速缓存是指用于处理内核频繁使用的一些数据结构,如进程描述符的高速缓存。所有的高速缓存通过cache_chain连接起来,后续创建的高速缓存都需要插入到cache_chain里面。

       slab分配器本身非常复杂,存在slab 管理数据和队列的存储开销比较大,冗余的 Partial 队列,性能调优困难等问题,内核开发人员 Christoph Lameter 在 Linux 内核 2.6.22 版本中引入一种新的解决方案:slub 分配器。slub分配器特点是简化设计理念,同时保留 slab 分配器的基本思想:每个缓冲区由多个小的 slab 组成,每个 slab 包含固定数目的对象。slub分配器简化了kmem_cache,slab 等相关的管理数据结构,摒弃了slab分配器中众多的队列概念,并针对多处理器、NUMA 系统进行优化,从而提高了性能和可扩展性并降低了内存的浪费。为了保证内核其它模块能够无缝迁移到 slub分配器,slub 还保留了原有 slab分配器所有的接口 API 函数。slub分配器目前未大规模投入使用。

      参考:Linux内存管理之SLAB原理浅析

                 Slab之着色

                 Linux SLUB 分配器详解

八、非连续内存区管理与vmalloc函数

       伙伴系统页框分配器和slab分配器分配的都是物理连续的内存空间,这样可以充分利用CPU高速缓存获得较低的平均访问时间,对应的内存分配函数是kmalloc。内存访问不频繁,对平均访问时间要求不高的场景下可以用连续的线性地址空间来访问非连续的页框,如为活动的交换区分配数据结构等,通过这种方式充分利用分配连续物理内存产生的外碎片,Linux提供了对应的vmalloc内存分配函数。vmalloc函数首先从内核的动态映射区找到一段满足大小的空闲的连续虚拟地址,如果存在则为每个页逐一分配页框,分配的页框在物理中不一定是连续的,最后修改页表,页目录建立映射关系。注意vmalloc函数只修改了内核页表,并没有修改进程页表,当进程访问vmalloc函数分配的虚拟地址时,触发缺页异常,缺页异常程序判断该虚拟地址在内核页表中,则将对应的记录拷贝到进程页表中,然后正常读取虚拟地址对应的页框。

     kmalloc和vmalloc两个函数的区别联系如下:

  1. kmalloc和vmalloc是分配的是内核使用的内存,即返回的虚拟地址属于内核空间;
  2. kmalloc返回的虚拟地址是直接映射的,32位下减去3G得到物理地址,vmalloc返回的虚拟地址需要经过分页单元转换成物理地址;
  3. kmalloc保证分配的内存在物理上是连续的,kmalloc分配的物理内存处于低端内存(ZONE_DMA、ZONE_NORMAL)区,vmalloc保证的是在虚拟地址空间上的连续,分配的物理内存位于高端内存区;
  4. kmalloc能分配的大小有限,最大不超过128kb,即32个页,vmalloc能分配的大小相对较大;
  5. kmalloc比vmalloc快

     参考:linux内存管理之非连续物理地址分配(vmalloc)

                linux中kmalloc和vmalloc的使用

猜你喜欢

转载自blog.csdn.net/qq_31865983/article/details/88765226