当前位置: 首页 > article >正文

定长内存池的实现、测试及错误分析

背景

C/C++ 申请内存使用的是 mallocmalloc 其实就是一个大众货,什么场景下都可以用,但是什么场景下都可以用就意味着什么场景下都不会有很高的性能。

定长内存池解决固定大小的内存申请释放需求, 性能达到极致,不考虑内存碎片等问题。

定长内存池成员设计

先分配一块大内存,为了方便对这段空间进行操作,将指针定义成 char 类型,想移动指针只需要加减 n 即可。

申请内存时,会判断大内存还有没有空间。如果大内存有空间,但不能满足需分配的小内存的需要,这时分配就会导致内存溢出。所以需要记录大内存剩余字节数 _remain_size

if(_memory == nullptr) {
	//malloc 分配定长内存
}
T* obj = (T*)_memory;
_memory += sizeof(T);

这时申请内存就不会出现上述问题了。

//剩余内存不够一个对象大小时,重新开内存
if (_remain_size < sizeof(T)) {
    _remain_size = 128 * 1024;
    _memory = (char*)malloc(_remain_size );
    if (_memory == nullptr) {
        throw std::bad_alloc(); 
    }
}
obj = (T*)_memory;
_memory += objsize;
_remainSizeBytes -= objsize;

内存被申请了,如何还回来呢?

可以通过自由链表的方式管理内存块,但并不需要设计链表,可以用内存块的前 4 个字节(32位机器)或者前 8 个字节(64 位)作为一个指针指向下一个内存块,再用一个头指针 freelist 维护这个链表。

指针的类型决定了解引用后可访问的字节大小。在 32 位系统上,一个指针通常是 4 个字节;在 64 位系统上,一个指针通常是 8 个字节。

*(void**)obj = _freelist;
_freelist = obj;

obj 指针强制转换为 void** 类型,也就是指向指针的指针类型,然后对其解引用,实际上就是把 obj 所指向的内存位置当作一个指针变量来看待,并将当前自由链表的头指针 _freelist 的值赋给它,并更新自由链表的头指针。

所以,定长内存池需要三个成员

char* _memory = nullptr;  	// 指向大块内存的指针,char* 有利于拓展
size_t _remain_size=0;  	// 内存块后面剩余的字节大小
void* _free_list;       	// 自由链表管理还回来的内存

申请内存时也要优先使用自由链表中的内存。

//优先使用还回来的内存块对象
if (_free_list) {
    void* next = *((void**)_free_list);
    obj = _free_list;
    _free_list = next;
}

但是分配内存时还存在着问题:

size_t objsize = sizeof(T) < sizeof(void*) ? sizeof(void*) : sizeof(T);

分配内存大小要满足上面代码, 方便还回来的时候连接到一起。

定长设计

利用非类型模板参数,让内存池每次申请的内存块大小都是固定的

template <size_t N>
class ObjectPool {

};

或者使用模板实现,将内存池设计为模板类,创建一个内存池需要给它赋一个类型,这样每次申请的对象都是一个类型,做到了定长。

template <class T>
class ObjectPool {

};

内存池如何申请对象

当内存池要申请对象时,优先使用前面释放的内存,即在 _free_list 中维护的内存块。

_free_list 为空,说明前面的空间没有释放,需要分配内存池后面的空间。

若后面的空间不够申请一个对象,内存池就重新向 OS 申请一块大内存,再申请对象。

不过这里定长内存池也是调用了 malloc 去申请大块的空间,能不能直接调用系统申请空间的接口呢?

可以直接向堆申请内存空间,在 Linux 下,可以调用 brkmmap 函数。在 Windows 下用的是 VirtualAlloc 函数。

brk 函数

brk 函数通常用于在堆的末尾扩展或收缩内存。

原型

#include <unistd.h>
void *brk(void *addr);

用法

  • addr 参数指定新的数据段末尾的地址。
  • 如果调用成功,返回新的数据段末尾的地址;如果失败,返回 (void *) -1 并设置 errno

示例

#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/types.h>

int main() {
    char *heap_start;
    
    // 获取当前堆的末尾
    heap_start = (char *) sbrk(0);
    
    // 请求增加1024字节的堆空间
    if (brk(heap_start + 1024) == (void *) -1) {
        perror("brk");
        exit(EXIT_FAILURE);
    }
    
    // 在新申请的堆空间中写入数据
    snprintf(heap_start, 1024, "Hello, heap!");
    printf("%s\n", heap_start);
    
    return 0;
}

优点

  • 相对简单,适用于小块内存的分配。

缺点

  • 不适合多线程环境,因为 brk 是全局的,会影响整个进程的堆。
  • 扩展堆空间时可能会导致内存碎片。
mmap 函数

mmap 函数用于在进程的地址空间中映射文件或匿名内存区域。它通常用于需要大块内存或希望避免内存碎片的场景。

原型

#include <sys/mman.h>
void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);

用法

  • addr:建议映射的内存起始地址(通常传 NULL 让系统自动选择)。
  • length:映射区域的长度。
  • prot:期望的内存保护标志(如 PROT_READ, PROT_WRITE)。
  • flags:控制映射对象的类型和行为(如 MAP_PRIVATE, MAP_ANONYMOUS)。
  • fd:文件描述符(对于匿名内存映射,传 -1)。
  • offset:文件中的偏移量(对于匿名内存映射,传 0)。

示例

#include <stdio.h>
#include <stdlib.h>
#include <sys/mman.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>

int main() {
    size_t length = 1024;
    void *mapped_memory;
    
    // 匿名内存映射
    mapped_memory = mmap(NULL, length, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
    if (mapped_memory == MAP_FAILED) {
        perror("mmap");
        exit(EXIT_FAILURE);
    }
    
    // 在映射的内存中写入数据
    snprintf(mapped_memory, length, "Hello, mmap!");
    printf("%s\n", (char *)mapped_memory);
    
    // 释放映射的内存
    if (munmap(mapped_memory, length) == -1) {
        perror("munmap");
        exit(EXIT_FAILURE);
    }
    
    return 0;
}

优点

  • 适用于大块内存分配。
  • 映射的内存区域可以独立管理,不容易受到其他内存分配的影响。
  • 可以更好地利用虚拟内存机制,提高内存管理的灵活性。

缺点

  • 相对复杂,需要更多的系统管理开销。
  • 需要手动释放内存(通过 munmap)。

选择使用 brk 还是 mmap 取决于具体的应用场景和需求。对于小块内存分配和简单的内存管理,brk 可能更合适;而对于大块内存分配和需要避免内存碎片的场景,mmap 通常更合适。

ObjectPool.h 的实现

#pragma once

#include<iostream>

#ifdef _WIN32
	#include<Windows.h> // Windows下的头文件
#else
    #include <sys/mman.h> // Linux下的头文件
#endif // _WIN32

// 直接去堆上按页申请空间
// 每页大小 8KB(2^13),kpage 为页数,总大小为 kpage << 13 字节
inline static void* SystemAlloc(size_t kpage)
{
#ifdef _WIN32 // Windows下的系统调用接口
	void* ptr = VirtualAlloc(0, kpage << 13, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
#else
	// linux下brk mmap等
    void* ptr = mmap(0, kpage<<13, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
#endif
	if (ptr == nullptr)
		throw std::bad_alloc();

	return ptr;
}

// 定长内存池模板类
template<class T>
class ObjectPool
{
public:
    // 申请一个T类型大小的空间
	T* New() {
		T* obj = nullptr;

        // 优先从空闲链表中分配
		if (_free_list) { 
            // 取出空闲链表头部
            obj = (T*)_free_list;
            // 将 _free_list 更新为下一个空闲块(原头部存储的地址)
            _free_list = *(void**)_free_list;
		}
        // 当前内存块剩余空间不足,申请新的大块内存
		else {
			// _memory中剩余空间小于T的大小的时候再开空间
			if (_remain_size < sizeof(T)) {
				_remain_size = 128 * 1024; // 128KB(16页)
				//_memory = (char*)malloc(_remain_size);
				
				// 申请16页
				_memory = (char*)SystemAlloc(_remain_size >> 13); 
				
				if (_memory == nullptr) {
					throw std::bad_alloc();
				}
			}

			// 计算步长:确保至少按指针大小划分,以兼容空闲链表指针操作
            size_t objsize = sizeof(T) < sizeof(void*) ? sizeof(void*) : sizeof(T);
            obj = (T*)_memory;       // 分配当前内存块起始位置
            _memory += objsize;      // 移动内存块指针
            _remain_size -= objsize; // 更新剩余空间
		}

        // 定位 new 显式调用构造函数
		new(obj)T;
		return obj;
	}

    // 释放对象内存,将其加入空闲链表
	void Delete(T* obj) {
		// 显式调用析构函数
		obj->~T();

		// 将释放的内存头插到空闲链表
        *(void**)obj = _free_list; // 将 obj 的前 8 字节指向原链表头
        _free_list = obj;          // 更新链表头为当前释放的块
	}

private:
    char* _memory = nullptr;     // 指向当前大块内存的起始地址
    size_t _remain_size = 0;     // 当前大块内存的剩余字节数
    void* _free_list = nullptr;  // 空闲链表头指针(维护返还的小内存块)
};

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

在最后返回申请到内存地址之前,使用了定位 new 的方法显示调用了 T 类型的默认构造函数去初始化这块对象空间。

new(obj)T;

这是有必要的,因为这块空间不是我们直接定义对象得到的,而是通过一个 T* 类型的指针变量 obj 指向一块内存得来的:

obj = (T*)_memory;

这种情况系统不会自动调用 T 类型的默认构造函数去初始化 obj 指向的这块内存空间,所以我们就在函数内部调用定位 new 来完成对这块内存空间初始化。

定位 new 的基本语法

定位 new 允许你在已经分配的内存中创建一个对象,而不再通过 new 来请求堆内存。它通常用于内存池管理、对象池等场景。

基本语法如下:

  • 调用默认构造函数(无初始化):

    new (place_address) type;
    

    这会在 place_address 指定的内存位置上调用 type 的默认构造函数,初始化对象。

  • 调用带参数的构造函数(使用初始化列表):

    new (place_address) type(initializer-list);
    

    这会在 place_address 指定的内存位置上调用 type 的带有初始化参数的构造函数。

定位 new 详解
  • place_address:这是你已经分配好的内存地址,通常是通过 mallocoperator new 或其他方法分配的内存地址。注意,place_address 必须是足够大的内存区域,足以存储你要创建的对象。

  • type:这是你要在 place_address 处构造的对象的类型。

  • initializer-list:这是一个用于初始化对象的列表,它会传递给 type 的构造函数。如果没有提供,默认构造函数会被调用。

默认构造函数
#include <iostream>

class MyClass {
public:
    MyClass() {
        std::cout << "MyClass default constructor" << std::endl;
    }
};

int main() {
    char buffer[sizeof(MyClass)]; // 预分配足够空间

    // 在 buffer 中创建 MyClass 对象,调用默认构造函数
    new (buffer) MyClass;

    return 0;
}
带参数的构造函数
#include <iostream>

class MyClass {
public:
    MyClass(int a, int b) {
        std::cout << "MyClass constructor with arguments: " << a << ", " << b << std::endl;
    }
};

int main() {
    char buffer[sizeof(MyClass)]; // 预分配足够空间

    // 在 buffer 中创建 MyClass 对象,调用带参数的构造函数
    new (buffer) MyClass(10, 20);

    return 0;
}
重要事项
  • 必须确保有足够的内存place_address 指定的内存区域必须足够大,能够容纳你要创建的对象。如果内存空间不足,可能会导致未定义行为(如内存越界)。

  • 显式调用析构函数:当你使用定位 new 创建对象时,需要手动显式调用对象的析构函数。如果你使用 new 分配内存并创建对象,C++ 会自动处理析构函数,但使用定位 new 后,你需要显式销毁对象。例如:

    obj->~MyClass();
    
  • 内存管理:定位 new 并不负责内存的管理。它只是告诉编译器在指定内存位置上构造对象,因此你需要自己负责内存的分配和回收。

在对象池中的应用示例

在对象池中,定位 new 可以帮助你在已分配的内存池中构建对象,而不是每次都调用 new 来分配新的内存空间。这样可以有效避免频繁的内存分配和释放。

template <typename T>
class ObjectPool {
public:
    T* allocate() {
        // 假设我们有一个已分配的足够大的内存区域
        char buffer[sizeof(T)];

        // 在预分配的内存中创建对象
        T* obj = new (buffer) T();

        return obj;
    }

    void deallocate(T* obj) {
        // 销毁对象,调用析构函数
        obj->~T();
    }
};

TestObjectPool.cpp 的实现

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

// 树节点结构体定义
struct TreeNode {
    int _val;         // 节点存储的值
    TreeNode* _left;  // 左子节点指针
    TreeNode* _right; // 右子节点指针

    // 构造函数:初始化值为0,子节点指针为空
    TreeNode() : _val(0), _left(nullptr), _right(nullptr) {}
};

// 内存池性能测试函数
void TestObjectPool() {
    // 测试参数配置
    const size_t Rounds = 10;   // 测试轮次(申请释放循环次数)
    const size_t N = 10000000;   // 每轮次操作次数

    /****************** 传统new/delete测试 ******************/
    size_t begin1 = clock();  // 记录测试开始时间
    
    std::vector<TreeNode*> v1; // 使用vector管理节点指针
    v1.reserve(N);             // 预分配内存避免扩容影响测试
    
    for (size_t j = 0; j < Rounds; ++j) {
        // 批量申请节点
        for (int i = 0; i < N; ++i) {
            v1.push_back(new TreeNode); // 每次new一个TreeNode
        }
        
        // 批量释放节点
        for (int i = 0; i < N; ++i) {
            delete v1[i]; // 逐个delete释放内存
        }
        v1.clear(); // 清空vector准备下一轮测试
    }
    
    size_t end1 = clock();  // 记录测试结束时间

    /****************** 内存池性能测试 ******************/
    ObjectPool<TreeNode> TNPool; // 创建TreeNode内存池实例
    
    size_t begin2 = clock();  // 记录内存池测试开始时间
    
    std::vector<TreeNode*> v2;
    v2.reserve(N);            // 预分配内存
    
    for (size_t j = 0; j < Rounds; ++j) {
        // 使用内存池申请节点
        for (int i = 0; i < N; ++i) {
            v2.push_back(TNPool.New()); // 从内存池获取节点
        }
        
        // 使用内存池释放节点
        for (int i = 0; i < N; ++i) {
            TNPool.Delete(v2[i]); // 将节点归还内存池
        }
        v2.clear(); // 清空vector
    }
    
    size_t end2 = clock();  // 记录内存池测试结束时间

    /****************** 结果输出 ******************/
    std::cout << "传统new/delete耗时: " << (end1 - begin1) / CLOCKS_PER_SEC << "s" << std::endl;
    std::cout << "定长内存池实现耗时:  " << (end2 - begin2) / CLOCKS_PER_SEC << "s" << std::endl;
}

int main() {
    TestObjectPool();  // 执行性能测试
    return 0;
}

测试代码易错点

std::vector<TreeNode*> v2.reserve(N)std::vector<TreeNode*> v2(N) 的关键区别在于容器初始化方式内存行为,这是导致段错误的根本原因。

行为v2.reserve(N)v2(N)
初始化元素数量0N
内存分配策略预分配,避免扩容精确分配,无预留空间
指针安全性安全(地址有效)危险(扩容后地址失效)
适用场景已知最大元素数量的高效插入需要初始值的场景

结论:在需要动态插入元素且已知最大数量的场景中,始终优先使用 reserve() + push_back,避免混合使用初始化大小和动态插入。

推荐一下

https://github.com/0voice


http://www.kler.cn/a/560345.html

相关文章:

  • Linux提权之docker提权(十三) 链接第八篇完整版
  • 【Swift 算法实战】利用 KMP 算法高效求解最短回文串
  • Python selenium 库
  • 可视化报表
  • IDEA创建Spring配置文件Spring Config的方法
  • 算法日常刷题笔记(2)
  • 【Mysql】我在广州学Mysql 系列——Mysql 性能优化
  • 【react】进阶教程02
  • 基于Python/Flask/机器学习链家网新房数据可视化及预测系统+万字文档+答辩PPT+指导搭建视频
  • OpenHarmony-4.基于dayu800 GPIO 实践(2)
  • 用PyTorch从零构建 DeepSeek R1:模型架构和分步训练详解
  • 如何解决 Django 网站登录人数过多导致的性能问题
  • 学习threejs,使用createMultiMaterialObject创建多材质对象
  • 微信小程序调用火山方舟(字节跳动火山引擎)中的DeepSeek大模型
  • GAMES104:18 网络游戏的架构基础-学习笔记
  • 力扣2382. 删除操作后的最大子段和
  • java编译和c语言编译区别
  • 中国的Cursor! 字节跳动推出Trae,开放Windows版(附资源),开发自己的网站,内置 GPT-4o 强大Al模型!
  • 策略模式 (Strategy)详解
  • C++ 设计模式 - 并发模式概述