垃圾回收與記憶體錯誤 (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
承接前節 (p.901)

本頁開頭收尾 9.9.14 的 segregated fitsbuddy 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。

9.10.1 可達圖 (Reachability Graph) (p.902–903)

Collector 把記憶體視為一張有向圖:節點分成 root nodesheap 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,可回收

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│
                              └────────────────────────────┘
關鍵觀念:是 collector 呼叫 free,而不是應用程式。malloc 失敗 → 呼叫 collector 回收 → 重試 → 還不夠才向 OS 要記憶體。

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;                            }
}

執行流程與範例 (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 的實作構成兩大挑戰:

  1. C 不對記憶體位置標記型別資訊isPtr 無法判斷輸入 p 到底是不是指標。
  2. 即使已知 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 but conservative (p.906)

  • 正確性 (correct):保證標記所有從 roots 可達的節點 —— 絕不會把還在使用的區塊提早還給 free list。
  • 保守性 (conservative):可能把實際不可達的區塊誤標為可達,漏收部分 garbage。這不影響程式正確性,但會造成不必要的 external fragmentation

保守的根本原因:scalar 偽裝成指標

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 bug 兩種結局 (p.906–907)

scanf("%d", val) 把 val 的內容當位址寫入:最好的情況是立即例外終止;最壞的情況是 val 恰好對應合法的 read/write 區域,默默覆寫記憶體,很久之後才出現災難性且令人費解的後果。

9.11.4 vs 9.11.5 比較:同一支 makeArray,兩種砸法

  • 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 全章知識地圖(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...x00000xxx...x10000)