前言
原文
- 这篇文章主要介绍
- Go内存分配
- Go内存管理
- 会轻微涉及
- 内存申请和释放
- 以及Go垃圾回收
- 从非常宏观的角度看,Go的内存管理就是下图这个样子
- 我们今天主要关注其中标红的部分
-
Go这门语言
- 抛弃了C/C++中的开发者管理内存的方式
- 实现了主动申请与主动释放管理
- 增加了逃逸分析和GC
-
将开发者从内存管理中释放出来
- 让开发者有更多的精力去关注软件设计
- 而不是底层的内存问题
-
这是Go语言成为高生产力语言的原因之一
-
我们不需要精通内存的管理,因为它确实很复杂
- 但掌握内存的管理
- 可以让你写出更高质量的代码
- 另外,还能助你定位Bug
- 但掌握内存的管理
-
这篇文章采用层层递进的方式,依次会介绍
- 关于存储的基本知识
- Go内存管理的 “前辈” TCMalloc
- 然后是Go的内存管理和分配
- 最后是总结
-
这么做的目的是
- 希望各位能通过全局的认识和思考
- 拥有更好的编码思维和架构思维
- 希望各位能通过全局的认识和思考
正文
存储基础知识回顾
- 这部分我们简单回顾一下
- 计算机存储体系
- 虚拟内存
- 栈和堆
- 堆内存的管理
- 这部分内容对理解和掌握Go内存管理比较重要
存储金字塔
- 这幅图表达了
- 计算机的存储体系
- 从上至下的访问速度越来越慢
- 访问时间越来越长
- 从上至下依次是:
- CPU寄存器
- CPU Cache
- 内存
- 硬盘等辅助存储设备
- 鼠标等外接设备
- 计算机的存储体系
-
你有没有思考过下面2个简单的问题
-
如果没有不妨想想:
- 如果CPU直接访问硬盘
- CPU能充分利用吗?
- 如果CPU直接访问内存
- CPU能充分利用吗?
- 如果CPU直接访问硬盘
-
CPU速度很快
-
但硬盘等持久存储很慢
-
如果CPU直接访问磁盘
- 磁盘可以拉低CPU的速度
- 机器整体性能就会低下
- 为了弥补这2个硬件之间的速率差异
- 所以在CPU和磁盘之间增加了比磁盘快很多的内存
- 磁盘可以拉低CPU的速度
-
然而,CPU跟内存的速率也不是相同的
-
从上图可以看到
- CPU的速率提高的很快(摩尔定律)
- 然而内存速率增长的很慢
-
虽然CPU的速率现在增加的很慢了
-
但是内存的速率也没增加多少
-
速率差距很大
-
从1980年开始CPU和内存速率差距在不断拉大
- 为了弥补这2个硬件之间的速率差异
- 所以在CPU跟内存之间增加了比内存更快的Cache
- Cache是内存数据的缓存
- 可以降低CPU访问内存的时间
-
三级Cache分别是L1、L2、L3
-
它们的速率是三个不同的层级
- L1速率最快
- 与CPU速率最接近
- 是RAM速率的100倍
- L2速率就降到了RAM的25倍
- L3的速率更靠近RAM的速率
- L1速率最快
-
看到这了
-
你有没有Get到整个存储体系的分层设计?
- 自顶向下
- 速率越来越低
- 访问时间越来越长
- 从磁盘到CPU寄存器
- 上一层都可以看做是下一层的缓存
- 自顶向下
-
看了分层设计,下面开始正式介绍内存
虚拟内存
- 虚拟内存
- 是当代操作系统必备的一项重要功能
- 对于进程而言
- 虚拟内存屏蔽了底层的RAM和磁盘
- 并提供了远超物理内存大小的内存空间
- 我们看一下虚拟内存的分层设计
-
上图展示了某进程访问数据
-
当Cache没有命中的时候
- 访问虚拟内存获取数据的过程
- 在访问内存
- 实际访问的是虚拟内存
- 虚拟内存通过页表查看
- 当前要访问的虚拟内存地址是否已经加载到了物理内存
- 如果已经在物理内存
- 则取物理内存数据
- 如果没有对应的物理内存
- 则从磁盘加载数据到物理内存
- 并把物理内存地址和虚拟内存地址更新到页表
-
物理内存就是磁盘存储缓存层
-
在没有虚拟内存的时代
- 物理内存对所有进程是共享的
- 多进程同时访问同一个物理内存会存在并发问题
- 而引入虚拟内存后
- 每个进程都有各自的虚拟内存
- 内存的并发访问问题的粒度
- 从多进程级别降低到多线程级别
- 内存的并发访问问题的粒度
栈和堆
- 我们现在从虚拟内存
- 再进一层
- 看虚拟内存中的栈和堆
- 也就是进程对内存的管理
- 上图展示了一个进程的虚拟内存划分
- 代码中使用的内存地址都是虚拟内存地址
- 而不是实际的物理内存地址
- 栈和堆只是虚拟内存上2块不同功能的内存区域:
- 栈在高地址
- 从高地址向低地址增长
- 堆在低地址
- 从低地址向高地址增长
- 栈在高地址
- 栈和堆只是虚拟内存上2块不同功能的内存区域:
栈和堆相比有这么几个好处:
- 栈的内存管理简单,分配比堆上快
- 栈的内存不需要回收
- 而堆需要进行回收
- 无论是主动free,还是被动的垃圾回收
- 这都需要花费额外的CPU。
- 栈上的内存有更好的局部性
- 堆上内存访问就不那么友好了
- CPU访问的2块数据可能在不同的页上
- CPU访问数据的时间可能就上去了。
- 堆上内存访问就不那么友好了
堆内存管理
-
我们再进一层
-
当我们说内存管理的时候
- 主要是指堆内存的管理
- 因为栈的内存管理不需要程序去操心
-
这小节看下堆内存管理到底完成了什么
- 如上图所示主要是3部分,分别是
- 分配内存块
- 回收内存块
- 组织内存块。
-
在一个最简单的内存管理中
-
堆内存最初会是一个完整的大块
- 即未分配任何内存
-
当发现内存申请的时候
- 堆内存就会
- 从未分配内存分割出一个小内存块(block)
- 然后用链表把所有内存块连接起来
- 需要一些信息描述每个内存块的基本信息
- 比如大小(size)
- 是否使用中(used)
- 下一个内存块的地址(next)
- 内存块实际数据存储在data中
- 堆内存就会
-
一个内存块包含了3类信息
-
如下图所示
- 元数据
- 用户数据
- 对齐字段
-
内存对齐是为了提高访问效率
-
下图申请5Byte内存的时候
- 就需要进行内存对齐
-
释放内存实质是
- 把使用的内存块从链表中取出来
- 然后标记为未使用
-
当分配内存块的时候
- 可以从未使用内存块中优先查找大小相近的内存块
- 如果找不到
- 再从未分配的内存中分配内存。
-
上面这个简单的设计中还没考虑内存碎片的问题
-
因为随着内存不断的申请和释放
- 内存上会存在大量的碎片
- 降低内存的使用率
- 为了解决内存碎片
- 可以将2个连续的未使用的内存块合并,减少碎片
- 内存上会存在大量的碎片
-
以上就是内存管理的基本思路
- 关于基本的内存管理
- 想了解更多
- 可以阅读这篇文章《Writing a Memory Allocator》
- 本节的3张图片也是来自这篇文章。
TCMalloc
-
TCMalloc是Thread Cache Malloc的简称
-
是Go内存管理的起源
-
Go的内存管理是借鉴了TCMalloc
-
随着Go的迭代
- Go的内存管理与TCMalloc不一致地方在不断扩大
-
但其主要思想、原理和概念都是和TCMalloc一致的
-
如果跳过TCMalloc直接去看Go的内存管理
- 也许你会似懂非懂
-
掌握TCMalloc的理念
-
无需去关注过多的源码细节
-
就可以为掌握Go的内存管理打好基础
- 在Linux操作系统中,其实有不少的内存管理库
- 比如glibc的ptmalloc
- FreeBSD的jemalloc
- Google的tcmalloc
- 等等
- 为何会出现这么多的内存管理库?
- 本质都是在多线程编程下
- 追求更高内存管理效率:
- 更快的分配是主要目的
-
我们前面提到
-
引入虚拟内存后
- 让内存的并发访问问题的粒度
- 从多进程级别降低到多线程级别
- 然而同一进程下的所有线程共享相同的内存空间
- 它们申请内存时需要加锁
- 如果不加锁
- 就存在同一块内存被2个线程同时访问的问题
- 让内存的并发访问问题的粒度
-
TCMalloc的做法是什么呢?
- 为每个线程预分配一块缓存
- 线程申请小内存时
- 可以从缓存分配内存
-
这样有2个好处:
- 为线程预分配缓存需要进行1次系统调用
- 后续线程申请小内存时直接从缓存分配
- 都是在用户态执行的
- 没有了系统调用
- 缩短了内存总体的分配和释放时间
- 这是快速分配内存的第二个层次
- 多个线程同时申请小内存时
- 从各自的缓存分配
- 访问的是不同的地址空间
- 从而无需加锁
- 把内存并发访问的粒度进一步降低了
- 这是快速分配内存的第三个层次
- 为线程预分配缓存需要进行1次系统调用
基本原理
- 下面就简单介绍下TCMalloc
- 细致程度够我们理解Go的内存管理即可
结合上图,介绍TCMalloc的几个重要概念:
-
Page
- 操作系统对内存管理以页为单位
- TCMalloc也是这样
- 只不过TCMalloc里的Page大小与操作系统里的大小并不一定相等
- 而是倍数关系
- 《TCMalloc解密》里称x64下Page大小是8KB
- 只不过TCMalloc里的Page大小与操作系统里的大小并不一定相等
-
Span
- 一组连续的Page被称为Span
- 比如可以有2个页大小的Span
- 也可以有16页大小的Span
- Span比Page高一个层级
- 是为了方便管理一定大小的内存区域
- Span是TCMalloc中内存管理的基本单位
- 一组连续的Page被称为Span
-
ThreadCache
- ThreadCache是每个线程各自的Cache
- 一个Cache包含多个空闲内存块链表
- 每个链表连接的都是内存块
- 同一个链表上内存块的大小是相同的
- 也可以说按内存块大小,给内存块分了个类
- 这样可以根据申请的内存大小
- 快速从合适的链表选择空闲内存块
- 由于每个线程有自己的ThreadCache
- 所以ThreadCache访问是无锁的。
-
CentralCache
- CentralCache是所有线程共享的缓存
- 也是保存的空闲内存块链表
- 链表的数量与ThreadCache中链表数量相同
- 当ThreadCache的内存块不足时
- 可以从CentralCache获取内存块
- 当ThreadCache内存块过多时
- 可以放回CentralCache
- 由于CentralCache是共享的
- 所以它的访问是要加锁的
- CentralCache是所有线程共享的缓存
-
PageHeap
- PageHeap是对堆内存的抽象
- PageHeap存的也是若干链表
- 链表保存的是Span
- 当CentralCache的内存不足时
- 会从PageHeap获取空闲的内存Span
- 然后把1个Span拆成若干内存块
- 添加到对应大小的链表中并分配内存
- 当CentralCache的内存过多时
- 会把空闲的内存块放回PageHeap中。
- 如下图所示
- 分别是1页Page的Span链表
- 2页Page的Span链表
- 等
- 最后是large span set
- 这个是用来保存中大对象的
- 毫无疑问,PageHeap也是要加锁的
-
前文提到了小、中、大对象
-
Go内存管理中也有类似的概念
- 我们看一眼TCMalloc的定义:
- 小对象大小:0~256KB
- 中对象大小:257~1MB
- 大对象大小:>1MB
- 我们看一眼TCMalloc的定义:
-
小对象的分配流程:
- ThreadCache -> CentralCache -> HeapPage
- 大部分时候,ThreadCache缓存都是足够的
- 不需要去访问CentralCache和HeapPage
- 无系统调用配合无锁分配
- 分配效率是非常高的
-
中对象分配流程:
- 直接在PageHeap中选择适当的大小即可
- 128 Page的Span所保存的最大内存就是1MB
-
大对象分配流程:
- 从large span set选择合适数量的页面组成span
- 用来存储数据