【高并发内存池】第一篇:定长内存池设计

一. 什么是内存池?

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;    
}  

补充说明

我们知道,单链表头插的第一步要把待插入节点和链表中第一个有效结点连接起来的,在这里我们让待插入节点的头 4/8 个内存空间去存储链表第一个节点的地址
在这里插入图片描述

另外,地址的大小(即一个指针变量的大小)在32位平台下是4字节,而在64位平台下是8字节,需要对这两种平台分别做处理。一开始我想到的办法是先用 sizeof() 计算一个指针变量的大小,然后根据得到的结果再分情况处理:

void Delete(T* obj)    
{    
  // 1、显示地调用对象类型的构造函数,完成对象空间的内容清理
  obj->~T();
  // 2、把传入的这块对象空间头插到自由链表中
  size_t addressLen = sizeof(int*);// 任意类型的指针变量都可以
  
  if(addressLen == 4)
    *(int*)obj = _freeList;// 拿出obj前4个字节的内存
  else 
    *(long long*)obj = _freeList;// 拿出obj前8个字节的内存                                                        
                                                  
  _freeList = obj;    
}  

上面我们是先判断所处平台地址的大小,再去待插入节点的起始内存空间中写入链表第一个节点地址。还有一种更简便的方法一步到位:先把 obj 强转为二级指针类型,然后再解引用拿到其指针大小(4/8byte)的内存空间。

*(void**)obj = _freeList;// 括号里也可以是其他类型:int**、char** 等等

类比整型指针解引用的操作可以帮助我们理解上面那行代码:
在这里插入图片描述

3.3 申请(New)一块对象空间

函数的接口如下,调用该函数后,会返回给外部一块大小为 sizeof(T) 字节的对象空间:

T* New() {...}

该函数的实现逻辑如下:

  1. 先看自由链表中是否有可用的对象空间,有的话直接从自由链表里拿。
  2. 没有的话从对象池中申请一块,申请之前要确保有内存池中有足够的空间去申请。
// 申请一块 T 类型的对象空间
T* New()
{
  T* obj = nullptr;
  // 1、先看自由链表中是否有可用的对象空间,有的话直接从哪里拿
  // 2、没有的话从对象池中申请一块
  if(_freeList)
  {
    void* next = *(void**)_freeList;
    obj = (T*)_freeList;
    _freeList = next;
  }
  else 
  {
    // 剩余空间不够一个对象的大小时要增容
    if(_remain < sizeof(T))
    {                                                                           
      size_t getSize = 128 * 1024;
      _memory = (char*)malloc(getSize);
      if(_memory == nullptr)
      {
        throw std::bad_alloc();
      }
      _remain = getSize;
    }
    // 确保容量足够分配出一块对象大小的内存空间
    obj = (T*)_memory;
    size_t blockSize = sizeof(T) < sizeof(void*)? sizeof(void*) : sizeof(T) ; 
    _memory += blockSize;
    _remain -= blockSize;
  }
  new(obj) T;// 定位new显示调用T类型的默认构造函数初始化内存空间
  return obj;
}

关于能否释放局部空间的问题

问题描述如下图所示:
在这里插入图片描述

这个问题换一种描述就是:我们通过 malloc 申请到一段连续的内存空间,能否在这段内存空间的中间任何位置进行 free 操作仅仅释放掉该位置后面部分的空间?

答案是不能,这种操作会导致程序运行时崩溃。正确释放空间做法应该是在这段连续空间空间的起始位置进行释放。前面说过 malloc 本质是一种使用范围更广的内存池,它有自己一套自己的内存管理方式,它会按照我们要求的字节数拿给我们需要的空间,它把每一块 malloc 出来的内存空间作为一个管理单元,想要对其进行释放必须从这块内存空间的起始位置开始,正如我们定长内存池中 Delete 操作必须传入对象空间的起始位置一样,这样就不会内存泄漏,也便于内存资源的管理。

关于定位 new 的知识点补充

1、为什么 New() 中要用到定位 new?

最终返回所申请到的内存地址之前,我们使用了定位 new 的方法显示调用了 T 类型的默认构造函数去初始化这块对象空间。这是有必要的,因为这块空间不是我们直接定义对象得到的,而是通过一个 T* 类型的指针变量 obj 指向一块内存得来的:obj = (T *)_memory; 这种情况系统不会自动调用 T 类型的默认构造函数去初始化 obj 指向的这块内存空间,所以我们就需要在函数内部调用定位 new 来完成对这块内存空间初始化,然后把这块空间返回给外部使用。

2、定位 new 的使用方法
new (ptr) type 或者 new (ptr) type(initializer-list)

  • ptr 是对象空间的首地址
  • initializer-list 是类型的初始化列表,可以不写,这样就是调用默认的构造函数。
  • 左边那个是调用默认构造函数的的形式(不传参),右边那个是调用带参数的构造函数的形式。

四. 定长内存池代码和性能测试

ObjectPool.h

#include <iostream>
using std::cout;
using std::endl;

template<class T>
class ObjectPool
{
  public:
    // 对象池默认构造函数
    ObjectPool()
      :_memory(nullptr)
      ,_remain(0)
      ,_freeList(nullptr)
    {}

    // 申请一块T类型的对象空间
    T* New()
    {
      T* obj = nullptr;
      // 1、先看自由链表中是否有可用的对象空间,有的话直接从哪里拿
      // 2、没有的话从对象池中申请一块
      if(_freeList)
      {
        void* next = *(void**)_freeList;
        obj = (T*)_freeList;
        _freeList = next;
      }
      else 
      {
        // 剩余空间不够一个对象的大小时要增容
        if(_remain < sizeof(T))
        {
          //delete[] _memory; // 错误操作
          size_t getSize = 100 * 1024;
          _memory = (char*)malloc(getSize);
          if(_memory == nullptr)
          {
            throw std::bad_alloc();
          }
          _remain = getSize;
        }
        // 确保容量足够分配出一块对象大小的内存空间
        obj = (T*)_memory;
        size_t blockSize = sizeof(T) < sizeof(void*)? sizeof(void*) : sizeof(T) ; 
        _memory += blockSize;
        _remain -= blockSize;
      }
      new(obj) T;// 定位new显示调用T类型的默认构造函数初始化内存空间
      return obj;
    }

    // 伪释放一块T类型的对象空间
    void Delete(T* obj)    
    {    
      // 1、显示调用对象类型的构造函数,完成对象空间的内容清理
      obj->~T();    
      // 2、把传入的这块对象空间头插到自由链表中
      *(void**)obj = _freeList;    
      _freeList = obj;    
    }  

  private:
    char* _memory;  // 对象池:指向一块连续待申请的空间
    size_t _remain; // 记录对象池中剩余可用空间的大小,单位是字节
    void* _freeList;// 自由链表:指向第一块不用对象空间
};

Test.cpp
比较 C++ 中 new/delete 和定长内存池中的 New/Delete 申请、释放一百万个对象空间时所用的时间:

#include"ObjectPool.h"
#include<vector>
#include<time.h>

using namespace std;

struct TreeNode 
{ 
	int _val; 
	TreeNode* _left;  
	TreeNode* _right;   
	TreeNode() :_val(0), _left(nullptr), _right(nullptr) {}
};

int main()
{
	size_t N = 1000000;
	vector<TreeNode*> vet;
	vet.resize(N);
  // 测试C++的new和delete效率
	size_t begin = clock();

	for (size_t i = 0; i < vet.size(); i++) 
  {
		vet[i] = new TreeNode;
	}
	for (size_t i = 0; i < vet.size(); i++) 
  {
		delete vet[i];
	}

  // 测试定长内存池的申请、释放对象空间的效率
	size_t end = clock();
	cout << "new TreeNode Time:" << end - begin << endl;

	ObjectPool<TreeNode> pool;
	size_t begin2 = clock();

	for (size_t i = 0; i < vet.size(); i++) 
  {
		vet[i] = pool.New();
	}
	for (size_t i = 0; i < vet.size(); i++) {
		pool.Delete(vet[i]);
	}
	size_t end2 = clock();

	cout << "ObjectPool New Time:" << end2 - begin2 << endl;
	return 0;
}

编译运行,可以看到使用定长内存池申请、释放特定类型对象的性能会更好:
在这里插入图片描述

  • 2
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值