Go 语言的内存管理是建立在操作系统的内存管理之上的,最大程度上发挥了操作系统内存管理层面的优势,避开了低效的情况。
Go 实现了主动申请与主动释放管理,增加了逃逸分析和GC(垃圾回收),将开发者从内存管理中释放出来,让开发者有更多的精力关注软件设计而不是底层的内存问题。
核心考量:
1、每次从操作系统申请一大块儿的内存(内存大小见 mheap),由Go来对这块儿内存做分配,减少系统调用。
2、内存分配算法采用 Google
的 TCMalloc
算法。核心思想就是把内存切分的非常细小,分为多级管理,以降低锁的粒度。
3、回收对象内存时,并没有将其真正释放掉,只是放回了预先分配的大块内存中,以便复用。只有内存闲置过多的时候,才会尝试归还部分内存给操作系统,降低整体开销。
CPU访问速度远大于存储设备的访问速度,弥补速率差异,引入了 cache。除了速率还有多任务的需求,也就是多进程。如果解决多进程的内存分配问题,引入了虚拟内存的概念,这样所有进程都以为自己独享了一整块内存。
进程间的虚拟内存都是隔离开的。每个进程都拥有自己独立的虚拟地址空间(如32位系统通常有4GB虚拟地址范围),操作系统通过内存管理单元(MMU)和页表机制,将进程的虚拟地址映射到物理内存的不同区域。因此,进程A的地址 0x4000 与进程B的同地址指向完全不同的物理内存位置。
操作系统将同一块物理内存映射到多个进程的虚拟地址空间中。
CPU 访问内存基本过程:
- CPU 使用虚拟地址访问数据,执行 MOV 指令(数据传送指令)加载数据到寄存器,把地址传递给 MMU (内存管理单元);
- MMU 生成 PTE (页表)地址,并从主存(或自己的cache)中得到它。
- 如果 MMU 根据 PTE 得到真实的物理地址,正常读取数据。流程到此结束。
- 如果 PTE 信息表示没有关联的物理地址,MMU 则触发一个缺页异常。
- 操作系统捕获到这个异常,开始执行缺页处理程序。在物理内存上创建一页内存,并更新页表。
- 缺页处理程序在物理内存中确定一个物理页,如果这个物理页上有数据,则把数据保存到磁盘上,然后将虚拟内存中的内容拷贝到这个物理页上。
- 缺页处理程序更新 PTE。
- 缺页处理程序结束,再回去执行上一条指令(导致缺页异常的那个指令,也就是 MOV指令),这次肯定命中了。
内存访问中的第三步,结束了,就是页命中了,反之就是未命中。假设 n 次内存访问中,出现命中的次数是 m,那么 m/n * 100%
表示命中率。这个指标可以衡量内存管理程序的好坏。
命中率低,性能下降。换页算法有 LRU 算法。
每个程序都有自己一套独立的地址空间可以使用,但在高级语言中,很少直接使用地址,而是通过变量名来访问数据的,编译器会自动将我们的变量名转换成真正的虚拟地址。
栈
堆
进程绕不开的堆和栈概念:
- 栈:在高地址,从高地址向低地址增长;ESP EBP 两个寄存器指针,分别指向栈帧(一次函数调用)栈顶和栈底。这俩指针是会随着程序的执行进行移动。
- 堆:在低地址,从低地址向高地址增长;
栈和堆相比的好处:
- 栈的内存管理简单,分配比堆上快,栈通过压栈出栈方式自动分配释放的。由系统管理,使用起来高效无感知,而堆用以动态分配的,由程序自己管理分配和释放;Go 里函数调用就会自动压栈(局部变量,参数,地址)和出栈。
- 栈上的内存不需要回收,而堆需要回收♻️。这是由栈的性质决定的。
- 栈上的内存有更好的局部性,堆上的内存访问就不怎么友好。写程序时尽量将变量分配到栈上
Thread Cache Malloc 的简称,是 Go 内存管理的起源(现在已经优化了很多)。引入虚拟内存后,让内存的并发访问问题的粒度由多进程级别,降低到多线程级别(线程栈)。然而同一进程下的所有线程共享相同的内存空间,它们申请内存时需要加锁,如果不加锁就存在同一块内存被2个线程同时访问的问题。
TCMalloc 的做法是,为每一个线程预分配一块缓存,线程申请小内存时,可以从缓存分配内存。这样2个好处:
- 为线程预分配缓存需要进行1次系统调用,后续线程申请小内存时直接从缓存分配,都是在用户态执行,没有了系统调用,缩短了内存总体的分配和释放时间。
- 多个线程同时申请小内存时,从各自的缓存分配,访问的是不同的地址空间,从而无需加锁,把内存并发访问的粒度进一步降低了。
内存池是一种内存管理方式,通常应用在服务器端后台开发中,主要是为了避免进程在运行过程中频繁的从堆上申请内存,使用以空间换取时间的方式来提高运行效率。比如 Linux 中常常直接调用 mmap 申请内存,但这会带来一些代价:
- 系统调用,需要程序从用户态切换到内核态,在调用结束后,又需要返回用户态;
- 频繁申请很小的内存空间,容易出现大量的内存碎片🧩,增大了操作系统整理碎片的压力;
- 为了保证内存访问具有很好的局限性,开发中需要投入大量的精力去做优化,这是个很重的负担;
解决的方式就是实现一个内存池,先向操作系统申请大块内存交由其管理。当程序释放内存时不实际归还操作系统,而是放回池子中重复利用。这样就不需要频繁申请内存,对象池将会被频繁利用,不会出现碎片,且程序访问的是同一块内存空间,具有很好的 局部性(内存页命中率高)。
多线程方面,每条线程都有自己的本地内存,然后有一个 全局的分配链,当某个线程中内存不足就向全局分配链中申请内存,避免了多线程同时访问共享变量时的加锁。
避免内存碎片方面,大块内存直接按页为单位分配,小块内存会切分成各种不同的固定大小的块, 申请做任意字节内存时会向上取整到最接近的块,将整块分配给申请者以避免随意切割。
系统线程的内存分配方面,使用了一个本地的 mcache,少量地址分配就直接从 mcache 中分配,并且定期做垃圾回收,将线程的 mcache 中的空闲内存返回给全局堆。小于 32k 为小对象,大对象直接从全局堆上以页(4k)为单位进行分配,也就是大对象总是以页对齐的,一个页可以存入一些相同大小的对象,小对象从本地内存链表中分配,大对象从中心内存堆中分配。
https://www.cnblogs.com/binlovetech/p/17571929.html
https://blog.csdn.net/weixin_50941083/article/details/125782118
Go 语言的内存分配器包含内存管理单元(runtime.mspan)、线程缓存(runtime.mcache)、中心缓存(runtime.mcentral)和页堆(runtime.mheap)几个重要组件。
![[Pasted image 20250704151239.png]]
所有的 Go 语言程序都会在启动时初始化如上图所示的内存布局。在 amd64 的 Linux 操作系统上,runtime.mheap
会持有 4,194,304 runtime.heapArena
,每个 runtime.heapArena
都会管理 64MB 的内存,单个 Go 语言程序的内存上限也就是 256TB。
Go 的内存管理本质上就是一个内存池,内部做了很多优化,比如:自动伸缩内存池大小,合理的分割内存块等。
程序启动初始,将会一次性从系统申请大块内存作为内存池,这个内存空间将会被放在 mheap 中进行管理。
结论:
go 的内存分配采用了 TCMalloc 算法,采用多级存储策略,好处是,能够避免锁竞争。提供系统性能
之前的 c/c++ 程序员需要自己去管理内存的申请和释放,这很麻烦,稍有不慎就会导致悬挂指针(dangling pointer)。而 Go 采用逃逸分析来决定内存的分配,是分配到栈上还是堆上,无需程序员操心。
主要是对于结构体字段来说,会存在填充(padding) 情况。在编译时按照一定规则自动进行内存对齐,是为了减少CPU访问内存的次数,加大CPU访问内存的吞吐量。不进行对齐,很可能增加CPU访问内存的次数。
使用到的函数:
unsafe.Sizeof()
unsafe.Alignof()
CPU 访问内存时,并不是逐个字节访问,而是以字(word)为单位访问。比如 64位CPU的字长(word size)为8bytes,那么CPU访问内存的单位也是8字节,每次加载的内存数据也是固定的若干字长,如8words(64bytes)、16words(128bytes)等
规则:字段size大的尽量放在前面。
go 栈都是 goroutine 申请,起始栈大小为 2kb,可以对比线程栈大小都是 MB 级别。go 栈的开销很小,主要用来保存一些函数变量、参数、栈帧和返回地址等。