写在前面

本文回答三件事。第一件,为什么 Java 在 classpath 之上又要造 OSGi、JBoss Modules、Jigsaw(JPMS)这些"模块化"层;它们各自把哪一类问题摆在最高优先级。第二件,"切换类加载器"这件事到底是什么意思——是切线程上下文,是用新加载器装新类,还是把老的整个加载器换掉;不同的语义下,老的对象实例分别会发生什么。第三件,SPI(service-provider interface)为什么是模块化和 CL 切换的真正交汇点——ServiceLoader 默认靠 TCCL 找实现,DriverManager 是反 SPI 的活化石,JPMS 把 SPI 直接编进了 module-info,OSGi 干脆用 Service Registry 替换了它。

后两件事是工程里反复出问题的源头:Tomcat reload 之后类没卸掉、OSGi 升级 bundle 之后老对象 ClassCastException、JDBC Driver 把整个 webapp 钉在内存里、Spring Boot DevTools 重启后某个 SPI 实现莫名其妙加载了旧版本——这些现象的根都在 JVM 的类身份模型,与某个具体框架无关。

从 OSGi 到 Jigsaw 的 Java 模块化路线图

一、classpath 模型留下的几个坑

把任意 jar 罗列进 -cp,JVM 就把所有 .class 看成同一个扁平命名空间。这是 1995 年的设计,能跑、足够简单,但留下了至少四类问题。

第一类是编译期没有可见性约束。一个 public 类对所有 jar 一律可见。库作者想说"com.foo.internal.* 是私有,请勿调用",只能写在 Javadoc 里,编译器和 JVM 没有任何手段拦住调用方。Java 平台自己也吃过亏:sun.misc.Unsafecom.sun.* 这些被外部代码大规模依赖,导致后续版本想改都改不动。这正是 JEP 200 提出 “strong encapsulation” 的直接动因(JEP 200: The Modular JDK)。

第二类是部署期没有"完整依赖"概念。jar 自己不声明依赖什么、不声明依赖的版本范围。运行时要等到第一次 Class.forName 才知道少了一个类。所谓 JAR Hell——同名不同版本、版本范围不兼容、缺失依赖、循环依赖——本质都是因为依赖关系直到 link 阶段才被检查。

第三类是运行期版本冲突无法表达。同一个 FQN(fully qualified name)的类,在 classpath 上只能存在一个版本。两个库分别要 guava-19guava-30,能塞进同一个 classpath 的只有一份;剩下那份默默被覆盖,等到调用某个新增 API 才崩。

第四类是没有一等的服务概念。Java 直到 JDK 6 才补上 ServiceLoader,而 ServiceLoader 又依赖线程上下文类加载器(TCCL)这种约定俗成的东西定位 SPI provider,本身在隔离环境下就容易踩坑。这件事单独成一章在 §4 展开。

把这四类问题压成一句:classpath 把"打包单元"“可见性单元”"加载单元"混成了一回事。后面三套方案都是要把这些层次拆开。

二、三套方案各自把什么放在最高优先级

维度 OSGi JBoss Modules Jigsaw / JPMS
目标优先级 动态生命周期 启动速度 + 静态拓扑 强封装 + 可靠配置
模块单元 bundle(jar + manifest) module(module.xml) module(module-info.class)
类加载模型 每 bundle 一个 CL 每 module 一个 CL 默认每 Layer 一个 CL,可一对一
可见性单元 package(Export-Package) package(path filter) package(exports)
版本表达 完整版本范围 + Resolver module slot 不解决
多版本共存 支持 支持 不支持
动态 install/uninstall 支持 不支持 不支持
SPI 机制 Service Registry(带生命周期事件) MSC + ServiceLoader module-infouses / provides,回退 META-INF/services
SPI 查找路径 由 BundleContext 显式定位 模块图静态解析 caller 的 module layer,再回退 unnamed module
服务注册中心 OSGi Service Registry MSC ServiceLoader(无注册中心)
主战场 Eclipse、IoT、电信 WildFly JDK 自身、应用

下面分别讲三者的取舍,重点在它们各自"为什么这样设计"。

2.1 OSGi:把动态放在第一位

OSGi 的核心论断是:模块要能在运行时装、卸、升、降。所以它给每个 bundle 配一个独立的 ClassLoader,bundle 之间的依赖通过 manifest 的 Import-Package / Export-Package 显式声明,由框架的 Resolver 在装载时绑定(OSGi Core R8 §3,Module Layer)。

1
2
3
4
Bundle-SymbolicName: com.example.foo
Bundle-Version: 1.2.0
Export-Package: com.example.foo.api;version="1.2"
Import-Package: org.slf4j;version="[1.7,2)"

这种拓扑长这样:

graph TB
  A[Bundle A: com.foo 1.0] -->|Import com.bar 1.0| B[Bundle B: com.bar 1.0]
  C[Bundle C: com.foo 2.0] -->|Import com.bar 2.0| D[Bundle D: com.bar 2.0]
  A -.独立 CL.-> CLA[ClassLoader A]
  B -.独立 CL.-> CLB[ClassLoader B]
  C -.独立 CL.-> CLC[ClassLoader C]
  D -.独立 CL.-> CLD[ClassLoader D]

同一个 com.bar.Util 类可以在 B 和 D 里以两个不同版本同时存在,因为它们由两个不同的 ClassLoader 定义——这是 JVM 类身份机制的直接利用,下一节会展开。

OSGi 的代价是把"动态"做到底带来的复杂度:bundle 生命周期 INSTALLED → RESOLVED → STARTING → ACTIVE → STOPPING、服务注册/订阅、版本范围解析、动态导入(DynamicImport-Package)、refresh 引发的级联重连接。任何一个静态确定的事情,OSGi 都要在运行时支持它变化。

2.2 JBoss Modules:放弃动态,换启动速度

JBoss Modules 的定位写在它自己的 manual 里:它不是一个容器,而是"a thin bootstrap wrapper for executing an application in a modular environment"(JBoss Modules manual)。WildFly 用它做应用服务器内部模块隔离,关心的是 EE 容器子系统之间不要互相污染,不关心应用运行时去 install / uninstall 一个模块。

模块依赖在 module.xml 里静态声明:

1
2
3
4
5
6
7
8
<module xmlns="urn:jboss:module:1.9" name="com.example.foo" slot="main">
<resources>
<resource-root path="foo.jar"/>
</resources>
<dependencies>
<module name="org.slf4j"/>
</dependencies>
</module>

JBoss Modules 同样给每个 module 配独立 CL,也支持同一个 module 多个 slot(事实上的多版本共存),但放弃了 OSGi 的动态装卸、服务注册中心、DynamicImport,换来更快的启动和更简单的拓扑。

2.3 Jigsaw / JPMS:放弃版本与动态,换强封装与可靠配置

Jigsaw 在 Java 9 落地(JEP 261,Module System)。它解决两件事:编译期可校验的依赖(reliable configuration)、可被 JVM 强制执行的封装(strong encapsulation)。代价是放弃了两件事:版本选择和动态装卸。

放弃版本是显式写在 JEP 261 里的:

When searching a module path for a module of a particular name, the module system takes the first definition of a module of that name. Version strings, if present, are ignored; if an element of a module path contains definitions of multiple modules with the same name then resolution fails… It is not a goal of the module system to address the version-selection problem.

Mark Reinhold 在多份文档里都重复过这个立场:版本选择是构建工具(Maven、Gradle)的事,模块系统只负责"给定一组确定的 jar,把依赖图算清楚、把封装边界守住"。把这件事放进运行时模块系统,复杂度会失控。

module-info.java 做的事很克制:

1
2
3
4
5
6
7
8
module com.example.foo {
requires org.slf4j;
requires transitive com.example.foo.api;
exports com.example.foo.api;
opens com.example.foo.internal to com.fasterxml.jackson.databind;
uses com.example.foo.spi.Plugin;
provides com.example.foo.spi.Plugin with com.example.foo.impl.DefaultPlugin;
}

exports 是编译期 + 运行期都强制的可见性边界,反射也越不过去(除非 opens)。这一点和 OSGi 的 Export-Package 类似,但 JPMS 把它做进了 javac 和 JVM 内核(HotSpot 的 access checks 直接看 module 关系),不依赖类加载器隔离。

JPMS 的类加载模型与 OSGi 有本质区别:默认情况下,启动 Layer 里的所有平台模块共享 platform CL,所有应用模块共享 application CL。模块边界靠 JVM 内核的 access check 守,不靠 CL 隔离。这是它能"和老的 classpath 应用混跑"的关键:unnamed module(即 classpath 上的代码)也由同一个 CL 加载。

只有当你手工调用 ModuleLayer.defineModulesWithOneLoader / defineModulesWithManyLoaders 创建新的 Layer 时,才会出现新的 CL。这是 JPMS 留给"有限度的动态加载"的口子,但官方没有 undefineLayer —— 一旦定义就只能等 GC(ModuleLayer API)。

三、ClassLoader 切换的语义到底是什么

ClassLoader 切换的三种语义

这一节是问题的核心。"切换类加载器"在工程语境下经常被笼统使用,但它至少对应三件根本不同的事,每件事对老对象的影响都不一样。先把 JVM 的类身份模型摆出来,否则后面所有现象都会显得像玄学。

3.1 类身份:(binary name, defining loader)

JVMS §5.3 规定,一个 class 在 JVM 里的身份不是它的全限定名(FQN),而是 (binary name, defining loader) 这个二元组(JVMS §5)。

graph LR
  A["FQN: com.foo.Util"] --> B["ClassLoader CL1"]
  A --> C["ClassLoader CL2"]
  B --> D["Class<?> @CL1"]
  C --> E["Class<?> @CL2"]
  D -. 不同身份 .- E

由 CL1 定义的 com.foo.Util 和由 CL2 定义的 com.foo.Util两个不同的 Class== 不等、isAssignableFrom 不成立、互相 cast 直接 ClassCastException。这不是 bug,是规范的核心机制——多版本共存就是靠这个。

JVMS §5.3.4 还规定了 loader constraints:当一个方法签名涉及别的类(参数、返回值),那个类必须由"对方法可见的同一个 defining loader"来解析。约束被违反时直接抛 LinkageError。这是 OSGi / JBoss Modules / 任何 CL 隔离方案绕不开的边界条件——给两个 bundle 各加一份 commons-lang 看似解决了冲突,但只要它们之间有方法签名涉及到 commons-lang 的类,仍会触发 loader constraint violation。

理解了类身份与 loader constraints 之后,"切换类加载器"的三种语义分别是什么就清楚了。

3.2 语义一:切换 Thread Context ClassLoader(TCCL)

1
2
3
4
5
6
7
ClassLoader prev = Thread.currentThread().getContextClassLoader();
try {
Thread.currentThread().setContextClassLoader(pluginCl);
runPluginCode();
} finally {
Thread.currentThread().setContextClassLoader(prev);
}

这种"切换"什么都没改:

  • 已经加载的所有 Class 还在它原来的 defining loader 下,身份不变;
  • 已经存在的对象实例不受任何影响;
  • TCCL 只是给框架代码提供一个"我应该用谁来 Class.forName"的 hint,例如 ServiceLoaderJDBC DriverManagerJNDI、各种基于 SPI 的库。

它的危险在于框架的隐式依赖。ServiceLoader.load(Foo.class) 不带 CL 参数时,默认拿 TCCL 找 provider;如果在容器线程池里偶然忘了切 TCCL,找出来的 provider 可能是错的版本、甚至来自错的 webapp。Tomcat 之所以在每个请求开始都把 TCCL 切到 WebappClassLoader,正是为了让应用代码在通过 SPI 调底层框架时还能定位到自己 webapp 内部的资源。

切 TCCL 是安全的、可逆的、随便切的;它只影响"未来的查找",不影响"已经存在的对象"。

3.3 语义二:用新 CL 加载新版本的类

1
2
3
4
5
6
7
URLClassLoader cl1 = new URLClassLoader(new URL[]{ jarV1 }, parent);
Class<?> c1 = cl1.loadClass("com.example.Plugin");
Object p1 = c1.getDeclaredConstructor().newInstance();

URLClassLoader cl2 = new URLClassLoader(new URL[]{ jarV2 }, parent);
Class<?> c2 = cl2.loadClass("com.example.Plugin");
Object p2 = c2.getDeclaredConstructor().newInstance();

这是热部署、插件系统、OSGi bundle update 的本质。两次 loadClass 的结果是两个不同的 Class,两个不同的 Class 的 instance 互相不能 cast。

老对象 p1 不会消失也不会自动迁移,它的归属如下:

  • p1.getClass() == c1,永远是;
  • c2.isInstance(p1) == false
  • (Plugin) p1cl2 加载的代码里执行会抛 ClassCastException,即便 FQN 完全相同;
  • p1 持有的所有静态/实例字段、方法引用,都还指向 cl1 加载的那一套——它是一个完整的、活在旧世界里的对象。

要让新代码能复用老对象,只有四条路:

  1. 走父 CL 加载的接口Plugin 接口由共同父 CL 加载,p1 实现的是父 CL 版本的 Plugin,新代码强转到这个公共接口可以成功。OSGi 的 Import-Package 在 wiring 阶段把同一个 package 的 wire 指向同一个 exporter bundle,正是为了让接口类型在边界上唯一。
  2. 反射调用:完全放弃静态类型,用 Method.invoke 调老对象。
  3. 序列化/反序列化复制:把老对象序列化成字节流,在新 CL 下反序列化出新对象。常见于配置数据迁移。
  4. 抛弃:让老对象随旧 CL 一起被回收。这是 OSGi update / Tomcat reload 的官方姿态。

工程里反复出现的"OSGi 升级 bundle 后老缓存里的对象用不了",“Spring DevTools 重启后某个静态字段里的 listener 调不通”,根因都是这一条:跨 CL 持有了老对象的引用。

3.4 语义三:把整个 CL 卸载重装

要把一个 ClassLoader 真正卸掉,需要同时满足三件事(HotSpot 在 metaspace 回收时执行类卸载,参见 HotSpot GC Tuning Guide 关于 class unloading 的一节):

  1. ClassLoader 实例本身从 GC roots 不可达;
  2. 该 CL 定义的所有 Class 对象不可达;
  3. 这些 Class 的所有 instance 不可达。

这三个条件是一个传递闭包:任何一个 instance 还活着 → 它的 Class 活着 → 它的 defining loader 活着 → 整个 CL 加载的所有 Class 都不能卸。一颗"钉子"扎在哪里都行。

graph TB
  A["GC Root(线程栈、ThreadLocal、static 字段、JNI 全局引用、Driver 注册表 ...)"] --> B["某个 instance"]
  B --> C["instance.getClass()"]
  C --> D["Class.getClassLoader()"]
  D --> E["整个 ClassLoader 不能卸载"]
  E --> F["该 CL 加载的所有其他 Class 都不能卸载"]

Tomcat 在 webapp reload 时反复打印 The web application registered the JDBC driver but failed to unregister it... This is very likely to create a memory leak,正是因为 DriverManager 里那张静态 CopyOnWriteArrayList<Driver> 拿着旧 webapp 的 Driver 实例不放(Tomcat MemoryLeakProtection 文档常见条目)。

工程上常见的"钉子":

  • ThreadLocal 持有 webapp 加载的对象,而 Thread 来自容器的共享线程池;
  • DriverManager.registerDriver 注册的 Driver 没在 contextDestroyed 时反注册;
  • 第三方库的静态缓存:日志 framework 的 LoggerContext、JDK 的 LogManager.loggersjava.beans.Introspector 的 cache、ResourceBundle 的 cache;
  • 未停止的非 daemon 线程,它持有线程栈上的局部变量;
  • JNI 代码里的 GlobalRef
  • Java agent 注册的 transformer 持有 webapp 的 Class;
  • 反射缓存:MethodHandle、Lambda 生成的隐藏类。

这些都不是 JVM 的问题,是 JVM 规范如实工作的副作用。规范说"只要还有一个 instance 可达,整个 CL 不能卸",那就是不能卸。

3.5 OSGi 的 update / refresh 是怎么做的

OSGi 的 Bundle.update() 不是简单地切 CL,它做了一组协调动作(OSGi Core R8 §4.4 Bundle Lifecycle):

  1. 调用旧 bundle 的 BundleActivator.stop,给它机会释放资源;
  2. 把旧 bundle 的状态从 ACTIVE 转回 INSTALLED,旧的 BundleWiring 标记为 stale;
  3. 装新 bundle 字节、resolve、wire、start;
  4. 旧 bundle 的 ClassLoader 在所有 stale wires 被清理后才有可能 GC。

关键在于"所有 stale wires 被清理"——如果有别的 bundle 还 wire 到旧 bundle 的某个 package,旧 CL 就会一直停留。OSGi 提供 FrameworkWiring.refreshBundles 把级联依赖一并 refresh:把所有依赖旧 bundle 的 bundle 也停掉,重新 wire 到新版本。这就是为什么 OSGi 系统升级一个底层 bundle 时,整个上层都会抖动。

老对象的命运和 §3.3 描述的完全一样:要么被废弃,要么通过共同的 API package 接口继续可用。OSGi 推荐的姿态是 ServiceTracker——不要直接持有 service 对象的强引用,而是通过 ServiceTracker 监听 ServiceEvent,bundle 一变就主动重新拿。这把"跨 CL 引用"的责任交给框架管理。

3.6 JPMS 为什么没有动态卸载

JPMS 没有 removeModuleLayer.unload 这种 API。原因有两层:

第一层是设计目标:JPMS 把"reliable configuration"放第一位——模块图必须在启动时被解析为一个确定的、能被 JVM 信赖的拓扑。如果允许 layer 中途消失,所有依赖它的 access check、类型解析都要应对一个跳变状态,复杂度向 OSGi 看齐,但 JPMS 团队明确不愿意承担。

第二层是事实:可以通过 ModuleLayer.defineModulesWithOneLoader 创建子 Layer,让它带一组隔离的 CL;只要不把这个 Layer 引用持有到全局 root,它和它的 CL 同样会随 GC 回收。JPMS 的"动态"就是这种程度的动态——能新建,但不能撤销已发生的事情。

实务上,需要插件式动态加载的项目(IntelliJ Plugin、Bukkit、Minestom 等)都还是手工组合 URLClassLoader + 自定义 Layer。它们承担的恰恰是 §3.4 列出的所有"钉子"风险。

四、SPI:模块化与 CL 切换的真正交汇点

§3 反复提到 ServiceLoader、TCCL、DriverManager,但都只是擦边带过。这一章把它们摆到一起讲,因为 SPI 是把"模块化"和"CL 切换"粘合到工程现场的第三只脚——任何"上层依赖接口、底层提供实现"的协议,最终都要回答一个问题:实现是从哪个 CL、哪个模块里被发现的。模块化方案的取舍,最终都落到这个问题上。

SPI、模块化与 ClassLoader 的交汇点

4.1 SPI 是什么、为什么必须谈

SPI 是 service-provider interface 的缩写。它和 API 的区别在于发现机制:API 是调用方主动 import 来用的;SPI 是上层定接口、下层提供实现,由框架在运行时把实现"注入"上层使用。java.sql.Driverjavax.xml.parsers.SAXParserFactoryjava.nio.file.spi.FileSystemProvider、SLF4J 的 StaticLoggerBinder,全是这个套路。

SPI 必然要回答两个问题。第一,实现的发现策略:去哪里找、按什么顺序找、找到多个时怎么排。第二,实现的加载策略:用哪个 CL 加载、是否带版本约束、能否动态替换。这两个问题在 classpath 模型下没有标准答案,所以 Java 的 SPI 历史就是一系列"在不破坏向后兼容的前提下打补丁"的过程。

4.2 ServiceLoader:classpath 时代的 SPI 机制

JDK 6 引入 java.util.ServiceLoader,把"老掉牙的 META-INF/services 约定"提升为标准 API。约定本身很简单:在 jar 的 META-INF/services/<接口 FQN> 文件里,每行写一个实现的 FQN:

1
2
3
# META-INF/services/com.example.spi.Plugin
com.example.impl.AcmePlugin
com.example.impl.FooPlugin

调用方:

1
2
ServiceLoader<Plugin> loader = ServiceLoader.load(Plugin.class);
for (Plugin p : loader) { p.run(); }

不带 CL 参数的 load(Class) 默认用 TCCL 找 provider(ServiceLoader Javadoc)。这就是 §3.2 反复说"切 TCCL 的实质就是改 SPI 查找路径"的来源——切了 TCCL 之后,下一次 ServiceLoader.load 就会去新的 CL 上读 META-INF/services 文件、用新的 CL 加载实现类。

graph TB
  A["业务代码 ServiceLoader.load(Plugin.class)"] --> B["读取 TCCL"]
  B --> C["TCCL.getResources(META-INF/services/com.example.spi.Plugin)"]
  C --> D["按行解析 FQN"]
  D --> E["TCCL.loadClass(FQN)"]
  E --> F["反射 newInstance"]

它的两个固有缺陷:

  1. 没有版本概念META-INF/services 文件不写版本,CL 上有一份就用一份。两个 jar 各带一份同名 SPI 实现,谁先找到谁赢,且行为不可预测。
  2. 依赖 TCCL 这个隐式参数。SPI 调用看上去是纯函数,实际行为却被当前线程的 TCCL 决定。多 CL 环境(OSGi、应用服务器、Spark Executor、Spring Boot DevTools)下,某个看似无关的代码改动可能让 SPI 突然换了 provider。

显式指定 CL 是修法之一:

1
ServiceLoader.load(Plugin.class, pluginClassLoader);

但问题是,业务代码很少这么写——大多数库(包括 JDK 自己)都用不带 CL 参数的版本。

4.3 DriverManager:反 SPI 的活化石

java.sql.DriverManager 是 JDBC 1.0 时代的产物,比 ServiceLoader 早十几年。它的注册机制在今天看来是反 SPI 的:

  1. Driver 实现类的静态初始化块里调用 DriverManager.registerDriver(this)
  2. DriverManager 内部维护一个静态的 CopyOnWriteArrayList<DriverInfo>
  3. getConnection(url) 时遍历这张表,用 URL 前缀匹配。

JDBC 4.0(JDK 6)补上了 SPI 入口:driver jar 里放 META-INF/services/java.sql.DriverDriverManager 在第一次被引用时通过 ServiceLoader 自动加载、触发静态块完成注册。但旧的注册机制没废,所以 Driver 实例最终还是落到那张静态表上(OpenJDK ServiceLoader 源码)。

这就是 Tomcat reload 内存泄漏的活化石:

graph LR
  A["WebappClassLoader"] --> B["MySQL Driver instance"]
  B --> C["DriverManager (system CL) 静态字段"]
  C -. 强引用 .-> B
  B -. getClass.getClassLoader .-> A
  D["webapp 卸载"] -. 但 A 仍可达 .-> A

webapp 把 driver 注册进 DriverManagerDriverManager 在 system CL 下,它的静态表持有 driver 实例的强引用,driver 实例的 defining loader 是 WebappClassLoader——按 §3.4 的传递闭包,整个 webapp 的 CL 钉死。Tomcat 8+ 的 WebappClassLoaderBase.clearReferencesJdbc 主动遍历表反注册,但仍然挡不住第三方库自己的静态注册逻辑。

JDK 9 之后给 DriverManager 加了一个补丁:用 caller 的 CL 而不是 TCCL 来过滤可见的 driver,避免 system CL 上的 DriverManager 把 webapp 的 driver 暴露给别的 webapp。但底层那张静态表还在,泄漏路径没有根本变化。

4.4 JPMS 的 uses / provides:把 SPI 编进模块系统

Jigsaw 给 SPI 做了它能做的最大补救:把 service 关系直接写进 module-info,让模块系统在编译期就知道"谁声明使用这个 service"“谁声明提供这个 service”。

1
2
3
4
5
6
7
8
9
10
11
// 提供方模块
module com.example.foo.impl {
requires com.example.foo.api;
provides com.example.foo.spi.Plugin with com.example.foo.impl.AcmePlugin;
}

// 使用方模块
module com.example.app {
requires com.example.foo.api;
uses com.example.foo.spi.Plugin;
}

provides ... with ... 让 javac 在编译期校验"实现类确实实现了接口";uses 让模块系统知道"这个模块会调用 ServiceLoader.load(Plugin.class)",从而在解析时把所有 provider 模块也带上。运行时,ServiceLoader 的查找规则变了(ServiceLoader Javadoc — Locating providers):

  1. 先在 caller 模块所在的 module layer 及其祖先 layer 中查 provides 声明;
  2. 再回退到 caller 的 CL 上的 unnamed module(即 classpath 上的代码)的 META-INF/services

这条规则不依赖 TCCL,而是依赖调用者所在的 module。可以用 ServiceLoader.load(ModuleLayer, Class) 显式指定从哪个 layer 找 provider,这是 JPMS 给"有限度的多版本共存"留的口子。

JPMS 下 SPI 的几个变化:

  • 实现类不再需要 public 无参构造函数。provides 可以指向一个 public static T provider() 方法,让实现类自己决定怎么构造——这是 JEP 374 之前没有的;
  • 实现类的反射构造在 JPMS 下要求 module-info 写了 provides,否则会因 access check 失败抛 ServiceConfigurationError
  • META-INF/services 仍然兼容,用于让 named module 能消费 classpath 上的老 provider,反过来也成立。

JEP 261 在 SPI 这一节的设计原则是"既要能让模块图静态可信,又不能让全世界的老 provider 立刻失效"——所以表面是双轨,实际是用 module-infoprovides 主导、用 META-INF/services 兜底。

4.5 OSGi Service Registry:换一种范式

OSGi 没用 ServiceLoader。它有自己的 Service Registry(OSGi Core R8 §5,Service Layer),整个 service 模型是"主动注册 + 事件订阅":

1
2
3
4
5
6
7
8
// provider 侧
context.registerService(Plugin.class, new AcmePlugin(), props);

// consumer 侧
ServiceTracker<Plugin, Plugin> tracker =
new ServiceTracker<>(context, Plugin.class, null);
tracker.open();
Plugin p = tracker.getService(); // 当前最高优先级的实现

Service Registry 解决了 ServiceLoader 解决不了的几件事:

  • 服务有生命周期事件:SERVICE_REGISTERED / MODIFIED / UNREGISTERING,consumer 可以在 bundle update 时主动放手(这正是 §3.5 推荐的姿态);
  • 服务有属性(service.ranking、自定义元数据),可以做过滤、优先级排序;
  • 服务跨 CL 边界仍然类型安全:所有 bundle 通过共同的 API package 看到同一份 Plugin.class,因为 OSGi 的 wiring 保证了"同一 package 由唯一一个 exporter 提供";
  • 不依赖 TCCL,因为每个调用都显式经过 BundleContext

代价是侵入性:要写 OSGi 代码就得用它的 API,不能像 ServiceLoader 那样无感切换。OSGi 同时也兼容 ServiceLoader(OSGi Compendium §133 ServiceLoader Mediator 把 classpath 风格的 SPI 映射到 Service Registry),让既有库能在 OSGi 容器里继续工作。

4.6 SPI 在四种"切换 CL"场景下分别怎么表现

把 §3 的三种 CL 切换语义和 SPI 放到一起,会得到一张更实用的对照表:

场景 ServiceLoader 行为 DriverManager 行为 OSGi Service Registry 行为
切 TCCL 下一次 load 在新 CL 上找;已加载的实例不变 不变(DriverManager 用 caller CL,不看 TCCL) 不受影响(用 BundleContext)
用新 CL 加载新版 service 新 CL 上的 load 看到新实现;老引用还活在旧 CL 老 driver 仍在静态表上,新 driver 也注册进去,按 URL 前缀竞争 旧 service 标记 UNREGISTERING,订阅者收事件后切到新 service
卸载整个 CL META-INF/services 缓存、ServiceLoader 内部缓存可能钉住旧 CL 静态注册表必然钉住旧 CL,必须显式 deregisterDriver bundle stop 时自动注销,订阅者主动放手
模块化 reload(JPMS) 新 layer 的 provides 在新 layer 内可见;旧 layer 仍在直到 GC 同上,DriverManager 是 system layer 的事,不受应用 layer 重建影响 N/A

这张表把前面的几节捏成了一句话:SPI 的可靠性等于发现策略可控性 × 实现引用可释放性ServiceLoader 在两个维度上都靠默认 TCCL 和静态缓存兜底,在 reload 场景下两条都不可控;DriverManager 则是发现策略尚可、实现引用绝对不可释放;OSGi Service Registry 把两个维度都做进了协议;JPMS 把发现策略做进了模块系统但不解决实现引用问题。

工程上的判断准则:

  • 平台代码(容器、应用服务器、CLI 框架)写 SPI 调用时,永远显式传 CL 或 ModuleLayer;不要靠 TCCL;
  • 提供 SPI 实现的模块,给 service 实例做一个明确的 close / shutdown 钩子;
  • 容器在卸载 CL 之前,主动调用各种已知 SPI 的反注册(DriverManager.deregisterDriver、SLF4J LoggerContext.stop、JNDI Context.closeIntrospector.flushCaches);
  • 长期持有 service 实例的代码(缓存、单例、注入容器),在 OSGi 下用 ServiceTracker,在 JPMS 下用 ServiceLoader.Provider 而不是 Provider.get() 缓存到字段(每次重新 get() 让它能反映最新 layer 状态)。

五、什么时候安全切换 CL,什么时候必出事

把上面的语义落到判定上,可以总结成下面的清单。

安全场景

  • 切 TCCL:只要在 try / finally 里恢复,永远安全;
  • 用新 CL 加载新代码,且新旧之间只通过父 CL 加载的接口/纯数据交互(String、原生类型、Serializable 边界);
  • 新 CL 与所有它产生的对象都是短生命周期,作用结束后没有任何外部引用;
  • OSGi 的 ServiceTracker 模式:不持有 service 强引用,靠事件感知重连。

必出事场景

  • 把新 CL 加载的对象赋给老 CL 上下文里的强引用字段(包括 static、ThreadLocal、单例的 field),却期待"切了 CL 就没事了";
  • 老对象通过具体类(不是接口)跨 CL 暴露给新代码,再做强转;
  • 把 CL 的引用塞进 GC root 等价物(线程对象、DriverManager、JMX MBeanServer、SLF4J LoggerContextIntrospector cache)后期望它能被卸载;
  • 在 JNI 中持有 GlobalRef,又指望 webapp reload 后 native 那一侧自动失效;
  • 同名同 FQN 的两个版本类,期望它们在 reflect 等场合"被识别为同一个"。

灰色场景

  • 反射调用 + 序列化迁移:能跑,但每跨一次 CL 都要做一次拷贝,性能与状态一致性都需要谨慎设计;
  • 让两个隔离 CL 通过共同父 CL 上的接口交互:能跑,但接口本身的版本变更会让父 CL 升级变成全局事件;
  • 自己实现 ClassLoader.findClass 做"父优先 / 子优先"翻转:可行(OSGi、Tomcat 都这么做),但要注意 loader constraint,否则会在某个不相关的方法签名解析时抛 LinkageError

六、工程上反复出现的几个坑

Tomcat / Spring Boot 的 reload 三大泄漏源。社区文档里讲过无数遍,但还是反复出现:

  1. ThreadLocal 持有 webapp 加载的对象,而 Thread 是容器的共享线程池——webapp 卸载后,线程还在,ThreadLocal 把整个 WebappClassLoader 钉死;
  2. DriverManager.registerDriver(driver) 注册到 JDK 的 system CL(更严格说是 DriverManager 静态字段所在的 CL),webapp 卸载时 Driver 实例还挂在那张表上;
  3. 静态字段缓存:典型是某些日志库的 LoggerContextjava.beans.Introspector 的 cache、Hibernate 4 的 c3p0 timer、各类自建的 ConcurrentHashMap<Class<?>, ...> 反射缓存。

Tomcat 8+ 的 WebappClassLoaderBase.clearReferences* 方法就是逐项擦除这些已知钉子的尝试,但它无法穷举第三方库——This is very likely to create a memory leak 警告就是从这里抛出来的。

Spring Boot DevTools 的双 CL 设计。DevTools 把"会变的应用代码"放到 RestartClassLoader,把"不会变的依赖 jar"放到 base CL。重启时只重建 RestartClassLoader,base CL 不动。这样既避免了重启 base 的性能代价,又把"老代码下产生的 Spring bean"整体丢弃——它从设计上就要求你不要在 base 层持有应用层对象的强引用。这条约束破坏后,DevTools 同样会内存泄漏。

反序列化跨 CLObjectInputStream 默认用 latestUserDefinedLoader 解析类,在 OSGi / 多 CL 环境里这个"最新的用户 CL"经常不是你想要的那个。常见现象是反序列化时拿到了 wrong CL 加载的 Class,对象创建出来但后续 cast 失败。修法是 override resolveClass,显式指定 CL。

Hot reload 与 Lambda / hidden class。JDK 15 之后 Lambda 用 hidden class 实现,hidden class 的生命周期和它的 nest host 绑定。如果你在 reload 链里给 lambda 注册了任何全局监听器(事件总线、消息中间件回调),hidden class 同样会被钉住,而且因为它是 hidden 的,常规的引用分析工具(jmap、mat)找不到合适的入口去归因。这是 2020 年之后逐渐冒出来的新坑。

七、设计哲学的论战:JSR 376 公审现场

前面六章把三套方案当成既成事实在比较。但 JPMS 长成今天这样不是必然——它是一场 2016—2017 年的公开论战之后,妥协出来的产物。理解这场论战,才能理解为什么 JPMS 既不像 OSGi 也不像 JBoss Modules。

7.1 一份被否决的规范

2017 年 5 月 8 日,JSR 376(Java Platform Module System)的 Public Review Ballot 在 JCP 执行委员会(EC)落下结果:10 票赞成、13 票反对、0 弃权——未通过。这是 JCP 历史上极为罕见的事件,主流规范在最后一公里被否,意味着 Java 9 的核心特性面临延期。投反对票的不是边缘玩家:IBM、Red Hat、Eclipse 基金会、Hazelcast、SAP、Werner Keil、London Java Community 等。投赞成的包括 Oracle、Twitter、Goldman Sachs、Hewlett Packard 等。Twitter 之后还专门写了一篇工程博客解释为什么投了赞成。

这一票直接逼出了二轮 Reconsideration Ballot。两个月后的 2017 年 7 月,规范在追加修订之后通过了。Java 9 才得以在 2017 年 9 月按计划发布。

中间这两个月,是 JPMS 形态被重塑的关键期。

7.2 三个阵营各自坚持的事

公开的 EG(Expert Group)会议纪要——2017 年 5 月 22 日、5 月 23 日两次会议——保留下了对峙现场的真实拓扑:

  • Oracle 阵营:Mark Reinhold(JPMS 规范主笔)、Alex Buckley、Brian Goetz。坚守的两条底线是 reliable configuration(可靠配置)和 strong encapsulation(强封装)。前者意味着模块依赖必须在编译期、启动期、运行期三个阶段一致地校验;后者意味着 module-info 没声明导出的包,外面就是看不见——setAccessible 也不行。
  • OSGi/动态阵营:Tim Ellison、Tom Watson(IBM)、Wayne Beaton、Sasikanth Bharadwaj、Stephan Herrmann(Eclipse)、David Lloyd(Red Hat)。这一派的核心立场是:真正的模块化必须支持 版本共存生命周期service registry——也就是 OSGi 已经做对的那些事。JPMS 把这些全砍掉,剩下的只是"带边界的 jar 包",配不上"模块系统"四个字。
  • 构建工具阵营:Robert Scholte(Apache Maven)。关心的不是哲学,而是非常具体的事——automatic module name 的稳定性。如果一个 jar 没有 module-info,它的模块名怎么从 Maven coordinate 推导出来?谁来对这个名字的稳定性负责?这关系到几十万个 jar 包向 JPMS 迁移期间能不能不互相打架。

注意第三个阵营的存在。Maven 阵营在投票里通常和 Eclipse/Red Hat 站一边,但他们的诉求和 OSGi 阵营完全不同——他们不要动态性,只要"我能稳定迁移"。这是 JPMS 后来妥协的真正杠杆。

7.3 真正的技术分歧

公开 EG 会议纪要里反复出现几个 hashtag:#AutomaticModuleNames#MultiModuleJARs#LayerPrimitives#RestrictedKeywords#IndirectQualifiedReflectiveAccess。剥开 hashtag 看本质,分歧落在四件事:

第一,自动模块的命名权。Reinhold 最初的方案是:从 jar 文件名按规则推导一个模块名(去掉版本号、连字符替换为点)。Maven 阵营立刻指出问题——jar 文件名是发布时刻的产物,下游消费者用的是 GAV 坐标,文件名变了模块名就变了,整条依赖链断。妥协结果是引入 Automatic-Module-Name Manifest 头:库作者可以在 jar 的 META-INF/MANIFEST.MF 里显式声明模块名,下游永远用这个名字,跟 jar 文件名无关。这给了库作者"先声明模块名、再实际写 module-info"的渐进路径。

第二,反射访问的让步。原始方案下,未导出的包从外部完全不可见,包括反射。这条规则一出,Spring、Hibernate、各类字节码生成框架(CGLIB、Byte Buddy)、各类序列化库直接全线告急——它们的工作方式就是反射访问 别人没打算给你看的东西。Reinhold 一开始坚持"那是它们的问题,让它们改",但在投票被否之后让步了。妥协结果:

  • 引入 --add-opens / --add-exports 命令行开关作为 escape hatch;
  • JDK 9—16 默认对未声明 --illegal-access=permit 的反射只警告不阻止;
  • 直到 JEP 403(Java 16)才把默认值改成 deny,又过了几个版本才真正硬封死。

这条妥协线的意义是:JPMS 把"立刻全民迁移"改成了"给生态六七年时间慢慢搬"。

第三,layer / dynamic loading 的边界。EG 会议里 David Lloyd(Red Hat,JBoss Modules 作者)连续提了 addExportsaddOpensaddPackageaddUses 四个 ModuleLayer.Controller 的方法诉求。他的目标是让 JBoss 这种应用服务器可以在运行期调整模块图——这是 JBoss Modules 已经在做的事,JPMS 也得能做才能在 WildFly 里替代它。Reinhold 的回应一以贯之:能不加的 API 就不加,运行期改 module graph 风险太大。最后让步是 addExports / addOpens 进了,addPackage 留作后续讨论,addUses 被推迟(“LATER, after additional exploration”)。

第四,restricted keywords 与可读性requiresexportsopenstransitiveusesprovideswithto ——这些 module-info 关键字是不是要变成 Java 语言关键字?Stephan Herrmann(Eclipse JDT 编译器作者)极力反对,说 IDE 实现成本巨大;Reinhold 和 Rémi Forax 站在另一边——他们认为 Java 语言的可读性比编译器实现成本重要得多。最后这些被设计成 上下文相关的 restricted keyword——只有在 module declaration 里才是关键字。

7.4 妥协的代价

二轮投票通过的 JPMS,是一个被打了五处补丁的设计:

  1. 自动模块:让没改造的旧 jar 也能放在 module path 上,但代价是引入了一种"什么包都导出、能 read 所有模块"的特殊形态——纯粹是为了迁移而存在;
  2. Automatic-Module-Name Manifest 头:模块命名权从平台移交给库作者;
  3. --add-opens / --add-exports:反射强封装的合法逃生口;
  4. default permit:连续多个 LTS 版本默认放过非法反射,给生态时间;
  5. classpath 与 module path 并存:unnamed module 永远存在,应用可以完全不用 JPMS。

这五处妥协的共同主题是 向后兼容压倒一切。Reinhold 在 reconsideration 周期里反复表达的态度是:JPMS 要做的是一条让真实代码能迁移的路径,而不是一份理想化的设计。这恰好是 OSGi 阵营批评最猛的点——OSGi 在 18 年里一直保持设计纯洁,代价是从来没成为 Java 主流。

7.5 真正在打的是什么

把这场论战的技术外壳剥掉,里面是两种关于"模块化"该长什么样的本体论分歧:

维度 Oracle 阵营 OSGi 阵营
模块化的目标 让大型程序可分析 让运行系统可演化
校验时机 编译期 + 启动期 运行期协议
单一真相在哪 module-info(静态) bundle headers + service registry(动态)
多版本共存 不要——会破坏可靠配置 要——这是动态性的基础
服务发现 uses / provides(声明式) Service Registry(带事件)
演化态度 一次到位、向后兼容 永远在演化、永远在替换

OSGi 阵营赢得过技术上的几场漂亮战役——版本系统、service registry、动态生命周期,至今没有 Java 主流方案能匹敌。但他们输掉了产品采纳:18 年里 OSGi 始终是少数派,被 Eclipse、IDE、嵌入式、电信级中间件用,没能进入主流应用开发。Oracle 阵营在 JSR 376 上的核心赌注是:“放弃动态性,换取生态采纳”——把"模块化"重新定义成"可分析",而不是"可热替换"。

二轮投票通过那一刻,这场关于 模块化是什么 的定义之争,在 Java 生态里出了胜负。

7.6 论战留下的遗产

今天回头看,2017 年那场投票留下了三件长期影响:

第一,JPMS 永远不会有版本和热替换。这两件事在投票过程中就被明确放弃了,不是技术做不到,是定义上不要。任何指望"等 JPMS 加上版本就能替代 OSGi"的想法,都没读过 EG minutes。

第二,OSGi 的生存空间被压缩但没消失。需要真正动态性的场景——IDE 插件(Eclipse 自身)、电信级中间件(Karaf、ServiceMix)、IoT 网关、汽车电子——OSGi 仍是事实标准。但通用 Java 应用开发已经默认 JPMS + classpath 混合模式。OSGi 在 R7 / R8 增加的 ServiceLoader Mediator(Compendium §133)就是承认这个现实——主动把 ServiceLoader 桥接到 OSGi Service Registry,让两边能共存。

第三,"渐进迁移"成了 Java 平台的默认演化模式。从 JEP 261 的 default permit 到 JEP 403 的 deny by default,再到后来 sealed class、record、虚拟线程的 preview → standard 路径,Java 不再做激进切换。这条文化变化的源头之一就是 JSR 376 投票事件——再正确的设计,也得给生态留出迁移时间。

剩下的工程问题——什么时候切 ClassLoader、SPI 实例怎么过桥、老对象怎么处理——是这套妥协后的 JPMS 留给应用层去解决的。这正好是本文前六章在做的事。

八、回到最初的问题

回到开头的三个问题。

模块化方案到底解决什么问题?把 classpath 一锅炖混成一团的几个层次拆开:可见性(哪些 package 对外)、版本(同名能不能多版本共存)、生命周期(能不能装卸)、配置(能不能编译期校验依赖)、SPI 发现(实现从哪来、用哪个 CL 加载)。三套方案在这个坐标系里选了不同的位置:OSGi 全要并把 SPI 换成自己的 Service Registry、JBoss Modules 要前两个加模块边界并复用 ServiceLoader、Jigsaw 只要可见性和编译期配置但把 SPI 编进了 module-info。选哪一个取决于部署形态——动态升级是产品核心能力的选 OSGi;只想做应用服务器内部隔离的选 JBoss Modules;想给现代 Java 应用一个干净的封装边界的选 JPMS。

什么时候可以切换类加载器?要先分清切的是哪一种:

  • 切 TCCL 是廉价操作,只是给 SPI 查找用的 hint;JPMS 之后这个 hint 的影响范围在变小,但 ServiceLoader 仍然吃这一口;
  • 用新 CL 加载新代码,老对象就活在旧 CL 里,跨边界要么走父 CL 接口要么走数据复制;SPI 实例的引用是最容易跨 CL 残留的那一类,要靠协议层(OSGi 事件、JPMS Provider 重 get)主动放手;
  • 卸载整个 CL 必须满足三件事同时成立,任何一个钉子都会让卸载失败,从而导致内存泄漏;DriverManager 这种"反 SPI 的活化石"就是钉子的典型来源,平台代码必须显式反注册。

老对象怎么处理这件事,归根结底由 JVMS §5.3 决定的类身份模型管着——一个对象的 Class 由它的 defining loader 决定,defining loader 不变它就属于旧世界。要让它"过到新世界",要么经过共同的父 CL 接口,要么变成普通数据再重生。除此之外没有第三条路。SPI 的演进也没有跳出这条规律:它能做的只是把"发现新世界的入口"标准化,但跨世界搬运对象这件事,仍然要回到 JVM 的类身份模型本身去解决。

跨出 Java 之外看,这套规律并不只是 JVM 一家的特殊性——.NETAssemblyLoadContext、Erlang/BEAM 的双版本机制、Python 的 importlib.reload、C 世界的 dlmopen、Go 的拒绝热替换,每一家都用自己的方式在同一组物理约束下做选择。这部分讨论放在续篇里:《模块化与动态加载的跨平台对照:从 ClassLoader 到 BEAM、ALC、dlmopen》

参考资料