1. 程式人生 > >c++ STL容器的記憶體分配

c++ STL容器的記憶體分配

一.前言

在使用STL各類容器的時候,有時會出現迭代器失效,引用(指標)失效等情況的而發生,即使看似你的操作都是合法的情況下。

要了解問題的原因,我們就要了解C++中stl容器的記憶體分配策略。我們才知道在哪些操作下可能導致迭代器失效,引用(指標)失效。

二.問題分類

首先我們把以上的問題分成兩類:

  • 容器的迭代器為什麼會失效?
  • 容器元素的引用(指標)為什麼會失效?

因為從記憶體角度上來講,如果引用是失效了那麼指標也就是失效了,也就是容器的該元素從這個記憶體地址搬家了!

三.第一類問題分析

問題: 容器的迭代器為什麼會失效?

對於上面的問題我們可以換個說法:容器的元素在容器內部搬家了

我們可以把容器看做是一個小鎮,有一個個的房子(無論是list,vector還是其他的,只是房子之間的聯絡關係不同。不過這不在我們目前考慮範圍);
而元素就是相當於住在房子裡面的人。
現在,假如小鎮又搬進來一戶人家。小鎮自然需要為這戶人家安排一戶房子:

  • 假如是空房子還好(只需要住進去就行);
  • 如果新來的這戶人家看中了已經居住的某戶人家的房子,那麼肯定需要之前那戶人家搬出來,然後才能進去住。而某些容器中為了保證資料的順序一致性,就會出現下面的情況:
    這裡寫圖片描述

很顯然,如果出現了以上圖片所示的情況,那麼就相當於容器的元素在容器內部搬家了,因為或多或少的造成了其他元素搬家。
而迭代器相當於什麼呢?恩,我們可以在這裡把它當做是房子的門牌號(每個房子的門牌號都不一樣)。
以前我們知道通過一戶人家的門牌號就可以很容易找到這戶人家。但是,如果該戶人家搬家了呢?
對,正是這樣的”搬家”導致容器的迭代器失效。

問題原因找出來了,我們下面就來總結一下,各類容器發生迭代器失效的情況:

Type Insert Remove(尾後迭代器end總是失效)
vector 插入位置之前的迭代器有效,之後的迭代器(包括尾後)失效。 刪除之前迭代器有效,之後迭代器(包括尾後)失效。
string 同上 同上
deque(雙向佇列) 任何插入位置都會使迭代器失效 刪除除首尾其他位置元素使迭代器失效
list 任何插入位置迭代器都有效 任何刪除位置迭代器都有效
forward_list 同上 同上

可以看出來非線性表的適應性是最好的,因為不需要記憶體地址連續。
而線性表中,不適宜的插入刪除位置會使得迭代器失效。(使得迭代器失效還有一種情況:容器儲存空間的重新分配

,我們後面來講)

下面我來舉個例子:
給定一個容器 vector< int >vi,刪除容器中所有為3的元素。

下面是兩個版本的程式碼:

  • 程式碼1:
for(auto it = vi.begin();it != vi.end();)
{
    if(*it == 3) it = vi.erase(it);
    else it ++;
}
  • 程式碼2:
auto end = vi.end();
for(auto it = vi.begin();it != end;)
{
    if(*it == 3) it = vi.erase(it);
    else it ++;
}

可能程式碼2的作者是考慮到每次迴圈都需要呼叫vi.end()的開銷,於是就在迴圈外記錄了尾後迭代器。
然而在上面的程式碼中。由於在迴圈內部可能會呼叫erase的函式;也就是說一旦發生刪除,那麼尾後迭代器就會失效。所以程式碼2是錯誤的!!
不過還有個地方值得注意:刪除erase的時候會使得it迭代器失效,所以需要用it來接受erase函式的返回值。

四.第二類問題分析

問題: 容器元素的引用或指標為什麼會失效?

這個問題就是涉及到了不同容器中如何去管理記憶體的。以下用 vector(string) 和 deque 來舉例。

  • vector(string 可看做vector< char >)

    為了支援快速隨機訪問,vector只能將元素連續儲存——每個元素緊挨著前一個元素儲存。然而當我們向vector 或者string新增元素的時候;如果沒有空間容納新元素,容器不可能簡單地把它新增在記憶體的其他位置——因為元素必須連續儲存。容器就必須重新分配新的記憶體空間來儲存已有的元素和新元素,將舊元素移動到新空間,然後新增新的元素。

  • deque

    deque看似和vector很相似,但是 deque 能高效的在首位進行元素的插入刪除;並且 deque 也支援隨機訪問;說明 deque 的內部記憶體實現要比vector複雜得多。事實上 deque 採用的是動態記憶體塊的策略,塊的內部是一段連續的記憶體,但是塊與塊之間實體記憶體不一定連續。

    deque的元素資料採用分塊的線性結構進行儲存,如圖所示。deque分成若干線性儲存塊,稱為deque塊。塊的大小一般為512個位元組,元素的資料型別所佔用的位元組數,決定了每個deque塊可容納的元素個數。

    所有的deque塊使用一個Map塊進行管理,每個Map資料項記錄各個deque塊的首地址。Map是deque的中心部件,將先於deque塊,依照deque元素的個數計算出deque塊數,作為Map塊的資料項數,創建出Map塊。以後,每建立一個deque塊,都將deque塊的首地址存入Map的相應資料項中。

    在Map和deque塊的結構之下,deque使用了兩個迭代器M_start和M_finish,對首個deque塊和末deque塊進行控制訪問。迭代器iterator共有4個變數域,包括M_first、M_last、M_cur和M_node。M_node存放當前deque塊的Map資料項地址,M_first和M_last分別存放該deque塊的首尾元素的地址(M_last實際存放的是deque塊的末尾位元組的地址),M_cur則存放當前訪問的deque雙端佇列的元素地址。
    這裡寫圖片描述

簡單介紹了 vector 和 deque 的記憶體管理策略之後;我們知道,如果當容器中進行了記憶體重新分配。那麼元素的物理儲存地址必然改變,所以這就是導致引用和指標失效的原因!!

我們下面就來總結一下,各類容器發生引用(指標)失效的情況:

Type Insert Remove
vector 插入位置之前的引用有效,之後的引用失效。如果發生記憶體重新分配那麼所有引用(指標)都失效。 刪除位置之前引用有效,之後引用失效。
string 同上 同上
deque(雙向佇列) 除首尾外地址插入會使得引用失效 除首尾外地址刪除會使得引用失效
list 任何插入位置引用都有效 任何刪除位置引用都有效
forward_list 同上 同上

五.後話

值得注意的是,不同的編譯器對於stl記憶體管理策略可能略有差別。下面拿 vector 舉例:

我們知道 vector 的記憶體管理策略是:當push_back的時候發現容量不夠儲存新的元素就需要去開闢一個更大的記憶體,然後將舊元素複製過去,然後再加入新元素。

那麼有個問題是:當容量不夠的時候需要開闢一個多大的記憶體呢?這個實現機制在不同的編譯器中就可能有不同。

目前我見過有三個版本的:

  • VS2013自帶G++編譯器 : 每次開闢新記憶體比原來多一個元素記憶體。
  • VS2010自帶G++編譯器: 每次開閉新記憶體比原來容量多一半。(*150%)
  • CodeBlocks自帶GCC編譯器: 每次開闢新記憶體為原來容量的一倍。(*200%)

不管開闢記憶體的策略如何,總需要滿足一個定則:
在一個初始化為空的 vector 上呼叫n次push_back來建立一個n個元素的vector,所花費時間不能超過n的常數倍。

還有一個值得注意的地方就是:
對於vector的 pop_back 或者 erase ,clear 只會減小容器的元素個數,並不會減少容器的容量。resize也是一樣,如果小於size。只會使得容器元素個數減小,容器的容量不會減小。

換句話來說,一個vector容器物件容量是隻增不減的。只會在解構函式的時候對佔用記憶體進行釋放。

當然以上問題也不是不可以解決的:

使用swap函式可以使得兩個vector物件內部元素甚至所佔記憶體空間都互換。
比如想釋放vector< int > vt 佔用的記憶體:

  • 清空所有元素,釋放所有容量

    vector<int>().swap(vt);
  • 元素不改變,釋放多餘的容量

    vector<int>(vt).swap(vt);

從第二段程式碼我們也可以得知,vector 的拷貝建構函式只是會將元素拷貝過來,而容量也只是會初始化為和元素個數一樣。

還值得注意的一個地方是:

如果會需要向一個vector中插入很多記錄,比如說100000條,為了避免在插入過程中移動記憶體,咱實現向系統預訂一段足夠的連續的空間,例如:

a.reserve(100000);