• 要搞明白 Go 语言的内存管理
  • 就必须先理解
    • 操作系统以及机器硬件是如何管理内存的
  • 因为 Go 语言的内部机制是建立在这个基础之上的
  • 它的设计
    • 本质上就是尽可能的会发挥操作系统层面的优势
  • 而避开导致低效情况

一、何为内存?

  • 说到内存
    • 如果您没有任何的软件基础知识
    • 那么第一印象应该想到的是如下这个东西:

在这里插入图片描述

  • 这个是“内存条”

    • 是计算机硬件组成的一个部分
    • 也是真正给我们提供“物理内存”的空间
    • 如果你的计算机没有这个条条
    • 那么根本谈不上有“内存”之说
  • 那么“内存”的作用在于什么呢

    • 我们可以将计算机的存储媒介中的处理性能容量做一个对比
    • 会出现如下的金字塔模型:

在这里插入图片描述

  • 可以看出来

    • 处理速度与存储容量是成反比的
  • 那么也就是说

    • 性能越大的计算机硬件
    • 他的合理的利用和分配就越重要
  • 我们只重点看

    • 内存与硬盘的对比
    • 因为硬盘的容量是非常廉价的
    • 但是内存目前也可以用到10G级别的使用
    • 但是从处理速度来看的话:
  • DDR3内存读写速度大概10G每秒(10000M)
  • 固态硬盘速度是300M每秒,是内存的三十分之一
  • 机械硬盘的速度是100M每秒,是内存的百分之一
  • DDR4内存读写速度大概50G每秒(50000M)
  • 固态硬盘速度是300M每秒,是内存的二百分之一
  • 机械硬盘的速度是100M每秒,是内存的五百分之一
变量全局变量函数跳转地址静态库执行代码临时开辟的内存结构体(对象)

二、内存为什么需要管理?

  • 当我们希望存储的东西越来越多

    • 也就发现物理内存的容量依然是不够用
    • 那么对物理内存的利用率和合理的分配
    • 管理就变得非常的重要
  • 首先操作系统就会对内存进行非常详细的管理

  • 其次基于操作系统的基础上

    • 不同语言的内存管理机制也应运而生
    • 但是
      • 有的一些语言并没有提供自动的内存管理模式
      • 有的语言就已经提供了自身程序的内存管理模式
内存自动管理的语言(部分)非自动管理的语言(部分)
GolangC
JavaC++
PythonRust
  • 所以为了降低内存管理的难度

    • 像C、C++完全将分配和回收内存的权限交给开发者
    • 而Rust则是通过生命周期限定开发者对非法权限内存的访问来自动回收
      • 因而并没有提供自动管理的一套机制
    • 但是像Golang、Java、Python这类为了完全让开发则关注代码逻辑本身
      • 语言层提供了一套管理模式
  • 回到我们的主题

    • 既然Golang给开发者提供了一套内存管理模式
    • 我们现在就应该看看他究竟帮助我们做了哪些好事?
    • 先不要着急
  • 因为在我们理解Golang语言层内存管理之前

  • 我们得先了解 操作系统针对物理内存做了哪些管理的方式

  • 那么我们接下来需要理解一下

    • 当我们插上内存条之后
    • 通过操作系统是如何将我们的软件数据
    • 最终存放在这个绿色的条子中去呢?

三、操作系统是如何管理内存的?

  • 刚才我们说了

    • 我们计算机对于内存真正的载体是“内存条”
    • 这个是实打实的物理硬件容量
  • 所以

  • 在操作系统中

    • 我们定义这部分的容量叫“物理内存”
  • 物理内存的布局实际上就是一个内存大“数组”

在这里插入图片描述

  • 每一个元素都会对应一个地址

    • 这个我们称之为物理内存地址
  • 那么cpu在运算的过程中

    • 如果需要从内存中取1个字节的数据
    • 就需要记住这个数据的物理内存地址就好了
    • 而且物理内存地址是连续的
    • 可以根据一个基准地址进行偏移来取得相应的一块连续内存数据
  • 但我们知道

  • 我们的一个操作系统是不可能只运行一个程序的

    • 那么这个“大数组”物理内存势必要被n个程序分成N分
      • 供每个程序使用
    • 但是程序是“活”的
      • 他可能一会需要1G内存
      • 一会需要1MB内存
    • 我们只能取这个程序允许的最大内存极限来分配内存给这个进程
    • 那么很显然
    • 每个进程都会多要去一大部分内存
      • 却不常使用
  • 但如果N个程序同时使用同一块内存

    • 那么读写的冲突也在所难免
  • 这些昂贵的内存条

    • 几乎跑不了几个程序
    • 内存的利用率也提高不上来
  • 所以就需要所谓的操作系统的内存管理方式

    • 他就是“虚拟内存

3.1 虚拟内存

  • 虚拟,当然就是“假", "凭空而造”的大致意思
  • 对比上个图
  • 你可以大致理解为虚拟内存的表现方式如下:

在这里插入图片描述

0x 0000 0000 ~ 0x ffff ffff

在这里插入图片描述

  • 如果一个内存几乎大量都是被读取的

    • 那么可能会多个进程共享同一块物理内存
    • 但是他们的各自虚拟内存是不同的
      • 当然这个共享并不是永久的
      • 当其中有一个进程对这个内存发生写
      • 就会复制一份
      • 执行写操作的进程就会
        • 将虚拟内存地址映射到新的物理内存地址上
  • 对于第3点

    • 就是虚拟内存为了最大化利用物理内存
    • 但是如果进程使用的内存足够大
      • 导致物理内存短暂的“供不应求”
      • 那么虚拟内存也会“开疆拓土”
      • 从磁盘(硬盘)上虚拟出一定量的空间
      • 挂在虚拟地址上
      • 当然
        • 这个偷摸的动作
        • 进程本身是不知道的
        • 因为进程只能够看见自己的虚拟内存空间
          在这里插入图片描述

3.2 MMU内存管理单元

  • 那么对于虚拟内存地址是如何映射到物理内存地址上的呢?
  • 难道就是硬代码写死的吗?
  • 这样会不会出现
    • 很多虚拟内存打到同一个物理内存上
    • 然后发现被占用再重新打
  • 这样貌似对映射的寻址的代价有些大
  • 所以操作系统又加了一层专门用来管理虚拟内存和物理内存映射关系的东西
    • 就是MMU(Memory Management Unit)
      在这里插入图片描述

MMU是在CPU里的

​ 或者说是CPU具有一个内存管理单元MMU

3.2.1 虚拟内存本身怎么存放
"页表(Page Table)"“页”“页表”

:
实际上就是操作系统的一个用来描述"内存大小"的一个单位名称

我们称为一个"页"的含义是 大小为"4K(1024*4=4096字节)"的内存空间

操作系统对虚拟内存空间是按照这个单位来管理的

页表:
页表实际上就是"页"的集合

就是基于"页"的一个数组

但是页只是表示内存的大小

而**页表条目(PTE)**才是页表数组中的一个元素

4K4K虚拟地址 -> 物理地址

img

虚拟地址翻译

你慢慢会发现整个计算机体系里面

缓存是无处不在的

整个计算机体系就是建立在一级级的缓存之上的

无论软硬件

  • 让我们来看一下 CPU 内存访问的完整过程:
    • CPU 使用虚拟地址访问数据
      • 比如执行了 MOV 指令加载数据到寄存器
      • 把地址传递给 MMU
    • MMU 生成 PTE 地址
      • 并从主存(或自己的 Cache)中得到它
    • 如果 MMU 根据 PTE 得到真实的物理地址
      • 正常读取数据
      • 流程到此结束
    • 如果 PTE 信息表示没有关联的物理地址
      • MMU 则触发一个缺页异常
    • 操作系统捕获到这个异常
      • 开始执行异常处理程序
      • 在物理内存上
        • 创建一页内存
        • 并更新页表
    • 缺页处理程序在物理内存中确定一个牺牲页
      • 如果这个牺牲页上有数据
      • 则把数据保存到磁盘上
    • 缺页处理程序更新 PTE
    • 缺页处理程序结束
      • 再回去执行上一条指令(导致缺页异常的那个指令,也就是 MOV 指令)
      • 这次肯定命中了
内存命中率
m / n * 100%iowait
CPU Cache
CPU --> L1 Cache --> L2 Cache --> L3 Cache --> 主存 --> 磁盘

img

  • 存储器层次结构

  • 在这种架构下

    • 缓存的命中率就更加重要了
    • 因为系统会假定所有程序都是有局部性特征的
    • 如果某一级出现了未命中
    • 他就会将该级存储的数据更新成最近使用的数据
  • 主存与存储器之间以 page(通常是 4K) 为单位进行交换

  • cache 与 主存之间是以 cache line(通常 64 byte) 为单位交换的

举个例子

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

func Loop(nums []int, step int) {
	l := len(nums)
	for i := 0; i < step; i ++ {
		for j := i; j < l; j += step {
			nums[j] = 4
		}
	}
}
1,3,5,7,9,2,4,6,8,1010000step = 1step = 16
goos: darwin
goarch: amd64
BenchmarkLoopStep1-4              300000              5241 ns/op
BenchmarkLoopStep16-4             100000             22670 ns/op
  • 可以看出
    • 2 种遍历方式会出现 3 倍的性能差距
    • 这种问题最容易出现在多维数组的处理上
    • 比如遍历一个二维数组很容易就写出局部性很差的代码

程序的内存布局

0x0000 ~ 0xffff

img

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

结论

  • 局部性好的程序
    • 可以提高缓存命中率
    • 这对底层系统的内存管理是很友好的
    • 可以提高程序的性能
  • CPU Cache 层面的低命中率
    • 导致的是程序运行缓慢
  • 内存层面的低命中率
    • 会出现内存颠簸
    • 出现这种现象时你的服务基本上已经瘫痪了
  • Go 语言的内存管理是参考 tcmalloc 实现的
  • 它其实就是利用了 OS 管理内存的这些特点
  • 来最大化内存分配性能的