「記在腦子裡的會發霉,寫在紙上的才追得回;至於死者的話,你得知道什麼時候可以鬆手。」
——《阿帕契開源審計錄》¹ 卷二·記憶回收篇
幕間
女巫按緊袖中藥瓶:「毒錯人比不毒更糟。預言家還活著,等我有把握再動。」
獵人點頭:「那我繼續趴著。我這顆子彈,要嘛不響,響了就必須帶走一隻真狼。」
天亮,法官宣布六號夜裡死亡。夜間死亡沒有遺言,他只是安靜地把牌倒扣在桌面,位子空了下來。
問題是,他昨天白天講過的一長串推理,還留在每個人腦子裡:他懷疑過三號、幫過八號說話、跟一號有過一次口角。這些資訊現在還算不算數?該立刻清掉,還是留著當背景?留太久,我會用一個已經沒有立場的人的判斷去推活人;清太早,又可能丟掉一條還有用的線索。
七號的羊皮紙上,我看見她在六號那一行旁邊補了一筆——不是刪掉,是標記「此人首夜先開口指三號」。她保留了「誰先開口」這件事,其餘的先擱著。
這正是每個程式執行期都要回答的問題:一塊記憶體,什麼時候沒有人再需要它、可以安全回收?四大語言給了四種完全不同的答案。
回收記憶體要解決兩件事:判斷一塊記憶體是否還可達(reachable),以及在什麼時機真正釋放。判斷交給執行期的垃圾收集器(GC),還是交給編譯期的規則?釋放是不確定時間點的批次處理,還是離開作用域就精準發生?四大語言正好站在四個位置。
這個選擇沒有免費的午餐。追蹤式 GC 讓你寫程式時幾乎不必想記憶體,代價是執行期要花 CPU 去掃描、偶爾還會停頓;編譯期的所有權規則讓釋放變得精準且零停頓,代價是寫程式的當下要把「誰擁有這塊資料、它活到什麼時候」想清楚,編譯器不讓你含糊。引用計數介於中間:釋放時機確定,但每次賦值都要動計數器,而且對付不了參考環。牌桌上那份「六號的推理該留多久」的猶豫,本質上就是同一道題:一份資訊沒有明確的擁有者和生命週期時,你就會要嘛抓太久、要嘛丟太早。
Go 用三色標記清除的追蹤式 GC。它的設計目標很明確:縮短「Stop-The-World」(STW,全部應用執行緒暫停)的時間。標記階段大部分和應用程式並行進行,靠寫入屏障(write barrier)維持一致性,真正的 STW 只剩下極短的開始與收尾,通常在毫秒以下。代價是吞吐量:GC 要和你的程式搶 CPU,而且回收時機由 GOGC 這類參數和堆積成長速度決定,不是你能精準預測的。
// The collector decides when 'attacked' becomes unreachable; we never free it.
type Claim struct {
speaker string
target string
round int
}
func recordNightClaims(round int) []Claim {
claims := make([]Claim, 0, 8)
claims = append(claims, Claim{speaker: "seat-6", target: "seat-3", round: round})
// Once the caller drops its reference to this slice and nothing else points to it,
// the concurrent GC will reclaim the backing array on a later cycle.
return claims
}
Kubernetes、Apache YuniKorn 這類長時間運行的控制平面服務,正是看上 Go「低延遲停頓」這個取捨——調度決策不能因為一次 GC 就卡住幾百毫秒。Go 的做法是把「可預測的短停頓」擺在「最高吞吐量」前面,並且刻意不提供太多旋鈕,讓多數服務用預設值就夠好。你能做的優化通常不是調 GC,而是減少配置本身:重用緩衝區、用 sync.Pool、預留 slice 容量,讓收集器根本沒那麼多垃圾要掃。
Java 的 JVM 建立在「弱分代假說」上:大多數物件出生後很快就死。於是堆積被分成年輕代與老年代,年輕代頻繁做小回收(minor GC),熬過幾輪的物件才晉升到老年代。現代收集器把停頓壓得很低:G1 把堆積切成一塊塊 region,多數工作並行,只在搬移(evacuation)時短暫暫停;ZGC 用染色指標與載入屏障,讓幾乎所有工作都並行,停頓通常在一毫秒內,而且從 JDK 21 起也分代了。
// The JVM tracks reachability; 'claims' is collected after the method returns.
List<String> collectDayClaims(int round) {
List<String> claims = new ArrayList<>();
claims.add("seat-6 accuses seat-3 on round " + round);
// Short-lived: dies in the young generation, reclaimed by a cheap minor GC.
return List.copyOf(claims);
}
Apache Kafka 的 broker 對 GC 停頓極度敏感——一次長 STW 可能觸發整個叢集的 consumer 重平衡,所以調校 G1/ZGC 是 Kafka 維運的基本功。
Rust 把「誰還需要這塊記憶體」這個問題整個搬到編譯期。所有權規則規定每個值同時只有一個擁有者,擁有者離開作用域,值就在那一刻被 drop,記憶體立即歸還——完全確定的時機,沒有背景執行緒,沒有停頓。借用檢查器在編譯期確保沒有任何懸空引用。你為此付出的代價是寫程式時要把生命週期想清楚。
// No garbage collector: `claims` is freed deterministically at the closing brace.
fn record_night_claims(round: u32) -> Vec<String> {
let mut claims = Vec::with_capacity(8);
claims.push(format!("seat-6 accuses seat-3 on round {round}"));
claims
// Ownership moves out on return; if it did not, `drop` would run right here.
}
Apache DataFusion 是查詢引擎,一次查詢會配置大量中間緩衝區,「離開作用域就精準釋放」讓它的記憶體用量可預測,不會被 GC 的不確定性干擾。當你需要多個擁有者時,Rust 用 Rc / Arc 這種明確的引用計數型別讓你選擇加入計數,而不是預設全域都算——你得自己承擔「這裡可能形成環」的責任,通常配合 Weak 打斷環。換句話說,Rust 不是沒有引用計數,而是把它變成一個你要明白寫出來的決定。
CPython 的主力機制是引用計數:每個物件記著有多少個引用指向它,歸零的瞬間立刻釋放——時機確定,而且不需要掃描整個堆積。但它有個致命盲點:兩個物件互相引用,計數永遠不會歸零。所以 CPython 額外掛了一個分代循環收集器,週期性掃描找出這種參考環。全域直譯器鎖(GIL)順帶保護了引用計數的正確性。
def record_night_claims(round: int) -> list[str]:
claims = ["seat-6 accuses seat-3 on round " + str(round)]
# Refcount drops to zero when the caller stops referencing the returned list;
# a reference cycle, if any, waits for the generational cyclic collector.
return claims
Apache Airflow 的排程器長時間常駐,一旦某個 DAG 物件不小心形成參考環,記憶體就會緩慢爬升,直到循環收集器介入——這也是為什麼 Airflow 的長跑穩定性測試要盯著 RSS 曲線。

「一份資訊什麼時候該被回收」這個問題,寫這個系列用的 Claude Code 本身每天都在面對。一次多輪對話的上下文視窗,本質上就是一塊會不斷成長的堆積:舊的工具輸出、失敗的除錯嘗試、已經解決的疑問,全部堆在裡面,越堆越重,卻不是每一句都還「可達」。Anthropic 官方的 Claude Code 最佳實踐指南把這件事講得很直白——建議工程師「積極管理上下文」(manage context aggressively),該用 /clear 就整個清空重來,該用 /compact 就主動壓縮,這對應的正是本篇第 34-36 頁講過的「追蹤式 GC」思路:不確定哪些還有用,就靠週期性的整理把死掉的部分清掉。
但同一份指南裡還有另一種完全不同的策略:把一段調查性質的子任務交給一個 subagent 去做。子代理人會拿到一份全新、獨立、範圍受限的上下文,執行完畢、把結論回報給主對話之後,它自己那份完整的推理過程與中間垃圾就直接關閉,不會滲回主對話視窗——這其實更接近 Rust 的所有權模型:一個值進入一個作用域(子代理人的任務),離開作用域(回報完成)就確定性地釋放,不需要事後靠掃描去猜它還有沒有用。兩種策略並存,恰好對應本篇兩種記憶體回收哲學的兩端:/compact 是不確定時機的追蹤式清理,subagent 的任務邊界則是離開作用域就精準釋放。
我發現自己犯了記憶體洩漏的錯:整個白天,我一直用六號昨天的推理在評估三號,好像那些話還有效力。可是六號已經倒牌,他的立場、他的動機,全都不再可達——那塊記憶早該回收。七號比我乾脆,她只留下「六號首夜先開口指三號」這一個結構性事實,其餘的判斷隨著他的死一起釋放掉了。
還有個細節:法官宣布六號死亡的那一刻,二號低頭在桌面下寫了幾筆。我問他寫什麼,他說「隨手記一下」,沒讓我看。也許真的沒什麼。
讀完這篇,你應該能講清楚「離開作用域就析構」(Rust 的 drop、RAII)和「追蹤式 GC」在時機與停頓上的根本差異,也知道為什麼純引用計數收不掉參考環。想深入,Java 25 官方文件的 GC 章節與 Rust 官方 Book 的所有權章節是最扎實的起點。
std::rc::Rc(引用計數型別)
gc 模組(循環垃圾回收器)
Go concurrent garbage collector tri-color mark sweep write barrier、Java ZGC G1 generational pause time、Rust ownership borrow checker drop RAII、CPython reference counting cyclic garbage collector gc module、Claude Code context management subagent scope
¹ 註:本書名為情境設定之虛構文獻,非真實歷史或開源紀錄。