程序中的数据和变量都会被分配到程序所在的虚拟内存中,内存空间包含两个重要区域:栈和堆区
设计原理
内存管理有三个组件构成:用户程序,分配器,收集器
当用户程序申请内存时,它会通过内存分配器申请新的内存,而分配器会负责从堆中初始化相应的内存区域
分配方法
编程语言的内存分配器一般包含两个分配方法,一种是线性分配器,一种是空闲链表分配器
线性分配器
线性分配器优点,高效简单,缺点会产生内存碎片
工作方式:
在使用线性分配器时,只需要在内存维护一个指向内存的指针,如果用户程序向内存分配器申请内存,分配器需要检查剩余的空闲内存,返回分配的内存区域并修改指针在内存中的位置
虽然线性分配器带来了较快的执行速度已经较低的实现复杂度,但是线性分配器无法在已分配的内存释放时重用内存
解决办法:
配合垃圾回收算法使用,例如标记压缩法,复制回收,分代回收算法,它们可以通过拷贝的方式整理存活对象的碎片,将空闲内存定期合并
空闲链表分配器
空闲链表分配器可以重用已经被释放的内存,它在内部会维护一个类似链表的数据结构。当用户程序申请内存时,空闲链表分配器会依次遍历空闲内存块,找到足够大的内存,然后申请新的资源并修改链表
因为内存是分块的所以,在内存分配的时候,就需要分配合适的内存块,有四种常用的内存分配方式,第一种结构最为简单,但是效率较低,第三种效率较高,但是耗时较多
- 首次适应(First-Fit)— 从链表头开始遍历,选择第一个大小大于申请内存的内存块;
- 循环首次适应(Next-Fit)— 从上次遍历的结束位置开始遍历,选择第一个大小大于申请内存的内存块;
- 最优适应(Best-Fit)— 从链表头遍历整个链表,选择最合适的内存块;
- 隔离适应(Segregated-Fit)— 将内存分割成多个链表,每个链表中的内存块大小相同,申请内存时先找到满足条件的链表,再从链表中选择合适的内存块
go语言使用的是第四种内存分配策略
隔离适应策略
该策略会将内存分割成由 4,8,16,32字节的内存块组成的链表,当我们向内存分配器申请8字节的内存时,它会在上图中找到满足条件的空闲内存块并返回。隔离适应的分配策略减少 了需要变了的内存块数量,提高了内存分配的效率
分级分配
线程缓存分配是用于分配内存的机制,它比glibc中的malloc还要快很多。Go语言的内存分配器借鉴了TCMalloc的设计实现高速的内存分配,他的核心理念是使用多级缓存将对象根据大小分类,并按照类别实施不同的分配策略
对象大小
Go语言的内存分配器根据申请分配的内存大小选择不同的处理逻辑,分为微对象,小对象和大对象三种
类别 | 大小 |
微对象 | (0, 16B) |
小对象 | [16B, 32KB] |
大对象 | (32KB, +∞) |
多级缓存
内存分配器不仅会区分对待大小不同的对象,还会将内存分成不同的级别管理,TCMalloc 和Go运行时分配器都会引入线程缓存,中心缓存,页堆三个组件分级管理内存:
执行策略:线程缓存属于每个独立的线程,它能够满足线程上绝大多数的内存分配需求,因为不涉及多线程,所以也不需要使用互斥锁来保护内存。当线程缓存不能满足需求时,运行时会使用中心缓存作为补充解决小对象的内存分配,在遇到大对象时,内存分配器会选择页堆直接分配大内存
虚拟内存布局
线性内存
GO语言程序在启动时会初始化正片虚拟内存区域,spans ,bitmap和arena分别预留了512MB,16GB,以及512GB的内存空间,虚拟内存不是真正存在的
三个区域的功能:
- •
spans区域:存储了指向内存管理单元runtime.mspan的指针,每个内存单元会管理几页的内存空间,每页大小8KB;
- •
bitmap区域:用于标识arena区域中的那些地址保存了对象,位图中的每个字节都会表示堆区中的32字节是否空闲;
- •
arena区域:是真正的堆区,运行时会将8KB看做一页,这些内存中存储了所有在堆上初始化的对象;
对于任意一个地址,我们都可以根据arena的基地址计算该地址所在的页数通过spans数组获得管理该片内存的管理单元runtime.mspan .spans数组中多个连续的位置可能对应同一个runtime.mspan结构
Go语言在垃圾回收时会根据指针的地址判断对象是否在堆中,并通过上一段中介绍的过程找到管理该对象的runtime.mspan。这些都是建立在堆区的内存是连续的这一假设上。这种设计虽然简单并且方便,但是在c和go混合使用时会导致程序崩溃:
1.分配的内存地址会发生冲突,导致堆的初始化和扩容失败;
2.没有被预留的大块内存可能会被分配给c语言的二进制,导致扩容后的堆不连续
线性的堆内存需要预留大块的内存空间,但是申请大块的内存空间而不使用是不切实际的,不预留内存空间却会在特殊场景下造成程序崩溃。虽然连续内存的实现比较简单,但是这些问题也没有办法忽略
稀疏内存
稀疏内存是Go语言在1.11中提出的方案,使用稀疏的内存布局不仅能移除堆大小的上限,还能解决c和Go混合使用时的地址空间冲突问题。
地址空间
因为所有的内存最终都是要从操作系统中申请的,所以Go语言的运行时构建了操作系统的内存管理抽象层,该抽象层将运行时管理的地址空间分成四种状态:
状态 | 解释 |
None | 内存没有被保留或者映射,是地址空间的默认状态 |
Reserved | 运行时持有该地址空间,但是访问该内存会导致错误 |
Prepared | 内存被保留,一般没有对应的物理内存访问该片内存的行为是未定义的可以快速转换到 Ready 状态 |
Ready | 可以被安全访问 |
运行时中包含多个操作系统实现的状态转换方法,所有的实现都包含在以 mem_ 开头的文件中,本节将介绍 Linux 操作系统对上图中方法的实现:
- runtime.sysAlloc 会从操作系统中获取一大块可用的内存空间,可能为几百 KB 或者几 MB;
- runtime.sysFree 会在程序发生内存不足(Out-of Memory,OOM)时调用并无条件地返回内存;
- runtime.sysReserve 会保留操作系统中的一片内存区域,访问这片内存会触发异常;
- runtime.sysMap 保证内存区域可以快速转换至就绪状态;
- runtime.sysUsed 通知操作系统应用程序需要使用该内存区域,保证内存区域可以安全访问;
- runtime.sysUnused 通知操作系统虚拟内存对应的物理内存已经不再需要,可以重用物理内存;
- runtime.sysFault 将内存区域转换成保留状态,主要用于运行时的调试;
运行时使用 Linux 提供的 mmap、munmap 和 madvise 等系统调用实现了操作系统的内存管理抽象层,抹平了不同操作系统的差异,为运行时提供了更加方便的接口,除了 Linux 之外,运行时还实现了 BSD、Darwin、Plan9 以及 Windows 等平台上抽象层
go语言如何进行内存分配的
面试的时候直接讲出设计原理即可,即使用的分配方法是空闲链表分配器,使用的策略是隔离适应,就是将内存分割成多个链表,每个链表中的内存大小相同,然后按照分级分配的方法, 将需要分配的数据分为,三种,分别是微对象,小对象,大对象,内存分配器还会将内存分成不同的级别分别管理,分别是线程缓存,中心缓存,页堆,每个线程都有一个线程缓存,能够满足绝大多数的内存分配需求,不涉及到多线线程不需要使用互斥锁,提高了性能,如果说线程缓存的内存不够,会向中心缓存申请内存,因为中心缓存是被多个线程所共享的,所以需要使用互斥锁,使用线程缓存和中心缓存的前提是申请分配的内存不是大对象,如果是申请分配的内存是大对象 ,内存分配器会选择页堆直接分配大内存