文章目錄
- C++內存相關
- C++內存分區
- C++對象的成員函數存放在內存哪里
- 堆和棧的區別
- 堆和棧的訪問效率
- “野指針”
- 有了malloc/free為什么還要new/delete
- alloca
- 內存崩潰
- C++內存泄漏的幾種情況
- 內存對齊
- 柔性數組
- 參考
- 推薦閱讀
C++內存相關
本篇介紹了 C++ 內存相關的知識。
C++內存分區
在C++中,內存分成5個區,他們分別是堆、棧、自由存儲區、全局/靜態存儲區和常量存儲區。
- 棧:在執行函數時,函數內局部變量的存儲單元都可以在棧上創建,函數執行結束時這些存儲單元自動被釋放。棧內存分配運算內置于處理器的指令集中,效率很高,但是分配的內存容量有限。
- 堆:就是那些由
new
分配的內存塊,他們的釋放編譯器不去管,由我們的應用程序去控制,一般一個new
就要對應一個delete
。如果程序員沒有釋放掉,那么在程序結束后,操作系統會自動回收。 - 全局/靜態存儲區:全局變量和靜態變量被分配到同一塊內存中。在以前的C語言中,全局變量又分為初始化的和未初始化的。在C++里面沒有這個區分了,他們共同占用同一塊內存區。
- 常量存儲區:這是一塊比較特殊的存儲區,他們里面存放的是常量,不允許修改。
- 代碼段:代碼段(code segment / text segment)通常是指用來存放程序執行代碼的一塊內存區域。這部分區域的大小在程序運行前就已經確定,并且內存區域通常屬于只讀, 某些架構也允許代碼段為可寫,即允許修改程序。在代碼段中,也有可能包含一些只讀的常數變量,例如字符串常量等。
根據c/c++對象生命周期不同,c/c++的內存模型有三種不同的內存區域,即
-
自由存儲區,動態區、靜態區。
-
自由存儲區:局部非靜態變量的存儲區域,即平常所說的棧。
-
動態區: 用operator new ,malloc分配的內存,即平常所說的堆。
-
靜態區:全局變量 靜態變量 字符串常量存在位置。
下圖為 C++ 內存模型,來自C++ Essentials。
- .text 部分是編譯后程序的主體,也就是程序的機器指令。
- .data 和 .bss 保存了程序的全局變量,.data保存有初始化的全局變量,.bss保存只有聲明沒有初始化的全局變量。
- heap(堆)中保存程序中動態分配的內存,比如 C 的
malloc
申請的內存,或者C++中new
申請的內存。堆向高地址方向增長。 - stack(棧)用來進行函數調用,保存函數參數,臨時變量,返回地址等。
- 共享內存的位置在堆和棧之間。
更詳細的內存段解釋見C與C++內存管理詳解。
下面的文章介紹了Linux虛擬地址空間布局。
- x86 程序內存堆棧模型
- Linux 中的各種棧:進程棧 線程棧 內核棧 中斷棧
C++對象的成員函數存放在內存哪里
類成員函數和非成員函數代碼存放在代碼段。如果類有虛函數,則該類就會存在虛函數表。虛函數表在Linux/Unix 中存放在可執行文件的只讀數據段中(rodata),即前面起到的代碼段,而微軟的編譯器將虛函數表存放在常量段。
堆和棧的區別
管理方式:對于棧來講,是由編譯器自動管理,無需我們手工控制;對于堆來說,釋放工作由程序員控制,容易產生memory leak
。
空間大小:一般來講在 32 位系統下,堆內存可以達到 4G 的空間,從這個角度來看堆內存幾乎是沒有什么限制的。但是對于棧來講,棧頂和棧底是之前預設好的,大小固定,可以通過ulimit -a
查看,使用ulimit -s
修改。
碎片問題:對于堆來講,頻繁的new/delete
勢必會造成內存空間的不連續,從而造成大量的碎片,使程序效率降低。對于棧來講,則不會存在這個問題,因為棧是先進后出的隊列,它們是如此的一一對應,以至于永遠都不可能有一個內存塊從棧中間彈出,在他彈出之前,在他上面的后進的棧內容已經被彈出。
生長方向:對于堆來講,生長方向是向上的,也就是向著內存地址增加的方向;對于棧來講,它的生長方向是向下的,是向著內存地址減小的方向增長。
分配方式:堆都是動態分配的,沒有靜態分配的堆。棧有2種分配方式:靜態分配和動態分配。靜態分配是編譯器完成的,比如局部變量的分配。動態分配由alloca
函數進行分配,但是棧的動態分配和堆是不同的,他的動態分配是由編譯器進行釋放,無需我們手工實現。
分配效率:棧是機器系統提供的數據結構,計算機會在底層對棧提供支持:分配專門的寄存器存放棧的地址,壓棧出棧都有專門的指令執行,這就決定了棧的效率比較高。堆則是C/C++函數庫提供的,它的機制是很復雜的,例如為了分配一塊內存,庫函數會按照一定的算法(具體的算法可以參考數據結構/操作系統)在堆內存中搜索可用的足夠大小的空間,如果沒有足夠大小的空間(可能是由于內存碎片太多),就有可能調用系統功能去增加程序數據段的內存空間,這樣就有機會分到足夠大小的內存,然后進行返回。顯然,堆的效率比棧要低得多。
從這里我們可以看到,堆和棧相比,由于大量new/delete
的使用,容易造成大量的內存碎片;由于沒有專門的系統支持,效率很低;由于可能引發用戶態和核心態的切換,內存的申請,代價變得更加昂貴。所以棧在程序中是應用最廣泛的,就算是函數的調用也利用棧去完成,函數調用過程中的參數,返回地址,EBP和局部變量都采用棧的方式存放。所以,我們推薦大家盡量用棧,而不是用堆。
雖然棧有如此眾多的好處,但是由于和堆相比不是那么靈活,有時候分配大量的內存空間,還是用堆好一些。
堆和棧的訪問效率
- 堆和棧訪問效率哪個更高
- 棧為什么效率比堆高
“野指針”
“野指針”不是NULL
指針,是指向“垃圾”內存的指針。“野指針”的成因主要有三種:
- 指針變量沒有被初始化,缺省值是隨機的;
- 指針被
free/delete
之后,沒有置為NULL
,讓人誤以為該指針是個合法的指針; - 指針操作超越了變量的作用域范圍(內存越界)。
有了malloc/free為什么還要new/delete
malloc
與free
是C++/C語言的標準庫函數,new/delete
是C++的運算符。它們都可用于申請動態內存和釋放內存。
對于非內部數據類型的對象而言,光用maloc/free
無法滿足動態對象的要求。**對象在創建的同時要自動執行構造函數,對象在消亡之前要自動執行析構函數。**由于malloc/free
是庫函數而不是運算符,不在編譯器控制權限之內,不能夠把執行構造函數和析構函數的任務強加于malloc/free
。因此 C++ 語言需要一個能完成動態內存分配和初始化工作的運算符new
,以及一個能完成清理與釋放內存工作的運算符delete
。
既然new/delete
的功能完全覆蓋了malloc/free
,為什么C++不把malloc/free
淘汰出局呢?這是因為C++程序經常要調用C函數,而C程序只能用malloc/free
管理動態內存。
如果用free
釋放“new創建的動態對象”,那么該對象因無法執行析構函數而可能導致程序出錯。如果用delete
釋放“malloc申請的動態內存”,結果也會導致程序出錯,該程序的可讀性也很差。所以new/delete
必須配對使用,malloc/free
也一樣。
alloca
man
中的介紹:
The alloca() function allocates size bytes of space in the stack frame of the caller. This temporary space is automatically freed when the function that called alloca() returns to its caller.
alloca
是從棧中分配空間。正因其從棧中分配的內存,因此無需手動釋放內存。
討論見stackoverflow。
內存崩潰
錯誤類型 | 原因 | 備注 |
---|---|---|
聲明錯誤 | 變量未聲明 | 編譯時錯誤 |
初始化錯誤 | 未初始化或初始化錯誤 | 運行不正確 |
訪問錯誤 | 1. 數組索引訪問越界 2. 指針對象訪問越界 3. 訪問空指針對象 4. 訪問無效指針對象 5. 迭代器訪問越界 | |
內存泄漏 | 1. 內存未釋放 2. 內存局部釋放 | |
參數錯誤 | 本地代理、空指針、強制轉換 | |
堆棧溢出 | 1. 遞歸調用 2. 循環調用 3. 消息循環 4.大對象參數 5. 大對象變量 | 參數、局部變量都在棧(Stack)上分配 |
轉換錯誤 | 有符號類型和無符號類型轉換 | |
內存碎片 | 小內存塊重復分配釋放導致的內存碎片,最后出現內存不足 | 數據對齊,機器字整數倍分配 |
其它如內存分配失敗、創建對象失敗等都是容易理解和相對少見的錯誤,因為目前的系統大部分情況下內存夠用;此外除 0 錯誤也是容易理解和防范。
C++內存泄漏的幾種情況
1. 在類的構造函數和析構函數中沒有匹配的調用new和delete函數
兩種情況下會出現這種內存泄露:一是在堆里創建了對象占用了內存,但是沒有顯示地釋放對象占用的內存;二是在類的構造函數中動態的分配了內存,但是在析構函數中沒有釋放內存或者沒有正確的釋放內存
2. 沒有正確地清除嵌套的對象指針
3. 在釋放對象數組時在delete中沒有使用方括號
方括號是告訴編譯器這個指針指向的是一個對象數組,同時也告訴編譯器正確的對象地址值病調用對象的析構函數,如果沒有方括號,那么這個指針就被默認為只指向一個對象,對象數組中的其他對象的析構函數就不會被調用,結果造成了內存泄露。如果在方括號中間放了一個比對象數組大小還大的數字,那么編譯器就會調用無效對象(內存溢出)的析構函數,會造成堆的奔潰。如果方括號中間的數字值比對象數組的大小小的話,編譯器就不能調用足夠多個析構函數,結果會造成內存泄露。
釋放單個對象、單個基本數據類型的變量或者是基本數據類型的數組不需要大小參數,釋放定義了析構函數的對象數組才需要大小參數。
4. 指向對象的指針數組不等同于對象數組
對象數組是指:數組中存放的是對象,只需要
delete []p
,即可調用對象數組中的每個對象的析構函數釋放空間指向對象的指針數組是指:數組中存放的是指向對象的指針,不僅要釋放每個對象的空間,還要釋放每個指針的空間,
delete []p
只是釋放了每個指針,但是并沒有釋放對象的空間,正確的做法,是通過一個循環,將每個對象釋放了,然后再把指針釋放了
5. 缺少拷貝構造函數
兩次釋放相同的內存是一種錯誤的做法,同時可能會造成堆的崩潰。
按值傳遞會調用(拷貝)構造函數,引用傳遞不會調用。
在C++中,如果沒有定義拷貝構造函數,那么編譯器就會調用默認的拷貝構造函數,會逐個成員拷貝的方式來復制數據成員,如果是以逐個成員拷貝的方式來復制指針被定義為將一個變量的地址賦給另一個變量。這種隱式的指針復制結果就是兩個對象擁有指向同一個動態分配的內存空間的指針。當釋放第一個對象的時候,它的析構函數就會釋放與該對象有關的動態分配的內存空間。而釋放第二個對象的時候,它的析構函數會釋放相同的內存,這樣是錯誤的。
所以,如果一個類里面有指針成員變量,要么必須顯示的寫拷貝構造函數和重載賦值運算符,要么禁用拷貝構造函數和重載賦值運算符。
6. 缺少重載賦值運算符
這種問題跟上述問題類似,也是逐個成員拷貝的方式復制對象,如果這個類的大小是可變的,那么結果就是造成內存泄露,如下圖:
7. 關于nonmodifying運算符重載的常見迷思
a. 返回棧上對象的引用或者指針(也即返回局部對象的引用或者指針)。導致最后返回的是一個空引用或者空指針,因此變成野指針。
b. 返回內部靜態對象的引用。
c. 返回一個泄露內存的動態分配的對象。導致內存泄露,并且無法回收。
解決這一類問題的辦法是重載運算符函數的返回值不是類型的引用,二應該是類型的返回值,即不是 int&而是
int
。
8. 沒有將基類的析構函數定義為虛函數
當基類指針指向子類對象時,如果基類的析構函數不是
virtual
,那么子類的析構函數將不會被調用,子類的資源沒有正確是釋放,因此造成內存泄露。
9. 野指針:指向被釋放的或者訪問受限內存的指針
造成野指針的原因:
- 指針變量沒有被初始化(如果值不定,可以初始化為
NULL
)。- 指針被
free
或者delete
后,沒有置為NULL
,free
和delete
只是把指針所指向的內存給釋放掉,并沒有把指針本身干掉,此時指針指向的是“垃圾”內存。釋放后的指針應該被置為NULL
。- 指針操作超越了變量的作用范圍,比如返回指向棧內存的指針就是野指針。
內存對齊
CPU是按字讀取內存。所以內存對齊的話,不會出現某個類型的數據讀一半的情況,需要再二次讀取內存。可以提升訪問效率。
內存對齊的作用:
- 可移植性:因為不同平臺對數據的在內存中的訪問規則不同,不是所有的硬件都可以訪問任意地址上的數據,某些硬件平臺只能在特定的地址開始訪問數據。所以需要內存對齊。
- 性能原因:一般使用內存對齊可以提高CPU訪問內存的效率。如32位的intel處理器通過總線訪問內存數據,每個總線周期從偶地址開始訪問32位的內存數據,內存數據以字節為單位存放。如果32為的數據沒有存放在4字節整除的內存地址處,那么處理器需要兩個總線周期對數據進行訪問,顯然效率下降很多;另外合理的利用字節對齊可以有效的節省存儲空間。
- C/C++語言內存對齊
- 內存對齊的規則以及作用
- C語言內存對齊
- 內存對齊
- C/C++ 各數據類型占用字節數
- C/C++ 結構體字節對齊
- C/C++內存對齊
柔性數組
柔性數組結構成員:C99 中,結構中的最后一個元素允許是未知大小的數組,這就叫做柔性數組成員,但結構中的柔性數組成員前面必須至少一個其他成員。柔性數組成員允許結構中包含一個大小可變的數組。sizeof
返回的這種結構大小不包括柔性數組的內存。包含柔性數組成員的結構用malloc
函數進行內存的動態分配,并且分配的內存應該大于結構的大小,以適應柔性數組的預期大小。
- C語言0長度數組(可變數組/柔性數組)詳解
- C99柔性數組成員介紹(其一)
- C99柔性數組成員介紹(其二)
參考
- C++ Essentials
- C和C++內存模型
- C與C++內存管理詳解
- C++ 常見崩潰問題分析
- C++虛函數表
- 虛函數表在對象內存中的布局
- C++內存泄漏的幾種情況
- 實習面經 --C/C++ 基礎
推薦閱讀
- C/C++程序內存的各種變量存儲區域和各個區域詳解