编程进阶网 编程进阶网
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接

杨充

专注编程 · 终身学习者
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接
  • README
  • C语言入门精通

  • Cpp入门到精通

  • Java入门精通

    • README
    • 入门教程

    • 综合案例

    • 专栏博客

      • README
      • JVM内存模型与对象
      • 类加载与双亲委派
      • 垃圾回收与GC调优
      • 异常体系与JVM机制
      • 字节码指令集javap实战
      • JIT编译与去优化机制
      • JVM性能诊断工具链
      • OOM八大现场全景剖析
      • JVM参数调优全景图
      • GraalVM与AOT编译原理
      • HashMap底层哈希设计
      • String不可变与常量池
      • ArrayList与LinkedList源码
      • ConcurrentHashMap并发
      • TreeMap与红黑树原理
      • LinkedHashMap与LRU实现
      • Java数字类型原理
      • Object通用方法的契约
      • 泛型擦除与类型系统
      • 枚举原理与最佳实践
      • 注解原理与编译期处理
      • Lambda与引用底层原理
      • Stream原理与流水线设计
      • Optional设计原理
      • Record密封类与模式
      • 反射机制与动态代理
      • MethodHandle与VarHandle
      • 三大字节码框架对比
      • JavaAgent与Instrumentation机制
      • AOP三种实现路线对比
      • synchronized与锁升级
      • volatile与JMM内存模型
      • 线程池核心源码设计
      • Thread线程生命周期
      • AQS同步框架源码
      • 并发锁三剑客
      • CAS和Atomic深入分析
      • 五大同步器对比
      • CompletableFuture异步
      • IO模型演进BIO到AIO
      • ByteBuffer与堆外内存
      • 序列化原理与替代方案
      • 文件IO与NIO.2
      • 面向对象的真意
      • JDK设计模式上
      • JDK设计模式下
      • SPI与模块化设计
        • 1. 案例引入
          • 1.1 一段反常代码
          • 1.2 顺藤摸到根因
          • 1.3 我们要回答什么
        • 2. 架构概览
          • 2.1 三大解耦层级
          • 2.2 为什么这么切
        • 3. SPI核心机制
          • 3.1 API与SPI的区分
          • 3.2 META-INF约定
          • 3.3 ServiceLoader源码
          • 3.4 懒加载与迭代器
          • 3.5 JDK9新版用法
        • 4. 双亲委派的破局
          • 4.1 双亲委派回顾
          • 4.2 JDBC破局之路
          • 4.3 线程上下文类加载器
          • 4.4 Tomcat的层级反转
          • 4.5 OSGi的网状委派
        • 5. SPI实战范本
          • 5.1 SLF4J绑定机制
          • 5.2 Dubbo增强SPI
          • 5.3 自己写一个SPI
          • 5.4 SPI的常见陷阱
        • 6. JPMS模块系统
          • 6.1 为什么需要模块
          • 6.2 module-info语法
          • 6.3 模块路径与类路径
          • 6.4 强封装与反射访问
        • 7. 模块化下的SPI
          • 7.1 provides与uses
          • 7.2 模块化ServiceLoader
          • 7.3 自动模块与未命名模块
          • 7.4 拆分包冲突
        • 8. JPMS与OSGi对比
          • 8.1 OSGi核心机制
          • 8.2 五大维度对比
          • 8.3 何时该用谁
        • 9. 模块化迁移与陷阱
          • 9.1 三阶段迁移策略
          • 9.2 jdeps与jlink
          • 9.3 反射访问破封装
          • 9.4 Spring Boot生态现状
        • 10. 综合案例串讲与全册收官
          • 10.1 案例真相揭晓
          • 10.2 启动一行代码的旅程
          • 10.3 全册七卷回望
          • 10.4 设计哲学速查
        • 全册结语
  • Go入门到精通

  • JavaScript入门

  • CodeX
  • Java入门精通
  • 专栏博客
杨充
2026-06-02
目录

SPI与模块化设计

# 47.SPI与模块化设计

# 目录介绍

  • 1. 案例引入
    • 1.1 一段反常代码
    • 1.2 顺藤摸到根因
    • 1.3 我们要回答什么
  • 2. 架构概览
    • 2.1 三大解耦层级
    • 2.2 为什么这么切
  • 3. SPI核心机制
    • 3.1 API与SPI的区分
    • 3.2 META-INF约定
    • 3.3 ServiceLoader源码
    • 3.4 懒加载与迭代器
    • 3.5 JDK9新版用法
  • 4. 双亲委派的破局
    • 4.1 双亲委派回顾
    • 4.2 JDBC破局之路
    • 4.3 线程上下文类加载器
    • 4.4 Tomcat的层级反转
    • 4.5 OSGi的网状委派
  • 5. SPI实战范本
    • 5.1 SLF4J绑定机制
    • 5.2 Dubbo增强SPI
    • 5.3 自己写一个SPI
    • 5.4 SPI的常见陷阱
  • 6. JPMS模块系统
    • 6.1 为什么需要模块
    • 6.2 module-info语法
    • 6.3 模块路径与类路径
    • 6.4 强封装与反射访问
  • 7. 模块化下的SPI
    • 7.1 provides与uses
    • 7.2 模块化ServiceLoader
    • 7.3 自动模块与未命名模块
    • 7.4 拆分包冲突
  • 8. JPMS与OSGi对比
    • 8.1 OSGi核心机制
    • 8.2 五大维度对比
    • 8.3 何时该用谁
  • 9. 模块化迁移与陷阱
    • 9.1 三阶段迁移策略
    • 9.2 jdeps与jlink
    • 9.3 反射访问破封装
    • 9.4 Spring Boot生态现状
  • 10. 综合案例串讲与全册收官
    • 10.1 案例真相揭晓
    • 10.2 启动一行代码的旅程
    • 10.3 全册七卷回望
    • 10.4 设计哲学速查

# 1. 案例引入

# 1.1 一段反常代码

我们接手了一个用 JDK 17 + Spring Boot 3 构建的"订单中心"服务,启动时连续踩了 5 个看起来不相关、但根因都指向 SPI 与模块化机制的坑:

// 模块 1:日志门面
package com.acme.order;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class OrderService {
    private static final Logger log = LoggerFactory.getLogger(OrderService.class);
    // ...
}
1
2
3
4
5
6
7
8
9
10

启动后控制台第一行:

SLF4J: No SLF4J providers were found.
SLF4J: Defaulting to no-operation (NOP) logger implementation
1
2
// 模块 2:数据库连接
public class OrderDao {
    public void init() throws Exception {
        Class.forName("com.mysql.cj.jdbc.Driver");        // ★ JDK 6 之后这一行其实没必要
        Connection c = DriverManager.getConnection(
            "jdbc:mysql://localhost:3306/order", "root", "root");
        // ...
    }
}
1
2
3
4
5
6
7
8
9

去掉 Class.forName 后仍然能跑——为什么?

// 模块 3:动态编译
public void compile(String src) {
    JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
    if (compiler == null) {
        throw new IllegalStateException("拿不到编译器,是不是 JRE 而非 JDK?");
    }
    // ...
}
1
2
3
4
5
6
7
8

线上某台机器上 compiler == null——为什么本地跑得好好的,线上偏偏拿不到?

// 模块 4:发布镜像
// Dockerfile
FROM openjdk:17
COPY target/order-service.jar /app/
ENTRYPOINT ["java", "-jar", "/app/order-service.jar"]
1
2
3
4
5

镜像 480MB——一个简单的服务真的需要这么大?

// 模块 5:JDK 17 兼容
// 启动报错
java.lang.reflect.InaccessibleObjectException: Unable to make field 
    private final transient java.util.HashMap$Node[] java.util.HashMap.table 
    accessible: module java.base does not "opens java.util" to unnamed module
1
2
3
4
5

老代码用反射改 HashMap 内部字段做缓存,JDK 17 之后直接抛 InaccessibleObjectException——这个异常哪来的?

# 1.2 顺藤摸到根因

把 5 个现象串起来:

  • 现象 ①——SLF4J 找不到 provider。SLF4J 是怎么发现日志后端实现的?为什么必须依赖 logback/log4j 才能工作?
  • 现象 ②——Class.forName 在 JDK 6 之后可以省略。DriverManager 是怎么自动发现并加载 MySQL 驱动的?
  • 现象 ③——ToolProvider.getSystemJavaCompiler() 时灵时不灵。它的实现也是 SPI 吗?
  • 现象 ④——OpenJDK 17 base 镜像 471MB,里面 90% 是用不到的模块。能不能只打包用到的?
  • 现象 ⑤——InaccessibleObjectException 是 JDK 9 引入的强封装错误,背后是 JPMS 模块系统的"强封装"。

这五个现象其实是同一个东西在不同层面的呈现:

SLF4J 后端发现   ┐
JDBC 驱动加载    │
ToolProvider     ├─── 都是 SPI 机制 + 双亲委派的破局
SLF4J 找不到     ┘

镜像太大        ┐
反射访问被拒    ├─── 都是 JPMS 模块系统的影响面
                ┘
1
2
3
4
5
6
7
8

这 5 个现象贯穿本篇 7 个核心问题:

① SPI 是什么?怎么加载?           → 第3章
② 为什么 SPI 必须破双亲委派?      → 第4章
③ 4 种破局方式各自的代价?         → 第4章
④ 工程实战 SPI 怎么写?陷阱在哪?  → 第5章
⑤ JPMS 解决了什么问题?           → 第6章
⑥ 模块化下 SPI 怎么写?           → 第7章
⑦ 跟 OSGi 有什么本质差别?         → 第8章
1
2
3
4
5
6
7

# 1.3 我们要回答什么

第 02 篇我们讲过类加载与双亲委派——本篇是它的收官篇:从"为什么双亲委派必须被打破"切入,把 SPI 机制讲到底;再从"为什么 JDK 9 要发明模块系统"切入,把 JPMS 讲透;最后做整本专栏的全册收官。

flowchart LR
    A[案例 5 大现象] --> B[第3章 SPI 核心]
    B --> C[第4章 双亲委派破局]
    C --> D[第5章 SPI 实战]
    D --> E[第6章 JPMS]
    E --> F[第7章 模块化下 SPI]
    F --> G[第8章 vs OSGi]
    G --> H[第9章 迁移陷阱]
    H --> I[第10章 全册收官]
1
2
3
4
5
6
7
8
9

# 2. 架构概览

# 2.1 三大解耦层级

我们站在更高视角看一下:Java 生态实现"插件化与解耦"经过三轮演进:

┌────────────────────────────────────────────────────────────┐
│ 层级 1:SPI(JDK 1.6) ─── 服务发现机制                     │
│   核心:ServiceLoader + META-INF/services/                 │
│   解耦:调用方只依赖接口,实现方运行时被发现                 │
│   颗粒:单个接口 + 多实现                                    │
├────────────────────────────────────────────────────────────┤
│ 层级 2:OSGi(2000+) ─── 真正的动态模块系统                │
│   核心:Bundle + Service Registry + 网状类加载器           │
│   解耦:模块版本隔离 + 热插拔                                │
│   颗粒:整个 Jar(Bundle)                                  │
├────────────────────────────────────────────────────────────┤
│ 层级 3:JPMS(JDK 9) ─── 官方模块系统                      │
│   核心:module-info.java + 强封装                          │
│   解耦:编译期可见性控制 + 强封装                            │
│   颗粒:整个 Jar(Module)                                  │
└────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

三者不是替代关系:

  • SPI 解决的是"接口与实现的运行时绑定"
  • OSGi 解决的是"模块版本隔离、动态生命周期"
  • JPMS 解决的是"JDK 自身的模块化 + 编译期强封装"

实战中Spring Boot 应用通常只用 SPI;Eclipse / 老版本 IDEA 插件走 OSGi;JDK 17+ 自身的 java.base / java.sql / java.compiler 走 JPMS。

# 2.2 为什么这么切

疑惑:JDK 已经有了 SPI(JDK 1.6),为什么 9 年后又搞了 JPMS?两者职责重叠吗?

论证:

  1. SPI 解决的是"运行时能找到实现"——但编译期没有约束。META-INF/services 里写错类名、运行时才报 ServiceConfigurationError。
  2. SPI 不管"包可见性"——只要实现类在 classpath 上、接口是 public,就能 implements。导致 Java 标准库的所有 internal 包都暴露在外:sun.misc.Unsafe、com.sun.* 谁都能用。
  3. SPI 不管"依赖版本"——同一个 jar 在 classpath 上多次(version 1.0 和 2.0),随机加载哪个看启动顺序。
  4. JDK 自己也吃不下了——JDK 8 的 rt.jar 60+MB 一个文件,启动慢、安全审计难、嵌入式裁剪不能。

结论:JPMS 不是 SPI 的替代品,而是 SPI 的补全——SPI 管运行时绑定,JPMS 管编译期可见性 + 包级强封装 + JDK 自身瘦身。两者正交、各司其职。

flowchart TB
    A[Java 解耦演进] --> B[运行时层<br/>SPI / ServiceLoader]
    A --> C[编译期层<br/>JPMS module-info]
    A --> D[动态层<br/>OSGi Bundle]
    
    B --> E[发现机制]
    C --> F[可见性控制]
    D --> G[热插拔 + 版本隔离]
1
2
3
4
5
6
7
8

# 3. SPI核心机制

# 3.1 API与SPI的区分

先把两个长得像的概念分开:

API(Application Programming Interface)
    调用方                      实现方(库作者)
    我用你的接口  ─── 调用 ───►  我提供接口和实现
    
    例:Map / List / String 的 API ── JDK 写好实现,我们调

SPI(Service Provider Interface)
    框架方(接口定义者)        提供方(插件作者)
    我定义协议   ─── 反向 ──►   我按协议实现,你来发现我
    
    例:JDBC Driver 接口 ── MySQL/PostgreSQL 各自实现 + JDK 用 SPI 发现
1
2
3
4
5
6
7
8
9
10
11

关键差别:API 是"我调你",SPI 是**"你调我,但你不认识我,靠协议(META-INF/services)找到我"——这是控制反转**在类加载层面的体现。

# 3.2 META-INF约定

SPI 的工程约定极简:

my-service.jar
└── META-INF/
    └── services/
        └── com.acme.spi.PaymentChannel       ← 文件名 = 接口全限定名
                内容:每行一个实现类全限定名
                ├── com.acme.alipay.AlipayChannel
                ├── com.acme.wxpay.WxPayChannel
                └── com.acme.bank.UnionPayChannel
1
2
3
4
5
6
7
8

调用方代码:

ServiceLoader<PaymentChannel> loader = ServiceLoader.load(PaymentChannel.class);
for (PaymentChannel ch : loader) {
    if (ch.support(orderType)) {
        ch.pay(order);
        break;
    }
}
1
2
3
4
5
6
7

没有任何 spring.factories、没有反射 newInstance、没有手写 Map——纯靠约定。

# 3.3 ServiceLoader源码

ServiceLoader 的源码并不复杂,核心干 4 件事:

// JDK 17 ServiceLoader.java 简化版
public final class ServiceLoader<S> implements Iterable<S> {
    private final Class<S> service;          // 接口 Class
    private final ClassLoader loader;        // 用谁来加载实现类(关键!)
    private final List<S> instantiatedProviders = new ArrayList<>();
    
    private ServiceLoader(Class<S> svc, ClassLoader cl) {
        this.service = svc;
        this.loader = cl;
    }
    
    public static <S> ServiceLoader<S> load(Class<S> service) {
        // ★ 默认用线程上下文类加载器(Thread.currentThread().getContextClassLoader)
        ClassLoader cl = Thread.currentThread().getContextClassLoader();
        return new ServiceLoader<>(service, cl);
    }
    
    public Iterator<S> iterator() { return new LazyIterator(); }
    
    private class LazyIterator implements Iterator<S> {
        Enumeration<URL> configs;
        Iterator<String> pending;
        
        public boolean hasNext() {
            if (configs == null) {
                String fullName = "META-INF/services/" + service.getName();    // ★ 拼路径
                configs = loader.getResources(fullName);                       // ★ 找文件
            }
            // 解析每一行,跳过 # 注释
            // ...
            return pending != null && pending.hasNext();
        }
        
        public S next() {
            String cn = pending.next();
            Class<?> c = Class.forName(cn, false, loader);                     // ★ 反射加载
            S p = service.cast(c.getDeclaredConstructor().newInstance());      // ★ 反射实例化
            instantiatedProviders.add(p);
            return p;
        }
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42

3 个关键点:

  1. 路径拼接:META-INF/services/ + 接口全限定名
  2. loader.getResources 而非 getResource——返回所有 jar 中匹配的文件(多个实现的 jar 各自有一份)
  3. Class.forName(cn, false, loader) 用的是 ServiceLoader 持有的 loader——这就是为什么 SPI 必须用线程上下文类加载器(4.3 节展开)
flowchart TB
    A[ServiceLoader.load] --> B[拿线程上下文类加载器 ContextClassLoader]
    B --> C[拼路径 META-INF/services/接口名]
    C --> D[loader.getResources 找所有匹配 URL]
    D --> E[逐 URL 解析行 跳过注释]
    E --> F[Class.forName 加载实现类]
    F --> G[反射调用无参构造]
    G --> H[强转回接口类型]
1
2
3
4
5
6
7
8

# 3.4 懒加载与迭代器

疑惑:为什么 ServiceLoader 不一次性把所有实现都加载好?

论证:

  1. SPI 实现类可能有数十个(比如 java.nio.file.FileSystemProvider)——一次全加载会拉长启动
  2. 调用方常常找到第一个能用的就停(如支付渠道选择)——后面的根本用不到
  3. 实现类构造可能抛异常——一次性加载意味着任意一个挂掉全军覆没

所以 ServiceLoader 实现了 Iterable,每次 hasNext / next 才实例化下一个——典型的迭代器模式(参考第 50 篇 3 章)。

结论:懒加载 = 启动快 + 单点失败可隔离 + 资源按需用。

# 3.5 JDK9新版用法

JDK 9 给 ServiceLoader 加了新 API:

// 老 API:必须 Iterable<S>,每个都已经实例化
for (PaymentChannel ch : ServiceLoader.load(PaymentChannel.class)) { ... }

// 新 API(JDK 9+):返回 Stream<Provider<S>>,先拿到元数据,按需 get()
ServiceLoader.load(PaymentChannel.class).stream()
    .filter(p -> p.type().getAnnotation(Premium.class) != null)   // ★ 不实例化先过滤
    .findFirst()
    .map(ServiceLoader.Provider::get)                              // ★ 真正需要时才实例化
    .ifPresent(ch -> ch.pay(order));
1
2
3
4
5
6
7
8
9

Provider 接口:

public interface Provider<S> extends Supplier<S> {
    Class<? extends S> type();    // ★ 拿到实现类 Class,不实例化
    S get();                       // ★ 真正实例化
}
1
2
3
4

这套新 API 解决了一个老痛点——老 API 强制实例化每个 Provider才能拿到类信息(注解、类名)。新 API 让我们能"先看再选"。

# 4. 双亲委派的破局

# 4.1 双亲委派回顾

第 02 篇讲过双亲委派的 4 步流程:

1. 检查类是否已被加载(缓存)
2. 找不到 → 委托父加载器加载
3. 父加载器递归处理(最终到 BootstrapClassLoader)
4. 父加载器都搞不定 → 自己尝试加载
1
2
3
4
┌─────────────────────────────────────────┐
│ Bootstrap ClassLoader  ─ rt.jar 等核心    │
└────────▲────────────────────────────────┘
         │ parent
┌────────┴────────────────────────────────┐
│ Platform/Extension ClassLoader ─ ext/   │
└────────▲────────────────────────────────┘
         │ parent
┌────────┴────────────────────────────────┐
│ Application ClassLoader  ─ classpath    │
└────────▲────────────────────────────────┘
         │ parent
┌────────┴────────────────────────────────┐
│ Custom ClassLoader (Tomcat WebApp 等) │
└─────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

核心原则:类只能由父或自身加载,不能向下委派——这保证了 java.lang.Object 永远是同一个 Class。

但 SPI 偏偏要向下委派——这就是冲突的根源。

# 4.2 JDBC破局之路

DriverManager 在 rt.jar 中(由 BootstrapClassLoader 加载),但 MySQLDriver 在用户的 classpath 上(由 ApplicationClassLoader 加载)。Bootstrap 看不到 Application 加载的类——这是双亲委派的硬性约束。

                            double-binding
DriverManager (rt.jar)  ────────────×─────────► MySQLDriver (用户 classpath)
被 Bootstrap 加载                              被 Application 加载
                                                
                                Bootstrap 永远看不到下层
1
2
3
4
5

那 DriverManager.getConnection("jdbc:mysql:...") 是怎么找到 MySQL 驱动的?

答案:DriverManager 在静态初始化时用线程上下文类加载器加载所有 JDBC 驱动:

// java.sql.DriverManager 简化源码
private static void loadInitialDrivers() {
    AccessController.doPrivileged((PrivilegedAction<Void>) () -> {
        ServiceLoader<Driver> loadedDrivers = ServiceLoader.load(Driver.class);
        // ★ ServiceLoader.load 默认用 Thread.currentThread().getContextClassLoader()
        Iterator<Driver> driversIterator = loadedDrivers.iterator();
        try {
            while (driversIterator.hasNext()) {
                driversIterator.next();    // ★ 触发实现类加载和静态块
            }
        } catch (Throwable t) { /* ... */ }
        return null;
    });
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

而 MySQL 驱动的静态块会反过来注册自己到 DriverManager:

// com.mysql.cj.jdbc.Driver
public class Driver extends NonRegisteringDriver implements java.sql.Driver {
    static {
        try {
            DriverManager.registerDriver(new Driver());     // ★ 反向注册
        } catch (SQLException e) { ... }
    }
}
1
2
3
4
5
6
7
8
sequenceDiagram
    participant App as 应用代码
    participant DM as DriverManager(Bootstrap)
    participant SL as ServiceLoader
    participant CL as Thread上下文ClassLoader(App)
    participant MD as MySQL Driver(App)
    App->>DM: getConnection("jdbc:mysql:...")
    Note over DM: 静态初始化 loadInitialDrivers
    DM->>SL: ServiceLoader.load(Driver.class)
    SL->>CL: 用 ContextClassLoader 找 META-INF/services/java.sql.Driver
    CL-->>SL: 找到 MySQL Driver 类名
    SL->>MD: Class.forName + newInstance
    MD-->>DM: 静态块 registerDriver 反向注册
    DM-->>App: 返回 Connection
1
2
3
4
5
6
7
8
9
10
11
12
13
14

回到现象 ②——为什么 Class.forName("com.mysql.cj.jdbc.Driver") 在 JDK 6 之后可以省略?因为 JDK 6 引入了 ServiceLoader,DriverManager 用它自动发现所有 jdbc 驱动——你不需要手动触发驱动类加载了。

# 4.3 线程上下文类加载器

线程上下文类加载器(Thread Context ClassLoader, TCCL) 是破局的核心武器:

public class Thread {
    private ClassLoader contextClassLoader;
    
    public ClassLoader getContextClassLoader() { return contextClassLoader; }
    public void setContextClassLoader(ClassLoader cl) { this.contextClassLoader = cl; }
}
1
2
3
4
5
6

机制:每个线程持有一个 ClassLoader 字段,缺省继承父线程的(最早是 main 线程的 AppClassLoader)。SPI 框架在加载实现时显式用 TCCL 而非自身的 ClassLoader,从而绕过双亲委派。

正常方式:MyClass.class.getClassLoader().loadClass(name)
         ↑ 用调用者所在加载器,必走双亲委派

SPI 方式:Thread.currentThread().getContextClassLoader().loadClass(name)
         ↑ 用线程上下文加载器,可以指向任何加载器
1
2
3
4
5

典型场景:

// Tomcat 启动一个 Web 应用前
Thread t = Thread.currentThread();
ClassLoader saved = t.getContextClassLoader();
try {
    t.setContextClassLoader(webAppClassLoader);    // ★ 切到 WebApp 加载器
    servlet.service(req, resp);                    // 内部任何 ServiceLoader.load 都用这个
} finally {
    t.setContextClassLoader(saved);                // ★ 恢复
}
1
2
3
4
5
6
7
8
9

结论:TCCL 是给上层框架(rt.jar 中的代码)一个口子,让它能"反向"看到下层 classpath。这是 Java 类加载体系有意打开的逃生通道。

# 4.4 Tomcat的层级反转

Tomcat 有 4 层 ClassLoader(简化):

Bootstrap ─► System ─► Common ─► Catalina ─► WebApp(每个app一个)
                                              ▲
                                    每个 War 独立一个加载器
1
2
3

关键差别:WebApp ClassLoader 优先自己加载(破坏双亲委派的"先委派父")——为什么?

论证:

  1. App A 用 jackson 2.10,App B 用 jackson 2.15——必须类隔离
  2. 如果走双亲委派,谁先加载就是谁——后到的应用直接 ClassCastException
  3. Tomcat WebApp 加载器先在自己的 WEB-INF/lib 里找——找到就用,找不到才向上
正向(双亲委派):先父后己       ── 保证核心类唯一性
反向(Tomcat WebApp):先己后父   ── 保证应用类隔离
1
2

但这种反转有边界——java.* 等核心类必须由 Bootstrap 加载,Tomcat 用一个**包名黑名单(delegate first)**强制走双亲委派。

# 4.5 OSGi的网状委派

OSGi 把"树形委派"彻底改成网状:

        Bundle A (导出 com.acme.core ver=1.0)
           │
           └── 被 Bundle B 导入
                  │
                  └── Bundle C 也导入 com.acme.core ver=2.0 (来自 Bundle X)
1
2
3
4
5

每个 Bundle 都有自己的 ClassLoader,按 import-package 声明决定从哪里要类——Bundle C 找 com.acme.core.Foo 时不再向父委派,而是查找 OSGi 框架管理的"已导出包注册表",找到对应的 Bundle X 加载器去拿。

结果:同一个 JVM 里可以共存 jackson 1.x、2.x、3.x,每个 Bundle 看到的版本不同——这是 SPI 和 JPMS 都做不到的。代价是学习曲线极陡 + 运行时复杂。

# 5. SPI实战范本

# 5.1 SLF4J绑定机制

回到现象 ①——SLF4J 找不到 provider 的真相。

**SLF4J 1.x 时代(已废弃)**用的是"静态绑定":

// SLF4J 1.x 在 LoggerFactory 中
private final static void bind() {
    // ★ 用 StaticLoggerBinder.getSingleton() 找到唯一实现
    StaticLoggerBinder.getSingleton();
}
1
2
3
4
5

每个日志后端(logback / slf4j-log4j12)都在自己的 jar 里实现了 org.slf4j.impl.StaticLoggerBinder——SLF4J 用 ClassLoader 加载这个固定类名。但同一个 classpath 上有两个 StaticLoggerBinder 时会有冲突警告。

SLF4J 2.x(2022+)改用标准 ServiceLoader:

// SLF4J 2.x 源码
private static List<SLF4JServiceProvider> findServiceProviders() {
    ServiceLoader<SLF4JServiceProvider> serviceLoader = 
        ServiceLoader.load(SLF4JServiceProvider.class);
    List<SLF4JServiceProvider> providerList = new ArrayList<>();
    for (SLF4JServiceProvider provider : serviceLoader) {
        providerList.add(provider);
    }
    return providerList;
}
1
2
3
4
5
6
7
8
9
10
项目里只有 slf4j-api、没有 slf4j-simple/logback/log4j-slf4j-impl
   ↓
ServiceLoader 找不到 SLF4JServiceProvider 实现
   ↓
回退到 NOP(什么都不打印)
   ↓
打印 "No SLF4J providers were found"
1
2
3
4
5
6
7

修复:加上一个后端依赖(如 logback-classic 或 slf4j-simple),它们的 jar 里都有 META-INF/services/org.slf4j.spi.SLF4JServiceProvider。

# 5.2 Dubbo增强SPI

JDK 原生 SPI 有 3 个工程级痛点:

痛点 1:一次性加载所有实现   → 实例化成本高
痛点 2:无法按 key 选择      → 多实现里挑一个要写循环
痛点 3:不支持 IoC / AOP     → 实现类不能注入依赖
1
2
3

Dubbo 的 SPI 机制(@SPI + ExtensionLoader)增强了这些:

// 接口
@SPI("dubbo")                          // ★ 默认实现 key = "dubbo"
public interface Protocol {
    Exporter export(Invoker invoker);
}

// META-INF/dubbo/com.alibaba.dubbo.rpc.Protocol
//   dubbo=com.alibaba.dubbo.rpc.protocol.dubbo.DubboProtocol
//   http=com.alibaba.dubbo.rpc.protocol.http.HttpProtocol
//   grpc=com.alibaba.dubbo.rpc.protocol.grpc.GrpcProtocol

// 使用
Protocol p = ExtensionLoader.getExtensionLoader(Protocol.class)
                            .getExtension("grpc");           // ★ 按 key 选
1
2
3
4
5
6
7
8
9
10
11
12
13
14

Dubbo SPI vs JDK SPI 对比:

特性 JDK SPI Dubbo SPI
配置位置 META-INF/services/接口名 META-INF/dubbo/接口名
配置内容 实现类名(每行一个) key=实现类名(按 key 选)
加载策略 全部加载 按需加载
默认实现 无 @SPI("xxx")
IoC 不支持 支持 setter 注入其他扩展
AOP 不支持 支持 Wrapper 类装饰

结论:Dubbo SPI 是"工业级 SPI"——但代价是 Dubbo 体系内才能用。Spring 的 spring.factories/META-INF/spring/...AutoConfiguration.imports 也是同思路的"增强 SPI"——只是配置位置不同。

# 5.3 自己写一个SPI

3 步从零写一个完整 SPI:

// Step 1:定义接口(放在 spi-api 模块)
package com.acme.spi;
public interface PaymentChannel {
    boolean support(String type);
    void pay(Order order);
}

// Step 2:写一个实现(放在 spi-alipay 模块)
package com.acme.alipay;
import com.acme.spi.PaymentChannel;
public class AlipayChannel implements PaymentChannel {
    public boolean support(String type) { return "alipay".equals(type); }
    public void pay(Order order) { /* ... */ }
}

// Step 3:声明 META-INF/services/com.acme.spi.PaymentChannel
//   com.acme.alipay.AlipayChannel
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

调用方:

public PaymentChannel selectChannel(String type) {
    return ServiceLoader.load(PaymentChannel.class).stream()
        .map(ServiceLoader.Provider::get)
        .filter(c -> c.support(type))
        .findFirst()
        .orElseThrow(() -> new IllegalArgumentException("不支持: " + type));
}
1
2
3
4
5
6
7

新增微信支付——只需新增一个 spi-wxpay.jar,不动调用方一行代码。这是回到第 49 篇的"封闭变化点"哲学。

# 5.4 SPI的常见陷阱

实战 5 大坑:

坑 1:实现类必须有公共无参构造
     → ServiceLoader 用 newInstance() 反射调用
     → 没有就 ServiceConfigurationError

坑 2:META-INF/services 文件名拼错
     → 必须是接口的 全限定名(含包名)
     → 文件名加 .txt 后缀就废了

坑 3:实现类抛异常
     → ServiceLoader.iterator().next() 抛出 ServiceConfigurationError
     → 一个挂掉,后面的可能也加载不到(取决于代码)

坑 4:jar shading 后 META-INF 丢失
     → maven-shade-plugin 必须配 ServicesResourceTransformer
     → 否则多个 jar 里同名 services 文件互相覆盖

坑 5:模块化下不写 module-info.java
     → JPMS 下走兼容性回退路径,可能加载不到
     → 详见第 7 章
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<!-- 修复坑 4:maven-shade 合并 services -->
<plugin>
    <artifactId>maven-shade-plugin</artifactId>
    <configuration>
        <transformers>
            <transformer implementation=
                "org.apache.maven.plugins.shade.resource.ServicesResourceTransformer"/>
        </transformers>
    </configuration>
</plugin>
1
2
3
4
5
6
7
8
9
10

# 6. JPMS模块系统

# 6.1 为什么需要模块

JDK 8 的 rt.jar 有 60MB 一个文件,里面有:

  • sun.misc.Unsafe——本来不该外用,但 Netty / Dubbo / Hadoop 都在用
  • com.sun.xml.internal.*——本来是 JDK 内部 XML 实现,被 JAXB 用户广泛依赖
  • sun.reflect.Reflection——内部反射工具

这些 internal 包没有 public/private 控制——任何 classpath 上的代码都能直接 import。JDK 升级一改 internal 包,几百个开源库立刻挂掉——这就是 JDK 多年不能瘦身的根本原因。

JPMS 要解决的 4 件事:

1. 强封装:包级 export 控制,没 export 就外面看不到
2. 显式依赖:模块必须声明依赖,不能走"transitive 隐式依赖"
3. JDK 自身瘦身:把 rt.jar 拆成 95+ 个 java.* 模块,按需打包
4. 替代 SPI 的 provides/uses 声明,编译期能查出 SPI 错配
1
2
3
4

# 6.2 module-info语法

每个模块根目录有 module-info.java:

module com.acme.order {
    // 声明依赖
    requires java.base;                    // ★ 缺省自动 requires,可省
    requires java.sql;
    requires transitive java.logging;      // ★ 传递依赖:依赖我的也自动获得 java.logging
    requires static lombok;                // ★ 仅编译期需要,运行时可无
    
    // 导出包给外部使用
    exports com.acme.order.api;            // ★ 公开包
    exports com.acme.order.internal to     // ★ 限定导出(白名单)
        com.acme.order.test;
    
    // 反射访问开放
    opens com.acme.order.entity to         // ★ 允许 Hibernate 反射访问
        org.hibernate.orm.core;
    opens com.acme.order.config;           // ★ 对所有模块开放反射
    
    // SPI
    uses com.acme.spi.PaymentChannel;      // ★ 我用这个 SPI
    provides com.acme.spi.PaymentChannel   // ★ 我提供这个 SPI 实现
        with com.acme.order.AlipayChannel;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
关键字           作用                            类比
──────────────  ──────────────────────────────  ──────────────
requires        声明依赖模块                     import jar
exports         公开包给外部 import              public class
opens           允许反射访问                     setAccessible(true) 通行证
uses            声明使用的 SPI                   ServiceLoader.load 占位
provides ... with  声明提供的 SPI 实现           META-INF/services 占位
1
2
3
4
5
6
7

# 6.3 模块路径与类路径

JDK 9+ 后多了一个 --module-path:

# 老写法:classpath
java -cp libs/*:app.jar com.acme.Main

# 新写法:modulepath
java --module-path libs:app.jar --module com.acme.order/com.acme.Main
1
2
3
4
5
维度 classpath(-cp) modulepath(--module-path)
内容 jar 平铺 模块化 jar / 自动模块
强封装 无(所有 public 都能看到) 有(仅 exports 的能看到)
SPI 发现 META-INF/services uses + provides
包冲突 谁先加载用谁 拆分包直接拒绝启动

实战中绝大多数 Spring Boot 应用仍跑在 classpath 模式下——因为生态完全转 modulepath 还差远了(详见 9.4 节)。

# 6.4 强封装与反射访问

回到现象 ⑤——InaccessibleObjectException。

JDK 17 之前,反射可以通过 setAccessible(true) 强制访问任何字段——包括 JDK 内部的 HashMap.table。JDK 9 之后,模块系统加了一层"opens 检查":

反射访问 some.Module.Field 时:
   1. 该 Module 是否 exports 给我了?
   2. 没 exports → 是否 opens 给我了?
   3. 都没有 → 抛 InaccessibleObjectException
1
2
3
4

JDK 16 之前:反射访问未 open 的包只是 warning("illegal reflective access")。JDK 17 之后:直接抛异常,硬性强封装。

修复 3 条路:

# 路 1:启动加 --add-opens 临时打开(最常用)
java --add-opens java.base/java.util=ALL-UNNAMED -jar app.jar

# 路 2:放弃反射,用公开 API
//   错:反射改 HashMap.table
//   对:直接 new HashMap<>(initialCapacity)

# 路 3:自己的代码用 module-info 声明 opens
opens com.acme.entity to org.hibernate.orm.core;
1
2
3
4
5
6
7
8
9

结论:JPMS 的强封装是 Java 长期被 internal API 绑架的一次"硬重置"——短期阵痛、长期收益。第 38 篇 Unsafe 在 JDK 17 之后逐步被 VarHandle 替代,就是这个浪潮的一部分。

# 7. 模块化下的SPI

# 7.1 provides与uses

模块化下 SPI 不再用 META-INF/services,改用 module-info.java:

// 接口模块
module com.acme.spi {
    exports com.acme.spi;
    uses com.acme.spi.PaymentChannel;          // ★ 声明:我会用 ServiceLoader 加载它
}

// 实现模块
module com.acme.alipay {
    requires com.acme.spi;
    provides com.acme.spi.PaymentChannel       // ★ 声明:我提供这个 SPI 的实现
        with com.acme.alipay.AlipayChannel;    
}
1
2
3
4
5
6
7
8
9
10
11
12

编译器现在能查出 SPI 错配:

  • provides ... with X,但 X 不实现该接口 → 编译报错
  • provides X with Y,但 Y 没有公共无参构造 → 编译报错

这是模块化对 SPI 的编译期增强——回到 2.2 节的论证 1。

# 7.2 模块化ServiceLoader

模块化下 ServiceLoader.load 的内部行为变了:

非模块化(classpath):
   1. 找 META-INF/services/接口名 文件
   2. 反射加载实现类

模块化(modulepath):
   1. 检查当前模块的 module-info 中 uses 是否声明
   2. 没声明 → 抛 ServiceConfigurationError
   3. 找所有 modulepath 上 provides 该接口的模块
   4. 反射加载(实现类必须 exports 或 opens 给 java.base)
1
2
3
4
5
6
7
8
9

关键变化:模块化下 uses 声明是必需的——没声明 ServiceLoader 加载不到任何实现。

# 7.3 自动模块与未命名模块

JDK 9 引入两个兼容性概念:

┌──────────────────────────────────────────────────────────┐
│ 命名模块(Named Module)                                  │
│   - 有 module-info.java                                   │
│   - 严格强封装                                             │
├──────────────────────────────────────────────────────────┤
│ 自动模块(Automatic Module)                              │
│   - jar 在 modulepath 上但没有 module-info.java           │
│   - 名字按 jar 文件名推断                                  │
│   - 自动 requires 所有其他模块(向后兼容)                 │
│   - 自动 exports 所有包(向后兼容)                       │
├──────────────────────────────────────────────────────────┤
│ 未命名模块(Unnamed Module)                              │
│   - jar 在 classpath 上                                   │
│   - 全部行为同 JDK 8(无强封装)                           │
└──────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

实战路径:

  1. 老应用全跑在未命名模块(classpath)——零修改
  2. 升级一些库为模块化后,把它们放 modulepath,老库变自动模块
  3. 最终所有库都模块化——全部 modulepath
flowchart LR
    A[全 classpath<br/>未命名模块] -->|逐步升级| B[mixed<br/>命名+自动]
    B -->|彻底模块化| C[全 modulepath<br/>命名模块]
1
2
3

陷阱:自动模块的名字按 jar 文件名推断(去掉版本号),文件名乱起的库被挪到 modulepath 后module 名也乱——遇到 module 名冲突直接启动失败。

# 7.4 拆分包冲突

疑惑:classpath 时代两个 jar 有同包名(如都有 org.junit.Assert),运行时随机加载——JPMS 是怎么处理的?

论证:JPMS 不允许同一个包跨多个模块——叫做"split package"。两个模块 export 同一个包 → JVM 启动直接抛 LayerInstantiationException。

典型踩坑:

javax.annotation 包同时存在于:
  - jakarta.annotation-api.jar
  - javax.annotation-api.jar
  - 部分老版 JDK 内置
1
2
3
4

modulepath 下三选一,多带必崩。这是 JPMS 给生态升级的"硬压力"——逼着大家清理重复依赖。

# 8. JPMS与OSGi对比

# 8.1 OSGi核心机制

OSGi 比 JPMS 早 10+ 年(2000 年首版),核心 3 块:

┌────────────────────────────────────────────────────┐
│ Bundle = jar + MANIFEST.MF(声明 import/export 包) │
│   Bundle-Version: 1.0.0                            │
│   Import-Package: org.slf4j;version="[1.7,2.0)"    │
│   Export-Package: com.acme.api;version="1.0"       │
└────────────────────────────────────────────────────┘
                    ↓
┌────────────────────────────────────────────────────┐
│ Service Registry(运行时服务注册表)                │
│   register / unregister / lookup                   │
└────────────────────────────────────────────────────┘
                    ↓
┌────────────────────────────────────────────────────┐
│ Lifecycle: install → resolve → start → stop → ...  │
│   每个 Bundle 都有完整生命周期,可热插拔             │
└────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

OSGi 最强的两个能力:

  • 同 JVM 多版本共存——版本范围 [1.7,2.0) 描述了能接受的版本区间
  • 热插拔——Bundle 启动 / 停止不需重启 JVM

代价:类加载器变成网状,调试困难,启动慢,错误信息晦涩。

# 8.2 五大维度对比

维度 JPMS(JDK 9+) OSGi
隔离粒度 整个 jar Bundle(即 jar)
版本支持 不支持多版本 支持版本区间 + 多版本共存
生命周期 静态(启动时确定) 动态(install/start/stop/uninstall)
类加载器 树形 + 受控反向 网状(每 Bundle 独立)
学习曲线 平缓(语法简单) 陡峭(需理解 Resolver)
生态成熟度 JDK 自身 + Spring 部分支持 Eclipse 老牌 / Karaf / Felix
调试难度 中(错误信息清晰) 高(CL 网状难追踪)
强封装 包级强封装 Bundle 级强封装

# 8.3 何时该用谁

flowchart TD
    A[要不要模块化?] --> B{需要热插拔吗?}
    B -->|要| C[OSGi]
    B -->|不要| D{需要多版本共存吗?}
    D -->|要| E[OSGi]
    D -->|不要| F{是 JDK 自身或基础库吗?}
    F -->|是| G[JPMS]
    F -->|否| H{业务应用?}
    H -->|是| I[继续用 classpath + Spring<br/>SPI 已够用]
1
2
3
4
5
6
7
8
9

实战结论:

  • 你写 Spring Boot 业务应用——继续 classpath + ServiceLoader + spring.factories,不要碰 JPMS 也不要 OSGi
  • 你写 JDK / 基础库 / 框架核心——JPMS(你不想被 internal 包绑架)
  • 你写 IDE 插件 / 工业控制系统 / 电信级中间件——OSGi(你需要热插拔 + 多版本)

# 9. 模块化迁移与陷阱

# 9.1 三阶段迁移策略

业务工程从 JDK 8 迁移到 JDK 17 + 部分模块化的推荐路径:

阶段 1(JDK 17 兼容):保留 classpath,修反射访问错误
   ├── 升级到 JDK 17
   ├── 跑起来,看 InaccessibleObjectException
   ├── --add-opens 临时打开(先跑起来再说)
   └── 把反射改 HashMap 这种烂代码逐步替换成公开 API

阶段 2(局部模块化):基础库率先模块化
   ├── 给自己的 SDK / 工具包加 module-info.java
   ├── 应用层仍跑 classpath,依赖时自动模块兜底
   └── 验证跨团队的模块名和 export 范围

阶段 3(应用模块化):(绝大多数应用永远停在阶段 2)
   ├── 全 modulepath
   ├── jlink 定制 JRE
   └── 适合超大型独立交付场景
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

# 9.2 jdeps与jlink

JDK 自带的两个模块化神器:

jdeps——分析依赖:

jdeps --module-path libs --check com.acme.order
# 输出:哪些 internal API 被使用了?哪些依赖未声明?
1
2

jlink——定制运行时:

jlink --module-path $JAVA_HOME/jmods:libs \
      --add-modules com.acme.order \
      --output customjre

# 结果:customjre 只包含用到的 JDK 模块(可能只 50MB 起步)
1
2
3
4
5

回到现象 ④——480MB 的镜像怎么瘦身?

# 阶段 1:用 JDK 镜像构建并 jlink 定制 JRE
FROM openjdk:17-jdk AS builder
COPY . /src
RUN jlink --module-path $JAVA_HOME/jmods --add-modules java.base,java.sql,java.logging \
          --strip-debug --no-man-pages --no-header-files --compress=2 \
          --output /customjre

# 阶段 2:基于 alpine 运行,只带 customjre
FROM alpine:3.18
COPY --from=builder /customjre /jre
COPY target/order-service.jar /app/
ENTRYPOINT ["/jre/bin/java", "-jar", "/app/order-service.jar"]
1
2
3
4
5
6
7
8
9
10
11
12

镜像体积可从 480MB 降到 80~120MB。

# 9.3 反射访问破封装

--add-opens 是迁移期最常用的逃生口,但生产慎用:

# 例 1:让 ByteBuddy 能反射访问 java.lang
--add-opens java.base/java.lang=ALL-UNNAMED

# 例 2:让 Spring 能反射访问 java.util
--add-opens java.base/java.util=ALL-UNNAMED

# 例 3:让 JNI 框架能访问 java.nio
--add-opens java.base/java.nio=ALL-UNNAMED
1
2
3
4
5
6
7
8

ALL-UNNAMED 表示对所有类路径上的代码开放——等于关闭了强封装。生产推荐改用 --add-opens java.base/java.util=specific.module 限定开放给具体模块。

# 9.4 Spring Boot生态现状

截至 2025+,Spring Boot 生态对 JPMS 的态度是**"运行时支持,开发期不强制"**:

  • Spring Framework 6 的 jar 是自动模块——没有 module-info.java,但能在 modulepath 跑
  • 大多数 Spring Boot 应用仍跑在 classpath
  • spring.factories / META-INF/spring/...AutoConfiguration.imports 都不是 JDK SPI——是 Spring 自己的 SPI 实现(基于 SpringFactoriesLoader)
// SpringFactoriesLoader 简化源码(spring-core)
public static <T> List<T> loadFactories(Class<T> factoryType, ClassLoader cl) {
    // 找所有 jar 中的 META-INF/spring.factories
    Enumeration<URL> urls = cl.getResources("META-INF/spring.factories");
    // 解析 properties 格式 (factoryType=impl1,impl2,...)
    // 反射 newInstance
}
1
2
3
4
5
6
7

为什么 Spring 不直接用 JDK SPI?

  1. JDK SPI 一个文件一个接口太碎——Spring 上百个扩展点要上百个文件
  2. JDK SPI 不支持注入依赖——Spring 必须 IoC
  3. 需要更早执行(在 ApplicationContext 启动前)——SPI 的懒加载在这里反而是缺点

# 10. 综合案例串讲与全册收官

# 10.1 案例真相揭晓

回到第 1 章 5 个现象,全部能解释:

# 现象 答案
① SLF4J: No SLF4J providers were found SLF4J 2.x 用 ServiceLoader.load(SLF4JServiceProvider.class) 找日志后端。classpath 没有 logback/slf4j-simple 等实现 → 找不到 → 回退 NOP。加一个后端依赖即可。见 5.1
② Class.forName 可以省略 JDK 6 开始 DriverManager 用 ServiceLoader.load(Driver.class) 自动发现驱动。MySQL 驱动 jar 中的 META-INF/services/java.sql.Driver 让 ServiceLoader 找到,静态块 registerDriver 反向注册。见 4.2
③ ToolProvider.getSystemJavaCompiler() 时灵时不灵 它也是 SPI,需要 tools.jar/java.compiler 模块在场。线上跑的是 JRE 而非 JDK 时(旧版 JRE)就拿不到。JDK 17 之后 JRE/JDK 已合并不再有这个问题。见 3 章
④ 镜像 480MB 整个 JDK 自身瘦身要靠 JPMS。用 jlink 定制只含必需模块的 customjre,镜像可降到 80~120MB。见 9.2
⑤ InaccessibleObjectException JPMS 强封装。JDK 17 之后未 opens 的包不允许反射访问。修复:换 API 或启动加 --add-opens。见 6.4 + 9.3

7 个核心问题对照表:

# 问题 答案章节
① SPI 怎么加载? 第 3 章
② 为什么必须破双亲委派? 4.1 + 4.2
③ 4 种破局方式代价? 4.3 (TCCL) / 4.4 (Tomcat) / 4.5 (OSGi)
④ 工程实战 SPI 写法? 5.3
⑤ JPMS 解决什么? 6.1 + 6.4
⑥ 模块化下 SPI? 第 7 章
⑦ 跟 OSGi 差别? 第 8 章

# 10.2 启动一行代码的旅程

把本篇所有概念串起来——SpringApplication.run 这一行背后到底发生了什么:

flowchart TD
    A[SpringApplication.run] --> B[1.加载 BootstrapClassLoader<br/>java.base 模块 strict 强封装]
    B --> C[2.PlatformClassLoader<br/>加载 java.sql 等]
    C --> D[3.AppClassLoader<br/>扫描 classpath]
    
    D --> E[4.SLF4J LoggerFactory.getLogger<br/>★ ServiceLoader.load SLF4JServiceProvider]
    E --> F[找到 LogbackServiceProvider]
    F --> G[5.DriverManager 静态块<br/>★ ServiceLoader.load java.sql.Driver]
    G --> H[找到 MySQL Driver<br/>静态块 registerDriver]
    
    D --> I[6.Spring SpringFactoriesLoader<br/>★ 自定义 SPI 走 META-INF/spring.factories]
    I --> J[加载所有 AutoConfiguration]
    
    D --> K[7.Tomcat WebApp ClassLoader<br/>★ 反向委派优先 / 应用类隔离]
    K --> L[Servlet Filter 责任链<br/>第 50 篇]
    
    M[8.JPMS module 检查] --> N[反射访问<br/>未 open 抛 InaccessibleObjectException]
    
    A -.-> M
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

一行 run 触发:

  • 3 层 ClassLoader 协作(第 02 篇)
  • 3 次 ServiceLoader 调用(SLF4J / JDBC / 部分组件)
  • 1 次 Spring 自定义 SPI(SpringFactoriesLoader)
  • 1 次 Tomcat 反向委派(每个 War 独立加载)
  • N 次 JPMS 强封装检查(反射访问 java.* 包)

这就是 51 篇专栏的最终图景——从字节码、类加载、容器、并发、IO、设计模式,所有的知识点最终都汇聚到"应用启动那一刻"。

# 10.3 全册七卷回望

至此,《Java 核心原理深度专栏 51 篇》正式收官——让我们最后回望一次:

flowchart TB
    A[卷一 JVM 与运行时核心 10 篇<br/>01/02/03/12/13/14/15/16/17/18] --> B[把字节码跑起来]
    C[卷二 容器与基础数据结构 8 篇<br/>04/05/19/20/21/22/23/24] --> D[把每根骨头摸清]
    E[卷三 类型系统与语言机制 7 篇<br/>06/25/26/27/28/29/30] --> F[把语法糖还原]
    G[卷四 反射与字节码增强 5 篇<br/>07/31/32/33/34] --> H[把动态行为打通]
    I[卷五 并发编程深水区 10 篇<br/>08/09/10/35/36/37/38/39/40/41] --> J[从锁到无锁串起]
    K[卷六 IO 网络序列化 4 篇<br/>11/42/44/47] --> L[数据怎么进出 JVM]
    M[卷七 设计思想与设计模式 4 篇<br/>48/49/50/51] --> N[为什么 Java 这么写]
    
    B & D & F & H & J & L & N --> O[一个 Java 工程师的完整心智地图]
1
2
3
4
5
6
7
8
9
10

七卷主线:

卷 篇数 核心问题 关键篇目
卷一 JVM 10 字节码 → 运行 13 字节码 / 14 JIT / 03 GC
卷二 容器 8 集合的每根骨头 04 HashMap / 20 ConcurrentHashMap
卷三 类型 7 语法糖背后 06 泛型擦除 / 27 Lambda / 28 Stream
卷四 字节码 5 动态修改运行时 07 反射 / 32 ASM / 33 Agent
卷五 并发 10 从锁到无锁 36 AQS / 38 CAS / 40 CompletableFuture
卷六 IO 4 数据进出 JVM 11 NIO / 42 ByteBuffer / 47 NIO.2
卷七 设计 4 为什么这么写 48 OO / 49 创建结构 / 50 行为 / 51 SPI

# 10.4 设计哲学速查

整本专栏背后的 10 条贯穿哲学:

1. 时间换空间,空间换时间——HashMap 负载因子 0.75 / TreeMap 红黑树高度
2. 分而治之——HashMap 分桶 / ForkJoinPool 工作窃取 / Spliterator 切片
3. 延迟绑定——多态 / SPI / Lambda invokedynamic / 类初始化
4. 不变量保护——封装 / final / Record / volatile
5. 模板与策略分离——AQS 骨架 + 钩子 / Comparator 组合子
6. 协议大于实现——SPI / JPMS uses-provides / Comparable.compareTo
7. 控制反转——SPI / 框架方反向调用 / Spring DI
8. 显式优于隐式——module-info / @NonNull / Optional / 泛型边界
9. 失败要快——fail-fast 迭代器 / OOM 第一现场 / Pattern 提前编译
10. 组合优于继承——Comparator / 装饰器 / 责任链 / Lambda
1
2
3
4
5
6
7
8
9
10

Java 之所以是 Java——不在于它的某一项语法,而在于这 10 条哲学被无数 JDK 设计者在 30 年里反复打磨、贯彻、坚持。


# 全册结语

我们走过的路:
   从 01 篇"JVM 内存模型与对象"
   到 51 篇"SPI 与模块化"
   
   51 篇 / 70+ 万字 / 数千张图与代码
   
   穿过 7 卷:
      JVM → 容器 → 类型 → 字节码 → 并发 → IO → 设计
   
   抵达同一个目标:
      看清 Java 的每一根骨头
      理解每一次设计权衡
      建立一张完整的心智地图
1
2
3
4
5
6
7
8
9
10
11
12
13

愿你合上这本书时,不是多记了 51 个知识点——而是多了 51 个"哦原来如此"的瞬间,和一种遇到任何陌生 Java 代码都能从原理层快速定位的眼力。

下一步走哪?

  • 想纵深 → 读 OpenJDK 源码(Hotspot src 目录)
  • 想横拓 → 读 Netty / Spring / Kafka 源码
  • 想现代化 → 关注 Loom(虚拟线程,第 41 篇)/ Valhalla(值类型)/ ZGC 演进
道阻且长,行则将至。
51 篇是终点也是起点。
1
2

—— 完 ——

上次更新: 2026/06/10, 11:13:41
JDK设计模式下
README

← JDK设计模式下 README→

最近更新
01
信号崩溃快速排查
06-15
02
CoreDump破案
06-15
03
perf火焰图实战
06-15
更多文章>
Theme by Vdoing | Copyright © 2019-2026 杨充 | MIT License | 桂ICP备2024034950号 | 桂公网安备45142202000030
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式