向团队内做了一次读书分享,由于会议室是投影,懒得打开,后来基本就是直接在黑板上写着讲了,这篇文章就相当于一个大纲吧。
现代操作系统大多使用虚拟地址来屏蔽进程对于物理内存的访问。在操作系统层面,基本的单位为页,与内存的物理单位页帧为相同大小,均为4k。
操作系统通过与cpu
的mmu
单元配合,对用户进程提供了屏蔽物理内存PA
地址的虚拟地址VA
访问方式。物理地址即cpu
通过地址总线与内存交互并且读写数据。
mmu
通过操作系统预设的页表来翻译虚拟地址至物理地址,每个进程均有一个页表,用于本进程的虚拟地址翻译。通过mmu
,可以使得每一个进程均有独立于其它进程的完整的地址空间,该地址空间依赖于cpu
位数。同时mmu
支持内存读写权限等控制,加强进程间内存隔离的安全性,同时访问不存在的页面的时候产生的page fault
,操作系统的中断处理程序可以很好的做到虚拟内存与物理内存的置换。
以32位操作系统为例,由于操作系统的页大小为4k,所以只需要12bit就可以在一页中寻址,故虚拟地址可以分为页索引部分20bit和12bit的页内偏移。虚拟地址转为物理地址的时候,根据页表入口加上页索引即可寻找到对应的物理页帧,然后通过页内偏移来定位到特定的字节。
由于12bit用于页内偏移,所以其余的20bit用于索引页,也就是说,在32位的情况下,有100万条页表项目,假设每个页表项占用4字节,那么整个页表将占用4MB的内存空间,并且每个进程都会有一个页表,那么页表的内存占用将非常大。
为了解决这个问题,通常采用多级页表来减少页表对于内存的消耗。这里以二级页表为例,虚拟地址分别被划分为页索引1,页索引2,页内偏移。通常页索引均被分配为10bit,所以每个页索引区域的索引都总共有1024个。页索引1中保存的是页索引2的页表地址,页索引2中存放的才是真正的物理页地址。由于程序中有很多虚拟内存地址不被使用,所以使用这种方式,不被使用的虚拟地址将不会产生二级页表项,同时也不会产生物理页表项,所以可以大大降低内存的消耗。
操作系统通过伙伴系统Buddy system
来管理内存,降低内存碎片的产生。系统中有11个链表,分别管理连续页数量为1、2、4、8、16、32、64、128、256、512、1024的连续页,每个连续页的起始地址为该连续页大小的整数倍。
假设需要分配的内存大小为1MB,也就是连续页为256,则按以下的流程:
在释放过程中,则将相应大小的连续页放入对应链表,并检测是否有相邻连续的连续页,假设有,则进行合并,放入下一个尺寸的连续页链表当中。
进程通过malloc
来向操作系统申请内存,在linux
的glibc
库中,其内部封装的系统调用是sbrk
和brk
。这两个系统调用主要是用来调整程序可写堆的范围,当扩大后,那么对应扩大堆的范围就可写了,也就相当于申请了内存。操作系统对于内存的分配是基于页来管理的,在进程这一层才会分割为对应满足程序使用要求的小块。所以假设使用系统调用调整了当前堆顶的字节小于页尺寸的size,而当前堆顶又处在前一页的末尾,那么总会去申请一整页的内存。
上面的2个系统调用,也仅仅是调整了堆的尺寸,或者说是预留了虚拟空间地址,而没有分配内存。当进程读写新申请的页后,会触发page fault
,操作系统才会分配页然后调整页表,使得程序可以正常的访问物理内存。
各种内存分配器,比如malloc
(glibc
中是ptmalloc
)、tcmalloc
等,均系统的管理多个页,使得页能高效的分配小内存对象,减少内存碎片的发生。
golang
的内存分配器设计基于tcmalloc
,具体可见其余文档。