垃圾回收與記憶體錯誤 (Garbage Collection & Memory Bugs)
Overview Table
| 主題 | 核心概念 | 關鍵詞 | 書頁 |
|---|---|---|---|
| Garbage Collector 基礎 | 把記憶體視為有向可達圖 (directed reachability graph),自動 free 不可達 (unreachable) 的 heap 區塊 | root nodes, heap nodes, reachable | p.901–903 |
| Mark&Sweep 演算法 | mark 階段標記所有可達區塊 → sweep 階段釋放未標記的已配置區塊 | mark bit, header 低位 spare bit | p.903–905 |
| Conservative GC for C | C 無型別標記,isPtr 無法確定是否為指標 → 保守:可達必標記、不可達可能誤標 |
balanced binary tree, left/right 欄位 | p.905–906 |
| 常見記憶體錯誤 (9.11) | 十大類 C 記憶體 bug,共同特徵:action at a distance(發作點與病灶在時空上都遠離) | segfault, overflow, leak | p.906–911 |
| 章節總結 (9.12) | VM 三大能力:caching、memory management、protection;allocator 分 explicit / implicit 兩類 | page fault, TLB, mmap, malloc | p.911–912 |
本頁開頭收尾 9.9.14 的 segregated fits 與 buddy system:buddy system 是 size class 全為 2 的冪的 segregated fits 特例;區塊與其 buddy 的位址恰好差一個 bit(如 32-byte 區塊 xxx...x00000 的 buddy 為 xxx...x10000,即 buddy = addr XOR size)。優點是搜尋與 coalescing 快,缺點是 2 的冪需求造成嚴重 internal fragmentation。完整內容見 09-Virtual-Memory/05-Dynamic-Memory-Allocation。
9.10 Garbage Collection 概念 (p.901–902)
Garbage collector 是會自動釋放程式不再需要的已配置區塊(即 garbage)的動態儲存配置器 (dynamic storage allocator)。在支援 GC 的系統中,應用程式只呼叫 malloc、從不呼叫 free;collector 週期性地找出 garbage 區塊,並代替應用程式呼叫 free 把它們放回 free list。
- 忘記 free 是常見程式錯誤:如
void garbage() { int *p = Malloc(15213); return; }— 區塊在程式整個生命期都佔著 heap,無法再被利用 (p.902)。 - GC 起源於 1960 年代初 MIT 的 John McCarthy(Lisp 系統);現代語言如 Java、ML、Perl、Mathematica 都內建 GC。
- 本章聚焦 McCarthy 最初的 Mark&Sweep 演算法,因為它可以架在既有 malloc package 之上,為 C/C++ 程式提供 GC。
9.10.1 可達圖 (Reachability Graph) (p.902–903)
Collector 把記憶體視為一張有向圖:節點分成 root nodes 與 heap nodes。每個 heap node 對應一個已配置 heap 區塊;有向邊 p → q 表示區塊 p 中某位置指向區塊 q 中某位置。Root nodes 是「不在 heap 內、但含有指向 heap 的指標」的位置:暫存器 (registers)、stack 上的變數、read/write data 區的全域變數。
Root nodes: [reg] [stack var] [global]
\ | /
v v v
Heap nodes: (A) --> (B) (C)
\ |
v v
(D) (E) (F) (G)
|<---- reachable ---->| |<-unreachable->|
= garbage,可回收
- 節點 p reachable(可達) ⇔ 存在從任一 root node 到 p 的有向路徑。
- 任一時刻的 unreachable 節點 = garbage,應用程式永遠無法再使用它們。
- Collector 的角色:維護可達圖的某種表示,週期性回收不可達節點(free 之、放回 free list)。
Exact vs. Conservative Collector (p.903)
| Exact collector | Conservative collector | |
|---|---|---|
| 適用語言 | ML、Java(語言嚴格控管指標的建立與使用) | C、C++(無法精確維護可達圖) |
| 可達圖表示 | 精確 (exact) | 近似 |
| 保證 | 回收所有 garbage | 可達區塊必被正確辨識;但部分不可達區塊可能被誤判為可達(漏收) |
Collector 可以 on demand 執行,也可作為獨立執行緒與應用程式平行執行、持續更新可達圖並回收 garbage。
與 C malloc package 整合的資料流 (Figure 9.50, p.903)
C application ──malloc()──> ┌────────────────────────────┐
program │ Dynamic storage allocator │
│ malloc 找不到合適 free 區塊 │
│ │ │
│ v │
│ Conservative GC ──free()──│──> 回收 garbage 至 free list
│ │ │
│ v │
│ malloc 重試;仍失敗 → │
│ 向 OS 要更多 heap; │
│ 成功回傳 ptr / 失敗回傳 NULL│
└────────────────────────────┘
9.10.2 Mark&Sweep 演算法 (p.903–905)
Mark&Sweep collector 分兩階段:mark 階段標記 root nodes 的所有可達且已配置的後代 (descendants);sweep 階段掃過整個 heap,釋放每個「已配置但未標記」的區塊。標記通常用 block header 中閒置的低位 bit (spare low-order bit) 表示。
假設 typedef void *ptr,依賴以下輔助函式 (p.904):
| 函式 | 功能 |
|---|---|
ptr isPtr(ptr p) |
若 p 指向某已配置區塊中的字組,回傳該區塊起始位址 b;否則回傳 NULL |
int blockMarked(ptr b) |
區塊 b 是否已標記 |
int blockAllocated(ptr b) |
區塊 b 是否已配置 |
void markBlock(ptr b) |
標記區塊 b |
int length(ptr b) |
區塊 b 的長度(字組數,不含 header) |
void unmarkBlock(ptr b) |
把 b 從 marked 改回 unmarked |
ptr nextBlock(ptr b) |
回傳 heap 中 b 的下一個區塊 |
/* Figure 9.51 (p.904) */
void mark(ptr p) { void sweep(ptr b, ptr end) {
if ((b = isPtr(p)) == NULL) while (b < end) {
return; if (blockMarked(b))
if (blockMarked(b)) unmarkBlock(b);
return; else if (blockAllocated(b))
markBlock(b); free(b);
len = length(b); b = nextBlock(b);
for (i = 0; i < len; i++) }
mark(b[i]); /* 遞迴 DFS */ return;
return; }
}
- mark 對每個 root node 呼叫一次;若 p 不指向已配置且未標記的 heap 區塊就立即返回,否則標記該區塊並對區塊中每個字組遞迴呼叫(深度優先走訪可達圖)。
- mark 階段結束時:已配置且未標記 ⇒ 保證不可達 ⇒ garbage。
- sweep 只呼叫一次,線性走訪 heap 每個區塊:已標記者清除標記(為下一輪做準備);未標記且已配置者
free。
執行流程與範例 (Figure 9.52, p.905)
狀態轉換: [Before mark] --mark(root)--> [After mark] --sweep(heap)--> [After sweep]
heap: 1 2 3 4 5 6 root → 4
指標關係: 3→1, 4→3, 4→6
Before mark: 全部 unmarked
After mark: 1* 2 3* 4* 5 6* (* = marked;1,3,4,6 從 root 可達)
After sweep: 1 [Free] 3 4 [Free] 6 (2、5 不可達 → 回收至 free list)
9.10.3 Conservative Mark&Sweep for C (p.905–906)
Mark&Sweep 適合 C 的原因:它 works in place、不移動任何區塊。但 C 對 isPtr 的實作構成兩大挑戰:
- C 不對記憶體位置標記型別資訊 →
isPtr無法判斷輸入 p 到底是不是指標。 - 即使已知 p 是指標,也沒有明顯方法判斷 p 是否指向某已配置區塊 payload 內的位置(p 可能指向區塊中間,而非開頭)。
第 2 個問題的解法:把已配置區塊集合維護成平衡二元搜尋樹 (balanced binary tree),不變量 (invariant) 為「左子樹的區塊位址都較小、右子樹都較大」。每個已配置區塊 header 需額外兩個欄位 left、right,各指向某已配置區塊的 header;isPtr 用此樹做二分搜尋,每一步靠 header 的 size 欄位判斷 p 是否落在該區塊範圍 (extent) 內。
Allocated block header (Figure 9.53, p.905):
┌──────┬──────┬───────┬─────────────────────┐
│ Size │ Left │ Right │ Remainder of block │
└──────┴──┬───┴───┬───┴─────────────────────┘
│ └──> 位址較大的區塊之 header
└──> 位址較小的區塊之 header
- 正確性 (correct):保證標記所有從 roots 可達的節點 —— 絕不會把還在使用的區塊提早還給 free list。
- 保守性 (conservative):可能把實際不可達的區塊誤標為可達,漏收部分 garbage。這不影響程式正確性,但會造成不必要的 external fragmentation。
C 不標記型別,所以 int 或 float 可能偽裝 (masquerade) 成指標。若某可達區塊 payload 中有一個 int,其值恰好等於另一已配置區塊 b 的 payload 位址,collector 無從得知那是 int 而非指標,只能保守地把 b 標成可達 —— 即使 b 實際上是 garbage。
9.11 C 程式常見記憶體錯誤 (p.906–911)
記憶體錯誤之所以可怕,在於它們常在時間與空間上都遠離病灶處才發作 (manifest at a distance):寫錯資料到錯的位置,程式可能跑好幾個小時後才在毫不相干的地方掛掉。以下十類是 C 程式的經典 bug。
| # | 錯誤類型 | 典型範例 | 後果 / 修法 |
|---|---|---|---|
| 9.11.1 | Dereferencing bad pointers 解參考壞指標 | scanf("%d", val)(應為 &val) |
寫到 VM 空洞 → segmentation exception;寫到唯讀區 → protection exception;最糟:val 恰為合法 R/W 位址而默默覆寫 |
| 9.11.2 | Reading uninitialized memory 讀未初始化記憶體 | y = Malloc(...) 後直接 y[i] += ... |
.bss 由 loader 清零,但 heap 不會;應顯式清零或改用 calloc |
| 9.11.3 | Stack buffer overflow | char buf[64]; gets(buf); |
gets 不檢查長度;改用 fgets 限制輸入長度(參見 3.10.3) |
| 9.11.4 | 假設指標與其指向物件同大小 | Malloc(n * sizeof(int)) 想配 n 個 int *(應為 sizeof(int *)) |
在指標比 int 大的機器(如 Core i7)寫過界,常砸壞 boundary-tag footer,直到很久之後 free 時 coalescing 才莫名爆炸 |
| 9.11.5 | Off-by-one errors | for (i = 0; i <= n; i++) A[i] = ...(n 元素陣列寫了 n+1 個) |
覆寫陣列後方記憶體 |
| 9.11.6 | 操作到指標本身而非其指向物件 | *size--;(應為 (*size)--) |
一元 -- 與 * 優先權相同、右結合 → 遞減的是指標;有疑慮就加括號 |
| 9.11.7 | 誤解指標算術 | p += sizeof(int);(應為 p++) |
指標算術以指向物件的大小為單位,不是 byte;此例每次跳 4 個 int,只掃到每第 4 個元素 |
| 9.11.8 | Referencing nonexistent variables 參考已消失的變數 | int val; return &val; |
回傳 stack 區域變數的位址;stack frame pop 後位址仍合法但變數已無效,之後寫 *p 可能改到別的函式的 frame |
| 9.11.9 | 參考已 free 的 heap 區塊 | free(x); ... y[i] = x[i]++; |
x 可能已成為其他區塊的一部分並被覆寫;錯誤要到發現 y 內容毀損時才顯現 |
| 9.11.10 | Memory leaks | x = Malloc(...); return; 忘記 free |
「slow, silent killers」;heap 逐漸被 garbage 填滿,最糟耗盡整個虛擬位址空間;對永不終止的 daemon / server 特別致命 |
scanf("%d", val) 把 val 的內容當位址寫入:最好的情況是立即例外終止;最壞的情況是 val 恰好對應合法的 read/write 區域,默默覆寫記憶體,很久之後才出現災難性且令人費解的後果。
makeArray1:sizeof(int)寫成錯的型別 → 配置太小,迴圈寫過界。makeArray2:配置正確 (sizeof(int *)) 但i <= n→ 多寫一個元素。
兩者都是覆寫 (overwriting) bug,發作點都遠在 free / 使用時。
9.12 章節總結 (p.911–912)
虛擬記憶體是主記憶體的抽象:處理器產生 virtual address,經位址轉譯 (address translation) 變成 physical address 再送往主記憶體;轉譯需要硬體(用 page table 轉譯)與軟體(OS 提供 page table 內容)緊密合作。
- VM 三大能力:
- Caching:自動把磁碟上最近使用的虛擬位址空間內容快取到主記憶體;VM 的快取區塊叫 page,存取磁碟上的 page 觸發 page fault,由 OS 的 fault handler 把 page 從磁碟複製進主記憶體(必要時 write back 被逐出的 page)。
- 簡化記憶體管理:進而簡化 linking、行程間資料共享、記憶體配置、程式載入。
- 簡化記憶體保護:在每個 PTE 中加入 protection bits。
- 位址轉譯必須與硬體快取整合;PTE 多半在 L1,但存取成本通常被 TLB(on-chip 的 PTE 快取)消除。
- Memory mapping:把虛擬記憶體區塊與磁碟檔案區塊關聯以初始化之;是共享資料、建立新行程、載入程式的高效機制;可用
mmap手動建立/刪除 area,但多數程式依賴 heap 上的動態配置器如malloc。 - Allocator 兩類:explicit allocator 要求應用程式自己 free;implicit allocator(= garbage collector) 自動釋放未使用且不可達的區塊。Allocator 是「有系統級手感的應用級程式」,幾乎不靠型別系統直接操作記憶體。
VM 全章知識地圖(9.12 總結視角)
virtual address ──> [TLB / page tables] ──> physical address ──> cache/DRAM
│ miss: page fault → OS handler 從 disk 調頁
│
memory mapping (mmap) ──> heap ──> dynamic allocator
├─ explicit (malloc/free)
└─ implicit (garbage collector)
Exam/Test Patterns
| 情境 / 關鍵字 | 答案 |
|---|---|
| GC 眼中的記憶體是什麼結構? | 有向可達圖:root nodes(暫存器、stack 變數、全域變數)+ heap nodes(已配置區塊);邊 p→q 表示 p 內有指標指向 q |
| 什麼是 garbage? | 從任何 root 都不可達 (unreachable) 的已配置 heap 區塊,程式永遠無法再使用 |
| 給定 heap 區塊指標關係與 root,問 sweep 後哪些被回收? | 從 root 做圖走訪;mark 後未標記的已配置區塊全部被 free(如 Fig 9.52:root→4→{3,6}、3→1,回收 2、5) |
| Java/ML 的 GC vs C/C++ 的 GC? | 前者 exact(回收全部 garbage);後者 conservative(可達必標記,但可能漏收不可達區塊) |
| 為何 C 的 GC 必須 conservative? | C 不標記型別 → int/float 可偽裝成指標,值恰為某區塊位址時只能保守標為可達 |
| conservative GC 漏收 garbage 的代價? | 不影響正確性,但造成不必要的 external fragmentation |
| malloc 找不到合適區塊時(有 GC 的系統)順序? | 呼叫 collector 回收 → 重試 malloc → 仍失敗才向 OS 要記憶體 → 成功回傳 ptr / 失敗回傳 NULL |
| mark 用什麼記錄標記? | block header 中閒置的低位 bit |
isPtr 如何判斷 p 落在哪個已配置區塊? |
已配置區塊組成平衡 BST(header 加 left/right 欄位,左小右大),二分搜尋 + size 欄位判斷 extent |
| 為何 Mark&Sweep 適合 C? | In place、不搬動區塊(C 指標不能隨意改值重定位) |
scanf("%d", val) 哪裡錯? |
傳了 val 的值而非位址 &val;scanf 把值當位址寫入 → 例外終止或默默覆寫 |
| heap 記憶體初始值? | 不保證為 0(.bss 才由 loader 清零);要零初始化用 calloc 或顯式清零 |
*size-- 為何錯? |
* 與 -- 同優先權、右結合 → 先 size-- 再解參考;應寫 (*size)-- |
p += sizeof(int) 掃 int 陣列為何錯? |
指標算術以元素大小為單位,此式一次跳 4 個 int;應 p++ |
回傳 &val(區域變數位址)會怎樣? |
位址仍合法但變數隨 stack frame pop 而無效;之後解參考可能改到其他函式的 stack frame |
| memory leak 對哪類程式最致命? | daemon、server 等永不終止的程式;heap 被 garbage 填滿,最壞耗盡整個虛擬位址空間 |
| buddy system 中 buddy 的位址? | 與原區塊位址恰差一個 bit:buddy = addr XOR size(如 xxx...x00000 ↔ xxx...x10000) |
Related Notes
- 09-Virtual-Memory/05-Dynamic-Memory-Allocation
- 09-Virtual-Memory/02-VM-Management-and-Protection
- 09-Virtual-Memory/04-Memory-Mapping
- 09-Virtual-Memory/01-Address-Spaces-and-VM-Caching
- 03-Machine-Level-Programs/06-Buffer-Overflow-and-Pointer-Safety
- 03-Machine-Level-Programs/04-Procedures-and-the-Stack
- 07-Linking/03-Executable-Loading-and-Dynamic-Linking