我们带着问题去探讨new之后的底层原理.
我们对此代码解读。
- new之后的流程是什么
- 内存如何被读写
- 地址如何产生
- 地址如何表示
1.吐槽
不得不吐槽一下,简书的markdown真抠脚,竟然没法画流程图。
2.各种概念
2.1.什么是虚拟内存(virtual memory)
在我看来,虚拟内存更像是对内存的管理,请看下图:
每一个进程的虚拟地址都是从0开始,包含放置代码区,堆,栈,和内核映射区。在这里的地址都是连续的,会被分成很多虚拟页,每一个虚拟页为4K大小,页会映射到物理内存,物理内存的地址不会重复,当一个进程开始执行或者请求堆内存,会在物理内存寻找空余的物理页(也是一页4K大小)存放。虚拟内存保证了每个进程的地址独立,不会被其他进程的访问到。
引进虚拟内存这个概念后,会使得访问物理内存变得慢一些了,因为会有两次寻址,先去页表找物理地址,找到物理地址后才能去物理内存上读取数据。虚拟内存这个概念,也算是不错的设计吧。每个进程的地址空间都是独立的,这使得每个进程地址都是相同的基本格式(进程虚拟地址起始为0,堆,栈也是固定的起始地址),不需要知道真正的物理内存放在什么地方。由于我们编写的程序没有权限直接访问硬件,这其中的访问物理内存,会从用户态到内核态的转换,分配好内存后再从内核态转换为用户态。系统不信任我们这群坑货,才如此设计的,流程大概是这样,用户态请求内存分配 -> 系统中断 -> 内核态分配好内存 -> 系统中断 -> 返回用户态 :
2.2.什么是虚拟页(virtual page)
虚拟内存由虚拟页构成,每个虚拟页4K大小。这一摞概念都是抽象的,在脑子里联想吧,你可以把虚拟内存联想成一把梯子,梯级之间的空间是虚拟页大小。
2.3.什么是物理页(physical page)
物理页也被称为页帧(page frame),4K大小,和虚拟页类似,只不过物理页是构成物理内存的,你把它想象成另一把梯子和梯级吧。物理页会产生内部内存碎片,但不会产生外部内存碎片,这与分段相反(还有一个种内存划分是分段的,产生外部内存碎片,但不产生内部内存碎片),这种分页的方式,类似内存对齐,关于内存对齐的理解,我写了另外一篇文章。总的来说,物理页构成了RAM(Random Access Memory,包含主存Main Memory等)。
2.4.什么是页表(page table)
页表存在RAM。操作系统为每个进程提供一个页表,多个进程对应多个页表。页表的作用是干翻译这行的,虚拟内存的地址能够找到物理内存的地址,就是通过页表映射的,页表保存了物理地址。
2.5.什么是快表(TLB,Translation Lookaside Buffer)和cpu高速缓存
毕竟页表是存在RAM,并且比较大,查表慢,有损性能,快表就是为了解决这个问题的。快表可以看作是页表的一个缓存,当CPU访问某一块物理内存后,把物理页存到CPU高速缓存,物理地址存到TLB。每次CPU提着虚拟地址来查表,先去TLB看看能不能找到对应的物理地址,如果找到了再去缓存找数据。如果在TLB找不到(称为TLB不命中,奇葩的命名),再去页表查找。快表本身也存在cpu缓存中,内存占用小访问速度快。unity的ECS运行速度快的其中一个原因,ECS把数据都放在一起了,当CPU访问其中一项数据,会把4K的一个物理页都存到CPU缓存中,下次使用邻近的数据时候,很可能已经被存到CPU缓存中了,所以访问速度快一些。因此,我们写代码时候,可以考虑把数据写在一起,或者函数之间调用挨着的。
2.6.VPO,VPN,PPO,PPN
- VPN :虚拟页号(Virtual Page Number ),一页通常是4K,VPN表示在虚拟内存的第几页。
- VPO :虚拟页偏移(Virtual Page Offset),虚拟内存的页内偏移,一页中的第几个字节。
- PPN :物理页号(Physical Page Number),一页通常是4K,VPN表示在物理内存的第几页。
- PPO :物理页偏移(Physical Page Offset),物理内存的页内偏移,一页中的第几个字节。
当我们要访问某一块内存时候,会使用虚拟页号(VPN)去页表找到真实的物理页号(PPN),通过虚拟页偏移(VPO)找到真实的物理地址。这里的寻址,为什么会与PPN,VPO有关呢?一个物理地址,实际上是物理页号+偏移值构成的,一个物理页占4K大小(虚拟页也是4K),页内偏移是指在这物理页号之后位移多少字节。我们用二维数组来比喻吧 ,每一行有4K列,现在要找9K,它在2行1K列(相当于它在2物理页号1K页内偏移)。请看地址计算公式,其中2^s表示一页占用的内存大小(一般为4K):
为什么此处使用VPO而非PPO,其实都一样的,页内偏移相等,VPO=PPO。
3.对整体流程的讲解
铺垫完概念,下面开始讨论一下上述代码的执行流程。
- 应用程序编译成指令,编译时候,每块数据都有相对地址了。
- 程序开始执行,代码被加载到主存中。
- 指令被送至cpu的指令寄存器。
- cpu从指令寄存器取出指令,执行new int(0);代码。
- 在主存中分配好内存,并且映射到虚拟内存的堆中,物理地址和虚拟地址在页表中关联。
- 与此同时,把p所在的物理页缓存到CPU高速缓存中,地址放进TLB。
- int* p = new int(0);返回p的虚拟地址。
- 读p的值。先去TLB查看是否存在p的地址,如果存在,去高速缓存查看值在不在。
- 如果TLB不存在对应的地址,去页表找。
有几点要说明的。
- 当我们要访问p地址对应的内存时候,操作系统会检查地址是否在虚拟内存的当前进程中,操作系统不允许访问其他进程的私有内存。
- 存在多级页表。
- 页表有操作系统维护,分配内存时填写响应的页表项,释放内存清除响应的页表项,程序退出释放它的页表。
- 有一种特殊情况,高速缓存和主存都没有找到,那么此时说明物理页在磁盘中。一般这种情况是爆内存了,主存不足以存放得下,那么把一部分不常用的内存放进磁盘。由于磁盘读写比较慢的原因,导致访问会卡顿。举个例子,当我们运行一个占用内存比较大的软件,内存占用为100%了,但是软件却不会闪退,原因是有一部分内存被移动到磁盘上了,把磁盘当成了主存的一部分,虚拟内存会映射到磁盘的扇区地址,去磁盘读写时候就显得比较慢了。本人曾经做过一个测试,在unity中不断加载资源到场景中,并且引用这些资源不给GC释放,观察内存占用情况,先是内存被占用为100%,然后发现E盘的可用大小变得越来越小。
老实说,学这些对你的代码能力没多大帮助,仅仅只是个人想了解一下而已。
Author : SunnyDecember
Date : 2019.11.9
原文