文章目录
一. 什么是内存池?
1. 池化技术
内存池是池化技术的一种应用。所谓“池化技术”,就是程序先向系统申请过量的资源,然后自己管理,以备不时之需。之所以要申请过量的资源,是因为每次申请该资源都有较大的开销,不如提前申请好了,这样使用时就触手可及,大大提高程序运行效率。
生活中的池化技术
在现实生活,每个月父母都会通过微信或者支付宝转给我们一定数量的零花钱,这些“零花钱”就是我们向父母申请的资源,而微信钱包或者支付宝钱包就是存储我们所申请到资源的“零花钱池”,里面的资源可以任由我们分配。这样我们一个月中大大小小的生活开销每次只需从“零花钱池”中攫取即可,而不是每一次需要用到一点小钱就向先父母讨要,这样做效率是很低的。
计算机中的池化技术
在计算机中,还有很多使用“池”这种技术的地方,除了内存池,还有连接池、线程池、对象池等。以服务器中的线程池为例,它的思想是:先启动若干数量的线程,让它们处于休眠状态,当接收到客户端的请求时,便立即唤醒池中某个休眠的线程,让它来处理客户端的请求,当处理完这个请求后,线程再次进入休眠状态。
2. 内存池概念
内存池是指程序预先从操作系统申请一块足够大内存,此后当程序中需要再次申请内存的时候,不是直接向操作系统申请,而是直接从内存池中获取;同理,当程序释放内存的时候,并不真正将内存返回给操作系统,而是返回给内存池保管。当程序退出时,内存池才将之前所申请的内存归还给系统。
在 C 中我们要动态申请内存都是通过 malloc 去申请完成的,但是我们要知道,实际我们不是直接去堆中申请内存的,而是去 malloc 这个内存池中申请。malloc 函数相当于向操作系统“批发”了一块较大的内存空间,然后“零售”给程序用。当全部“售完”或程序有更大的内存需求时,再根据实际需求向操作系统“进货”。malloc 的内部实现方式有很多种,一般不同系统用的都是不一样的。比如 windows 的 vs 系列用的微软自己写的一套,linux gcc 用的是 glibc 中的 ptmalloc。
二. 为什么要有内存池?
通常我们习惯直接使用 new、malloc 等 API 去动态申请内存,但是这样做的缺点在于:由于所申请内存的大小不确定,频繁使用时会造成大量的内存碎片并进而降低性能。C/C++ 的内存分配(通过 malloc 或 new)需要花费一定的时间。更糟糕的是,随着时间的流逝,内存碎片会越积越多,所以一个应用程序运行了很长时间并执行了很多的内存分配(释放)操作的时候,它会越来越慢。
1. 内存碎片问题
内存碎片分为外碎片和内碎片。
- 外碎片:由于多个空闲内存块的空间不连续,导致无法整合出一块更大更连续的内存空间,以至于最终不能满足一些更大的内存分配申请需求。
- 内碎片:由于一些对齐的需求,导致分配出去的空间中一些内存无法被利用。
2. 内存池带来的好处
如果我们给一个程序设计一个它自己的内存池,这可以一定程度上缓解上面出现的问题。内存池可以给我们的程序带来以下两个好处:
- 非常少(几乎没有) 内存碎片。
- 速度比通常的内存申请/释放(malloc、 free)快。
这两个好处综合后可以显著提高程序的运行效率。
三. 定长内存池设计
1. 定长内存池特点
- 每次只能申请、释放特定类型大小的内存。
- 申请、释放内存的性能达到极致。
- 不考虑内存碎片问题。
2. 定长内存池基本思想
第一步:首先我们向系统申请一段长度固定、连续的内存空间,用一个 char* 类型的指针变量 _memory
指向这段空间的起始地址:
第二步:每次程序可以从这段连续内存空间中申请特定类型:T
,即大小为 sizeof(T) 字节的一块对象空间,申请完成后 _memory 往后移动 sizeof(T) 的距离,继续指向新的待申请空间的起始地址:
问题:为什么要把 _memory 的类型定义为 char*
首先我们要知道,对于一个指针变量来说,它的类型决定了它如下的两个特点:
- 指针变量解引用时,所能获取到数据空间的大小
- 指针变量 ++/-- 时,单步的步长。
程序申请走 sizeof(T) 个字节的对象后,_memory 应该往后走 sizeof(T) 个字节的长度,以指向新的待申请空间起始地址。我们把 _memory 定义成 char* 类型,这样单步就只走一个字节(方便控制),只需直接加上 sizeof(T) 就到了我们想要的位置。
补充:void* 类型的指针变量比较特别,它有如下几个特点:
- 不能 +/- 整数
- 不能解引用
- 它可以接收所有指针类型的地址。
- 使用时,一般是把它强转成其他类型的指针,然后再对其进行解引用、加减整数、赋值等操作。
第三步:释放一块不用的对象空间时,不能直接 free,而是应该把这块对象空间链接到一个链表中保存,这样下次再申请时就直接从链表中拿,而不用再去 _memory 中申请。这样可以加强内存空间的利用率。
那如何链接这些归还回来的对象呢?对于不再使用的对象空间我们让它的头 4 个或 8 个字节保存下一个节点的地址,这样每一块对象空间就以单链表的形式组织起来了:
我们定义一个指针变量 _freeList
去指向第一块对象空间的地址,类型定义成 void* 即可,这个指针变量仅仅起到一个哨兵位头节点的作用。
3. 定长内存池实现
3.1 基本框架
因为我们是针对特定大小空间来进行申请和释放操作的,所以我们把类名叫做对象池(ObjectPool):
template<class T>
class ObjectPool
{
public:
// 对象池默认构造函数
ObjectPool()
:_memory(nullptr)
,_remain(0)
,_freeList(nullptr)
{
}
private:
char* _memory; // 对象池:指向一段连续待待分配的空间
size_t _remain; // 记录对象池中剩余可用空间的大小,单位是字节
void* _freeList;// 自由链表:指向第一块不用的对象空间
};
3.2 释放(Delete)一块对象空间
调用者在外部把需要释放对象的地址传入,Delete() 内部负责把这块对象空间头插到自由链表中:
// 释放一块 T 类型的对象空间
void Delete(T* obj)
{
// 1、显示调用对象类型的析构函数,完成对象空间内容的清理
obj->~T();
// 2、把对象空间头插到自由链表中
*(void**)obj = _freeList;
_freeList = obj;
}
补充说明