其实现在计算机内存管理的方式都是一步步演变过来的,最开始是非常简单的,后来为了满足各种需求而增加了各种各样的机制,越来越复杂。这里我们只介绍和开发者息息相关的一些机制。

最原始的方式

我们可以吧内存看成一个数组,每个数组元素的大小是1B,也就是8位。CPU通过内存地址来获取内存中的数据,内存地址可以看做成数组的游标(index)。
image.png
CPU在执行指令的时候,就是通过内存地址,将物理内存上的数据载入到寄存器,然后执行机器指令。但是随着时代的发展,出现了多任务的需求,也就是多个任务能同时在系统上运行,这就出现了一些问题:

  1. 内存的访问冲突;程序很容易出现bug, 就是多个程序使用了同一块内存空间,导致数据读写错乱,程序崩溃。更有些黑客利用这个缺陷来制作病毒
  2. 内存不够用:因为每个程序都需要自己单独使用一块内存,内存的大小就成了任务数量的瓶颈
  3. 程序开发成本高:你的程序要使用多少内存,内存地址是多少,这些都不能搞错。对于开发者来说,写出正确的程序很费脑子

举个例子,假如有一个程序,当代码运行到某处时,需要使用100M内存,其他时候1M内存就足够了。为了避免和其他程序冲突,程序初始化时,就必须申请100M内存来保证正常的运行。这是一种极大的浪费,因为100M在大多时候用不上,其他程序还不能用。

虚拟内存

虚拟内存的出现,很好地解决了上述的一系列问题。用户程序只能使用虚拟的内存地址来获取数据,系统会将这个虚拟地址翻译成为实际的物理地址。

所有程序统一使用一套连续虚拟地址,比如0x0000 - 0xffff,从程序的角度来看,它觉得自己独享了一整块内存,不用考虑访问冲突的问题。系统会讲虚拟地址翻译成为物理地址,从内存上加载数据。

对于内存不够用的问题,虚拟内存本质上就是将磁盘当成最终存储。而主存作为一个cache, 程序可以从虚拟内存上申请很大的空间使用,比如1G。但是操作系统不会真的在物理内存上开辟1G的空间,它只是开辟了很小的一块,比如1M给程序使用。这样在程序访问内存的时候,操作系统看访问的地址是否能转换成物理内存,能则正常访问,不能则再开辟。这使得内存得到了更高效的利用。

如下图所示,每个进程所使用的虚拟地址空间都是一样的,但是他们的虚拟地址会被映射到主存的不同区域上,甚至映射到磁盘上(当内存不够用的时候)。

  1. ![image.png](https://cdn.nlark.com/yuque/0/2021/png/2332713/1616030645702-05f46916-748c-46f9-a369-4b474cb3b1f0.png#height=246&id=sZLvd&margin=%5Bobject%20Object%5D&name=image.png&originHeight=409&originWidth=664&originalType=binary&size=75427&status=done&style=none&width=400)

其实本质上很简单,就是操作系统将程序的常用数据放到内存里面加速,不常用的数据放在磁盘上。这一切对用户来说完全是透明的,用户可以假装所有数据都在内存里面,然后通过虚拟内存去访问数据。在这背后,操作系统会自动将数据在主存和磁盘之间进行交换。

虚拟地址翻译

虚拟内存的实现方式,大多数都是通过页来实现的,操作系统虚拟内存空间分成一页一页来管理,每页的大小为4K(这是可以配置的,不同的操作系统不一样) 。磁盘和主内存之间的置换也是以页为单位的来操作的。4K算是通过实践折中得出来的通用值,太小了会出现频繁的置换,太大了又浪费内存。

虚拟内存 -> 物理内存的映射关系由页表(Page Table)记录,它其实就是一个数组,数组中每个元素叫做页表条目(Page Table Entry, 简称PTE)。PTE由一个有效位和n位地址字段构成,有效位标识这个虚拟地址是否分配了物理内存。

页表被操作系统放在物理内存的指定位置,CPU上有个memory managerment unit (MMU)单元,CPU把虚拟内存给到MMU, MMU去物理内存中查询页表,得到物理内存。当然,MMU不会每次都去查询,它自己也有一份缓存叫Tanslation Lookaside Buffer(TLB),是为了加速地址翻译。

  1. ![image.png](https://cdn.nlark.com/yuque/0/2021/png/2332713/1616077466390-ada37e6b-fb02-4cc6-9255-8b625d817e18.png#height=238&id=bZqz9&margin=%5Bobject%20Object%5D&name=image.png&originHeight=420&originWidth=795&originalType=binary&size=43637&status=done&style=none&width=450)

备注:其实你会慢慢发现,整个计算机体系里面,缓存是无处不在的,整个计算机体系就是建立在一级级缓存之上,无论软硬件。

让我们来看一下CPU内存访问的完整过程:

  1. CPU使用虚拟地址访问数据,比如执行了MOV指令加载数据到寄存器,把地址传递给MMU
  2. MMU生成PTE地址,并从主存(或者自己的Cache)中得到它
  3. 如果PTE信息表示没有关联的物理地址,MMU则触发一个缺页异常
  4. 操作系统捕获到这个异常,开始执行异常处理程序,在物理内存上创建一页内存,并更新页表
  5. 缺页处理程序在物理内存中确定一个牺牲页,如果这个牺牲页上有数据,则把数据保存在磁盘上
  6. 缺页处理程序更新PTE
  7. 缺页处理程序结束,再回去执行上一条指令(导致缺页异常的那个指令,也就是MOV指令),这次肯定会命中

内存命中率

你可能会发现,上述的访问步骤中,从第4步开始都是些比较繁琐的操作,频繁的执行对性能的影响很大。毕竟访问磁盘是非常慢的,它会引发程序性能急剧下降。如果内存访问到第三步成功结束了,我们就说是页命中了,反之则是缺页,表示它开始执行第4步了。

假设在n次内存访问中,出现命中的次数是m, 那么m/n * 100%就表示命中率,这是衡量内存管理程序好坏的一个很重要的指标。

如果物理内存不足了,数据在主存和磁盘之间频繁交换,命中率很低,性能出现急剧下降,我们称这种现象为内存颠簸。这时你会发现系统的swap空间利用率开始增高,CPU利用率中iowait占比开始增高。

大多数情况下,只要物理内存够用,页命中率不会非常低,不会出现内存颠簸的情况,因为大多数程序都有一个特点,就是局部性。

局部性就是说被引用过一次的存储器位置,很有可能在后续被多次引用;而且在该位置附近的其他位置,也很有可能会在后续一段时间内被引用。

前面还说过计算机到处使用一级级的缓存来提高性能,归根结底就是利用了局部性的特征,如果没有这个特征,一级级的缓存不会有这么大的作用,所以一个局部性很好的程序运行速度会更快。

CPU Cache

随着技术的发展,CPU的运算速度越来越快,但是内存访问的速度却一直没什么突破,最终导致了CPU访问主存成了整个机器的性能瓶颈。CPU Cache的出现就是为了解决这个问题。在CPU和主存之间再加上Cache, 用来缓存一块内存中的数据。现代计算机一般都有3级Cache, 其中L1 Cache的反问速度和寄存器差不多。

现在访问数据的大致顺序是CPU -> L1 Cache -> L2 Cache -> L3 Cache -> 主存 -> 磁盘。从左到右,访问速度越来越慢,空间越来越大,单位空间(比如每字节)的价格越来越低。

现在存储器的整体层次结构大致如下图:

  1. ![image.png](https://cdn.nlark.com/yuque/0/2021/png/2332713/1616080147693-f4163a5d-0e69-4388-9d4b-c608f0382a36.png#height=316&id=aXUOT&margin=%5Bobject%20Object%5D&name=image.png&originHeight=553&originWidth=700&originalType=binary&size=51040&status=done&style=none&width=400)<br />在这种架构下,缓存的命中率就更加重要了,因为系统会假定所有程序都是局部性特征的,如果某一级出现了未命中,他就会该级存储的数据更新最近使用的数据。主存和存储器之间以page(通常是4k)为单位进行交换,cache与主存之间是以cache line(通常是64 bytes)为单位交换的。

举个例子(如何做benchmark测试)

让我们通过一个例子来验证一下命中率的问题,下面的函数是循环一个数组,然后为每个元素赋值:

  1. func Loop(nums []int, step int) {
  2. l := len(nums)
  3. for i := 0; i < step; i++ {
  4. for j := i; j < l; j += step {
  5. nums[j] = 4
  6. }
  7. }
  8. }

当参数为step为1时,和普通一层循环一样,假设step为2,则效果就是跳跃式遍历数组,如1,3,5,7,9,2,4,6,8,10。这样,step越大,访问的跨度也就越大,程序的局部性也就越不好。

下面nums长度为10000,step = 1和step = 16时的压测结果:

  1. goos: darwin
  2. goarch: amd64
  3. BenchmarkLoopStep1-4 300000 5241 ns/op
  4. BenchmarkLoopStep16-4 100000 22670 ns/op

可以看出,这两种遍历方式会出现3倍的性能差距,这种问题最容易出现在多维数组的处理上,比如遍历一个二维数组很容易就写出局部性很差的代码。

程序的内存布局

最后看一下程序的内存布局,现在我们知道了每个程序都有自己一套独立的地址空间可以使用,比如0x0000~0xffff,但我们在用高级语言,无论是c还是go写程序的时候,很少直接使用这些地址,我们都是通过变量名来访问数据的,编译器会自动将我们的变量名转换为真正的虚拟地址。

那最终编译出来的二进制文件,是如何被操作系统加载到内存并执行的呢?

其实,操作系统已经将一整块内存划分好了区域,每个区域用来做不同的事情,如图:
image.png

  • text段:存储程序的二进制指令,以及其他的一些静态内容
  • data段:用来存储已经被初始化的全局变量,比如说常量
  • bss段:用来存放未被初始化的全局变量,和data段一样都属于静态分配,在这里的变量数据在编译就确定了大小,不释放
  • stack 段:栈空间,主要用于函数调用时存储临时变量的,这部分的内存是自动分配自动释放的
  • heap 段:堆空间,用于动态分配,c语言中的malloc和free操作的内存就在这里,go语言只要靠gc自动管理这部分

其实现在的操作系统,进程内部的内存区域没有这么简单,要比这复杂多了,比如内核区域,共享库区域。因为我们不是要真的开发一套操作系统,细节可以忽略。这里只是需要记住堆空间和栈空间即可。

  • 堆空间是通过压栈出栈的方式自动分配释放的,由系统管理,使用起来高效无感知
  • 堆空间是用来动态分配的,由程序自己管理和释放。Go语言虽然可以帮我们自动管理分配和释放,但是代价也是很高的

总结

  • 页表就是一个映射关系表,是一个数组。虚拟内存空间是使用页来实现的,但是映射关系为页表
  • Go 语言的内存管理是参考 tcmalloc 实现的,它其实就是利用好了 OS 管理内存的这些特点,来最大化内存分配性能的

参考

Go 语言内存管理(一):系统内存管理