Go语言内存模型及堆的分配管理(上)

本文深入探讨Go语言的内存管理机制,源于TCMalloc的设计理念,旨在揭示Go如何实现高效内存分配,减轻开发者对内存管理的负担。内容涵盖存储金字塔、虚拟内存、栈与堆以及TCMalloc的基本原理,通过理解这些基础知识,帮助开发者提升代码质量并更好地定位问题。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

前言

原文

  • 这篇文章主要介绍
    • Go内存分配
    • Go内存管理
  • 会轻微涉及
    • 内存申请和释放
    • 以及Go垃圾回收
  • 从非常宏观的角度看,Go的内存管理就是下图这个样子
  • 我们今天主要关注其中标红的部分

在这里插入图片描述

  • Go这门语言

    • 抛弃了C/C++中的开发者管理内存的方式
    • 实现了主动申请与主动释放管理
    • 增加了逃逸分析和GC
  • 将开发者从内存管理中释放出来

    • 让开发者有更多的精力去关注软件设计
    • 而不是底层的内存问题
  • 这是Go语言成为高生产力语言的原因之一

  • 我们不需要精通内存的管理,因为它确实很复杂

    • 掌握内存的管理
      • 可以让你写出更高质量的代码
      • 另外,还能助你定位Bug
  • 这篇文章采用层层递进的方式,依次会介绍

    • 关于存储的基本知识
    • Go内存管理的 “前辈” TCMalloc
    • 然后是Go的内存管理和分配
    • 最后是总结
  • 这么做的目的是

    • 希望各位能通过全局的认识和思考
      • 拥有更好的编码思维和架构思维

正文

存储基础知识回顾

  • 这部分我们简单回顾一下
    • 计算机存储体系
    • 虚拟内存
    • 栈和堆
    • 堆内存的管理
  • 这部分内容对理解和掌握Go内存管理比较重要

存储金字塔

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-KMtjuYYQ-1641130351189)(C:\Users\您是弟弟\AppData\Roaming\Typora\typora-user-images\image-20211203093610586.png)]

  • 这幅图表达了
    • 计算机的存储体系
      • 从上至下的访问速度越来越慢
      • 访问时间越来越长
      • 从上至下依次是:
        • CPU寄存器
        • CPU Cache
        • 内存
        • 硬盘等辅助存储设备
        • 鼠标等外接设备

  • 你有没有思考过下面2个简单的问题

  • 如果没有不妨想想:

    • 如果CPU直接访问硬盘
      • CPU能充分利用吗?
    • 如果CPU直接访问内存
      • CPU能充分利用吗?
  • CPU速度很快

  • 但硬盘等持久存储很慢

  • 如果CPU直接访问磁盘

    • 磁盘可以拉低CPU的速度
      • 机器整体性能就会低下
    • 为了弥补这2个硬件之间的速率差异
      • 所以在CPU和磁盘之间增加了比磁盘快很多的内存

  • 然而,CPU跟内存的速率也不是相同的

  • 从上图可以看到

    • CPU的速率提高的很快(摩尔定律
    • 然而内存速率增长的很慢
  • 虽然CPU的速率现在增加的很慢了

  • 但是内存的速率也没增加多少

  • 速率差距很大

  • 从1980年开始CPU和内存速率差距在不断拉大

    • 为了弥补这2个硬件之间的速率差异
    • 所以在CPU跟内存之间增加了比内存更快的Cache
      • Cache是内存数据的缓存
      • 可以降低CPU访问内存的时间
  • 三级Cache分别是L1、L2、L3

  • 它们的速率是三个不同的层级

    • L1速率最快
    • L2速率就降到了RAM的25倍
    • L3的速率更靠近RAM的速率
  • 看到这了

  • 你有没有Get到整个存储体系的分层设计?

    • 自顶向下
      • 速率越来越低
      • 访问时间越来越长
    • 从磁盘到CPU寄存器
      • 上一层都可以看做是下一层的缓存
  • 看了分层设计,下面开始正式介绍内存

虚拟内存
  • 虚拟内存
    • 是当代操作系统必备的一项重要功能
  • 对于进程而言
    • 虚拟内存屏蔽了底层的RAM和磁盘
    • 并提供了远超物理内存大小的内存空间
  • 我们看一下虚拟内存的分层设计

在这里插入图片描述

  • 上图展示了某进程访问数据

  • Cache没有命中的时候

    • 访问虚拟内存获取数据的过程
    • 在访问内存
      • 实际访问的是虚拟内存
      • 虚拟内存通过页表查看
        • 当前要访问的虚拟内存地址是否已经加载到了物理内存
        • 如果已经在物理内存
          • 则取物理内存数据
        • 如果没有对应的物理内存
          • 则从磁盘加载数据到物理内存
          • 并把物理内存地址和虚拟内存地址更新到页表
  • 物理内存就是磁盘存储缓存层

  • 在没有虚拟内存的时代

    • 物理内存对所有进程是共享的
    • 多进程同时访问同一个物理内存会存在并发问题
      • 而引入虚拟内存后
      • 每个进程都有各自的虚拟内存
        • 内存的并发访问问题的粒度
          • 从多进程级别降低到多线程级别

栈和堆
  • 我们现在从虚拟内存
  • 再进一层
    • 看虚拟内存中的栈和堆
    • 也就是进程对内存的管理

  • 上图展示了一个进程的虚拟内存划分
  • 代码中使用的内存地址都是虚拟内存地址
  • 而不是实际的物理内存地址
    • 栈和堆只是虚拟内存上2块不同功能的内存区域:
      • 栈在高地址
        • 从高地址向低地址增长
      • 堆在低地址
        • 从低地址向高地址增长

栈和堆相比有这么几个好处:

  • 栈的内存管理简单,分配比堆上快
  • 栈的内存不需要回收
    • 而堆需要进行回收
    • 无论是主动free,还是被动的垃圾回收
      • 这都需要花费额外的CPU。
  • 栈上的内存有更好的局部性
    • 堆上内存访问就不那么友好了
      • CPU访问的2块数据可能在不同的页上
      • CPU访问数据的时间可能就上去了。

堆内存管理

在这里插入图片描述

  • 我们再进一层

  • 当我们说内存管理的时候

    • 主要是指堆内存的管理
    • 因为栈的内存管理不需要程序去操心
  • 这小节看下堆内存管理到底完成了什么

    • 如上图所示主要是3部分,分别是
    • 分配内存块
    • 回收内存块
    • 组织内存块。
  • 在一个最简单的内存管理中

  • 堆内存最初会是一个完整的大块

    • 即未分配任何内存
  • 当发现内存申请的时候

    • 堆内存就会
      • 未分配内存分割出一个小内存块(block)
      • 然后用链表把所有内存块连接起来
    • 需要一些信息描述每个内存块的基本信息
      • 比如大小(size)
      • 是否使用中(used)
      • 下一个内存块的地址(next)
      • 内存块实际数据存储在data中
        在这里插入图片描述
  • 一个内存块包含了3类信息

  • 如下图所示

    • 元数据
    • 用户数据
    • 对齐字段
  • 内存对齐是为了提高访问效率

  • 下图申请5Byte内存的时候

    • 就需要进行内存对齐

  • 释放内存实质是

    • 把使用的内存块从链表中取出来
    • 然后标记为未使用
  • 当分配内存块的时候

    • 可以从未使用内存块中优先查找大小相近的内存块
    • 如果找不到
      • 再从未分配的内存中分配内存。
  • 上面这个简单的设计中还没考虑内存碎片的问题

  • 因为随着内存不断的申请和释放

    • 内存上会存在大量的碎片
      • 降低内存的使用率
    • 为了解决内存碎片
    • 可以将2个连续的未使用的内存块合并,减少碎片
  • 以上就是内存管理的基本思路

    • 关于基本的内存管理
    • 想了解更多
    • 本节的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次系统调用
      • 后续线程申请小内存时直接从缓存分配
      • 都是在用户态执行的
        • 没有了系统调用
      • 缩短了内存总体的分配和释放时间
        • 这是快速分配内存的第二个层次
    • 多个线程同时申请小内存时
      • 从各自的缓存分配
      • 访问的是不同的地址空间
        • 从而无需加锁
      • 内存并发访问的粒度进一步降低了
        • 这是快速分配内存的第三个层次
基本原理
  • 下面就简单介绍下TCMalloc
  • 细致程度够我们理解Go的内存管理即可

在这里插入图片描述

结合上图,介绍TCMalloc的几个重要概念:

  • Page

    • 操作系统对内存管理以页为单位
    • TCMalloc也是这样
      • 只不过TCMalloc里的Page大小与操作系统里的大小并不一定相等
        • 而是倍数关系
      • 《TCMalloc解密》里称x64下Page大小是8KB
  • Span

    • 一组连续的Page被称为Span
      • 比如可以有2个页大小的Span
      • 也可以有16页大小的Span
    • Span比Page高一个层级
      • 是为了方便管理一定大小的内存区域
    • Span是TCMalloc中内存管理的基本单位
  • ThreadCache

    • ThreadCache是每个线程各自的Cache
    • 一个Cache包含多个空闲内存块链表
      • 每个链表连接的都是内存块
      • 同一个链表上内存块的大小是相同的
        • 也可以说按内存块大小,给内存块分了个类
      • 这样可以根据申请的内存大小
        • 快速从合适的链表选择空闲内存块
    • 由于每个线程有自己的ThreadCache
      • 所以ThreadCache访问是无锁的。
  • CentralCache

    • CentralCache是所有线程共享的缓存
      • 也是保存的空闲内存块链表
    • 链表的数量与ThreadCache中链表数量相同
    • 当ThreadCache的内存块不足时
      • 可以从CentralCache获取内存块
    • 当ThreadCache内存块过多时
      • 可以放回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
  • 小对象的分配流程:

    • ThreadCache -> CentralCache -> HeapPage
    • 大部分时候,ThreadCache缓存都是足够的
    • 不需要去访问CentralCache和HeapPage
    • 无系统调用配合无锁分配
      • 分配效率是非常高
  • 中对象分配流程:

    • 直接在PageHeap中选择适当的大小即可
    • 128 Page的Span所保存的最大内存就是1MB
  • 大对象分配流程:

    • 从large span set选择合适数量的页面组成span
    • 用来存储数据
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值