Home
探索 Uedu
學生控制台
註冊會員/登入
研究知情同意中心
教師控制台
課程設定
支援與訊息
Uptime 數據

UeduGPTs

--

Jupyters

4

UG26 CISOSE26
臺北 AQI 46 · 臺中 AQI 26 · 臺南 AQI 21 · 高雄 AQI 33

AI 回覆桌面通知

AI 助教回覆完成時顯示桌面通知

聊天訊息通知

同學在討論區發送訊息時通知

聲音通知

每當有新通知時播放提示音

記憶體管理

記憶體管理進階:分頁表、配置器與垃圾回收的真實成本

同一個程式「用了多少記憶體」為何沒有單一答案?穿過多級分頁表、TLB、使用者空間配置器、COW 與 GC 的層層機制,看清記憶體如何被計價、共享與回收。

當你的程式「沒在用的記憶體」其實一直在花錢

在入門篇裡,你已經知道作業系統會把虛擬位址(virtual address)切成頁(page),透過分頁表(page table)對應到實體記憶體(physical memory),缺頁時觸發 page fault 再從磁碟搬進來。這套機制看起來很完整——但它隱藏了一個讓無數工程師踩雷的真相:

你的程式「用了多少記憶體」這句話,根本沒有單一答案。

同一個行程(process),用 ps 看到 2 GB,用工作管理員看到 800 MB,用 malloc 統計只配置了 300 MB,而真正「獨佔」的實體頁可能只有 120 MB。這些數字全部正確,只是衡量的東西不同。進階篇要帶你穿過這層迷霧:我們會拆解多級分頁表(multi-level page table)與 TLB 的真實成本、剖析使用者空間配置器(user-space allocator)如何在 OS 之上再蓋一層、釐清 RSS/PSS/commit 這些常被混為一談的指標,最後進到垃圾回收(garbage collection)與寫時複製(copy-on-write)的底層機制。

如果說入門篇回答「記憶體怎麼對應」,進階篇回答的是「記憶體怎麼被計價、被共享、被回收」。

記憶體管理進階概念示意圖

多級分頁表:為什麼不能用一張大表

入門篇通常把分頁表畫成「一張查找表」。但讓我們算一下這張表如果真的攤平會多大。

假設 64 位元系統實際使用 48 位元虛擬位址,頁大小 4 KB(即 $2^{12}$ bytes)。那麼頁的數量是 $2^{48} / 2^{12} = 2^{36}$ 個。每個分頁表項(page table entry, PTE)8 bytes,攤平的單層表就要:

$$2^{36} \times 8 \text{ bytes} = 2^{39} \text{ bytes} = 512 \text{ GB}$$

每一個行程都要 512 GB 的表——這顯然荒謬。而且絕大多數行程只用到位址空間裡極小的一塊(程式碼、堆疊、堆積各佔幾個區段),這張巨表 99.99% 是空的。

解法是多級分頁表:把虛擬位址切成數段,逐層索引,只為「真的有用到」的子樹配置下層表。x86-64 用 4 級(PML4 → PDPT → PD → PT),位址欄位長這樣:

位元   47        39 38      30 29      21 20      12 11        0
     ┌───────────┬──────────┬──────────┬──────────┬───────────┐
     │  PML4 idx │ PDPT idx │  PD idx  │  PT idx  │ page offset│
     │   9 bits  │  9 bits  │  9 bits  │  9 bits  │   12 bits  │
     └───────────┴──────────┴──────────┴──────────┴───────────┘

每級用 9 個位元索引一張 512 項($2^9$)的表,每張表剛好 4 KB(512 × 8 bytes),自己就是一個頁。一次位址轉換要走 4 次記憶體查找——這正是 TLB 存在的理由。

動手算一下:稀疏配置省下多少

假設一個行程只用了 4 個頁(程式碼 1 頁、資料 1 頁、堆積 1 頁、堆疊 1 頁),且它們的虛擬位址恰好分散在四個不同的 PML4 子樹。在最壞情況下需要的表數量是:

# 每用到一個分散的頁,最壞情況需要 4 級各一張表
levels = 4
table_size = 4096          # 每張表 4 KB
pages_used = 4

# 最壞:每個頁各自獨佔一條從根到葉的路徑(但共用根 PML4)
worst_tables = 1 + pages_used * (levels - 1)   # 1 張 PML4 + 每頁 3 張下層
overhead = worst_tables * table_size
print(f"分頁表開銷上界:{worst_tables} 張表 = {overhead/1024:.0f} KB")
# 分頁表開銷上界:13 張表 = 52 KB

從 512 GB 降到數十 KB。代價是查找變慢(4 次記憶體存取),這個代價由 TLB 來吸收。

TLB:被忽略的效能瓶頸

TLB(Translation Lookaside Buffer)是 MMU 內的小型快取,存「虛擬頁號 → 實體頁框」的對應。命中時轉換幾乎零成本;未命中(TLB miss)就得走完整的 page table walk。

關鍵數字感受一下:典型 L1 TLB 只有 64~128 個項,每項覆蓋 4 KB,也就是只能即時覆蓋約 256 KB~512 KB 的位址範圍。一旦你的工作集(working set)跳出這個範圍且存取模式分散,TLB miss 會主宰效能。

看一個例子:列優先 vs 欄優先的 TLB 災難

#define N 4096
double a[N][N];   // 連續配置,每列 N*8 = 32 KB

// 版本 A:列優先(row-major),記憶體連續存取
for (int i = 0; i < N; i++)
    for (int j = 0; j < N; j++)
        a[i][j] += 1.0;

// 版本 B:欄優先(column-major),每次跨 32 KB
for (int j = 0; j < N; j++)
    for (int i = 0; i < N; i++)
        a[i][j] += 1.0;

兩個版本做的事完全相同,記憶體量也相同。但版本 B 每存取一個元素就跳一整列(32 KB)——遠超一個頁,幾乎每次都 TLB miss 也 cache miss。實測下版本 B 常慢 5~10 倍。

緩解手段是大頁(huge page)。把頁從 4 KB 換成 2 MB,單一 TLB 項覆蓋範圍擴大 512 倍:

# Linux 顯式大頁(一次配置 512 個 2MB 大頁 = 1 GB)
echo 512 > /sys/kernel/mm/hugepages/hugepages-2048kB/nr_hugepages

# 或讓核心透明地嘗試合併(Transparent Huge Pages)
cat /sys/kernel/mm/transparent_hugepage/enabled
# [always] madvise never

代價:大頁無法被分頁出去(不可 swap)、可能造成記憶體浪費(內部碎片)、且 THP 的背景合併可能引入延遲尖峰,對延遲敏感的系統(如交易引擎、即時資料庫)反而常選擇關閉 THP。

使用者空間配置器:OS 之上的第二層

這裡是入門篇通常略過、但實務最關鍵的一層。當你呼叫 malloc(24)作業系統完全不知道這件事。OS 只認頁(4 KB 為單位),它不可能為了 24 bytes 給你一個系統呼叫。

真相是:malloc 是 C 函式庫裡的使用者空間配置器(如 glibc 的 ptmalloc、Google 的 tcmalloc、FreeBSD 的 jemalloc)。它向 OS 批發大塊記憶體(透過 brk/sbrk 推高堆積邊界,或 mmap 映射匿名區段),然後在這塊大記憶體裡零售小區塊給你。

        你的程式                配置器              作業系統
    ┌──────────────┐      ┌──────────────┐    ┌──────────────┐
    │ malloc(24)   │─────▶│ 從 size-class │    │              │
    │ malloc(40)   │      │ 的 free list  │    │  brk / mmap  │
    │ free(p)      │      │ 切一塊給你    │◀───│ 批發整頁區段 │
    └──────────────┘      │ 不夠才找 OS  │    │  (4KB 為單位)│
                          └──────────────┘    └──────────────┘
       零售(bytes)        中間商(size class)   批發(pages)

這層設計解釋了三件常讓初學者困惑的事:

  1. free 後記憶體常不還給 OS。 配置器把釋放的區塊放回自己的 free list 等下次重用,因為 brk 只能線性增減、munmap 也有成本。所以你 free 了大量物件,作業系統看到的 RSS 卻沒降。
  2. 配置不是 $O(1)$ 也不是無碎片。 現代配置器把請求按大小分到不同的 size class(如 8, 16, 32, 48… bytes),每類維護獨立的 free list。malloc(24) 實際給你 32 bytes——多出的 8 bytes 是內部碎片(internal fragmentation)。
  3. 多執行緒下有 arena。 ptmalloc 為每個執行緒分配獨立的 arena 以減少鎖競爭,這也是為什麼多執行緒程式的記憶體用量常比預期高。

動手算一下:內部碎片的真實成本

# 模擬 size-class 對齊造成的內部碎片
size_classes = [8, 16, 32, 48, 64, 80, 96, 112, 128]

def round_up(req):
    for c in size_classes:
        if req <= c:
            return c
    return ((req + 15) // 16) * 16   # 大於 128 後以 16 對齊

requests = [24, 40, 1, 65, 100, 7, 33]
total_req = sum(requests)
total_alloc = sum(round_up(r) for r in requests)
waste = total_alloc - total_req
print(f"請求 {total_req} bytes,實配 {total_alloc} bytes,"
      f"內部碎片 {waste} bytes({waste/total_alloc:.1%})")
# 請求 270 bytes,實配 336 bytes,內部碎片 66 bytes(19.6%)

近 20% 的浪費並不罕見。這是「速度 vs 空間」的經典取捨:對齊到 size class 讓配置/釋放變成 free list 的 $O(1)$ 推入彈出,代價是空間浪費。

RSS、PSS、Commit:到底用了多少記憶體

現在我們有能力回答開頭的謎題了。不同工具量的是不同層次:

指標 全名 量的是什麼
VSZ / VIRT Virtual Size 行程虛擬位址空間總大小(包含從未碰過的保留區)
RSS Resident Set Size 此刻在實體記憶體的頁,但共享頁重複計算
PSS Proportional Set Size 共享頁按分攤計(被 $N$ 個行程共享就算 $1/N$)
Commit Committed memory 系統承諾可提供的量(影響 OOM 與是否能 mmap

關鍵洞見:VSZ 幾乎沒意義(你 mmap 一個 1 GB 檔案但只讀第一頁,VSZ 就 +1 GB),RSS 會高估共享(10 個行程共用 glibc,每個 RSS 都算進那份函式庫),PSS 才是「公平分攤」的真實佔用

# 看一個行程各記憶體區段的 RSS 與 PSS 細分
cat /proc/<pid>/smaps_rollup
# Rss:              812345 kB
# Pss:              520118 kB    ← 扣掉共享分攤後的真實佔用
# Shared_Clean:     180000 kB    ← 與他人共享、未修改(如函式庫程式碼)
# Private_Dirty:    410000 kB    ← 只有你改過、無法共享,最「貴」

Private_Dirty 是最該盯的數字——它是這個行程獨佔、且因為被修改而不能與任何人共享、也不能單純丟棄的頁。要降低記憶體壓力,就是要降這個。

Overcommit:作業系統的超賣策略

Linux 預設超賣(overcommit)記憶體:malloc 成功不代表實體記憶體真的存在,只是回了一段虛擬位址。實體頁要等你真的寫入時才透過 page fault 配置(demand paging)。

// 在預設 overcommit 下,這行可能「成功」即使你沒有 100 GB RAM
char *huge = malloc(100L * 1024 * 1024 * 1024);
if (huge) {
    huge[0] = 'A';                    // 只有這一頁真的被配置
    // 若真的逐頁寫滿,OOM Killer 才會被觸發殺掉某個行程
}

這就是為什麼「malloc 回傳非 NULL」不等於「記憶體可用」,也是 fork 一個大行程卻沒立刻爆掉的原因——它依賴下一個主題。

寫時複製:fork 的魔法

當你 fork() 一個 4 GB 的行程,作業系統不會複製 4 GB。它讓父子行程的分頁表都指向同一批實體頁,並把這些頁全部標記為唯讀。只有當任一方寫入某頁時,才觸發保護錯誤(protection fault),核心此時才真的複製那一頁——這就是寫時複製(copy-on-write, COW)。

fork 之後(尚未寫入)            子行程寫入頁 P 之後
父 ──┐                          父 ──▶ [頁 P 原本]
     ├──▶ [共享頁 P,唯讀]            
子 ──┘                          子 ──▶ [頁 P 副本]  ← 只複製這一頁

COW 讓 fork+exec 啟動新程式幾乎零複製成本,也讓 Redis 用 fork 做背景持久化(bgsave)時,子行程能拿到資料的「快照」而不阻塞主行程。

但 COW 有個著名陷阱:寫入會放大記憶體。如果 fork 後父行程持續修改大量頁,每次修改都複製一頁,原本「共享」的省記憶體效果會逐漸消失。更隱蔽的是——讀取也可能觸發複製:垃圾回收語言(如 Python 的引用計數、含標記的 GC)在「讀」物件時會更新物件標頭裡的計數或標記位元,這個寫入讓整頁被 COW 複製。這就是為什麼 fork 重的 Python/Ruby 服務的記憶體會莫名膨脹。

垃圾回收:自動記憶體管理的代價

C/C++ 要你手動 free;Java、Go、Python、JavaScript 則用垃圾回收器(GC)自動找出不再可達(unreachable)的物件並回收。這不是「免費」的——只是把成本從「程式設計師的腦力」轉移到「執行期的 CPU 與延遲」。

主流策略:

  • 引用計數(reference counting):每物件記錄被引用次數,歸零即回收。優點是即時、可預測;缺點是無法回收循環引用(A 指 B、B 指 A 但外界都不可達),且每次賦值都要更新計數(多執行緒下還要原子操作)。CPython 用它為主,再加一個循環偵測器補洞。
  • 追蹤式(tracing GC):從根集合(roots:堆疊、全域變數、暫存器)出發走訪所有可達物件,沒走到的就是垃圾。標記—清除(mark-sweep)是基礎;分代回收(generational GC)基於「多數物件早夭」的弱分代假說(weak generational hypothesis),把新生代(young gen)頻繁、快速地回收,老生代(old gen)少回收,大幅降低平均成本。
import gc, sys

a = []
b = []
a.append(b)      # a -> b
b.append(a)      # b -> a,形成循環引用
ref = sys.getrefcount(a)
del a, b         # 外界不再引用,但循環讓引用計數不歸零

# 引用計數無法回收循環;要靠分代 GC 的循環偵測器
collected = gc.collect()
print(f"循環 GC 回收了 {collected} 個物件")

GC 的核心痛點是停頓(stop-the-world pause):傳統標記階段需要暫停所有應用執行緒以取得一致的物件圖。現代回收器(Go 的並行三色標記、Java 的 ZGC/Shenandoah)致力於把停頓壓到次毫秒級,代價是更複雜的讀/寫屏障(read/write barrier)與更高的吞吐量開銷。這又是一次「延遲 vs 吞吐量」的取捨。

重點回顧

  • 單層分頁表會大到荒謬(48 位元位址下需 512 GB/行程);多級分頁表只為實際用到的子樹配置下層表,把開銷壓到 KB 級,代價是多次記憶體查找——由 TLB 吸收。
  • TLB 是隱形瓶頸:覆蓋範圍僅數百 KB,存取模式分散時 TLB miss 主宰效能;大頁(huge page)可把單項覆蓋擴大 512 倍,但犧牲彈性與可 swap 性。
  • malloc 不等於系統呼叫:使用者空間配置器向 OS 批發大塊、按 size class 零售小塊,因此會有內部碎片、free 後常不還 OS、多執行緒有 arena。
  • 「用了多少記憶體」沒有單一答案:VSZ 含未碰過的保留區、RSS 高估共享、PSS 才公平分攤,而 Private_Dirty 是最該優化的真實佔用。
  • COW 與 GC 都是取捨:COW 讓 fork 近乎免費,但寫入(甚至引用計數的「讀取」)會放大記憶體;GC 省去手動管理,代價是執行期 CPU 與停頓延遲。

深入探討(研究所視角)

1. 分頁表走訪的硬體加速與安全副作用。 現代 CPU 在 MMU 內設有 page walk cache(如 Intel 的 Paging-Structure Caches),快取中間級的分頁表項以縮短 miss 後的走訪。但這條精密的轉換路徑也是 Meltdown/Spectre 一類微架構側通道(microarchitectural side channel)的攻擊面:推測執行(speculative execution)會在權限檢查完成前載入受保護頁,再透過 cache 時序洩漏內容。緩解手段 KPTI(Kernel Page Table Isolation)把核心與使用者分頁表分離,代價是每次系統呼叫都要切換 CR3 並沖刷 TLB,可量化地降低吞吐量——這是「安全 vs 效能」在記憶體管理層的直接體現。值得追的延伸:PCID(Process-Context Identifier)如何讓 TLB 不必全沖刷以減輕 KPTI 開銷。

2. 碎片化的理論界限。 使用者空間配置的外部碎片(external fragmentation)有可證明的下界。Robson(1971)證明:任何不可移動物件的配置器,在最壞情況下實際使用峰值為 $M$、最大區塊為 $n$ 時,可能需要 $\Theta(M \log n)$ 的記憶體。這意味著沒有任何 malloc 策略能在最壞情況下避免碎片——除非允許移動物件(compaction),而那正是追蹤式 GC(如複製式回收 copying collector)相對 malloc 的根本優勢:它能搬移存活物件、消除外部碎片,代價是更新所有指向被搬物件的指標(需要可被精確識別的指標,即 precise GC)。

3. 記憶體與並行的交會:RCU 與危險指標。 在無鎖(lock-free)資料結構中,「何時可以安全釋放一個已被移除但可能仍有讀者在存取的節點」是核心難題(the memory reclamation problem)。Linux 核心的 RCU(Read-Copy-Update)讓讀者零開銷地存取,寫者更新後延遲釋放舊版本,直到確認所有先前的讀者都已離開臨界區(grace period)。另一條路線是危險指標(hazard pointers,Michael 2004)。這把「記憶體何時可回收」從單行程問題提升為分散式同步問題,是作業系統、並行演算法與記憶體管理三者交會的前沿。

4. 延伸閱讀方向。 想深入的學生可循三條線:(a) 持久記憶體(persistent memory, NVM)如何打破「易失 RAM vs 非易失磁碟」的二分,逼迫重新設計配置器與故障一致性(failure atomicity);(b) 分代假說在不同工作負載下的失效情形,以及 region-based memory management(如 Rust 的所有權模型)如何在編譯期靜態保證安全而完全免去 GC;(c) 將 TLB、cache、NUMA 拓樸納入考量的 locality-aware allocator,這在多通道、多 socket 的伺服器上對效能有決定性影響。

AI 共讀助教正在陪你讀:記憶體管理進階:分頁表、配置器與垃圾回收的真實成本
嗨!我是這篇文章的共讀助教,只根據〈記憶體管理進階:分頁表、配置器與垃圾回收的真實成本〉的內容回答。可以問我「解釋某段」「舉個例子」「出題考我」,或反白文中段落後點下方「解釋選取段落」。