[SGI STL]空間配置器--記憶體管理
[SGI STL]系列文章前言
廢話不多說,讀侯捷的SGI STL原始碼分析目的有三個:
1,接觸c++不久就開始跟STL打交道,一直有個好奇心,這麼強大的庫到底是誰、咋實現的?;
2,不熟悉實現就用不好STL,所以想更好的應用STL,就有必要一探其底層驅動;
3,引用林語堂先生的一句話:“只用一樣東西,不明白它的道理,實在不高明”;
目錄
1,如何使用空間介面卡
其實以運用STL的角度來看,完全可以忽略空間介面卡,因為每個容器都是通過預設引數指定好了allocator,通過檢視vector的宣告可以看出:
template<class _Ty,class _Alloc = allocator<_Ty> >
class vector
{
//...
}
如下程式碼中的vector沒有指定allocator,預設的allocator會自動根據你傳入的元素,調整記憶體空間:
#include <vector>
void main()
{
std::vector<int> vecTemp;
for (int i = 0;i<10;i++)
{
vecTemp.push_back(i);
}
getchar();
}
其實,完整的vecTemp宣告應該是 vetor<int, allocator<int>> vecTemp。
假如我們自定義了將記憶體分配指向磁碟或者其他儲存介質空間的allocator,那麼只要在宣告時傳入設計好的allocator,不再使用預設的allocator就行了。
那麼問題來了,怎麼樣才能設計一個allocator呢?繼續看~
2,一個標準的空間配置器
首先,設計一個空間配置器需要包含什麼介面呢?我們從如下的例子引入:
class Foo{...};
Foo* pFoo = new Foo;//< 第一階段,幹了倆事:1,配置記憶體 2,在配置好的記憶體上構造物件
delete pFoo; //< 第二階段,也幹了倆事:1,析構物件 2,釋放記憶體
所以,一個allocator至少要包含四個功能:申請記憶體、構造物件、析構物件、釋放記憶體。
其次,我可以很負責人的告訴你,如果你的allocator只包含上述四個功能,肯定無法再STL中運用^_^。因為STL對allocator的組成已經規定好了,即STL規範。那麼STL中的allocator相關的規範是啥呢?我們通過一個符合STL標準的allocator(主要參考書中的JJ::allocator,略有修改)來說明:
namespace JJ
{
template <class T>
class allocator
{
public:
//< 七個typedef主要是為了迭代器的型別萃取,迭代器章節會提到
typedef T value_type;
typedef T* pointer;
typedef const T* const_pointer;
typedef T& reference;
typedef const T& const_reference;
typedef size_t size_type;
typedef ptrdiff_t difference_type;
//< 成員模板 rebind
//< 定義了一個associated type other,other也是一個allocator的例項,但是負責管理的物件型別與T不同
//< 具體可以參考https://blog.csdn.net/qq100440110/article/details/50198789
template <class U>
struct rebind
{
typedef allocator<U> other;
};
//記憶體申請 直接使用new
pointer allocate(size_type n, const void* hint = 0)
{
T* tmp = (T*)(::operator new((size_t)(n * sizeof(T))));
if (tmp == 0)
cerr << "out of memory" << endl;
return tmp;
}
//建構函式 使用placement_new 在p處構造T1
void construct(pointer p, const T& value)
{
new(p) T1(value);
}
//解構函式
void destroy(pointer p)
{
p->~T();
}
//釋放記憶體 直接使用delete
void deallocate(pointer p)
{
::operator delete(p);
}
//取地址
pointer address(reference x)
{
return (pointer)&x;
}
//返回const物件的地址
const_pointer const_address(const_reference x)
{
return (const_pointer)&x;
}
//可成功配置的最大量
size_type max_size() const
{
return size_type(UINT_MAX / sizeof(T));
}
};
}// NAMESPACE_JJ_END
這樣,我們設計的第一個allocator完成了,就可以在實際中使用了:
int ia[5] = { 1,2,3,4,5 };
vector<int, JJ::allocator<int> > vec(ia, ia + 5);
3,SGI STL 空間配置器架構
有人可能會想,既然設計一個空間配置器這麼簡單,STL的多個毛啊,為啥它的這麼NB。其實,STL的空間配置器不只多個毛,是多很多毛,不是NB,而是很NB,從這就能看出來王者與青銅的差別了,膜拜之~
由於一個記憶體配置與釋放操作通常分兩個階段(見2中的例子),為了精密分工,STL allocator將這兩個階段的操作區分開來:
1,記憶體配置由alloc::allocate()負責,記憶體釋放由alloc::deallocate()負責;
2,物件構造由::construct()負責,物件析構由::destroy()負責。
其實,對於記憶體配置和釋放還有個allocator::allocate()和allocator::deallocate(),這是SGI定義的符合部分STL標準的配置器,但由於效率不佳,不推薦使用。其實它就是對::operator new和::operator delete做了一層薄薄的封裝。
思維導圖如下:
書中原圖:
毋庸置疑,<stl_construt.h>和<stl_alloc.h>是STL空間配置器的重頭菜,我們在這裡分別介紹。
4,構造和析構的基本工具:construct()和destroy()
先上一張書中的construt()和destroy()示意圖,對照著圖就很容易理解了:
首先,對於construct()來說很簡單了,就是接受一個指標p和一個初值value,用途就是將初值設定到指標所致的空間上,可以通過placement new來完成。
template<class T1, class T2>
void construct(T1 *p, const T2 &value)
{
new(p) T1(value);
}
其次,從圖中可以看出destroy()有兩個版本:
第一個版本:接受一個指標(圖中的第四個),準備將所指之物析構掉。這很簡單,直接呼叫解構函式即可。
template<class T>
void destroy(T *ptr)
{
ptr->~T();
}
第二個版本:接受一個迭代器區間,準備將這個範圍內的物件析構掉。
再講這個版本的destroy()之前,講一下trivial destructor:如果不定義解構函式,而是使用系統自帶的,也就是解構函式沒什麼作用,那麼這個解構函式稱為trivial destructor。
這裡提現了STL作者的設計亮點,他不是直接呼叫每個物件的析構,而是首先確定每個物件是否有non_rivial destructor(即自己定義了解構函式)。如果有,則呼叫物件析構,如果沒有,就什麼也不做結束。
反正思路就是上面寫的,具體可以看一下書上的程式碼,至於每個物件是否有non_rivial destructor的判斷,則用到了_type_traits<T>,會在後面講述。
圖中的第二個和第三個是第二個版本的char*和wchar*的特化。
以上就是關於construt()和destroy()的所有內容,其實還是挺簡單的。
5,空間的配置與釋放,alloc
這一節我覺得是整個SGI STL空間配置器的核心。
設計者設計了兩個配置器,準確的說是兩級配置器,兩個配置器相輔相成,相互配合最終完成空間的配置。
第一級配置器直接使用malloc()和free(),第二級則視情況採取不同的策略。而分界點是配置的記憶體是否大於128B,大於就用第一級,小於等於則通過第二級訪問複雜的memory pool整理方式。
通過是否定義_USE_MALLOC巨集,來設定是隻開啟第一級還是同時開啟第一級與第二級。SGI STL沒定義那個巨集,也就是同時開放一、二級。
5.1 第一級配置器 __malloc_alloc_template
先說一下整體的思路。就像上圖所說的,這個配置器中的allocator()直接呼叫C中的malloc(),reallocator()直接呼叫C中的realloc()。如果配置成功,則返回指標,如果不成功則呼叫out of memory處理;deallocator()直接呼叫free()。
out of memory主要呼叫使用者設定的__malloc_alloc_oom_handler,這個可以通過模擬C++中的set_new_handler()的set_malloc_handler()來設定。如果使用者指定了,則迴圈呼叫這個handler,直到分配到記憶體,如果沒定義,則拋bad_alloc異常。
具體程式碼如下:
#if 0
#include<new>
#define __THROW_BAD_ALLOC throw bad_alloc
#elif !defined(__THROW_BAD_ALLOC)
//#include<iostream.h>
#define __THROW_BAD_ALLOC cout<<"Out Of Memory."<<endl; exit(1)
#endif
//inst完全沒用
template<int inst>
class __malloc_alloc_template
{
private:
//以下用來處理記憶體不足的情況;oom:out of memory
static void * oom_malloc(size_t n);
static void * oom_realloc(void *p, size_t n);
static void(*__malloc_alloc_oom_handler)();
public:
static void* allocate(size_t n)
{
void *result = malloc(n); //< 直接呼叫malloc()
if (result == 0)
result = oom_malloc(n); //< 分配失敗呼叫oom_malloc()
return result;
}
static void deallocate(void *p, size_t)
{
free(p); //< 直接呼叫free()
}
static void* reallocate(void *p, size_t old_sz, size_t new_sz)
{
void *result = realloc(p, new_sz); //< 直接呼叫C中的realloc()
if (0 == result)
result = oom_realloc(p, new_sz); //< 分配失敗呼叫oom_realloc
return result;
}
//模擬C++中的set_new_handler(),也就是通過這個函式指標來指定自己的out-of-memory操作
static void(* set_malloc_handler(void(*f)()))()
};
// 初值為0,客戶端指定
template<int inst>
void(*__malloc_alloc_template<inst>::__malloc_alloc_oom_handler)() = 0;
//如果指定了 __malloc_alloc_oom_handler,則迴圈呼叫,直到分配到記憶體,否則拋異常
template<int inst>
void* __malloc_alloc_template<inst>::oom_malloc(size_t n)
{
void(*my_malloc_handler)();
void *result;
for (;;)
{
my_malloc_handler = __malloc_alloc_oom_handler;
if (0 == my_malloc_handler)
{
__THROW_BAD_ALLOC;
}
(*my_malloc_handler)();
result = malloc(n);
if (result)
return result;
}
}
//如果指定了 __malloc_alloc_oom_handler,則迴圈呼叫,直到分配到記憶體,否則拋異常
template<int inst>
void* __malloc_alloc_template<inst>::oom_realloc(void *p, size_t n)
{
void(*my_malloc_handler)();
void *result;
for (;;)
{
my_malloc_handler = __malloc_alloc_oom_handler;
if (0 == my_malloc_handler)
{
__THROW_BAD_ALLOC;
}
(*my_malloc_handler)();
result = realloc(p, n);
if (result)
return result;
}
}
所謂的C++ new handler機制是指,你可以要求系統在記憶體配置需求無法被滿足時,呼叫一個你指定的函式。之所以要模擬這種機制,因為它並不是使用::operator new來配置記憶體的。
5.2 第二級配置器 __default_alloc_template
其實第一級配置器可以說使用者是通過new和free直接與系統記憶體打交道的,而第二級配置器相對比較複雜,大概分為3塊記憶體,簡要的溝通機制可參考下圖:
三塊空間分別為freelist、mempool、系統記憶體。
總是通過freelist來獲得記憶體,freelist如果沒有記憶體了,則呼叫refill()向mempoor獲得記憶體,mempoor如果也不夠,則呼叫trunk_alloc()向記憶體申請,記憶體都沒有呼叫第一級配置器,看看out of memory機制能夠起作用。
整個第二級配置器無非就是對上述freelist空間、mempoor空間的建立、記憶體申請、記憶體回收、以及之間的通訊。原始碼如下:
enum { __ALIGN = 8 }; //< 小型區塊的上調邊界
enum { __MAX_BYTES = 128 }; //< 小型區塊的上限
enum { __NFREELISTS = __MAX_BYTES / __ALIGN }; //< freelist個數:16個
template<bool threads, int inst>
class __default_alloc_template
{
public:
//< 三個介面
static void *allocate(size_t n);
static void deallocate(void *p, size_t n);
static void* reallocate(void *p, size_t old_sz, size_t new_sz);
private:
// 將申請的size上調至__ALIGN的倍數
static size_t ROUND_UP(size_t bytes)/
{
return (bytes + __ALIGN - 1) & ~(__ALIGN - 1);//
}
// freelist節點結構
union obj
{
union obj * free_list_link;
char client_data[1];
};
// 根據要申請的區塊大小,決定使用第n號freelist,n從0算起
static size_t FREELIST_INDEX(size_t bytes)
{
return (bytes + __ALIGN - 1) / __ALIGN - 1;
}
// 返回一個大小為n的區塊物件,並可能(通常)加入大小為n的其他區塊到freelist
static void* refill(size_t n);
// 配置一大塊空間,可容納nobjs個大小為size的區塊
// 注意此處nobjs是引用,如果配置有所不便(記憶體不夠),nobjs會降低
static char* chunk_alloc(size_t size, int &nobjs);
static obj * free_list[__NFREELISTS]; //< 16個freelist
static char *start_free;//< 記憶體池其實位置
static char *end_free; //< 記憶體池結束位置
static size_t heap_size; //< 配置記憶體的附加量
};
// 賦初值
template<bool threads, int inst>
char* __default_alloc_template<threads, inst>::start_free = 0;
template<bool threads, int inst>
char* __default_alloc_template<threads, inst>::end_free = 0;
template<bool threads, int inst>
size_t __default_alloc_template<threads, inst>::heap_size = 0;
template<bool threads, int inst>
__default_alloc_template<threads, inst>::obj*
__default_alloc_template<threads, inst>::free_list[__NFREELISTS] =
{ 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 };
5.2.1 freelist
簡單說,就是16個8byte倍數但小於128byte的連結串列,連結串列的節點如下:
union obj
{
union obj* free_list_link;
char client_data[1];
};
其實我對這個節點的定義還是有點疑問的,具體請看我的另一篇部落格:
一共16個freelist,每個freelist就是一個連結串列,16個的區別就是連結串列節點所佔空間大小不一樣,:
對於每個freelist的空間申請與釋放,其實就是一些連結串列的操作。
5.2.2 二級配置器中的空間配置函式allocator()
以下程式碼描述瞭如何利用二級配置器中的allocator()配置空間,以及freelist空間如何與mempool之間通訊,程式碼如下:
static void* allocate(size_t n)
{
obj* volatile* my_free_list;
void* result = 0;
//如果大於128B, 直接呼叫一級配置器
if (n > (size_t)_MAX_BYTES)
{
return (malloc_alloc::allocate(n));
}
//尋找 16個free-list 中的一個
my_free_list = free_list + FREELIST_INDEX(n);
result = *__my_free_list;
if (result == 0)
{
//如果freelist上沒有可用空間,則將空間調整至8的倍數
//呼叫refill,向mempool申請記憶體,重新填充該freelist
result = refill(ROUND_UP(n));
return result;
}
else
{
*my_free_list = result->_M_free_list_link;
}
return result;
};
其中freelist與mempool之間的通訊函式refill(),原始碼如下:
template<bool threads, int inst>
void* __default_alloc_template<threads, inst>::refill(size_t n)
{
//預設取20個新節點連線到freelist上(其實是19個,第一個返回給使用者)
int nobjs = 20;
//呼叫chunk_alloc(),嘗試取得nobjs個區塊作為freelist的新節點
//注意此處引數nobjs是通過引用傳入,有可能變小
char* chunk = chunk_alloc(n, nobjs);
obj* volatile* my_free_list;
obj * result;
obj * current_obj, *next_obj;
int i;
//如果只獲得一個區塊,則將這個區塊直接反饋,freelist無新節點
if (1 == nobjs)
{
return chunk;
}
//找到需要填充的連結串列的位置
my_free_list = freeList + FREELIST_INDEX(n);
result = (obj*)chunk;//第一塊返回給客戶端
//引導freelist指向新的空間
*my_free_list = next_obj = (obj*)(chunk + n);//這裡把第二塊先掛到指標陣列對應位置下 //注意這裡的n在傳引數時已經調整到8的倍數
for (i = 1;; i++) {//從1開始,0返回給客戶端
cur_obj = next_obj;
next_obj = (obj*)((chat*)next_obj + n);
if (nobjs - 1 == i) { //因為第一次從記憶體池取下的空間在物理上是連續的 尾插方便用 以後用完還回自由連結串列的就不是了
cur_obj->free_list_link = NULL;//這裡沒有新增節點
break;
}
else {
cur_obj->free_list_link = next_obj;//nobjs - 2是最後一次新增節點
}
}
return result;
}
5.2.3 空間釋放函式deallocate()
如果釋放的空間大於128b則呼叫第一級配置器,如果小於128b,則將要釋放的空間連結到對應的freelist上,也就是一個在連結串列頭插入節點的過程:
static void deallocate(void* p, size_t n)
{
obj* volatile* my_free_list;
obj* q = (obj*)p;
//如果大於128,呼叫第一級配置器
if (n > (size_t)_MAX_BYTES)
{
malloc_alloc::deallocate(p, n);
return;
}
//尋找對應的freelist
my_free_list = _S_free_list + _S_freelist_index(n);
//回收該區塊
q->free_list_link = *my_free_list;
*my_free_list = q;
}
5.2.4 記憶體池(mem pool)
chunk_alloc()是負責mem pool與系統記憶體打交道的,原始碼如下:
template<bool threads, int inst>
void* __default_alloc_template<threads, inst>::chunk_alloc(size_t size, int& nobjs)
{
char * result;
size_t total_bytes = size * nobjs;
size_t bytes_left = end_free - start_free;
// 記憶體池剩餘空間完全滿足需求量
if (bytes_left >= total_bytes)
{
result = start_free;
start_free += total_bytes;
return result;
}
else if (bytes_left >= size)
{
// 記憶體池剩餘空間不能完全滿足需求量,但足夠供應一個(含)以上的區塊
nobjs = bytes_left / size;
total_bytes = size * nobjs;
result = start_free;
start_free += total_bytes;
return result;
}
else
{
// 記憶體池剩餘空間連一個區塊的大小都無法提供
size_t bytes_to_get = 2 * total_bytes + ROUND_UP(heap_size >> 4);
// 以下試著讓記憶體池中的殘餘零頭還有利用價值
if (bytes_left > 0)
{
// 記憶體池內還有一些零頭,先配給適當的free list
// 首先尋找適當的free list
obj * volatile * my_free_list = free_list + FREELIST_INDEX(bytes_left);
// 調整free list,將記憶體池中的殘餘空間編入
((obj *)start_free)->free_list_link = *my_free_list;
*my_free_list = (obj *)start_free;
}
// 配置heap空間,用來補充記憶體池
start_free = (char *)malloc(bytes_to_get);
if (0 == start_free)
{
// heap空間不足,malloc失敗
int i;
obj * volatile * my_free_list, *p;
// 試著檢視我們手上擁有的東西,這不會造成傷害。我們不打算嘗試配置
// 較小的區塊,因為那在多程序機器上容器導致災難
// 以下搜尋適當的free list
// 所謂適當是指“尚未用區塊,且區塊夠大”的free list
for (i = size; i <= __MAX_BYTES; i += __ALIGN)
{
my_free_list = free_list + FREELIST_INDEX(i);
p = *my_free_list;
if (0 != p)
{ // free list內尚有未用塊
// 調整free list以釋放未用區塊
*my_free_list = p->free_list_link;
start_free = (char *)p;
end_free = start_free + i;
// 遞迴呼叫自己,為了修正nobjs
return chunk_alloc(size, nobjs);
// 注意,任何殘餘零頭終將被編入適當的free list中備用
}
}
end_free = 0; // 如果出現意外,呼叫第一級配置器,看看oom機制能否盡力
start_free = (char *)malloc_alloc::allocate(bytes_to_get);
// 這會丟擲異常 或 記憶體不足的情況得到改善
}
heap_size += bytes_to_get;
end_free = start_free + bytes_to_get;
// 遞迴呼叫自己,為了修正nobjs
return chunk_alloc(size, nobjs);
}
}
我覺得書上舉的例子對這段程式碼的解釋再合適不過了,非常透徹:
5.2.5 第二級配置器總的流程框圖
自己懶得畫了摘了一個:
6 記憶體基本處理工具
提供的三個工具uninitialized_copy()、uninitialized_fill()、uninitialized_fill_n(),用於將記憶體的配置與物件的構造分別開來。如何分開的呢?我們先看一下對於一個全區間的建構函式,如何構造物件的:
1,配置記憶體區塊,足以包含範圍內的所有元素;
2,在記憶體上構造物件;
那麼這三個函式是如何發揮作用的呢?這裡用到了is_POD_type的概念。POD意指Plain Old Data,也就是標量型別或傳統的C struct型別。POD必然擁有trivial ctor/dtor/copy/assignment函式,因此我們可以:
對POD型別採取最有效的初值填寫法,如:
int a;
a = 5;
而對non-POD型別採取最保險的安全做法:
char* p = new char;
new(p) char(5);
至於怎麼判斷一個迭代器所指物件的型別,那就是利用__type_trait了,後續再說。
如果is_POD_type是__true_type,那麼這幾個工具就呼叫相應的演算法copy()、fill()、fill_n()。如果是__false_type則呼叫第4節提到的construct()。
7 小結
花了三天晚上看書,加上一個週末的下午+晚上串聯思想與寫這篇部落格,總體感覺收穫還是蠻多的,對於STL的記憶體配置以及泛型變成都有了一定得了解,還可以~