當涉及C++記憶體分區模型時,我們必須理解棧、堆和全局/靜態存儲區的概念。棧用於存儲函數調用和局部變數,堆用於動態記憶體分配,而全局/靜態存儲區用於全局變數和靜態變數。同時,我們還探討了棧幀重用現象,它可能在函數調用時導致局部變數地址重疊。瞭解這些記憶體分區的特點和優化行為,可以幫助我們編寫高效、可靠的C... ...
C++記憶體分區模型
在執行C++程式的過程中,記憶體大致分為四個區域:
-
棧區(Stack):用於實現函數調用。由編譯器自動分配釋放,存放函數的參數值和局部變數等
-
堆區(Heap):用於存放動態分配的變數。由程式員動態分配和釋放,使用new和delete操作符
-
全局/靜態存儲區(Data Segment & BSS Segment):存放全局變數和靜態變數,程式結束時釋放
數據段 Data Segment (全局/靜態存儲區) : 存放初始化了的全局變數和靜態變數
BSS段 BSS Segment : 用於存放未初始化的全局變數和靜態變數,節省空間,實際上不占用磁碟空間
-
代碼區(Text Segment):通常也被稱為文本區或只讀區。存放程式的二進位代碼和常量,代碼段是只讀的,可以被多個進程共用
也有人認為 常量存儲區 在記憶體中是獨立的,C++標準並沒有明確將常量存儲區單獨列為記憶體分區模型的一部分。因此,記憶體分區模型的確切細節可以根據不同的觀點和上下文而有所不同
註意:不同的操作系統對程式記憶體的管理和劃分會有所不同。上述的C++記憶體區域劃分主要是針對通用的情況,並不限定在某個特定操作系統上
1. 代碼區
代碼區 (Code Segment) 也被稱為文本段 (Text Segment) 或者只讀區,主要包括可以執行的文件 ELF (Executable and Linkable Format) 和常量
代碼區(Code Segment)也被稱為文本段(Text Segment)或只讀區,它有以下幾個主要特征:
- 代碼區是程式的靜態存儲區域,存放程式執行所需的機器指令和常量數據,如字元串字面量和 const 變數的初始化值
- 代碼區的內容為只讀屬性,不允許修改程式中的代碼,保障程式的安全性和穩定性
- 程式運行前,代碼區的記憶體大小已在可執行文件中確定,載入時系統會自動分配適當的記憶體
- 多個進程可以共用相同的代碼區,節省記憶體空間
- 為提高效率,一些字元串字面量 (例如 "Hello") 可能會存放在共用的只讀數據區而不是代碼區
- 若代碼區中的常量初始化需要運行時計算,會將其放在數據區而不是代碼區
- 代碼區也包含只讀數據,如跳轉表和常量表等
- 程式運行時,代碼區通常不會改變大小,若需擴展,依賴操作系統提供的機制
2. 全局/靜態存儲區
全局/靜態存儲區主要包括以下兩個部分:
-
數據段(Initialized Data Segment)
-
用於存放初始化了的全局變數和靜態變數
-
存儲在此段的數據在程式運行前分配,運行結束後釋放
-
有初始化值的全局變數和靜態變數存放在此
-
數據段屬於可讀可寫區域
-
-
BSS段(Block Started by Symbol)
-
用於存放未初始化的全局變數和靜態變數
-
不占用實際的磁碟空間,只在編譯時預留記憶體空間
-
無初始化值的全局變數和靜態變數存放在此
-
程式啟動時會自動初始化為預設值
-
屬於可讀寫區域
-
兩者的主要區別在於初始化狀況。全局變數和靜態變數可以顯式初始化,如果沒有顯式初始化,它們會被自動初始化為預設值(0 或 nullptr,取決於變數的類型),該區域的數據在程式整個運行周期中一直存在
3. 棧區
棧區是用於實現函數調用和局部變數存儲的一種記憶體區域。在 C++ 中,每當調用一個函數時,系統會自動在棧區為該函數分配一塊記憶體,稱為棧幀(Stack Frame)。棧幀用於存儲函數的參數值、局部變數以及函數執行期間的一些控制信息 ^f6a053
以下是棧區的一些關鍵特點:
- LIFO(Last-In-First-Out)原則:棧區採用後進先出的原則,即最後壓入棧的數據會最先彈出。這是因為每次調用函數時,會將函數的棧幀壓入棧頂,函數執行結束後,棧幀會從棧頂彈出
- 自動分配和釋放:棧區的記憶體分配和釋放是由編譯器自動管理的,當進入函數時,會為該函數分配一塊連續的記憶體區域,併在函數返回時自動釋放這塊記憶體。這樣的自動分配和釋放使得棧區的記憶體管理相對高效,但也意味著棧上的數據生命周期必須在函數調用內部
- 局部變數存儲:棧區主要用於存儲函數的局部變數,這些變數的生命周期與函數的調用和返回相對應。當函數調用結束,棧幀會被銷毀,其中的局部變數也會被銷毀,因此在函數外部無法訪問這些局部變數
- 函數調用:當調用函數時,函數的參數值和返回地址會被壓入棧幀中,函數執行過程中的其他局部變數也會存儲在棧幀中。函數返回時,棧幀會從棧頂彈出,恢復調用函數的現場
- 棧溢出:棧區的大小是有限的,如果遞歸調用過深或者函數中使用了大量的局部變數,可能導致棧溢出(Stack Overflow)錯誤,即棧區的記憶體已被耗盡
- 線程私有:每個線程都有自己的棧區,棧區的記憶體是線程私有的,不同線程之間的棧區不共用
3.1 棧溢出
棧溢出(Stack Overflow)是指程式在運行過程中,不斷調用函數,導致棧空間被占滿,無法再分配新的棧幀。棧是一種先進後出的數據結構,用於存儲局部變數、參數、返回地址等信息。棧的大小是有限的,一般為8~10MB。棧溢出通常發生在遞歸調用過深或死迴圈的情況下
以下是一個程式在棧區的示例圖。在主函數調用自定義的函數,函數內部形成遞歸
當程式一直運行時,遞歸的函數會不斷占用棧區的記憶體空間,從而導致 棧溢出
3.2 緩衝區溢出
在C++中,緩衝區溢出通常發生在棧區。棧區用於存儲函數調用時的局部變數和函數調用的返回地址
當函數調用時,函數的棧幀(包含局部變數和其他控制信息)被壓入棧中。如果函數中使用了緩衝區(數組)並且沒有進行足夠的邊界檢查,很容易導致寫入超出緩衝區邊界的數據,從而覆蓋棧上其他變數和控制信息,這就是緩衝區溢出
例如以下代碼就是一個緩衝區溢出的案例:
#include <iostream>
int main()
{
//創建並初始化數組
char arr[3] = {'a', 'a', 'a'};
//向數組寫入數據,但超出了數組的邊界
for (int i = 0; i <= 4; i++)
{
arr[i] = 'b';
}
}
我們在合適的位置添加調試斷點,並對數組arr添加監視,觀察記憶體變化
找到數組對應的地址,觀察記憶體中的變化
按下F11逐語句調試,發現數組越界後仍然向未被允許訪問的記憶體中寫入數據
顯示數組所申請的記憶體附近堆棧被損壞,這就是緩衝區溢出的危害
3.3 數據不穩定
棧區中的局部變數生命周期與函數的調用和返回相關。當函數返回後,棧幀會被銷毀,其中的局部變數也會被銷毀。如果在函數內部使用了指向棧區局部變數的指針,併在函數返回後繼續使用這些指針,會導致懸空指針(Dangling Pointers)也就是野指針的問題,訪問已被銷毀的局部變數的記憶體區域,可能引發未定義的行為
下麵是示例代碼,證明棧幀銷毀後局部變數也被銷毀,造成懸掛指針的問題:
#include <iostream>
int* dangPtr;
void test() {
int x = 10;
dangPtr = &x;
std::cout << "dangPtr in test: " << *dangPtr << std::endl;
}
int main() {
test();
std::cout << "dangPtr in main: " << *dangPtr << std::endl;
return 0;
}
在函數test調用完成之後,此時test函數中的所有局部變數已經被銷毀了。這時候指針dangPtr
指向的變數x
的記憶體地址被系統釋放,指針變為懸空指針 (Dangling Pointers) 也就是野指針,這在記憶體中是非法的,引發程式中斷
此時函數還未結束,局部變數x
還存在沒有被系統銷毀
此時test函數執行結束,局部變數被系統銷毀,同樣的變數x所存的值也被銷毀
這時候指針解引用訪問地址後的值就變成了野指針,非法訪問未申請的記憶體
3.4 棧幀重用(Stack Frame Reuse)
棧幀重用(Stack Frame Reuse) 是一種編譯器優化技術,旨在減少函數調用時的棧記憶體分配和釋放開銷。它是通過在不同函數調用中復用棧空間來實現的,使得多個函數調用可以共用同一塊棧空間
除了"棧幀重用",這種優化技術也可能被稱為其他類似的術語,例如:
- 棧記憶體優化(Stack Memory Optimization):指代對棧記憶體的優化措施,其中棧重用是可能的一種優化方式
- 棧空間復用(Stack Space Reuse):強調的是復用棧空間,使得多個函數調用可以共用同一塊棧空間
棧幀重用是一種現象,出現在自動變數(由編譯器自動分配並回收棧上生命周期僅在函數內的變數)位於函數的棧幀上時。在函數執行過程中,每個函數都會為其局部變數在棧上分配記憶體空間,併在函數結束時釋放這些棧記憶體。當後續函數再次向棧上請求記憶體時,有可能會獲得先前函數釋放的棧記憶體。如果後續函數中出現了和先前函數相同名稱和類型的局部變數,那麼這些變數的地址可能會重疊,即它們在同一片棧記憶體區域中
棧區採用自動管理機制,編譯器可能出於效率考慮而選擇復用先前函數釋放的棧空間。這種自動的棧幀重用特性對於那些只關心變數生命周期而不依賴於其地址的代碼來說,並不會產生影響
#include <iostream>
void funcA() {
int a = 10;
std::cout << "Address of variable 'a' in funcA: " << &a << std::endl;
}
void funcB() {
int b = 20;
std::cout << "Address of variable 'b' in funcB: " << &b << std::endl;
}
int main() {
funcA();
funcB();
return 0;
}
輸出結果:
Address of variable 'a' in funcA: 0000009F4A6FF884
Address of variable 'b' in funcB: 0000009F4A6FF884
如果確實需要保證變數地址不變,可以採用以下方法:
- 使用
static
關鍵字:在函數內部聲明static
局部變數,這樣變數的生命周期將持續到程式的整個執行過程,而不是只在函數執行時存在 - 將變數置於堆或全局區中:通過使用動態記憶體分配(例如
new
、malloc
等)在堆上分配記憶體,或者將變數聲明為全局變數,可以保證其地址在函數調用之間不變
需要註意的是,棧幀重用是編譯器的一種優化行為,開發者不需要顯式地實現它。瞭解棧幀重用的概念有助於理解編譯器的優化機制,但在實際編程中,重要的是編寫簡潔、易讀和正確的代碼,而非過度關註微觀優化。優化應該基於實際的性能分析和需求。
4. 堆區
當涉及堆區時,需要理解動態記憶體分配的概念。堆區是程式運行時用於存儲動態分配的數據的一部分記憶體,它的管理與棧區不同
-
動態記憶體分配:在堆區進行動態記憶體分配意味著程式員可以在運行時請求額外的記憶體空間來存儲數據。與棧區不同,棧區的記憶體是在編譯時自動分配和釋放的,而堆區的記憶體是在運行時手動申請併在不再使用時手動釋放
-
操作符new和delete:在C++中,使用
new
操作符可以在堆區動態地分配記憶體。new
返回所分配記憶體的指針,該指針指向存儲分配的數據的堆區記憶體。而使用delete
操作符可以釋放堆區記憶體並將其返回給操作系統,以便其他程式可以使用。註意,堆區記憶體的釋放是程式員的責任,避免記憶體泄漏cppCopy codeint* ptr = new int; // 分配一個int大小的堆區記憶體 *ptr = 10; // 將值10存儲在堆區記憶體中 delete ptr; // 釋放堆區記憶體
-
生命周期:堆區的生命周期由程式員控制。在使用
new
分配記憶體後,記憶體將一直存在,直到使用delete
釋放記憶體或程式結束。如果忘記釋放記憶體,將導致[[^記憶體泄漏]],堆區的記憶體將永遠無法回收,直到程式結束 -
不穩定性:由於堆區的記憶體是手動管理的,如果程式員使用指針錯誤地引用已釋放的堆區記憶體(懸空指針),或者釋放後繼續訪問已釋放的記憶體(野指針),會導致不穩定性和未定義行為
-
堆區碎片:隨著時間的推移,動態記憶體的頻繁分配和釋放可能導致堆區出現碎片。堆區碎片是指堆中剩餘的不連續、無法利用的小塊記憶體。雖然這不會直接影響程式的正確性,但在某些情況下,可能會降低記憶體的利用率
-
線程共用:堆區記憶體可以線上程之間共用,多個線程可以訪問和使用堆區的相同記憶體。這使得堆區在多線程編程中非常有用,但也需要註意同步和避免競爭條件
-
異常安全性:由於堆區記憶體的手動管理,需要特別註意異常安全性。如果在使用
new
分配記憶體後,出現異常而未能釋放記憶體,可能導致記憶體泄漏。為了確保異常安全性,可以使用智能指針等資源管理工具來管理動態記憶體,避免手動釋放記憶體的繁瑣工作
4.1 記憶體溢出
記憶體溢出(Out of Memory)是指程式在運行過程中,創建了大量的對象或線程,導致堆空間或方法區空間被占滿,無法再分配新的記憶體
堆和方法區是兩種動態分配的記憶體區域,用於存儲對象實例、類信息、常量等信息。堆和方法區的大小是可配置的,一般為幾百MB到幾GB。記憶體溢出通常發生在對象或線程泄漏、記憶體分配過大、GC效率低下的情況下
#include <iostream>
int main() {
// 請求分配一個非常大的整數數組(堆區記憶體)
// 這裡如果開闢單個數組沒辦法報錯
// 所以為了模擬更確切的情況,假設就在程式中一次性開闢很大的空間
int* hugeArray0 = new int[999999999];
int* hugeArray1 = new int[999999999];
int* hugeArray2 = new int[999999999];
int* hugeArray3 = new int[999999999];
// 使用堆區記憶體,可能導致記憶體溢出
for (int i = 0; i < 999999999; i++) {
hugeArray0[i] = i;
}
// 不要忘記釋放堆區記憶體
delete[] hugeArray0;
system("pause");
return 0;
}
記憶體分配失敗,拋出 std::bad_alloc 異常並終止執行,而且可以看到右側進程記憶體已經快達到極限了
4.2 記憶體泄漏
堆區的記憶體泄漏是指在程式中動態分配了記憶體(使用 new
或 malloc
等操作符),但在不再需要這些記憶體時未及時釋放,導致程式無法再訪問這些記憶體,從而造成了資源浪費
記憶體泄漏是一種常見的編程錯誤,特別是在長時間運行的程式中,如果頻繁地分配記憶體而不釋放,最終可能會耗盡可用記憶體,導致程式崩潰或系統變慢
#include <iostream>
int main() {
while (true) {
// 在堆區動態分配一個整數數組,但未釋放記憶體
int* ptr = new int[1000];
// 未釋放記憶體,重覆分配,導致記憶體泄漏
}
return 0;
}
4.3 多重釋放與非法指針
多重釋放:在堆區釋放了記憶體後,如果再次嘗試釋放相同的記憶體,就會導致多重釋放問題。這會破壞堆的記憶體管理結構,可能導致程式崩潰
#include <iostream>
int main()
{
int* arr = new int[10];
delete[] arr;
delete[] arr;
system("pause");
return 0;
}
非法指針:在堆區釋放了記憶體後,如果仍然保留指向該記憶體的指針,就會導致懸空指針。而指向未初始化或已釋放的記憶體的指針稱為野指針。使用懸空指針或野指針可能導致程式崩潰或產生不可預測的行為
#include <iostream>
int main()
{
char* ch = new char[10];
for (int i = 0; i < 10; i++)
{
ch[i] = 'a';
}
char* ptr = ch;
std::cout << ptr << std::endl;
delete[] ch;
std::cout << *ptr << std::endl;
std::cout << *ptr << std::endl;
return 0;
}
4.4 產生堆記憶體碎片
記憶體塊碎片化: 多次動態分配和釋放堆區記憶體可能導致記憶體塊碎片化,使得分配大塊連續記憶體變得困難,從而降低堆區記憶體的效率
#include <iostream>
int main() {
const int N = 1000;
const int M = 100;
// 創建一個數組用於存儲指向動態分配的記憶體塊的指針
int* ptrArray[N];
// 模擬多次動態分配和釋放記憶體
for (int i = 0; i < N; i++) {
// 每次動態分配 M 個 int 大小的記憶體塊
ptrArray[i] = new int[M];
// 將記憶體塊清零,以模擬實際使用場景
for (int j = 0; j < M; j++) {
ptrArray[i][j] = 0;
}
}
// 輸出每個記憶體塊的首地址,用於觀察地址分佈情況
for (int i = 0; i < N; i++) {
std::cout << "Block " << i + 1 << ": " << ptrArray[i] << std::endl;
}
// 釋放動態分配的記憶體
for (int i = 0; i < N; i++) {
delete[] ptrArray[i];
}
return 0;
}