背景
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);
#endifif (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