Redis6源码系列(一)- 内存管理zmalloc(上)
在探究Redis的内存管理机制之前,先来看看Linux系统下进程的内存布局和内存管理机制。
1、内存结构
进程是一个可执行程序的实例,也就是运行中的程序;由 程序代码、与代码相关联的数据 和 进程状态信息 这三个元素组成。
虚拟地址空间
操作系统通过 进程控制块(Processing Control Block,PCB)对进程进行管理,在Linux中是一个task_truct结构体。
task_struct结构体也被称为进程描述符(process descriptor),是用于维护进程状态信息的内核数据结构,包含进程运行时的所有信息;例如进程的唯一标识符、任务状态、优先级和程序计数器等等。从内核的角度看,进程就是task_struct结构体。
task_struct结构体包含一个mm_struct属性,用于描述操作系统为每个进程分配独立的虚拟地址空间(virtual memory space)。所以mm_struct结构体也称为 内存描述符(memory descriptor),指向的线性空间称为用户空间(user-space),用于装载进程的程序代码指令和与代码相关联的数据信息;相应的,装载task_struct结构体的地址空间称为内核空间(kernel-space)。
内核空间是一段进程间共享使用的区域,用于装载系统内核代码、进程的内核数据结构等信息。从单一进程的角度看,内核空间映射到 进程独有的虚拟地址空间 的高位地址上,可以认为进程的虚拟地址空间分为两部分:共享的内核空间 和 私有的用户空间。
进程的用户空间是装载程序代码指令和与程序代码相关联的数据的区域,通常就由三个部分组成:文本段、数据段和堆栈段。
文本段(text segment)
也称为代码段,存储了进程运行的程序机器语言指令,是一块具有只读属性的区域。
数据段
用于存储全局变量和静态变量,根据变量是否有显式初始化过,可以细分为 初始化数据段(data segment,数据段) 和 未初始化数据段(bss segment,BSS段) 。
数据段(data segment)存储经过 程序 初始化的全局变量和静态变量,而BSS段存储由操作系统初始化为0值的数据(未经程序初始化)。
堆栈段
堆栈段是进程运行时动态扩展的内存区域,可以细分成三部分:堆(heap)、栈(stack) 和内存映射区
堆(heap)
堆是程序运行时的主要数据区,从未初始化数据段(bss)的末尾开始,地址空间由低向高增长。
栈(stack)
栈是一块由高向低增长的有界空间,主要用来维护函数调用的上下文(存放函数的参数值、返回值和局部变量等信息)。栈空间由编译器自动创建和释放,内部存储数据的结构称为栈帧,栈帧随着函数的调用创建并入栈,随着函数的返回出栈。
内存映射区
内存映射区处于栈(stack)和堆(heap)之间的空余部分,由栈空间的边界向下增长,与堆空间共享空闲的可扩展内存空间。主要用于内存映射mmap机制,与文件磁盘地址进行映射。
program break
当前已申请堆内存空间的边界(堆顶端),称之为“program break"。
初始化状态下,programe break位置处于未初始化数据段(bss)末尾之后,与 &end 位置相同(不考虑地址随机化ASLR机制,开启ASLR后,段与段之间有随机偏移大小的间隔,初始位置会在数据段结尾后的随机偏移处)。
programe break的位置抬升后,程序可以访问新分配区域内的任何内存地址(需要注意的是,这里分配的是虚拟内存地址空间,不是实际物理内存页,物理内存页在使用过程中触发缺页异常时分配)。
从program break的角度看,改变分配堆内存大小的操作,就是改变进程的program break位置。
2、系统调用
操作系统内核进程运行在内核空间(kernel-space),而用户进程运行在用户空间(user-space);为了保护系统内核的安全,不允许用户空间的进程访问内核空间的资源,而是需要通过系统调用接口(System Call API)请求操作系统内核进行分配。
Linux提供了2种不同的对内存资源进行分配的系统调用,分别是 brk 和 mmap
brk
先来看下从传统的UNIX系统继承下来系统调用 brk、sbrk
NAME brk, sbrk - change data segment size SYNOPSIS #include <unistd.h> int brk(void *addr); void *sbrk(intptr_t increment);复制代码
系统调用 brk() 允许传入地址指针参数*addr,将 program break 直接设置为 *addr 所指定的位置。
sbrk() 在 brk() 的基础上做了封装,传入整数类型的参数increment,将 program break 从原有地址增加 increment 大小,并返回指向新分配内存起始位置的指针。
brk/sbrk系统调用直接指定地址分配内存,所以如果通过它们来管理内存,释放内存需要等到高地址内存释放后才能操作;以上图为例,在B占用的内存空间释放之前,A占用的内存空间是不可能释放的。这就可能导致大量无用的内存空间无法释放,这部分无用却无法释放的内存被称为 内存碎片。
mmap
相较于brk和sbrk,mmap/munmap 系统调用接口在应用实现中可能更为常见。
NAME mmap, mmap64, munmap - map or unmap files or devices into memory SYNOPSIS #include <sys/mman.h> void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset); void *mmap64(void *addr, size_t length, int prot, int flags, int fd, off64_t offset); int munmap(void *addr, size_t length复制代码
mmap是一种内存映射方法,将文件或其他对象映射到当前进程的虚拟内存地址空间;以读写进程内存的方式操作文件,而不必调用read、write等系统调用,从而实现用户空间和内核空间的高效交互。
mmap映射可以分为文件映射和匿名映射,从共享内存发生变更的可见性角度还可以分成共享映射(MAP_SHARDED)和私有映射(MAP_PRIVATE,映射内容上发生的变更对其他进程不可见)。
共享文件映射一般用于文件映射I/O或者是进程间的通信(IPC),私有匿名映射则用于进程的内存分配。
私有匿名映射mmap将虚拟文件映射到调用进程的虚拟内存(匿名映射实际上没有对应的文件),映射的内存分页初始化以零值填充。每次调用mmap()创建一个私有匿名映射,都会在调用进程的虚拟内存地址空间(堆和栈之间的空间)分配一段满足需求的连续的虚拟内存,并返回新映射的起始位置。
mmap64系统调用的作用与 mmap() 是一致的,用于在32位操作系统上支持大文件与内存地址空间的映射(默认情况下,32位操作系统的文件限制大小为2G,超过该大小的文件读写到2G后将会抛出“文件大小超出限制”的错误)。启用大文件支持后(Large File Support),mmap会被重新定义为mmap64。
munmap 是对 mmap 的逆向操作,即从调用进程的虚拟内存地址空间释放(删除)一个内存文件映射。
3、C函数库
在标准C语言库中,基于底层brk和mmap等系统调用定义了更易于使用的函数用于分配和释放内存。
malloc/free函数
一般情况下,在堆上分配内存会考虑使用malloc/free函数。相较于直接使用系统调用而言,malloc/free函数使用更为简单,允许分配小块内存,并且允许随意释放内存块。
NAME calloc, malloc, free, realloc - Allocate and free dynamic memory SYNOPSIS #include <stdlib.h> void *calloc(size_t nmemb, size_t size); void *malloc(size_t size); void free(void *ptr); void *realloc(void *ptr, size_t size复制代码
malloc
malloc函数在堆上分配指定参数size大小的内存,并返回指向新分配内存起始位置处的指针(void*),分配的内存未经初始化。
malloc()本质上是对 brk、mmap 系统调用的封装;默认情况下,如果参数size大于128K,函数内部将会使用mmap(私有匿名映射)来分配,否则使用 brk(128k的阈值大小由MMAP_THRESHOLD控制,该参数可以通过mallopt()库函数来调整)。
malloc函数在分配内存时,会额外分配几个字节来存放记录这块内存大小的整数值。该数值位于内存块起始位置,调用函数实际返回的内存地址位于这一长度记录字节之后。需要注意的一点是,malloc()返回的内存块会基于8字节或16字节的边界进行对齐。
calloc
malloc函数分配的内存未经初始化,在复用原先已经分配过、后续又回收了的内存块时,可能会遗留有上次分配使用的数据。calloc函数与malloc函数的区别就在于,calloc会将已分配的内存初始化为0。
free
free函数与malloc、calloc函数搭配使用,调用malloc()、calloc()申请到的内存空间,适用并应该用free()进行释放。
free函数维护了空闲内存列表(双向链表),一般情况下,调用free函数不会降低program break的位置,而是将这块内存填入到空闲内存列表中,用于后续malloc函数循环使用。
空闲列表的内存块包含长度信息,并在该内存块的空间里存放了指向前一块空闲内存、指向后一块空闲内存的指针:
随着malloc、free函数的不断使用,空闲列表中的空闲内存会和已分配的在用内存混杂在一起,如下图所示:
分配对齐的内存:memalign和posix_memalign函数
memalign和posix_memalign函数分配的内存块,起始地址与2的整数次幂边界对齐
NAME posix_memalign, memalign, valloc - Allocate aligned memory SYNOPSIS #include <stdlib.h> int posix_memalign(void **memptr, size_t alignment, size_t size); #include <malloc.h> void *valloc(size_t size); void *memalign(size_t boundary, size_t size);复制代码
memalign函数分配size个字节的内存,起始地址是参数boundary的整数倍,而boundary必须是2的整数次幂;分配成功后返回已分配内存的地址。memalign函数提供比malloc函数更为丰富的对齐方式,例如4K对齐。
4、常见内存分配器
为了方便复用,对内存分配、释放等管理算法进行封装,称为 内存分配器(allocator)。一款优秀的内存分配器,应该具备更快的分配速度和更少的额外空间损耗,同时尽量减少内存碎片,在通用性、兼容性和可移植性上也有较好的表现。
除了Linux系统C标准库实现glibc(使用ptmalloc2)之外,可选的内存分配器还有Google的tcmalloc、Facebook的jemalloc等;后两者在性能方面宣称比ptmalloc2有更好的表现。
作者:张小胜
链接:https://juejin.cn/post/7031112042660495368