从 OSGi 到 Jigsaw:Java 模块化、SPI 与类加载器切换
写在前面
Java 的模块化不是从 JPMS 才开始的。很长一段时间里,classpath 像一条足够宽的路,所有 jar 都往上面放,能跑就行。等应用长大,库的版本开始冲突,内部包被外部代码依赖,插件想热更新,SPI 实现又被错误的类加载器发现,这条路才慢慢暴露出它没有边界的问题。
OSGi、JBoss Modules、Jigsaw(JPMS)都试图在 classpath 之上重新划边界,只是三者选择的边界不一样。OSGi 把运行时动态性放在第一位,JBoss Modules 更关心应用服务器内部的静态隔离,JPMS 则把强封装和可靠配置做进了 Java 平台本身。
类加载器切换是这条线上的另一面。Tomcat reload 之后类没卸掉、OSGi 升级 bundle 之后老对象 ClassCastException、JDBC Driver 把整个 webapp 钉在内存里、Spring Boot DevTools 重启后某个 SPI 实现加载了旧版本,这些现象表面上属于不同框架,根子都在 JVM 的类身份模型:一个类不只由全限定名决定,还由定义它的 ClassLoader 决定。
SPI 又把这两条线拧在了一起。ServiceLoader 默认靠 TCCL 找实现,DriverManager 保留了早期 JDBC 的静态注册表,JPMS 把 uses / provides 写进 module-info,OSGi 则用 Service Registry 替换了 classpath 风格的发现机制。模块边界一变,服务发现路径也会跟着变。
一、classpath 模型留下的几个坑
把任意 jar 罗列进 -cp,JVM 就把所有 .class 看成同一个扁平命名空间。这是 1995 年的设计,能跑、足够简单,但留下了至少四类问题。
第一类是编译期没有可见性约束。一个 public 类对所有 jar 一律可见。库作者想说"com.foo.internal.* 是私有,请勿调用",只能写在 Javadoc 里,编译器和 JVM 没有任何手段拦住调用方。Java 平台自己也吃过亏:sun.misc.Unsafe、com.sun.* 这些被外部代码大规模依赖,导致后续版本想改都改不动。这正是 JEP 200 提出 “strong encapsulation” 的直接动因(JEP 200: The Modular JDK)。
第二类是部署期没有"完整依赖"概念。jar 自己不声明依赖什么、不声明依赖的版本范围。运行时要等到第一次 Class.forName 才知道少了一个类。所谓 JAR Hell——同名不同版本、版本范围不兼容、缺失依赖、循环依赖——本质都是因为依赖关系直到 link 阶段才被检查。
第三类是运行期版本冲突无法表达。同一个 FQN(fully qualified name)的类,在 classpath 上只能存在一个版本。两个库分别要 guava-19 和 guava-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-info 的 uses / 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 | |
这种拓扑长这样:
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 | |
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 | |
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 切换的语义到底是什么
“切换类加载器”在工程语境下经常被笼统使用,但它至少对应三件根本不同的事,每件事对老对象的影响都不一样。先把 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 | |
这种"切换"什么都没改:
- 已经加载的所有 Class 还在它原来的 defining loader 下,身份不变;
- 已经存在的对象实例不受任何影响;
- TCCL 只是给框架代码提供一个"我应该用谁来
Class.forName"的 hint,例如ServiceLoader、JDBC DriverManager、JNDI、各种基于 SPI 的库。
它的危险在于框架的隐式依赖。ServiceLoader.load(Foo.class) 不带 CL 参数时,默认拿 TCCL 找 provider;如果在容器线程池里偶然忘了切 TCCL,找出来的 provider 可能是错的版本、甚至来自错的 webapp。Tomcat 之所以在每个请求开始都把 TCCL 切到 WebappClassLoader,正是为了让应用代码在通过 SPI 调底层框架时还能定位到自己 webapp 内部的资源。
切 TCCL 是安全的、可逆的、随便切的;它只影响"未来的查找",不影响"已经存在的对象"。
3.3 语义二:用新 CL 加载新版本的类
1 | |
这是热部署、插件系统、OSGi bundle update 的本质。两次 loadClass 的结果是两个不同的 Class,两个不同的 Class 的 instance 互相不能 cast。
老对象 p1 不会消失也不会自动迁移,它的归属如下:
p1.getClass() == c1,永远是;c2.isInstance(p1) == false;(Plugin) p1在cl2加载的代码里执行会抛ClassCastException,即便 FQN 完全相同;p1持有的所有静态/实例字段、方法引用,都还指向cl1加载的那一套——它是一个完整的、活在旧世界里的对象。
要让新代码能复用老对象,只有四条路:
- 走父 CL 加载的接口:
Plugin接口由共同父 CL 加载,p1实现的是父 CL 版本的Plugin,新代码强转到这个公共接口可以成功。OSGi 的Import-Package在 wiring 阶段把同一个 package 的 wire 指向同一个 exporter bundle,正是为了让接口类型在边界上唯一。 - 反射调用:完全放弃静态类型,用
Method.invoke调老对象。 - 序列化/反序列化复制:把老对象序列化成字节流,在新 CL 下反序列化出新对象。常见于配置数据迁移。
- 抛弃:让老对象随旧 CL 一起被回收。这是 OSGi update / Tomcat reload 的官方姿态。
工程里反复出现的"OSGi 升级 bundle 后老缓存里的对象用不了",“Spring DevTools 重启后某个静态字段里的 listener 调不通”,根因都是这一条:跨 CL 持有了老对象的引用。
3.4 语义三:把整个 CL 卸载重装
要把一个 ClassLoader 真正卸掉,需要同时满足三件事(HotSpot 在 metaspace 回收时执行类卸载,参见 HotSpot GC Tuning Guide 关于 class unloading 的一节):
- ClassLoader 实例本身从 GC roots 不可达;
- 该 CL 定义的所有 Class 对象不可达;
- 这些 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.loggers、java.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):
- 调用旧 bundle 的
BundleActivator.stop,给它机会释放资源; - 把旧 bundle 的状态从 ACTIVE 转回 INSTALLED,旧的 BundleWiring 标记为 stale;
- 装新 bundle 字节、resolve、wire、start;
- 旧 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 没有 removeModule 或 Layer.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 把模块边界带到运行时
ServiceLoader、TCCL、DriverManager 看起来像三个独立话题,实际都绕不开同一个问题:实现类从哪个 ClassLoader、哪个模块里被发现。任何“上层依赖接口、底层提供实现”的协议,只要进入多 ClassLoader 或模块化环境,就必须回答这个问题。
4.1 SPI 是什么、为什么必须谈
SPI 是 service-provider interface 的缩写。它和 API 的区别在于发现机制:API 是调用方主动 import 来用的;SPI 是上层定接口、下层提供实现,由框架在运行时把实现"注入"上层使用。java.sql.Driver、javax.xml.parsers.SAXParserFactory、java.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 | |
调用方:
1 | |
不带 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"]
它的两个固有缺陷:
- 没有版本概念。
META-INF/services文件不写版本,CL 上有一份就用一份。两个 jar 各带一份同名 SPI 实现,谁先找到谁赢,且行为不可预测。 - 依赖 TCCL 这个隐式参数。SPI 调用看上去是纯函数,实际行为却被当前线程的 TCCL 决定。多 CL 环境(OSGi、应用服务器、Spark Executor、Spring Boot DevTools)下,某个看似无关的代码改动可能让 SPI 突然换了 provider。
显式指定 CL 是修法之一:
1 | |
但问题是,业务代码很少这么写——大多数库(包括 JDK 自己)都用不带 CL 参数的版本。
4.3 DriverManager:早期注册机制留下的静态表
java.sql.DriverManager 是 JDBC 1.0 时代的产物,比 ServiceLoader 早十几年。它的注册机制在今天看来是反 SPI 的:
Driver实现类的静态初始化块里调用DriverManager.registerDriver(this);DriverManager内部维护一个静态的CopyOnWriteArrayList<DriverInfo>;getConnection(url)时遍历这张表,用 URL 前缀匹配。
JDBC 4.0(JDK 6)补上了 SPI 入口:driver jar 里放 META-INF/services/java.sql.Driver,DriverManager 在第一次被引用时通过 ServiceLoader 自动加载、触发静态块完成注册。但旧的注册机制没废,所以 Driver 实例最终还是落到那张静态表上(OpenJDK ServiceLoader 源码)。
Tomcat reload 里的 JDBC driver 泄漏,正是这套机制留下的典型问题:
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 注册进 DriverManager,DriverManager 在 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 | |
provides ... with ... 让 javac 在编译期校验"实现类确实实现了接口";uses 让模块系统知道"这个模块会调用 ServiceLoader.load(Plugin.class)",从而在解析时把所有 provider 模块也带上。运行时,ServiceLoader 的查找规则变了(ServiceLoader Javadoc — Locating providers):
- 先在 caller 模块所在的 module layer 及其祖先 layer 中查
provides声明; - 再回退到 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-info 的 provides 主导、用 META-INF/services 兜底。
4.5 OSGi Service Registry:换一种范式
OSGi 没用 ServiceLoader。它有自己的 Service Registry(OSGi Core R8 §5,Service Layer),整个 service 模型是"主动注册 + 事件订阅":
1 | |
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、SLF4JLoggerContext.stop、JNDIContext.close、Introspector.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、SLF4JLoggerContext、Introspectorcache)后期望它能被卸载; - 在 JNI 中持有
GlobalRef,又指望 webapp reload 后 native 那一侧自动失效; - 同名同 FQN 的两个版本类,期望它们在 reflect 等场合"被识别为同一个"。
灰色场景:
- 反射调用 + 序列化迁移:能跑,但每跨一次 CL 都要做一次拷贝,性能与状态一致性都需要谨慎设计;
- 让两个隔离 CL 通过共同父 CL 上的接口交互:能跑,但接口本身的版本变更会让父 CL 升级变成全局事件;
- 自己实现
ClassLoader.findClass做"父优先 / 子优先"翻转:可行(OSGi、Tomcat 都这么做),但要注意 loader constraint,否则会在某个不相关的方法签名解析时抛LinkageError。
六、工程上反复出现的几个坑
Tomcat / Spring Boot 的 reload 三大泄漏源,社区文档里讲过无数遍,但还是反复出现:
ThreadLocal持有 webapp 加载的对象,而Thread是容器的共享线程池——webapp 卸载后,线程还在,ThreadLocal把整个WebappClassLoader钉死;DriverManager.registerDriver(driver)注册到 JDK 的system CL(更严格说是DriverManager静态字段所在的 CL),webapp 卸载时Driver实例还挂在那张表上;- 静态字段缓存:典型是某些日志库的
LoggerContext、java.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 同样会内存泄漏。
反序列化跨 CL 也容易出错。ObjectInputStream 默认用 latestUserDefinedLoader 解析类,在 OSGi / 多 CL 环境里这个"最新的用户 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 作者)连续提了 addExports、addOpens、addPackage、addUses 四个 ModuleLayer.Controller 的方法诉求。他的目标是让 JBoss 这种应用服务器可以在运行期调整模块图——这是 JBoss Modules 已经在做的事,JPMS 也得能做才能在 WildFly 里替代它。Reinhold 的回应一以贯之:能不加的 API 就不加,运行期改 module graph 风险太大。最后让步是 addExports / addOpens 进了,addPackage 留作后续讨论,addUses 被推迟(“LATER, after additional exploration”)。
第四,restricted keywords 与可读性。requires、exports、opens、transitive、uses、provides、with、to ——这些 module-info 关键字是不是要变成 Java 语言关键字?Stephan Herrmann(Eclipse JDT 编译器作者)极力反对,说 IDE 实现成本巨大;Reinhold 和 Rémi Forax 站在另一边——他们认为 Java 语言的可读性比编译器实现成本重要得多。最后这些被设计成 上下文相关的 restricted keyword——只有在 module declaration 里才是关键字。
7.4 妥协的代价
二轮投票通过的 JPMS,是一个被打了五处补丁的设计:
- 自动模块:让没改造的旧 jar 也能放在 module path 上,但代价是引入了一种"什么包都导出、能 read 所有模块"的特殊形态——纯粹是为了迁移而存在;
Automatic-Module-NameManifest 头:模块命名权从平台移交给库作者;--add-opens/--add-exports:反射强封装的合法逃生口;- default permit:连续多个 LTS 版本默认放过非法反射,给生态时间;
- 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 实例怎么过桥,老对象怎么处理。这些都被留给了应用层。
八、收束到工程判断
Java 模块化要解决的,不是“jar 包怎么起一个新名字”,而是把 classpath 混在一起的几层边界拆开:哪些 package 对外可见,同名类能不能多版本共存,模块能不能装卸,依赖能不能在编译期校验,SPI 实现从哪里被发现。
三套方案在这个坐标系里选了不同位置。OSGi 几乎全要,并把 SPI 换成自己的 Service Registry;JBoss Modules 要模块边界和多版本隔离,但不追求运行时动态生命周期;Jigsaw 只把强封装和可靠配置做进平台,同时用 module-info 接管一部分 SPI 声明。动态升级是产品核心能力时,OSGi 仍然合适;应用服务器内部隔离更接近 JBoss Modules 的地盘;现代 Java 应用只想要一个干净的封装边界,JPMS 才是平台给出的答案。
类加载器能不能切,取决于切的是哪一种:
- 切 TCCL 是廉价操作,只是给 SPI 查找用的 hint;JPMS 之后这个 hint 的影响范围在变小,但 ServiceLoader 仍然吃这一口;
- 用新 CL 加载新代码,老对象就活在旧 CL 里,跨边界要么走父 CL 接口要么走数据复制;SPI 实例的引用是最容易跨 CL 残留的那一类,要靠协议层(OSGi 事件、JPMS Provider 重
get)主动放手; - 卸载整个 CL 必须满足三件事同时成立,任何一个钉子都会让卸载失败,从而导致内存泄漏;
DriverManager的静态注册表就是钉子的典型来源,平台代码必须显式反注册。
老对象怎么处理,最后仍然由 JVMS §5.3 的类身份模型决定。一个对象的 Class 由它的 defining loader 决定,defining loader 不变,它就属于旧世界。要让它“过到新世界”,要么经过共同父 ClassLoader 加载的接口,要么变成普通数据再重建。除此之外没有第三条路。
SPI 的演进也没有跳出这条规律。它能做的是把“发现新实现”的入口标准化,却不能替应用层搬运已经存在的对象。跨模块、跨 ClassLoader、跨 reload 边界的设计,最后都要回到这个朴素事实上。
跨出 Java 之外看,这套规律并不只是 JVM 一家的特殊性——.NET 的 AssemblyLoadContext、Erlang/BEAM 的双版本机制、Python 的 importlib.reload、C 世界的 dlmopen、Go 的拒绝热替换,每一家都用自己的方式在同一组物理约束下做选择。这部分讨论放在续篇里:《模块化与动态加载的跨平台对照:从 ClassLoader 到 BEAM、ALC、dlmopen》。
参考资料
- JEP 200: The Modular JDK
- JEP 220: Modular Run-Time Images
- JEP 261: Module System
- JEP 282: jlink: The Java Linker
- JSR 376: Java Platform Module System
- JVMS Chapter 5: Loading, Linking, and Initializing
- OSGi Core Release 8: Module Layer
- OSGi Core Release 8: Lifecycle Layer
- JBoss Modules Manual
- Mark Reinhold: Project Jigsaw 系列博客
- Java SE ModuleLayer API
- HotSpot Virtual Machine Garbage Collection Tuning Guide: Class Unloading
- Apache Tomcat Wiki: MemoryLeakProtection
- Java SE 17 ServiceLoader Javadoc
- OSGi Core Release 8: Service Layer
- OSGi Compendium R7 §133: ServiceLoader Mediator Specification
- JDBC 4.0 / java.sql.DriverManager Javadoc
- JSR 376 Public Review Ballot Results(jcp.org)
- InfoQ: JCP EC Votes against the Java Platform Module System(2017-05)
- InfoQ: JSR 376 Passes the Public Review Reconsideration Ballot(2017-07)
- OpenJDK Project Jigsaw EG Minutes:2017/5/23
- JEP 403: Strongly Encapsulate JDK Internals


