学过内存池,跟着视频实现过c的内存池代码,也看过nginx的内存池,在自己的角度对内存池的概念,实现有一定的认知。
但有一次面试,面试官问到内存池的实现原理,如果基于内存池用new对对象进行内存申请如何实现?
第一次思考这个问题,当时却懵了,感谢这次经历,让我发现又一个知识点。
操作系统管理内存的方式是 提供malloc/calloc/realloc 及free接口。
参考操作系统管理内存的方式,我们要实现内存池,即控制一块内存并进行管理,需要实现可供对外使用的类似的接口(这里可以采用hook/重载的方式)。
==》hook:接管系统库函数,调用自己函数的技术。
1.2.1:进程空间,堆、栈内存(没有十分搞懂)
linux虚拟地址空间范围为0~4G(32位操作系统)。
每个进程运行在属于自己的内存沙盒中(虚拟地址空间),在内存中,由页表(操作系统控制)映射到物理内存。
linux内核将4G空间分为两部分,
===》(从虚拟地址0xC0000000到0xFFFFFFFF)供内核使用,称为“内核空间”。
===》而将较低的3G字节(从虚拟地址0x00000000到0xBFFFFFFF)供各个进程使用,称为“用户空间。
===》可以通过系统调用进入内核,内核由所有进程共享。
===》内核空间持续存在,所有进程中映射到同样的物理内存,公用内核地址,内核的代码,数据是一样的
===》进程用的虚拟内存,由操作系统页表映射到对应的物理内存。
===》虚拟地址保护内核空间不被用户破坏。
===》内核为每个进程维护一张页表?描述每页在虚拟内存的地址以及对应的物理地址。
简单分析:
===》进程启动时会分配进程空间。
===》运行时,对堆和栈内存区域进行控制。
===》内存映射段在进程申请大块内存(malloc)时会使用。
===》栈内存:遵循FIFO的顺序,由进程控制。
===》堆内存,提供对应的api(malloc/calloc/realloc/free),由用户控制。
===》栈内存操作系统提供了大小限制(看有的文档说是8M,我没验证),那么申请过多,会有栈溢出的报错。
===》堆内存:需要程序员自己释放,或者程序结束时,由os回收,由链表进行控制,查找空闲链表进行分配内存。(会有内存碎片)
遗留:如果不考虑物理内存,这里的虚拟内存逻辑大约这些,如果考虑物理内存呢? 以及linux环境下进程的个数最多有多少个? 有参数限制,与内存有什么关系?等内存相关一系列问题。
1.2.2:内存碎片
1:操作系统提供api(malloc/calloc/realloc/free)由程序员控制内存的申请与释放,底层是通过链表进行管理内存(已经使用的内存/已经释放的可重用内存)的。
2:链表管理堆内存,频繁的申请和释放(例如,频繁申请和释放小内存),
===》小内存释放后,链表管理中,无法有效重用,内存利用率不高,内存碎片过多,甚至可能用完内存,导致程序崩溃。
注意:项目中大多数的异常崩溃,长时间偶现问题,都是与内存相关的问题。
1.2.3:内存碎片解决方案:内存池
解决问题:依赖操作系统api,频繁的申请和释放内存,可能使内存使用率过低,内存碎片过多,对于长时间运行的程序,可能导致内存不够,程序崩溃。
原理:参考操作系统管理(申请/释放)内存的方案,把内存的管理上升到用户层,由我们自己管理。
目的: 还是实现类似malloc/free(申请/释放内存)相关api,把由操作系统控制的这类库函数,用自己的方案实现。
实现方案:基本思路,用链表管理每一个申请的内存,设计内存可重用。(最简单方案)
===》1:定义节点,保存内存地址,申请大小。
===》2:用链表对当前申请内存进行管理。
===》3:malloc申请内存策略,首先遍历链表,查找可用,可用则用,不可用则申请。
===》4:free内存释放,先查找内存,找到则修改对应节点标志,下次malloc可重用。
===》5:伪代码:
struct memnode{
void* alloc; //指针
struct memnode * next; //控制链表
int flag; //标识是否释放
int size; //申请内存的大小
}; //每一次申请内存的节点,管理内存
// malloc时:
{
if(memnode_t = Traverse(node_list, size))
{
return memnode_t->alloc;
}
void *ptr = malloc(size); //申请节点
memnode_t->alloc = ptr; //管理每个节点
memnode_t->size = size;
add_node(node_list, memnode_t);
}
//free时:
{
memnode_t = search(node_list, ptr); //查找要释放的节点
node->flag = 0; //bu
}
思考: (貌似没有多大意义,和操作系统底层差距不大,引入思考。。。)
缺点:内存的利用率不高,内存只申请没有释放。
链表遍历查找效率低,free时查找O(n)
思考:在内存申请上优化,申请大一点的固定内存,对内部节点的使用进行管理,以一个大块内存为节点进行重用。(貌似nginx就是这样,进程有生命周期,进程的内存会随着进程的释放而销毁,如果特定的业务场景,如socket链接的生命周期使用,可以不关注内存释放,对内存块进行管理即可)
实现方案:申请一块大一点的内存,重载malloc/free函数,对内存块进行管理。
===》1:申请一个固定大小块内存(有内存节点对齐问题),以固定块内存为节点组织数据结构进行管理,如链表,用于申请及管理小内存。
===》2:每个块内存中,只标识了可申请内存的尾节点,小块内存在根据尾节点及剩余大小判断进行申请还是重新申请块内存。
===》3:大块内存使用另外的逻辑,链表进行管理,申请和释放。
=》4:释放:大块内存直接释放,小块内存不释放。=》(这里只是符合nginx业务的一种处理)
//存储大块内存的指针
typedef struct mp_large_t{
struct mp_large_t *next;
void * alloc;
}MP_LARGE_T;
//存储小节点的结构
typedef struct mp_node_t
{
unsigned char * last;
unsigned char * end;
struct mp_node_t *next;
size_t failed;
}MP_NODE_T;
//定义管理节点的结构 柔性数组,实际的内存是head[0]
typedef struct mp_pool_t
{
size_t max; //标识块的大小
struct mp_node_t * current; //小块内存申请时使用时用
struct mp_large_t * large; //大块内存的节点,起始节点,头插法
struct mp_node_t head[0]; //小块内存的起始节点,销毁用
}MP_POOL_T;
===》扩展:其实可以对小块内存内部的内存进行链表管理,进行重用/释放处理,更可靠。
===》思考及遗留:内存池的设计有时候考虑具体的业务场景,这里未对小块内存进行释放在某些持续运行场景下是不合理的。
场景一中,对申请的每块内存进行释放/重用管理减少碎片,和场景中对申请的一块大块固定内存的小块内存进行释放/重用管理,其实是同一种方案喽,
内存池的方案思考: 假设在堆上申请一大块内存(内存池子),专供我们小块内存的申请释放使用,如何设计策略呢?
有两个问题:
===》1:内存的高效使用,可重用,碎片少。 (malloc申请内存策略)
===》2:查找内存的速度,使用数据结构/算法进行优化。(链表,红黑树,hash,b-tree,b+tree,skip-list)(free时,需要找到节点进行释放删除)
查找速度,可以考虑红黑树,依次从内存大小和内存地址作为key值进行考虑。
可行性方案:
站在malloc速度,可用性进行分析:(以申请内存的大小size进行管理)
===》加一些限制,申请小块内存时,不是随机指定,而是以2^n进行申请。
===》根据申请内存的大小,使用数据结构进行管理。
==》如图,我们可以限定内存申请的大小(始终以2^n进行限定申请内存)。
==》可以使用数组对限定的内存大小进行标识。
==》数组中的每个元素对应一个list,管理的是对应内存大小,申请的链表指向(如array[0]标识内存大小为16的地址申请list节点,下次malloc申请会查找这个list,有则使用,无则申请)
伪代码:
struct memnode{
void *ptr;
int flag; //标识是否释放,如果释放,下次可重用
struct memnode *next;
};
struct memlist_hdr{
struct memnode * list;
int size;
}
struct memtable{
struct memlist_hdr array_node[9];//假设了9个
};
//特大大块内存可以重新考虑
void * malloc(int size) //对size进行校验,取离2^n最近的一个值
{
//用偏移位对size进行处理
void *ptr = search_table(size); //==在size对应的数组节点链表中进行查找,找到有释放的空闲节点则使用,没找到则申请并插入
if(!ptr)
{
ptr = sys_malloc(size); //这里的size经过偏移处理2^n
add_node_list(size, ptr);//加入对应的size对应的数组管理的list中
}
return ptr;
}
void free()
{
//如果要对某个节点进行释放,只知道节点的地址,需要遍历所有的节点进行查找,效率太低
}
思考:这种方案一定程度上可以降低内存碎片的产生,同时,需要考虑极限情况,如果内存不够用如何扩展。(不同的数组之间,如何进行内存的调整扩展)
===》1:大块拆小块,(拆分原则:128=64+32+16+16)
===》2:小块合并大块(依次分析节点中的list,根据相邻节点大小偏移,排序可以合并)
释放的时候只知道内存的地址,如何有效在1.3.3的基础上对其释放性能进行提升?
===》在上面的基础上,使用红黑树,以地址作为key值,对所有的内存节点进行管理。
伪代码如下:
struct memnode{
void *ptr;
int flag; //标识是否释放,如果释放,下次可重用
struct memnode *next;
rbtree_node(memnode) rbtree;
};
struct memlist_hdr{
struct memnode * list;
int size;
}
struct memtable{
struct memlist_hdr array_node[9];//假设了9个
rbtree_node(memnode) root;
};//管理节点
//释放时,通过这里的管理节点,我们可以很快找到要释放的节点memnode
//通过红黑树中节点的指向,可以对list链表节点进行控制
//同时,在malloc时,往list中插入节点时,通过节点中红黑树的根节点指向插入红黑树。
如果把该实现作为一个框架组件:
1:线程安全的保证,需要加锁/其他方案。
2:在整个流程中,没有释放内存也是一个问题,如何回收?(大块拆小块,小块合并大块)
1:频繁的申请和释放小块内存的业务场景,会导致系统内存碎片过多,甚至可能内存不够用。
2:可以用线程池解决该问题,本质还是对申请的内存指针进行管理,有关内存管理,已经有一些算法(伙伴算法,buddy,slab等)
3:内存池的设计,可以从申请内存的大小和内存的地址两个角度作为key值进行考虑。
4:内存释放问题需要考虑,如果没有把内存回归系统,需要考虑如何回收内存(大块拆小块,小块合并大块)
5:内存池的使用是建立在具体的业务场景下的,可以具体情况具体分析,同时,内存/内存池的生命周期,与使用它的进程/线程生命周期相关。
6:线程安全问题。
说明:
只是在理论的基础上,对内存池的实现方案进行学习和梳理。
遗留:
1:内存池中重载new 对象的实现代码。
2:进程的内存空间,虚拟内存,操作系统进程的内存管理
3:相关的一些内存控制算法(伙伴算法,buddy,slab等)
4:已有的实际的一些内存池应用源码库具体了解一下,验证理论。
原文地址:https://zhuanlan.zhihu.com/p/606253119
作者:Linux