内存管理是一个古老的话题,C/C++这类语言,需手动管理堆内存的申请与释放。Go、Java这类带有垃圾回收器(GC)的语言,堆内存的申请与释放可以交给其运行时来完成。Rust这种新兴语言通过编译器确定内存管理(分配与回收)方式,其不需要手动管理内存,也不需要垃圾回收器,它是将对象的生命周期限定在作用域内,对象生命周期超出作用域,自动执行Drop方法来销毁对象,这是编译器指定的行为。
Go语言的内存管理可以分为四个阶段,分别是:
向操作系统申请内存和归还内存调用其syscall即可。操作系统会为应用程序做虚拟内存与物理内存的映射,之后返回虚拟内存的空间。
Go语言淡化了堆与栈的概念,用户无法直接操作Go运行时的堆栈。Go程序的中,操作系统为进程划分的栈空间为Go runtime所用,同时堆空间被分为了两部分,即runtime所使用的堆和Go 用户态代码所使用的堆,同时goroutine的栈也是从Go用户代码所使用的堆中进行分配的,这使得goroutine的栈可以"任性"扩容或缩容。下图描述了其大概原理。goroutine所使用的堆栈和传统的堆栈使用方法类似,栈上的空间会随着方法的执行结束而被回收,堆上的空间多数情况下由GC代为管理,但Go 1.20之后加入的Arena特性,可以将"部分堆空间"的管理权限交给用户,这也是Go内存管理方式的改进。
os stack
-------------------
| |
| runtime |
| |
-------------------
|
|
os heap |
------------------------------------------
| | |
| | Go heap | -------------
| runtime | | | stack |
| | user code | | goroutine |
| | |------------->-------------
------------------------------------------
runtime 所占的堆空间可以称之为堆外内存,而用户代码持有的堆空间可以称之为Go堆。
Go运行时对堆内存对象管理做了三级划分,分别用mheap,mcentral,mcache表示不同的层级,mheap是保存所有内存对象的结构,mheap中有一个central字段,其类型如下:
central [numSpanClasses]struct {
mcentral mcentral
pad [cpu.CacheLinePadSize - unsafe.Sizeof(mcentral{})%cpu.CacheLinePadSize]byte
}
可见为数组类型,数组的长度为nSpanClasses,该值在Go 1.19.3版本中为68 << 1 = 136。central数组,根据下标划分,保存了不同级别的central,不同级别的central中保存了该级别的mspan链表,mspan是Go管理堆内存的基本单元,其代表被分配的内存(相连的页)。mspan级别被分为67级,以下是Go1.19.3源码中的注释,位于src/runtime/sizeclasses.go文件中
// 级别 单个内存占用 总内存占用 分配对象个数 浪费字节数 最大浪费率 最小对齐方式
// class bytes/obj bytes/span objects tail waste max waste min align
// 1 8 8192 1024 0 87.50% 8
// 2 16 8192 512 0 43.75% 16
// 3 24 8192 341 8 29.24% 8
// 4 32 8192 256 0 21.88% 32
// 5 48 8192 170 32 31.52% 16
// 6 64 8192 128 0 23.44% 64
// 7 80 8192 102 32 19.07% 16
// 8 96 8192 85 32 15.95% 32
// 9 112 8192 73 16 13.56% 16
// 10 128 8192 64 0 11.72% 128
// 11 144 8192 56 128 11.82% 16
// 12 160 8192 51 32 9.73% 32
// 13 176 8192 46 96 9.59% 16
// 14 192 8192 42 128 9.25% 64
// 15 208 8192 39 80 8.12% 16
// 16 224 8192 36 128 8.15% 32
// 17 240 8192 34 32 6.62% 16
// 18 256 8192 32 0 5.86% 256
// 19 288 8192 28 128 12.16% 32
// 20 320 8192 25 192 11.80% 64
// 21 352 8192 23 96 9.88% 32
// 22 384 8192 21 128 9.51% 128
// 23 416 8192 19 288 10.71% 32
// 24 448 8192 18 128 8.37% 64
// 25 480 8192 17 32 6.82% 32
// 26 512 8192 16 0 6.05% 512
// 27 576 8192 14 128 12.33% 64
// 28 640 8192 12 512 15.48% 128
// 29 704 8192 11 448 13.93% 64
// 30 768 8192 10 512 13.94% 256
// 31 896 8192 9 128 15.52% 128
// 32 1024 8192 8 0 12.40% 1024
// 33 1152 8192 7 128 12.41% 128
// 34 1280 8192 6 512 15.55% 256
// 35 1408 16384 11 896 14.00% 128
// 36 1536 8192 5 512 14.00% 512
// 37 1792 16384 9 256 15.57% 256
// 38 2048 8192 4 0 12.45% 2048
// 39 2304 16384 7 256 12.46% 256
// 40 2688 8192 3 128 15.59% 128
// 41 3072 24576 8 0 12.47% 1024
// 42 3200 16384 5 384 6.22% 128
// 43 3456 24576 7 384 8.83% 128
// 44 4096 8192 2 0 15.60% 4096
// 45 4864 24576 5 256 16.65% 256
// 46 5376 16384 3 256 10.92% 256
// 47 6144 24576 4 0 12.48% 2048
// 48 6528 32768 5 128 6.23% 128
// 49 6784 40960 6 256 4.36% 128
// 50 6912 49152 7 768 3.37% 256
// 51 8192 8192 1 0 15.61% 8192
// 52 9472 57344 6 512 14.28% 256
// 53 9728 49152 5 512 3.64% 512
// 54 10240 40960 4 0 4.99% 2048
// 55 10880 32768 3 128 6.24% 128
// 56 12288 24576 2 0 11.45% 4096
// 57 13568 40960 3 256 9.99% 256
// 58 14336 57344 4 0 5.35% 2048
// 59 16384 16384 1 0 12.49% 8192
// 60 18432 73728 4 0 11.11% 2048
// 61 19072 57344 3 128 3.57% 128
// 62 20480 40960 2 0 6.87% 4096
// 63 21760 65536 3 256 6.25% 256
// 64 24576 24576 1 0 11.45% 8192
// 65 27264 81920 3 128 10.00% 128
// 66 28672 57344 2 0 4.91% 4096
// 67 32768 32768 1 0 12.50% 8192
如下介绍浪费率的计算方式:单个65级span占用字节数为27264,为该span级别分配的总内存是81920,81920/27264 = 3,81920 % 27264 = 128,所以在分配三个span后还剩余128字节无法被分配,回看单个64级span,其占用字节数为24576,多疑当需要一个24577字节内存的span时,会分配65级的span。65级的span的浪费率的计算公式是((27264 - (24576 + 1)) * 3 + 128)/ 81920 = 0.999 ≈ 10 %。
在GMP调度模型中,mcentral被所有的P共享,同时P中有一个称之为mcache的span缓存。当P绑定的M执行G的时候需要使用内存,则去mcache缓存中去获取,若可以获取则拿来使用,若无法获取则去mcentral中去寻找。mcache可以说是一个二级缓存。
Go 内存分配策略可以分为顺序分配和自由表分配两种,前者是连续的一片内存区域,前边的内存区域是已被分配的内存,后边的内存区域是未被分配的内存;后者使用链表链接起来不同的未被分配的内存区域。前者对CPU的空间局部性十分友好,但容易产生内存碎片,不太便于管理。后者十分灵活。在Go语言中用自由表分配策略维护堆外空间的内存分配,用顺序分配维护Go堆的内存分配(用户侧代码)。
Go内存分配部分演进的比较挫折,早期Go版本这方面逻辑非常粗糙。后期得以改进。在内存管理中,如果需要超大的内存空间,则有mheap单独向操作系统申请,mheap中维护了代表内存使用状态的位图,使用radix tree管理线性的地址空间。
Reference
《Go 语言底层原理剖析》
《Go 程序员面试笔试宝典》
Go 1.19.3 runtime源码