上次我们了解了最靠近CPU寄存器的缓存——高速缓存。
这次我们再来了解下操作系统对于主内存的映射管理和JVM虚拟机与之相关的一些知识,这就是大名鼎鼎的虚拟存储器。
什么是虚拟存储器
当CPU寄存器请求一个地址的数据时,会依次访问寄存器,高速缓存,直到主内存,磁盘等其他IO设备。而和高速缓存一样,主内存是被操作系统中的多个进程所共享的。当然操作系统为了安全性,核心内存和多个进程之间的内存是相互独立开的。
这样为了更有效的管理存储器,现代操作系统对于主内存上建立了一种抽象的概念,就是虚拟存储器(VM)。VM为每个进程提供了一个大的,完整的,私有的地址空间,映射到了物理存储之上(包括主内存和作为交换分区的部分磁盘存储)。它主要提供了三个能力:
- 它将主存看为磁盘空间上的地址空间的高速缓存,并通过良好的过期机制保证良好的局部性
- 为每个进程提供了一致的地址空间,屏蔽了实际的物理地址
- 保护每个进程的地址空间不被其他进程所破坏
虚拟存储器的好处
- 简化链接 VM使每个进程都可以认为自己访问一致的全部的地址空间,大大简化了编程复杂度,并且屏蔽了不同进程间的内存,提升安全性。
- 扩展物理内存 VM可以是实际物理内存的许多倍,因为虽然操作系统可以同时运行很多进程,但同时活动的进程确实有限的。这样VM提供自动化的内存片段的失效和加载能力,提升了系统负载。
- 简化存储器分配 对于每个进程来说,所能访问的地址空间都是连续的,但实际上主内存和物理内存上的分片却可以是零散的,所有的映射和分配工作都由虚拟存储器屏蔽掉了。
虚拟存储器的结构
32位操作系统最大支持2^32 的地址空间也就是4GB,而64位操作系统最大支持2^64 的地址空间,也就是理论上17179869184GB的地址空间。
当然,一般物理内存是有限的,而虚拟存储器却可以映射更多的地址空间。
计算机系统在实际的物理存储器和磁盘存储器之上,建立了一层映射,叫做PTE(page table entry)页表条目。PTE就像一个索引表,将每个虚拟地址,映射到相应的物理存储器或者磁盘存储器之上。计算机系统并不是按照每个地址所对应的字(4字节)来管理内存的,而是将其划分为不同的页page来管理,一个页一般来说在4KB~2MB。而因为主内存和磁盘直接巨大的性能差距,所以当数据更新时,都采取写回的方式而不是直写。
以下是PTE和页表的关系结构:
其中有效位代表是否在主内存之中,如果存在直接访问主内存中的地址,如果不存在则需要访问磁盘,并将其刷新至主内存之中。
CPU中都有专门的MMU模块来访问PTE,将虚拟地址转化为物理地址进行访问。
而我们可以设想这种访问量是非常巨大的,所以PTE的加载和访问性能就特别重要,为此,CPU又建立专门的TLB(Translation Lookaside Buffer)翻译后备缓冲器来进行PTE的高速缓存,其作用和原理于L1,L2高速缓存基本类似,如下是一个用来访问TLB的虚拟地址的组成部分,用来在TLB高速缓存中查询:
image.png
我们再来假设如果只有一个PTE索引的话,那么就算我们只有32位的地址空间,4KB的页和4字节的PTE,那么也总是需要4MB的页表保留在存储器中的,而有可能其中的大部分都是空。而如果是64位系统的话,那么将花费非常大的空间来进行存储。
为了解决这个问题,需要建立多级页表,例如第一级页表每个PTE映射4MB的片,每个片又由1024个4KB的片组成,从而映射到二级页表,再最终映射到物理存储器。这样当第一级页表为空时,二级页表中的1024个片就完全不用存在,从而节约了大量的缓存空间:
image.png
需要注意的是,每个进程都享有独立的PTE,这样就做到了每个进程都可以访问一致的,完整的,连续的地址空间并能够相互隔离。
而对于实际的Linux操作系统来说,页表的层级更多:32bit的Linux采用三级映射:PGD-->PMD-->PTE,64bit的Linux采用四级映射:PGD-->PUD-->PMD-->PTE,多了个PUD(因为64位管理的内存地址更多):
image.png
同时Linux也为每个进程分配独立的虚拟空间地址task_struct:
image.png
JVM虚拟机对于虚拟存储器的使用
好了,我们了解了有关于虚拟存储器的大概知识后再来看看其与之JVM虚拟机之间的关系。
首先和其他所以运行在操作系统中的进程一样,JVM也是通过虚拟内存来进行内存的访问的,而其中的页的切换命中过期等操作,也都是完全由操作系统屏蔽掉的,是我们基本不用关心的部分。
但就算如此,因为JVM中其实也是有着大量关于虚拟机内存的管理逻辑,所以在其垃圾回收部分,也巧妙的利用了虚拟内存的特性,有效的提升了性能。
我们设想一个64位的操作系统,能够最大管理17179869184GB的地址空间,但实际上绝大部分时候,我们是利用不到这么多的地址的。所以在最新的ZGC垃圾回收期中,JVM使用了染色指针技术,也就是将64位地址的高18位用来做对象的特殊标记,而剩下的46位作为内存地址。这样ZGC所能管理的最大内存就不能超过4TB了,但对于我们来说也是远远够用的。
那么ZGC用18位的标记来做什么呢?我们先来简单了解一下垃圾回收的知识。
JVM采用根遍历的方式来做垃圾回收,也就是找到若干的GCRoots,再循环遍历所有的关联的对象图谱,将其中没有和GCRoots关联到的对象进行回收。而又可以分为标记清除,标记复制,标记整理三类回收算法。
这里不对JVM的垃圾回收再展开讨论,只是需要注意的是,不管在标记的过程中,还是JVM运行的过程中,为了最终成功的进行回收,除了对象本身外,都还要记录例如对象的分代年龄,引用标记,是否被移动等一系列额外信息。在除了ZGC的垃圾回收方式外,其他例如CMS,G1和Serial都等是利用了对象本身的Markword对象头或者索引表来记录这些信息的。这样就产生了如下缺点:
- JVM中需要使用20%~20%的额外空间来存储引用标记和索引信息
- 对象修改时要建立额外的写屏障来更新维护标记和索引信息,更多的指令降低性能
- 访问对象时可能产生额外的路由信息(例如对象在不同的块中被转移)而产生多次寻指访问操作
而为了解决这些问题ZGC将关于对象的标记直接存储在其虚拟内存地址上,这样在Linux操作系统的多重映射技术支持下,可以将多个带有标记的虚拟内存,截断标志位后再映射到同一段物理地址上,同时截断的部分也能快速读出对象的标记信息,从而大大降低了内存的使用以及提升了访问效率。
参考资料:
《深入理解计算机系统》
《深入理解JAVA虚拟机》
《Linux服务器性能调整》
网友评论