写在前面

这篇是 《从 OSGi 到 Jigsaw:Java 模块化、SPI 与类加载器切换的真相》 的续篇。前文只在 Java 一个语种内部比较 OSGi、JBoss Modules、JPMS 三套方案。这篇把视野拉宽——其他主流平台对"模块化、动态加载、热替换"这套问题各自给出了怎样的回答,谁的方案到工业级别还活着,谁干脆放弃了。

讨论的对象是六家:.NET(CoreCLR)、Erlang/BEAM、Node.js(ESM)、Python(CPython)、C/C++(dlopen 系)、Go。每一家都拿同一组问题去问:模块身份是什么、谁来隔离、能不能加新版、能不能卸老版、老对象怎么办、SPI 怎么做

跨平台模块化与动态加载对照图

一、坐标系:把"模块化"切成五件事

跨平台比较最容易陷入"功能罗列"。先把坐标系定清楚,对照表才有意义。模块化这个词在工程上至少要拆成五件事:

  1. 隔离单元:什么是平台眼里的"一个模块"?文件、命名空间、加载器、还是进程?
  2. 命名空间:同名符号能不能在系统里同时存在两份?
  3. 可见性:模块内部的东西,能不能默认对外不可见、强制要声明才能透出去?
  4. 生命周期:能不能在不停机的前提下加载新版本、卸载旧版本?
  5. 实例迁移:旧版本上的对象在新版本加载后是死是活、怎么过桥?

这五件事正交。前两件偏静态,决定"程序长成什么样";后三件偏动态,决定"程序能不能演化"。Java 这边的 ClassLoader / module 就是用来同时管这五件的,但代价是工程上极其复杂。其他平台的选择往往是只挑其中几件,剩下几件留给操作系统或干脆放弃。

下面六节按平台展开,最后一节做总览。

二、.NET:AssemblyLoadContext 的 collectible 模式

.NET 在这套问题上的设计与 Java 是平行演化的关系——更晚但更干净。

身份与隔离。CoreCLR 的最小加载单元是 Assembly(一个 dll 或 exe)。Assembly 的身份不是文件路径,而是 assembly identity(name + version + culture + public key),和 Java 的 (loader, binary name) 两元组同构。隔离器叫 AssemblyLoadContext(ALC):每个 ALC 是一个独立的命名空间,同一个 Assembly 名可以在不同 ALC 里加载不同版本,互不打扰。把它和 Java ClassLoader 摆一起几乎一一对应:

Java .NET
ClassLoader AssemblyLoadContext
loadClass(name) LoadFromAssemblyName / LoadFromAssemblyPath
getParent() Default 链与 Resolving 事件
子先委托父(默认) 默认走 Default ALC
(loader, name) 决定类身份 (ALC, assembly identity) 决定类型身份

可见性。.NET 走的是另一条路:internal 修饰符 + InternalsVisibleTo 程序集级别白名单。这是编译期 + 加载期共同强制的,不依赖运行期反射封锁。比 JPMS 的 exports A to B 更轻量,但表达力也更弱(粒度只能到 assembly,不能到 namespace 子集)。

生命周期:collectible ALC。.NET Core 3.0 之后 AssemblyLoadContext 多了一个 isCollectible: true 的构造参数。把它设成 true,再加载若干 Assembly,最后调用 Unload(),整个 ALC 就可以被 GC 回收。这是 .NET 官方支持的"插件热加载"机制。

但和 Java 一样,Unload() 只是发起卸载,不是同步操作。文档里写得很明确:调用 Unload 后,要等所有运行中的方法返回、所有该 ALC 加载的对象不再被外部引用、GC 完成回收,才真正卸载。卸载之前依然占内存。社区里反复出现的"调用了 Unload 内存却越用越多"的吐槽,原因只有一个——被钉住了

  • 静态字段缓存(和 Java 一样);
  • 事件订阅没退订;
  • ThreadStatic 状态 + 共享线程池;
  • WeakReference 用错;
  • Dynamic method / Delegate 持有了 ALC 内的 MethodInfo

这套坑列表与 Java webapp reload 的坑列表是同一份,只是名词换了。ClassLoader 卸载的工程定律不分平台

SPI。.NET 没有 ServiceLoader 这样的语言级 SPI,但有事实标准:早期是 MEF(Managed Extensibility Framework),现在更主流的是直接用 DI 容器(Microsoft.Extensions.DependencyInjection)+ 程序集扫描。在 ALC 隔离场景下,SPI 的 TCCL 等价问题表现为:跨 ALC 边界的实例只能通过两边都能看见的接口(通常在 Default ALC 加载)来传递——和 Java 跨 CL 走父接口完全一致。

结论。.NET 在这件事上的设计点比 Java 晚了 20 年,所以更克制:没有 OSGi 那一套版本协议、没有 JPMS 那一套强封装新关键字,只有 ALC + collectible + 加载事件。够用,但同样不能解决"插件作者写错代码就泄漏"这件事。

三、Erlang/BEAM:唯一在工业级别支持原生热替换的运行时

Erlang/BEAM 是这个对照表里独一份的存在——所有其他平台谈的"热替换",本质上都是模仿它

身份与隔离。BEAM 的最小代码单元是 module。每个 module 在系统里有一个唯一的 atom 名(比如 gen_server),由 code server 集中管理。没有 ClassLoader 那样的层级隔离,但有一个特殊的双版本机制——

双版本协议。BEAM 对每个 module 最多保留两个版本,叫 current codeold code

  • 加载新版本时,原 current 自动降级为 old,新版变成 current;
  • 进程里只要做一次完全限定调用 Module:function(...),就跳到 current 版本——这是热替换的语言级原语;
  • 进程里那些已经在执行、还没返回的栈帧,仍然跑在 old code 上;
  • 想加载第三个版本之前,必须先 code:purge/1,把 old code 清掉。purge 时还在 old code 里执行的进程会被 kill。

这条规则非常关键:最多两版本 是设计选择,不是实现限制。它把"什么时候老代码必须退场"变成一个明确动作(purge),而不是 GC 那种概率事件。

实例迁移:code_change/3。OTP 的 gen_server / gen_statem 等行为模块定义了 code_change(OldVsn, State, Extra) 回调。升级时 OTP 调度器会暂停进程,调用 code_change 把状态从老结构变成新结构,再恢复运行。这是"老对象怎么过桥"在语言层面给出的标准答案——不要把老 struct 直接喂给新 module,要走显式迁移

为什么 Erlang 能做到。Erlang 能做这件事不是因为它聪明,而是它的整个语言生态预先付了代价:

  1. 所有数据是不可变的,状态封装在进程里;
  2. 进程之间只能消息通信,没有共享内存;
  3. 进程是廉价的(几 KB),数量可以到百万级;
  4. 没有 native object 跨进程引用——你不可能持有"另一个进程里的 record"。

这四条共同构成了"老进程跑老代码、新进程跑新代码、它们之间只发消息"的物理基础。Java/.NET/Python 都不具备这套基础,所以不能照抄。

这套设计的代价。Erlang/BEAM 在大部分领域跑不过 JVM 几个数量级的吞吐,单核计算性能也不算强。Telecom 行业认这个代价是因为 99.9999% 可用性比 throughput 重要。当业务对"5 个 9"没硬要求时,"重启一下完事"几乎总是更经济的选择——这是 Erlang 没成为通用主流的根本原因。

四、Node.js:ESM 把"卸载"从语言里删了

Node.js 在这套问题上经历了一次明显的设计回退。

CommonJS 时代require 系统有一个公开的 require.cache 对象,key 是绝对路径,value 是 Module 实例。要"卸载一个模块",删掉这个 key 就行:

1
2
delete require.cache[require.resolve('./plugin')];
const plugin = require('./plugin'); // 重新加载

这是脏的:已经被其他模块 import 过的旧 module.exports 还活着,旧的类和函数对象继续被引用。只是新的 require() 调用会拿到新版。语义上等价于 Java “用新 CL 加载新版本”——老对象不消失,只是新代码进来了

ESM 时代import 是 spec 设计的语句,模块身份在 spec 里是稳定的:同一个 specifier 在同一个 realm 里 import 多次得到同一个 Module Record。Node 的 ESM 实现遵守这条 spec——没有 require.cache 那种公开 API,没有官方的 invalidate 机制。

事实上的"绕开 cache"做法只有一条:

1
const mod = await import(`./plugin.mjs?v=${Date.now()}`);

往 specifier 里加 query string,让 module key 变化,相当于声明这是另一个模块。结果是新旧版本同时存在于 module map 里,老版本永不释放——在 Node 进程的生命周期内,ESM 模块只能加载、不能卸载

社区里 nodemonts-node-devtsx watch 这些工具的主流方案都是重启进程。少数尝试"进程内 HMR"的项目(比如 node-esm-hmr)也是通过 query string trick 实现,并不是真正的 unload。

为什么这样设计。ECMAScript spec 把 module 身份定义成 unique within a Realm——这是给浏览器 ESM 设计的,目的是保证 import 在静态分析时是可推断的。这条决定从 spec 写出来那天起就排除了"运行期 unload"。Node 作为实现方没法越过 spec 自创 API。

结论。Node 在这套问题上的回答是:“放弃运行期演化,靠 fast restart + immutable infrastructure 解决”。这是当代很多语言的默认选择——比起复杂的热替换机制,重启进程便宜可靠得多。

五、Python:importlib.reload 的语义陷阱

Python 与 Java 在这件事上的同构关系最为明显,也最容易被工程师误解。

模块身份sys.modules 是一个 dict,key 是模块名,value 是 module 对象。import x 第一次会执行 x.py 并把 module 对象存进去,之后的 import x 直接返回这个对象。这就是 Python 的"模块缓存"。

reload 的真实语义importlib.reload(x) 做的事是:

  1. 重新执行 x.py
  2. 同一个 module 对象里覆盖属性(class、function、变量绑定)。

注意第二点。x.Foo 在 reload 之后指向新的 class 对象,但 reload 不会回头去更新别的地方对老 x.Foo 的引用:

1
2
3
4
5
6
7
8
import mymod
old_foo = mymod.Foo
instance = old_foo()
importlib.reload(mymod)
mymod.Foo # 新 class
old_foo # 还是旧 class
isinstance(instance, mymod.Foo) # False
type(instance) is mymod.Foo # False

这一段代码现象与 Java"用新 ClassLoader 加载新版 Foo,再去 instanceof 旧实例"的现象完全同构:旧实例的 __class__ 指向旧 class 对象,旧 class 对象不会因为 reload 而被替换或迁移。

IPython autoreload 的 hack%autoreload 之所以"看起来好用",是因为它做了一件 reload 不做的事——遍历旧 class 对象的方法表,把方法替换成新的。这只是补丁,遇到下面这些情况依然会失败:

  • class 加了新方法但旧实例的 __dict__ 不会自动得到;
  • 元类变了;
  • __slots__ 结构变了;
  • C 扩展模块定义的类型对象(PyTypeObject)几乎完全不能更新。

SPI。Python 的 SPI 等价物是 importlib.metadata.entry_points()(PEP 621)和老的 pkg_resources。机制是包安装时把"我提供哪个 entry point group 的实现"写进 dist-info / egg-info,运行期扫描。和 Java ServiceLoader 同源(都是从 jar/manifest 扫描),但没有 TCCL 概念——Python 没有 ClassLoader 层级,所以也不存在"用哪个 CL 加载实现"这种问题。

结论。Python 的 reload 工作机理是 Java ClassLoader 切换的简化版:身份隔离、新加载不替换老引用、跨边界要走数据复制。Java 工程师理解 Python reload 的最快路径就是把它类比成"用新 CL 加载新类,老对象不变"——前文 §3 那一整章在 Python 上一字不改地适用。

六、C/C++:dlopen 是最古老的"模块化"

dlopen / LoadLibrary 是这个对照表里最古老的角色——它存在的时候 Java 还没出生。

身份与隔离dlopen("libfoo.so", flags) 把一个共享库加载进进程地址空间,返回一个 handle。这个 handle 加上 dlsym(handle, "name") 就能拿到符号。默认不做隔离——所有 RTLD_GLOBAL 加载的库共享一个全局符号表,先加载的赢。

关键 flag

  • RTLD_LAZY vs RTLD_NOW:函数符号是访问时解析还是 dlopen 时立即解析。Lazy 是默认,启动快但运行期可能死在第一次调用;
  • RTLD_LOCAL vs RTLD_GLOBAL:本库定义的符号是不是参与后续 dlopen 库的解析。RTLD_LOCAL 是默认,避免符号污染;
  • RTLD_NODELETE:dlclose 时不实际卸载。

dlmopen:链接命名空间。glibc 的 dlmopen(lmid, file, flags) 是 dlopen 的扩展,多了 lmid(link map id)参数。每个 lmid 是一个独立的命名空间,里面有自己一份 libc、自己的 TLS、自己的全局符号表。这是 C 世界里最接近 Java ClassLoader 的设计——它是工业级的"用不同隔离器加载同名符号",能让两个版本的 OpenSSL 在同一个进程里共存而不打架。

dlmopen 在生产环境里以"玄学多"出名:默认 Lmid_t 个数是 16;TLS 在不同 lmid 之间不共享,pthread 库会闹脾气;auditing 接口与 lmid 交互复杂;很多第三方库假设自己在 default namespace 里运行,跑到非默认 lmid 就崩。社区的总体共识是"能不用就不用"。

生命周期dlclose(handle) 把引用计数减 1,到 0 时有可能卸载。glibc 的实现里 dlclose 实际是否真的 unmap 受多个因素影响——RTLD_NODELETE、TLS 析构、C++ 静态对象的 dtor、atexit handler 是否调用。著名的"dlclose 之后 segfault"故事大半都是这套语义的体现。

实例迁移。C/C++ 没有"对象身份"概念,但有等价问题:

  • 函数指针:旧 handle 给的函数指针在 dlclose 之后调用是 UB;
  • 全局/静态对象:dlclose 触发析构,析构里如果还引用别的库的全局对象,就互相杀;
  • 多版本库的 RTTI:两个版本的 class Foo 各自有 _ZTI*Foodynamic_cast 跨边界能不能成功取决于符号是否合并——这与 Java loader constraints 是同一个问题,但 C++ 没有平台级保护,错了就直接 SIGSEGV。

SPI。C/C++ 没有语言级 SPI,靠手写约定:插件二进制导出某个固定名字的函数(比如 plugin_register),主程序 dlopen + dlsym 找到它、调用它,由插件自己往主程序传一个 vtable。Apache、PostgreSQL、Nginx、PHP、Vim、Postgres extensions 全是这套模式。

结论。dlopen 是"最少的模块化"——只解决了第 1 件(隔离单元)和第 2 件的一半(命名空间,需要 dlmopen 才完整)。可见性、生命周期、实例迁移完全交给开发者自律。它存活了 35 年的原因不是它好,而是它够低层——上面所有平台(包括 JVM、CoreCLR、Python C 扩展、Node native addon)都是靠 dlopen 来实现自己更高层的模块系统。

七、Go:编译期模块化 + 运行时几乎不可演化

Go 在这套问题上的态度最为彻底——尽量在编译期解决,运行期不演化

身份与隔离。Go 的模块身份是 import path,由 go.mod 的 module 行声明。整个程序的依赖图在编译期被解析,结果是一个静态链接的二进制——没有运行期的"加载新模块"概念。

版本选择:MVS。Go 1.11+ 的 module 系统用 Minimum Version Selection(最小版本选择):依赖图里每个模块取所有需求中的最高版本,但不超过你显式声明的上限。这条算法可重现、可审计,比 npm 的 SAT 求解和 Maven 的"先到先得"都要稳。Java 那边 OSGi 的 Import-Package: foo;version="[1.0,2.0)" 在表达力上和 MVS 是一个量级。

运行期模块化:plugin 包。Go 1.8 加了一个实验性的 plugin 包:plugin.Open(path) 加载一个 .so 文件,然后用 Lookup 找符号。看起来像 dlopen,但有一长串硬约束:

  • 主程序和插件必须用完全相同的 Go 版本编译;
  • 必须用完全相同的 build flag
  • 依赖的第三方包版本必须完全一致
  • 不能在 Windows 上用;
  • 加载之后不能 unload——plugin 包没有 Close 方法。

最后一条是关键。Go runtime 里 iface / eface 类型描述符表是全局的,加载插件相当于给全局表追加 entry,没有清理路径。社区里"Go plugin hot reload 失败"几乎是标准结论:能 load 不能 unload,就不可能做真正的热替换。

主流做法:进程级热替换。Go 工程的"热替换"几乎都不是模块层面的——是进程层面的:

  1. graceful restart:父进程 fork 子进程、把 listening socket 通过 SCM_RIGHTS 传过去(envoy hot restart、HAProxy hitless reload、Nginx);
  2. rolling deploy:靠负载均衡器把流量从旧 pod 慢慢挪到新 pod;
  3. side-load + cutover:新版本启动后,旧版本停止接新流量,等 in-flight 处理完退出。

这套做法的代价是必须有外部基础设施(load balancer、orchestrator),但好处是不依赖语言的热替换原语——同样的方案在 Rust、C++、Java 上都能用。

SPI。Go 没有 ServiceLoader 等价物。事实上的 SPI 是 init() 函数 + import _ "..." 副作用注册:实现包在自己的 init() 里调用 someRegistry.Register(impl),使用方 import 一下这个包就完成注册。这是 Go 编译期 SPI 的全部,跟 ClassLoader 完全无关——所有注册在 main 之前都已经完成,运行期没有加载发现。

结论。Go 用一个非常坚决的设计选择回答了这套问题:绝大多数演化需求,在进程边界以上解决。这是 SRE 文化(Google 自己的实践)和云原生时代(Kubernetes 的不可变基础设施模式)的产物。Go 的设计师不认为运行期热替换是值得做的功能。

八、横向对照表

把六家放在一起:

维度 Java (ClassLoader) .NET (ALC) Erlang (BEAM) Node (ESM) Python (importlib) C/C++ (dlopen) Go
身份单元 (loader, class) (ALC, assembly) (module, version) module specifier module name so 文件 + 符号 import path
命名空间隔离 ClassLoader 树 ALC code server 单层 单 realm sys.modules 单层 默认平铺,dlmopen 多 ns
编译期可见性 module-info exports internal + InternalsVisibleTo export 列表 默认全 export,私名 _x 弱约定 __all__ 弱约定 header / extern 大写小写约定
多版本共存 不同 CL 加载 不同 ALC 加载 仅 current + old 两份 仅 query string 绕开 不支持 dlmopen 可以 不支持
卸载支持 CL 可 GC,但常被钉 collectible ALC,同上 purge 显式触发 dlclose(半可靠)
实例迁移协议 跨 CL 走父接口 跨 ALC 走 Default code_change/3 进程重启 数据复制 函数指针自管 进程重启
SPI 机制 ServiceLoader / JPMS uses-provides / OSGi Service Registry DI 容器 / MEF gen_server / behaviour 显式注册 entry_points / pkg_resources 手写 vtable init() + 副作用 import
热替换工业级 OSGi 行 / JPMS 不行 ALC 半行 唯一原生支持 不行 不行 不行 不行
主流落地策略 重启 / OSGi / JBoss 多版本 重启 / collectible ALC 插件 OTP relup 滚动升级 重启 / nodemon 重启 重启 / 长生命周期插件 重启 / graceful restart

这张表里只有一行写着"原生支持"——BEAM。

九、几个跨平台的普遍规律

把六家放一起观察,能看出几条不分语种的规律:

规律一:身份隔离是通用解。无论叫 ClassLoader、ALC、dlmopen lmid 还是 module realm,本质都是同一招——给加载单元附加一个隔离标签,让"同名"在不同标签下变成"不同身份"。这是工程上唯一行得通的多版本共存方案。它的代价(跨边界要走共同接口、老对象不能直接跨)也是通用的。

规律二:能卸载的前提是能枚举所有引用。BEAM 能 purge 老代码是因为它知道哪些进程跑在老代码上(运行时维护这个表);Java/.NET 不能确定地卸 CL/ALC 是因为引用可能藏在用户态任何地方(静态字段、ThreadLocal、cache、回调)。这条规律解释了为什么"能不能热替换"的根本不在 ClassLoader 设计,而在运行时能不能反向追踪引用

规律三:静态语言更倾向重启,动态运行时更倾向热替换。Go、Rust、C/C++、JPMS 这些"编译期定型"的方案都偏向"重启进程"作为默认演化模式;Erlang、Smalltalk、Common Lisp 这些"运行期可塑"的运行时把热替换写进语言核心。这不是技术能力问题,是哲学选择——静态可分析性 vs 动态可演化性,二者大部分时候不可兼得

规律四:SPI 永远是"哪个隔离器加载实现"的问题。Java 用 TCCL 解决,.NET 用 ALC 上下文解决,Erlang 不需要解决(因为没有隔离器层级),Go 不需要解决(因为 init 在 main 之前完成)。SPI 的复杂度与平台的隔离器复杂度成正比——平台分得越细,SPI 越要小心 hint 要传哪个隔离器。

规律五:不可变数据 + 消息传递是"无停机"的物理基础。这条由 Erlang 30 年实践印证。Akka、Vert.x、Service Mesh 在 JVM 上模仿这条思路(Actor、Worker、Sidecar 进程),但都不完整——只要还允许跨进程共享对象引用,热替换就只能做到"伪热替换"

十、回到 Java:三套方案在跨平台坐标里的位置

把前文那三套 Java 方案放进这张坐标里看:

  • OSGi:选了 Erlang 路线 + 静态语言约束。它有真正的版本机制、生命周期、Service Registry,在工业级别支持热替换。代价是需要约束开发者按协议做事——这是为什么 OSGi 学习曲线陡峭、采纳率不高。
  • JPMS:选了 .NET ALC 路线。强调静态可分析(reliable configuration)、强封装(strong encapsulation)、collectible 生命周期,但运行期协议比 OSGi 弱得多——ModuleLayer 能创建能销毁,但没有版本协议、没有生命周期事件、没有 Service Registry。
  • JBoss Modules:在 OSGi 和 JPMS 中间,offers slot 多版本和模块化加载,但没有 OSGi 的动态生命周期。

Java 是少数把这三条路线都走过一遍的平台:1998 年的 classpath(无隔离)、2000 年代的 OSGi(动态全套)、2010 年代的 JBoss Modules(中庸路线)、2017 年的 JPMS(静态可分析)。这条演化轨迹在任何其他平台都没出现过——.NET 直接跳到 ALC,Erlang 从一开始就是双版本协议,Go 从一开始就拒绝热替换。

理解了这条轨迹,再看本系列前文那张三套方案对照表,就不再只是 Java 内部问题。它是 Java 生态在 动态性可分析性 之间反复横跳的历史——这场横跳每一步都在前面六家平台的某个位置上落过脚。

读完这两篇,那几个最初的工程问题——什么时候切 ClassLoader、SPI 实例怎么过桥、老对象怎么处理、模块系统能不能让我热替换 ——应该有一个跨平台的回答框架了。具体到每个平台,规则不一样;但底层的物理约束完全一致。

参考资料