技術筆記 / Java

Java 熱替換:不重啟就換掉一段邏輯

在遊戲 server 或長跑服務裡,我們常想「改一段邏輯不重啟就生效」。Java 做得到,但要繞一圈。這篇整理兩條路線、實作模式,以及一定會踩的坑。

兩條路線

第一條是 JDK 內建的 HotSwap(JVMTI / instrumentation)。 Debug 模式下可以 redefine 一個 class 的方法內部實作,IntelliJ、Eclipse 的 "Hot Swap" 就是走這條。但限制很硬:不能新增或刪除方法、欄位,不能改繼承結構或方法簽章。只能改「方法內的那幾行」。

第二條是 ClassLoader 熱替換。 如果你可以接受「舊 instance 不變、新 instance 用新版就好」,這條路更乾淨,也不用碰 instrumentation。以下主要講這條。

ClassLoader 熱替換

核心模式是每次重載就開一個新的 ClassLoader:

// 1. Runtime 編譯 .java → bytecode
JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
compiler.run(null, null, null, "-d", "out/", "src/Plugin.java");

// 2. 每次重載開新 ClassLoader
URLClassLoader cl = new URLClassLoader(
    new URL[]{ new File("out/").toURI().toURL() },
    parentLoader  // 通常是 app classloader
);
Class<?> c = cl.loadClass("Plugin");
Object instance = c.getDeclaredConstructor().newInstance();

舊 class 之後被 GC,新物件走新版邏輯。既有 instance 不受影響。

一定會踩的坑

必須有穩定的介面,而且它要放在 parent loader。 同名 class 由不同 loader 載入 = 兩個不相容的型別,instanceof 和 cast 全掛。所以 interface 放 parent,實作類才放子 loader:

// parent loader: interface Plugin { void run(); }
// child loader:  class PluginImpl implements Plugin
Plugin p = (Plugin) instance;  // 這樣才 work

Parent-first delegation。 介面要能從 parent 找到,別讓子 loader 自己載一份,否則又變成兩個不相容型別。

舊 loader 要能被 GC。 所有指向舊 instance 或舊 Class 的 reference 都要斷乾淨,否則 metaspace 洩漏。ThreadLocal、static cache、註冊過但沒反註冊的 listener 是最常見的洩漏源。

static 狀態會重置。 新 loader 有自己的一份 static 欄位——這通常正是你要的行為。

編譯到記憶體(進階)

想省 IO、也免掉 out/ 目錄的並發問題,可以用 JavaFileManager + SimpleJavaFileObject 把 bytecode 收在記憶體的 Map<String, byte[]>,再讓自訂 ClassLoader 的 findClass() 直接 defineClass()。

效率

要分兩個時間點看。

執行期:不輸原生。 載完的東西就是正常 bytecode,一樣被 JIT 優化,跟平常沒差別。長跑的 hot loop 該熱的還是會熱。

替換那一瞬間:貴。 要跑 javac(幾十到幾百毫秒)、開 loader、重建 instance。所以「改一行、看效果」的高頻迴圈體感偏慢——這是最痛的地方,但只發生在替換當下,不是永久稅。

突破 HotSwap 限制的方案

如果需要加方法、加欄位、改繼承又不想自己刻 ClassLoader:

實務建議

把「一次熱替換的單位」定成一個模組——一組 class 加一個 entry interface,每個模組一個 loader。這樣邊界清楚、GC 好回收。本質上就是 OSGi / JPMS layer 在做的事,只是自己刻更輕。

在 GitHub 看原文