4.1 從解譯到執行時編譯:1960-至今的 JIT 史#
定義與問題#
JIT(Just-In-Time compilation,執行時編譯):在程式執行期間,把(位元碼或 AST)翻譯成機器碼並執行。它位於前兩章的交點——
graph LR
A[原始碼] --> B[編譯到位元碼<br/>第3章]
B --> C[直譯執行<br/>慢但快啟動]
B --> D[JIT 觀察熱點]
D -->|熱門函式| E[編成機器碼<br/>快且仍快啟動]
E --> F[直接呼叫機器碼]JIT 解決的矛盾:啟動速度(直譯好)vs. 峰值速度(編譯好)。AOT 編譯(C/Rust)啟動快、峰值快,但編譯本身要時間且互動性差;純直譯啟動零成本但峰值慢 10-100 倍。JIT 的答案是「先直譯、觀察、對熱點編譯」——花費編譯成本只花在被證明值得的地方。
歷史:四個里程碑#
1960:LISP 1.5 的 compile 奧秘#
最早的「執行時編譯」出現在 LISP:compile 函式可以把 S-運算式編成機器碼,編好的函式放進函式表就被呼叫。這是「直譯為主、選擇性編譯」的混合模式——JIT 的思想雛形,但由使用者手動觸發,不是自動的。
1980-1984:Smalltalk-80 的動態翻譯#
Smalltalk-80 一切方法呼叫都經位元碼直譯,效能成為採用障礙。Dave Ungar 為 Smalltalk 的後繼者 SOAR(1984)引入動態翻譯,Peter Deutsch 與 Allan Schiffman 在 PS(Fast Portable Smalltalk, 1984) 中確立了 JIT 的兩大基礎技術:
- 動態翻譯:方法第一次被呼叫時從位元碼編成原生碼
- 內聯快取(inline cache):在呼叫點快取「上次命中的型別與方法位址」,下次呼叫直接比對快取
這兩項技術至今仍是所有 JIT 的基礎。
1989-1995:Self 與去虛擬化#
Ungar 的 Self 語言(原型繼承、無 class)逼出了更激進的技術:型別回饋(type feedback,記錄呼叫點實際出現的型別)與去虛擬化(devirtualization)——JIT 觀察到某呼叫點 99% 是同一型別,就內聯該方法並留下型別檢查的 guard;若 guard 失敗,回退(deopt)到直譯。「樂觀編譯 + guard + 回退」成為 JIT 的通用模式。
1999-2008:HotSpot 與 V8 把 JIT 帶進主流#
- HotSpot(1999,源自 Self 團隊加盟 Sun):名稱就是技術——「熱點偵測」。計數器找出熱方法,C1(快速、少最佳化)與 C2(慢、深度最佳化)兩級編譯。
- V8(2008):Chrome 競賽的產物,JS 直接編機器碼(無直譯起步,後來又加回直譯 Ignition——證明純 JIT 啟動太慢)。V8 的多代編譯器(Crankshaft → TurboFan → Sparkplug/Maglev)演示了「JIT 管線分層」的成熟形態。
graph LR
S[原始碼] --> IGN[直譯器 Ignition<br/>收集型別回饋]
IGN -->|計數器超過門檻| MAG[快速 JIT<br/>Sparkplug/Maglev]
MAG -->|更熱| TF[最佳化 JIT TurboFan<br/>內聯/去虛擬化/型別特化]
TF -->|guard 失敗| IGNV8 管線是本節的最佳教材:它證明了 JIT 不是一個編譯器,而是一條分層管線,每層有不同速度/品質權衡,加上「deopt 回退」的迴路。
JIT 為什麼能快:三個資訊優勢#
JIT 比靜態編譯器多知道的是執行期資訊:
- 型別分佈:
a + b在直譯時觀察到 100% 是整數 → JIT 可以生成純整數加法指令,省去動態分派 - 熱點路徑:知道哪個函式、哪個分支真的常跑 → 最佳化力氣花在刀口上
- 實際呼叫圖:知道誰真的呼叫誰 → 內聯決策有依據
代價是執行期編譯本身要花時間,所以 JIT 永遠是權衡:編譯成本 vs. 執行收益。這個權衡公式(大略):
其中 \(N\) 是未來預計執行次數。計數器就是在估 \(N\) 。
系統層基礎:可執行記憶體#
JIT 的機器碼要放到 CPU 能執行的記憶體,這把我們直接帶到作業系統的頁保護機制(第 6 章詳述):
mmap(NULL, len, PROT_READ|PROT_WRITE|PROT_EXEC, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0);
歷史上的安全事件驅動了演變:
- W^X(Write XOR Execute)原則:可寫又可執行的記憶體是攻擊者的樂園(緩衝區溢位直接注入 shellcode)
- 2017 年,Linux 4.14 預設阻止同時 W+X 的 mmap(除非明確要求);macOS 自 Apple Silicon 起完全強制 W^X
- 現代 JIT 的應對:雙映射(dual mapping)——同一塊實體記憶體映射兩次,一次可寫(RW,給編譯器寫入)、一次可執行(RX,給 CPU 執行),永遠沒有一個映射同時 W+X
graph TD
P[同一塊物理記憶體頁] -->|映射1| RW[mmap RW<br/>JIT 寫入機器碼]
P -->|映射2| RX[mmap RX<br/>CPU 執行]
RW -.->|寫完後不可再寫<br/>或用快照式寫入| RXWasmtime/Cranelift(都是 Rust 寫的)實作的正是這個模式。第 4.2 節我們用 Rust 實作單映射版迷你 JIT,4.3 節看 Cranelift 的工程級做法。
JIT 譜系與 Rust 的位置#
| JIT | 語言 | 實作語言 | 特點 |
|---|---|---|---|
| HotSpot C2 | Java | C++ | 世界上最成熟的最佳化 JIT |
| TurboFan | JS | C++ | 型別回饋驅動的 Sea-of-Nodes IR |
| Cranelift | WASM | Rust | 安全優先、可驗證的 IR |
| LLVM-ORC | 任意 | C++ | 把 AOT 後端做成 JIT |
| rbpf / solana | eBPF | Rust | 核心 eBPF JIT 的使用者態複刻 |
Rust 生態的意義:Cranelift 證明了JIT 後端本身可以用安全語言寫,且其 IR(CLIF)設計上支援形式化驗證。第 4.3 節我們用 Cranelift 給 MinLang 做 JIT 後端。
習題#
- 用 4.1 的划算公式估算:一個函式直譯每秒慢 20ns、JIT 編譯花 1ms,需要執行多少次才划算?
- 為什麼 V8 在 2008 年捨棄直譯器、2016 年又加回來?(提示:手機上的啟動時間、記憶體。)
- W^X 下「寫入與執行不能同時」與 JIT「邊寫邊跑」如何並存?(提示:雙映射、mprotect、寫入屏障。)