定长内存池的实现、测试及错误分析
背景
C/C++
申请内存使用的是 malloc
,malloc
其实就是一个大众货,什么场景下都可以用,但是什么场景下都可以用就意味着什么场景下都不会有很高的性能。
定长内存池解决固定大小的内存申请释放需求, 性能达到极致,不考虑内存碎片等问题。
定长内存池成员设计
先分配一块大内存,为了方便对这段空间进行操作,将指针定义成 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
下,可以调用 brk
或 mmap
函数。在 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
:这是你已经分配好的内存地址,通常是通过malloc
、operator 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) |
---|---|---|
初始化元素数量 | 0 | N |
内存分配策略 | 预分配,避免扩容 | 精确分配,无预留空间 |
指针安全性 | 安全(地址有效) | 危险(扩容后地址失效) |
适用场景 | 已知最大元素数量的高效插入 | 需要初始值的场景 |
结论:在需要动态插入元素且已知最大数量的场景中,始终优先使用 reserve()
+ push_back
,避免混合使用初始化大小和动态插入。
推荐一下
https://github.com/0voice