引言

内存管理 java 内存管理单元_jvm

MMU(Memory Management Unit),即内存管理单元,是现代CPU架构中不可或缺的一部分,MMU主要包含以下几个功能:

  • 虚实地址翻译
    在用户访问内存时,将用户访问的虚拟地址翻译为实际的物理地址,以便CPU对实际的物理地址进行访问。
  • 访问权限控制
    可以对一些虚拟地址进行访问权限控制,以便于对用户程序的访问权限和范围进行管理,如代码段一般设置为只读,如果有用户程序对代码段进行写操作,系统会触发异常。
  • 引申的物理内存管理
    对系统的物理内存资源进行管理,为用户程序提供物理内存的申请、释放等操作接口。

使用MMU带来的好处或者优势:

  • 提升物理内存的利用率
  • 物理内存按需申请,如代码段的内存在执行时进行映射和转换,进程fork后,t通过写时复制(Copy-On-Write)进行真正的物理内存分配。
  • 解决内存管理碎片化的问题,即在系统运行一段时间后,频繁的内存申请和释放会导致内存碎片化,无法申请到一块足够大的地址连续的内存。
  • 对内存地址的访问进行控制
    如上述代码段只读权限控制,多线程的栈内存之间的空洞页隔离可以防止栈溢出后改写其他线程的栈内存,不同进程之间的地址隔离等等。
  • 将进程的地址空间隔离
    不同进程之间可以使用相同的虚拟内存地址空间,而进程间的物理内存又可以做到隔离,这保证了进程的独立性同时,又简化了地址的访问方式,如在早期32位CPU上,为了支持4G以上的物理内存,一般物理地址有36-bit(如PowerPC-604系列),但是用户的虚地址仍然使用32-bit,做法就是将用户的不同进程的32-bit虚地址在MMU转换时,转换为36-bit的物理地址,这样每个进程仍然能访问0-3G虚地址范围,将多个进程的3G空间映射到36-bit的物理内存空间中去。

1. MMU的由来

  • Swap模式时代 早期计算机在执行程序时,将程序从磁盘加载到内存执行中执行,在多用户系统中,当新的用户程序被执行前,需要先将当前用户的程序从内存swap到磁盘,然后从磁盘加载新的程序执行,当前用户退出后,在将前一用户程序从磁盘中加载到内存继续执行,每次用户切换伴随程序的swap,消耗较大。
  • 内存管理 java 内存管理单元_网络_02

  • Page模式时代 后来人们将内存划分为固定大小的Page,一般为4K或者更小,这样用户程序按需以Page的方式加载到内存,不需要将整个程序加载到内存,这样内存可同时容纳更多程序,而无需按照用户切换进行swap,提升了内存利用率和加载时间(PageFault是Page时代的产物,而非MMU时代独有)。
  • 内存管理 java 内存管理单元_网络_03

  • 动态地址转换(DAT - Dynamic Address Translation) 最早可以追溯到1966年IBM研发的System/360-Model67,在该计算机的设计中首先引入了动态的地址转换机制,在Page模式基础上,为用户程序分配虚地址(VA),通过DAT转换为物理地址(PA)进行访问。通过好处是用户可以使用连续的地址,而不再受制于物理内存大小和Page碎片化的限制,原则上用户的程序只受磁盘大小限制,代价是增加虚实地址转换机制。
  • 》实现方式
    采用segment、page、offset模式,将24-bit虚拟地址分为3段,0-7bit保留,8-11bit索引segment,一共16个segment,12-19bit索引page,每个segment最多256个page,20-31bit为page offset,每个用户有一个虚实地址映射表,分为2级,即segment表和page表,每个segment指向一个page表。
  • 》转换过程
    当CPU访问一个虚地址时,先通过VA的8-11bit查找segment表得到page表,再根据12-19bit在page表中找到PA的page起始地址,加上20-31bit的page offset就得到实际的PA了。

这就是MMU最早的雏形了。

2. 现代MMU

  • 实地址模式 即CPU状态位中MMU使能位清零,MMU处于关闭状态,此时CPU操作的地址不经过转换(VA=PA),直接作为物理地址进行访问,CPU上电时或者在异常入口时处于该状态,在该状态下可以访问任意物理内存,非常危险,一般操作系统在CPU上电后做完必要初始化以后便使能MMU,或者在异常处理的入口保存好必要信息后使能MMU。
  • 块地址转换 或者成为固定的地址转换或静态配置的地址转换表,这种模式支持配置一些固定的内存地址映射(VPN->RPN),比如Linux Kernel加载的地址,以PowerPC604为例,0xC0000000这段地址开始的256M内存映射使用了该模式的转换,好处是这种配置转换速度快,一般在特定的寄存器中配置,没有页表查找过程,缺点是缺乏灵活性,一次配置永久使用。
  • 页地址转换 类似于早期的动态地址转换DAT,即将VA的一部分bit用于索引segment,另外一部分bit用于索引PTE表,最终得到物理地址的Page起始地址,再加上最后12bit(4K)的Page offset得到真正的物理地址。
    相比早期的DAT,有以下优化:
  • 》 增加了PTE表的缓存TLB(Translation lookaside Buffer),有些处理器将ITLB(指令)和DTLB(数据)分开,以减少指令和数据之间的缓存冲突。
  • 》支持更大的物理地址(36bit以上)或逻辑地址,如在PowerPC中,用以应对现代操作系统的多进程管理将32bit通过segment寄存器扩展为52bit的逻辑地址,然后通过hash函数得到key用来查找PTE,并最终转换为36bit物理地址,逻辑地址的扩展用于减少多进程之间的PTE冲突。
  • 》 支持多种PTE查找方式,如硬件查找和软件查找。

现在CPU的MMU地址转换流程如下:

内存管理 java 内存管理单元_jvm_04

主要分为几个阶段:

  • 用户进程访问虚存地址。
  • 触发TLB查找过程,该部分通过硬件完成(灰色背景),没有软件参与。
  • TLB miss场景下,查找PTE(粉色背景),该部分在不同CPU上实现不同,像X86都是硬件查找,PowerPC有些处理器使用软件查找,即在内核实现一个TLB miss的异常处理,可以灵活做到TLB查找。
  • Do Page Fault,分为几种情况:
  • 》新申请内存第一次读写,触发物理内存分配
  • 》进程fork后子进程写内存触发Copy-On-Write。
  • 》非法内存读写,错误处理。

MMU在CPU的配合下(通过页异常触发),实现了线性地址到物理地址的动态映射,为正在CPU上运行的应用程序(进程)提供了一个独立的连续内存空间(线性地址空间,或称虚拟内存空间,其中放置了代码段、数据段和堆栈段),屏蔽了地址分配、内存分配和内存回收等一系列复杂的系统行为。

MMU的线性地址转换是通过页表进行的,具体过程如下图所示(摘自intel程序员手册卷3):

内存管理 java 内存管理单元_jvm_05


其实最简单明了的方法是通过一个一维数组来记录映射关系:下标代表线性地址,数组元素内容代表物理地址。可是如此一来,用来表示映射关系的内存空间比被表示的物理空间还要大,显然这不是一个可行的方案。

工程师们采用了分段分级的思路来表示这种映射关系:先把线性空间以4K大小为单位进行划分(页),然后再以大段连续空间进行转换,在每个大段空间内部再次划分成小段进行转换,直到段大小变为4K页大小。用以表示和段空间映射关系的结构称为页表,其大小也是一个页面。由于采用了分段的方法,页表空间大大减小;同时未映射的空间不必分配页表,这也进一步降低了页表占用空间。

x86_64架构下Linux用了四级页表来表示一个映射关系,依次为PGD、PUD、PMD、PT。每级页表4K大小,内部元素大小为8字节,高位指向了下一级页表的物理地址,低位表示页表属性(是否存在、读写权限、是否脏等等)。顶层页表PGD的物理地址存放在CPU的CR3寄存器中,供MMU访问。48位线性地址也相应地分成了五段:前四段,每段长9位,用来索引对应页表的元素;最后一段长12位,用来在页面中索引物理地址。各级页表的详细内容参考下表:

  

内存管理 java 内存管理单元_页表_06

3. 页表转换过程

Linux系统中每个进程对应用户空间的pgd是不一样的,但是linux内核 的pgd是一样的。当创建一个新的进程时,都要为新进程创建一个新的页面目录PGD,并从内核的页面目录swapper_pg_dir中复制内核区间页面目录项至新建进程页面目录PGD的相应位置,具体过程如下:

do_fork() 
  -> copy_mm() 
    -> mm_init() 
      -> pgd_alloc() 
        -> set_pgd_fast() 
          -> get_pgd_slow() 
            -> memcpy(&PGD + USER_PTRS_PER_PGD, swapper_pg_dir +USER_PTRS_PER_PGD, (PTRS_PER_PGD - USER_PTRS_PER_PGD) * sizeof(pgd_t))

这样一来,每个进程的页面目录就分成了两部分,第一部分为“用户空间”,用来映射其整个进程空间(0x0000 0000-0xBFFF FFFF)即3G字节的虚拟地址;第二部分为“系统空间”,用来映射(0xC000 0000-0xFFFF FFFF)1G字节的虚拟地址。可以看出Linux系统中每个进程的页面目录的第二部分是相同的,所以从进程的角度来看,每个进程有4G字节的虚拟空间,较低的3G字节是自己的用户空间,最高的1G字节则为与所有进程以及内核共享的系统空间。每个进程有它自己的PGD( Page Global Directory),它是一个物理页,并包含一个pgd_t数组。

  • PTE: 页表项(page table entry)
  • PGD(Page Global Directory)
  • PUD(Page Upper Directory)
  • PMD(Page Middle Directory)
  • PT(Page Table)

PGD中包含若干PUD的地址,PUD中包含若干PMD的地址,PMD中又包含若干PT的地址。每一个页表项指向一个页框,页框就是真正的物理内存页。

4. TLB

Translation lookaside buffer,即旁路转换缓冲,或称为页表缓冲;里面存放的是一些页表文件(虚拟地址到物理地址的转换表)。又称为快表技术。如果匹配到逻辑地址就可以迅速找到页框号(页框号可以理解为页表项),通过页框号与逻辑地址的后12位的偏移自合得到最终的物理地址。如果没在TLB中匹配到逻辑地址,就出现TLB不命中(TLB Misss),需要通过常规的页表查询。如果TLB足够大,那么这个转换就会变得迅速。但是事实是TLB的容量非常小,一般都是几十项到几百项不等。

在有些的处理器架构中,为了提供效率,还将TLB进行分组,以X86架构为例,一般都分为以下四个组:

第一组:缓存一般页表(4KB页面)的指令页表缓存(Instruction-TLB)
   第二组:缓存一般页表(4KB页面)的数据页表缓存(Data-TLB)
   第三组:缓存大尺寸页表(2MB/4M页B面)的指令页表缓存(Instruction-TLB)    
   第四组:缓存大尺寸页表(2MB/4MB页面)的数据页表缓存(Data-TLB)

举一个例子:假设一个应用程序需要使用8KB的物理内存,如果使用常规页(4KB)并且使TLB总能命中,那么至少需要在TLB表中存放两个表项,在这种情况下,只要寻址的内容都在该内容页内,那么只要两个表项就足够了。如果该应用程序需要使用512个内容页也就是2MB大小,那么需要512个页表表项才能保证不会出现TLB不命中的情况。但是TLB容量有限,随着程序的变大或者使用内存的增加,那么势必会增加TLB的使用项,最后导致TLB出现不命中的情况。此时,大页的优势就显示出来了,如果使用2MB作为分页的基本单位,那么只需要一个页表项就可以保证不出现TLB不命中的情况;对于消耗内存以GB(2^30)为单位的大型程序,可以采用1GB为单位作为分页的基本单位,减少TLB不命中的情况。