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

简介: 内存池是池化技术的一种应用。所谓“池化技术”,就是程序先向系统申请过量的资源,然后自己管理,以备不时之需。之所以要申请过量的资源,是因为每次申请该资源都有较大的开销,不如提前申请好了,这样使用时就会变得非常快捷,大大提高程序运行效率。

一. 什么是内存池?


1. 池化技术


内存池是池化技术的一种应用。所谓“池化技术”,就是程序先向系统申请过量的资源,然后自己管理,以备不时之需。之所以要申请过量的资源,是因为每次申请该资源都有较大的开销,不如提前申请好了,这样使用时就会变得非常快捷,大大提高程序运行效率。

   生活中的池化技术

在现实生活,每个月父母都会通过微信或者支付宝转给我们一定数量的零花钱,这时“零花钱”就是我们向父母申请的资源,而微信钱包或者支付宝钱包就是存储我们申请到的资源的“零花钱池”,里面的资源可以任由我们分配。这样我们一个月中大大小小的生活开销只需从“零花钱池”中攫取即可,而不是每一次需要用到一点小钱就向先父母讨要,这样效率是很低的。

   计算机中的池化技术

在计算机中,有很多使用“池”这种技术的地方,除了内存池,还有连接池、线程池、对象池等。以服务器上的线程池为例,它的主要思想是:先启动若干数量的线程,让它们处于睡眠状态,当接收到客户端的请求时,唤醒池中某个睡眠的线程,让它来处理客户端的请求,当处理完这个请求,线程又进入睡眠状态。


2. 内存池概念


内存池是指程序预先从操作系统申请一块足够大内存,此后,当程序中需要申请内存的时候,不是直接向操作系统申请,而是直接从内存池中获取;同理,当程序释放内存的时候,并不真正将内存返回给操作系统,而是返回内存池。当程序退出(或者特定时间)时,内存池才将之前申请的内存真正释放。

C/C++中我们要动态申请内存都是通过 malloc 去申请完成的,但是我们要知道,实际我们不是直接去堆中申请内存的,而是去malloc这个内存池中申请。malloc函数相当于向操作系统“批发”了一块较大的内存空间,然后“零售”给程序用。当全部“售完”或程序有更大的内存需求时,再根据实际需求向操作系统“进货”。malloc的实现方式有很多种,一般不同编译器平台用的都是不同的。比如windows的vs系列用的微软自己写的一套,linux gcc用的是glibc中的ptmalloc。


二. 为什么要有内存池?


通常我们习惯直接使用new、malloc等API申请分配内存,这样做的缺点在于:由于所申请内存块的大小不定,当频繁使用时会造成大量的内存碎片并进而降低性能。C/C++的内存分配(通过malloc或new)需要花费一定的时间。更糟糕的是,随着时间的流逝,内存(memory)将形成碎片,所以一个应用程序运行了很长时间并执行了很多的内存分配(释放)操作的时候,它会越来越慢。


1. 内存碎片问题


内存碎片分为外碎片和内碎片。

  •    外部碎片是一些空闲的连续内存区域太小,这些内存空间不连续,以至于合计的内存足够,但是不能满足一些的内存分配申请需求。
  •    内部碎片是由于一些对齐的需求,导致分配出去的空间中一些内存无法被利用。

下图说明外碎片的情况:

图片.png


2. 内存池带来的好处


如果我们给一个程序设计一个它自己的内存池,可以一定程度上缓解上面出现的问题。内存池可以给我们的程序带来以下两个好处:

  •    非常少(几没有) 堆碎片。
  •    速度比通常的内存申请/释放(malloc, free)的方式快。

这两个好处综合可以显著提高程序的运行效率。


三. 定长内存池设计


1. 定长内存池特点

  •    每次只能申请、释放特定类型大小的内存。
  •    申请、释放内存的性能达到极致。
  •    不考虑内存碎片问题。


2. 定长内存池基本思想


第一步:首先我们向系统申请一段长度固定、连续的内存空间,用一个char*类型的指针变量_memory指向这段空间的起始地址:

图片.png

第二步:每次程序可以从这段连续内存空间中申请特定类型:T,即大小为sizeof(T)字节的一块对象空间,申请完成后_memory往后走sizeof(T)字节的距离,继续指向新的待申请空间的起始地址:

图片.png

   问题:为什么要把_memory的类型定义为char*

首先我们要知道,对于一个指针变量来说,它的类型决定了它具有如下的两个特点:

   指针变量解引用时,它的类型决定了它能够访问访问内存空间的大小。

   指针变量+/-整数时,它的类型决定了它每一步的步长。

图片.png

程序申请走sizeof(T)个字节的一块对象空间后,_memory应该往后走sizeof(T)个字节指向新的待申请空间起始地址,我们把_memory定义成char*类型,每一步走一个字节,这样它直接加上sizeof(T)就到了我们想要的位置。

补充:void*类型的指针变量比较特别,它有如下几个特点:

   不能+/-整数。

   不能解引用。

   使用时,一般是把它强转成其他类型的指针,然后再对其进行解引用、加减整数、赋值等操作。

   它可以接收所有指针类型的地址。

第三步:释放一块不用的对象空间时,不能直接free,而是应该把这块对象空间链接到一个链表中,这样下次再申请时可以直接从链表中拿,不用再去_memory中申请。这样做加强了内存空间的利用率。另外,这块不再使用的对象空间我们让它的头4个或8个字节存下一个节点的地址,怎样做每一块对象空间就以单链表的形式组织起来了:

图片.png

我们定义一个指针变量_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;    
}

我们知道,单链表头插的第一步要把待插入节点和链表的第一个结点连接起来,在这里我们让待插入节点的起始内存空间存储链原自由链表的第一个节点的地址:

图片.png

另外,地址的大小(即一个指针变量的大小)在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强转为二级指针类型,然后再解引用拿到指针大小的内存空间。

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

类比整型指针解引用的操作可以帮助我们理解上面那行代码:

图片.png

最后还有一个问题,如果T的类型是char,即我们要申请的只是一个字节大小的内存空间,这样空间太小是存不下地址的。这个不用担心,因为在申请一块对象空间时会检测sizeof(T)是否大于sizeof(指针类型),如果小于的话那就给你一个指针大小的内存空间,满足申请需求的同时确保每一块对象空间至少能存下一个地址。


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


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


T* New()
{}

该函数的实现逻辑如下:


   先看自由链表中是否有可用的对象空间,有的话直接从自由链表里拿。

   没有的话从对象池中申请一块,申请之前要确保有内存池中有足够的空间。


// 申请一块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;
}

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

问题描述如下图所示:

图片.png

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

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

   关于定位new的知识点补充

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

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

2、定位new的使用方法

new (place_address) type 或者 new (place_address) type(initializer-list)

   place_address必须是一个已申请出来的空间的地址

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

编译运行,可以看到定长内存池申请、释放对象空间的性能会更好:

图片.png


相关文章
|
存储 缓存 安全
高并发内存池实战:用C++构建高性能服务器(下)
高并发内存池实战:用C++构建高性能服务器
高并发内存池实战:用C++构建高性能服务器(下)
|
12天前
|
监控 Java 数据库连接
线程池在高并发下如何防止内存泄漏?
线程池在高并发下如何防止内存泄漏?
|
3月前
|
存储 缓存 NoSQL
Redis内存管理揭秘:掌握淘汰策略,让你的数据库在高并发下也能游刃有余,守护业务稳定运行!
【8月更文挑战第22天】Redis的内存淘汰策略管理内存使用,防止溢出。主要包括:noeviction(拒绝新写入)、LRU/LFU(淘汰最少使用/最不常用数据)、RANDOM(随机淘汰)及TTL(淘汰接近过期数据)。策略选择需依据应用场景、数据特性和性能需求。可通过Redis命令行工具或配置文件进行设置。
71 2
|
5月前
|
存储 缓存 NoSQL
Redis是一种高性能的内存数据库,常用于高并发环境下的缓存解决方案
【6月更文挑战第18天】**Redis摘要:** 高性能内存数据库,擅长高并发缓存。数据存内存,访问迅速;支持字符串、列表等多元数据类型;具备持久化防止数据丢失;丰富命令集便于操作;通过节点集群实现数据分片与负载均衡,增强可用性和扩展性。理想的缓存解决方案。
75 1
|
4月前
|
设计模式 安全 NoSQL
Java面试题:设计一个线程安全的单例模式,并解释其内存占用和垃圾回收机制;使用生产者消费者模式实现一个并发安全的队列;设计一个支持高并发的分布式锁
Java面试题:设计一个线程安全的单例模式,并解释其内存占用和垃圾回收机制;使用生产者消费者模式实现一个并发安全的队列;设计一个支持高并发的分布式锁
64 0
|
4月前
|
设计模式 安全 Java
Java面试题:如何实现一个线程安全的单例模式,并确保其在高并发环境下的内存管理效率?如何使用CyclicBarrier来实现一个多阶段的数据处理任务,确保所有阶段的数据一致性?
Java面试题:如何实现一个线程安全的单例模式,并确保其在高并发环境下的内存管理效率?如何使用CyclicBarrier来实现一个多阶段的数据处理任务,确保所有阶段的数据一致性?
56 0
|
4月前
|
设计模式 存储 缓存
Java面试题:结合建造者模式与内存优化,设计一个可扩展的高性能对象创建框架?利用多线程工具类与并发框架,实现一个高并发的分布式任务调度系统?设计一个高性能的实时事件通知系统
Java面试题:结合建造者模式与内存优化,设计一个可扩展的高性能对象创建框架?利用多线程工具类与并发框架,实现一个高并发的分布式任务调度系统?设计一个高性能的实时事件通知系统
51 0
|
4月前
|
存储 安全 Java
Java面试题:假设你正在开发一个Java后端服务,该服务需要处理高并发的用户请求,并且对内存使用效率有严格的要求,在多线程环境下,如何确保共享资源的线程安全?
Java面试题:假设你正在开发一个Java后端服务,该服务需要处理高并发的用户请求,并且对内存使用效率有严格的要求,在多线程环境下,如何确保共享资源的线程安全?
66 0
|
6月前
|
缓存 Java 程序员
【项目日记(一)】高并发内存池项目介绍
【项目日记(一)】高并发内存池项目介绍
【项目日记(一)】高并发内存池项目介绍
|
6月前
|
SQL 资源调度 关系型数据库
实时计算 Flink版产品使用合集之可以使用高并发大内存的方式读取存量数据吗
实时计算Flink版作为一种强大的流处理和批处理统一的计算框架,广泛应用于各种需要实时数据处理和分析的场景。实时计算Flink版通常结合SQL接口、DataStream API、以及与上下游数据源和存储系统的丰富连接器,提供了一套全面的解决方案,以应对各种实时计算需求。其低延迟、高吞吐、容错性强的特点,使其成为众多企业和组织实时数据处理首选的技术平台。以下是实时计算Flink版的一些典型使用合集。
104 3