「堆」與「棧」是計算機科學中極易混淆的兩組概念,因為它們同時存在於兩個完全不同的層面:
本文將從這兩個維度徹底釐清它們的區別與聯繫。
一個典型的 C/C++ 編譯程序在運行時,虛擬地址空間主要劃分為以下區域:
| 區域 | 管理方式 | 存放內容 | 特點 |
|---|---|---|---|
| 棧區 | 編譯器自動分配/釋放 | 函數參數、局部變量、返回地址、暫存寄存器 | LIFO、連續內存、大小固定(通常 1-8MB)、極快、自動管理 |
| 堆區 | 程式設計師手動管理(malloc/free, new/delete) | 動態分配的內存塊 | 非連續、大小受限於虛擬內存、靈活但易產生碎片、需手動釋放否則洩漏 |
| 全局/靜態區 | 編譯器靜態分配,程序結束由OS回收 | 全局變量、靜態變量(初始化與未初始化分段) | 生命週期 = 程序生命週期 |
| 常量區 | 編譯器分配,只讀 | 字串常量、const 修飾的全局變量 | 通常不可寫入,編譯器可能優化合併相同常量 |
| 代碼區 | 只讀 | 函數體二進制機器碼 | 可共享、不可修改 |
int a = 0; // 全局初始化區
char *p1; // 全局未初始化區 (BSS)
main() {
int b; // 棧
char s[] = "abc"; // 棧 (字串內容拷貝到棧數組)
char *p2; // 棧 (指針變量本身)
char *p3 = "123456";// "123456\0" 在常量區,p3 在棧
static int c = 0; // 全局/靜態初始化區
p1 = malloc(10); // 指向 10 字節的【堆】內存
p2 = malloc(20); // 指向 20 字節的【堆】內存
strcpy(p1, "123456");// "123456\0" 在常量區,內容拷貝到 p1 指向的【堆】
}
關鍵認知:p1、p2、p3 這些指針變量本身都在棧上,它們指向的內容才可能在堆或常量區。
int b; 一行代碼,編譯器生成幾條汇編指令(移動棧指針),極快,但程式設計師不可控。malloc(10),庫函數搜尋空閒鏈表、分裂塊、更新元數據,較慢,但大小、生命週期完全由程式設計師控制。ulimit -s 默認 8MB)。棧溢出 = 彈盡糧絕。char s1[] = "aaaaaaaaaaaaaaa"; // 棧上數組
char *s2 = "bbbbbbbbbbbbbbbbb"; // s2在棧,指向常量區/堆
a = s1[1]; // 汇編: mov cl, [ebp-0Fh] -> 直接偏移訪問,極快
a = s2[1]; // 汇編: mov edx, [ebp-14h]; mov al, [edx+1] -> 先取指針再間接訪問,多一條指令
結論:棧上數組/結構體存取比指針間接訪問更快,因為省去了解引用步驟,且棧內存熱度高,Cache 命中率極佳。
高地址 +------------------+ | 主函數局部變量 | <- 上一幀棧底 +------------------+ | 參數 (右到左壓入) | +------------------+ | 返回地址 (EIP/RIP)| <- 關鍵:ret 指令彈出跳轉 +------------------+ | 被調函數局部變量 | <- 當前幀棧頂 | ... | 低地址
這就是為什麼局部變量生命週期綁定函數調用、遞歸過深導致棧溢出、緩衝區溢出可覆蓋返回地址劫持控制流的根本原因。
這是另一個完全不同的維度,請勿混淆:
push/pop/top。insert/extract-min。歷史趣聞:內存管理的「堆」之所以得名,早期實現確實使用了「資料結構堆」(空閒塊按大小組織)來管理,後來演變為更複雜的分配器,但名字留下了。
| 維度 | 棧 | 堆 |
|---|---|---|
| 本質 | CPU 支持的 LIFO 內存區 / LIFO 抽象資料類型 | 動態內存池 / 滿足堆性質的樹結構 |
| 分配速度 | 極快 (指針移動) | 較慢 (搜尋/分裂/合併) |
| 大小限制 | 小、固定 (MB 級) | 大、靈活 (GB/TB 級) |
| 生命週期 | 自動 (作用域綁定) | 手動 (程式設計師控制) |
| 碎片問題 | 無 | 有 (外部/內部碎片) |
| 適用場景 | 局部變量、函數調用、小型短命物件 | 大型物件、動態生命週期、跨函數共享數據 |
malloc/new,並嚴格配對 free/delete(或用智能指針/RAII)。char buf[1024*1024] 直接導致棧溢出,改用靜態/全局/堆。