模块化项目为什么读不到服务实现:uses 与 provides 排查
Java 模块化项目里 ServiceLoader 读不到服务实现,最常见的原因集中在四处:消费模块没有声明 uses,实现模块没有声明 provides ... with ...,提供者类型不满足公开构造规则,或者实现模块根本没有进入 module path 与模块解析图。先把这四处对齐,再看业务代码,通常比反复改 ServiceLoader.load() 更快。
Java SE 25 ServiceLoader 官方文档:https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/util/ServiceLoader.html
在命名模块中,服务消费和服务提供都是模块契约的一部分:消费方用uses声明要发现什么,提供方用provides ... with ...声明由哪个类型提供。应用模块通常不应该直接requires实现模块。
我第一次遇到的现象:代码没报错,服务列表却是空的
我会用一个“文本格式化器”来复现这类问题。项目被拆成三个命名模块:com.example.api 放接口,com.example.impl 放 Markdown 实现,com.example.app 负责加载并调用服务。拆分之前直接 new MarkdownFormatter() 很直观,改成 ServiceLoader 后,最让人困惑的就是实现类明明已经编译,迭代器里却没有元素。
这里有一个容易带偏排查方向的直觉:既然应用要使用实现,是不是应该在应用模块里写 requires com.example.impl?这样虽然可能让实现模块进入解析图,却破坏了服务机制想要的解耦。正确关系是应用依赖 API,并声明自己消费服务;实现模块依赖 API,并声明自己提供服务。模块系统通过服务绑定找到提供者。
最小配方:三个模块各自只做一件事

先建立 API 模块。接口必须能被消费模块和实现模块访问,因此 API 模块需要导出接口所在包:
// com.example.api/module-info.java:只导出服务接口所在包
module com.example.api {
exports com.example.api;
}
package com.example.api;
// 服务类型保持小而稳定,调用方只依赖这个契约
public interface TextFormatter {
String format(String text);
}
消费模块只读取 API 模块,并通过 uses 表明它会用 ServiceLoader 查找这个服务:
// com.example.app/module-info.java:声明读取 API 和消费服务
module com.example.app {
requires com.example.api;
uses com.example.api.TextFormatter;
}
实现模块同样读取 API,但不需要把实现包导出给应用。它通过 provides 把服务类型与提供者类型配对:
// com.example.impl/module-info.java:把接口和具体提供者绑定
module com.example.impl {
requires com.example.api;
provides com.example.api.TextFormatter
with com.example.impl.MarkdownFormatter;
}
package com.example.impl;
import com.example.api.TextFormatter;
// 提供者是 public,并具有隐式 public 无参构造方法
public final class MarkdownFormatter implements TextFormatter {
@Override
public String format(String text) {
// 示例只演示服务发现,格式化逻辑保持简单
return "**" + text + "**";
}
}
这个配置里没有 exports com.example.impl。这不是遗漏:ServiceLoader 并不要求实现包对消费模块导出。减少实现包的外部可见性,正是模块服务带来的一个直接好处。
关键调用:加载动作必须发生在声明 uses 的模块中
应用的入口类可以只引用服务接口。为了让“没有提供者”不再静默通过,我更喜欢先把结果收集起来,再给出明确异常:
package com.example.app;
import com.example.api.TextFormatter;
import java.util.List;
import java.util.ServiceLoader;
public final class Main {
public static void main(String[] args) {
// 加载调用位于声明 uses 的 com.example.app 模块中
List providers = ServiceLoader
.load(TextFormatter.class)
.stream()
.map(ServiceLoader.Provider::get)
.toList();
// 空列表通常说明提供声明或模块解析范围仍有问题
if (providers.isEmpty()) {
throw new IllegalStateException("没有发现 TextFormatter 服务实现");
}
// 示例逐个调用所有已发现的实现,便于观察多提供者情况
for (TextFormatter formatter : providers) {
System.out.println(formatter.format("module service"));
}
}
}
Java 语言规范把 uses 定义为“当前模块消费某个服务”的声明,把 provides 定义为“当前模块为某个服务提供哪些提供者”的声明。它们不是注释,也不是给构建工具看的可选元数据,而是命名模块服务发现的一部分。
完整片段:编译和启动时都要使用 module path
源码目录以模块名为第一层目录时,可以用下面的方式编译三个模块并启动应用:
# 一次编译 API、实现与应用三个命名模块 javac -d out --module-source-path src \ -m com.example.api,com.example.impl,com.example.app # 从 module path 启动应用模块,不要把实现 JAR 只放到 class path java --module-path out \ -m com.example.app/com.example.app.Main
预期结果是应用至少发现一个 TextFormatter 并输出格式化文本。这里没有把命令输出包装成真实运行证据;落地到自己的项目时,应把 out 换成实际模块产物目录,并确认实现模块的 JAR 或 exploded module 确实位于 module path。
如果编译成功但运行时返回空列表,我首先比较启动命令,而不是继续改接口。IDE 里也要区分 module path 和 class path:一个包含 module-info.class 的实现 JAR 如果被放错位置,运行期看到的模块图可能和编译期并不相同。
我会按四个位置排查,而不是反复改 ServiceLoader

| 检查位置 | 正确写法 | 常见现象 | 修复重点 |
|---|---|---|---|
| 消费模块 | uses 完整服务类型名 | 调用处出现服务配置错误,或服务契约不完整 | 把 uses 放在真正调用 ServiceLoader 的命名模块 |
| 实现模块 | provides 服务 with 提供者 | 服务列表为空 | 确认服务类型与 uses 中完全一致 |
| 提供者类型 | public 顶级/静态类型,满足构造或 provider 方法规则 | 迭代时抛 ServiceConfigurationError | 检查公开无参构造或公开静态 provider 方法 |
| 模块路径 | 实现模块可观测并进入解析图 | 编译正常,运行时找不到实现 | 检查 module path、模块名与启动根模块 |
排查 uses 时,我会看“谁调用了 ServiceLoader.load”,而不是看接口定义在哪个模块。接口可以定义在 API 模块,真正的消费声明应该在执行加载的模块里。若加载被封装在另一个库模块中,uses 也要跟着加载动作移动。
排查 provides 时,我会逐字比较服务类型的完全限定名。提供者必须声明在当前实现模块中。若提供者使用普通构造方式,它需要是服务类型的子类型,并提供公开无参构造;另一种合法变体是提供公开静态无参 provider() 方法,由该方法返回服务类型的子类型。
两个容易混淆的变体
同一个服务允许多个实现
provides 的 with 子句可以列出多个提供者,不同实现模块也可以分别提供同一服务。ServiceLoader 返回的是可迭代的提供者集合,不承诺业务优先级。需要“默认实现”时,不要依赖偶然顺序,应该在服务契约中增加明确元数据,再由应用按规则选择。
// 一个实现模块可以为同一服务声明多个提供者
module com.example.impl {
requires com.example.api;
provides com.example.api.TextFormatter with
com.example.impl.MarkdownFormatter,
com.example.impl.PlainTextFormatter;
}
模块化 JAR 与传统 META-INF/services 不要混着想
传统 class path SPI 使用 META-INF/services/服务完全限定名 文件登记实现;显式命名模块则把服务关系写进 module-info.java。自动模块可以暴露传统服务配置,但当项目已经使用显式模块描述符时,我会优先统一到 uses 与 provides,避免一部分配置在描述符、一部分配置藏在资源文件中。
兼容坑:能编译不代表运行时模块图相同
这类问题最明显的代价是排查范围跨越源码、构建和启动三层。接口和实现代码都可能完全正确,但打包插件漏掉 module-info.class、实现 JAR 被放到 class path、运行脚本使用了不同模块目录,都会让运行时服务发现与开发环境不一致。
我会保留一条最小化启动命令作为部署检查项,并在需要时添加 --show-module-resolution 观察模块解析信息。它适合确认实现模块是否进入解析图,但不应把整段控制台输出做成文章配图;记录模块名和服务绑定结论就够了。
最后的验收清单
- API 模块导出了服务接口所在包,消费与实现模块都能读取它。
- 调用
ServiceLoader.load的模块声明了uses。 - 实现模块声明了
provides 服务 with 提供者,类型名完全一致。 - 提供者是公开顶级或静态类型,并满足构造方法或
provider()方法约束。 - 实现模块产物位于运行时 module path,模块名与启动配置一致。
- 应用不直接依赖具体实现,业务也不依赖提供者迭代顺序。
对我来说,这套排查顺序最大的收获不是“记住两行 module-info”,而是把服务发现看成一份跨模块契约:uses 说明需求,provides 说明供给,module path 决定供给是否可观测,提供者规则决定实例能否创建。四件事分开检查,空列表和配置错误就不再是一团模糊的反射问题。
相关问题
实现包必须 exports 吗?
通常不需要。服务提供者可以位于未导出的实现包中,消费方只通过服务接口使用它,这正好缩小模块公开 API。
应用模块要 requires 实现模块吗?
服务解耦场景通常不应该这样做。应用读取 API 并声明 uses,实现模块声明 provides;只要实现模块可观测,服务绑定会把合适的提供者纳入模块图。
为什么实现类有带参数构造后就加载失败?
普通提供者构造方式要求公开无参构造。若创建过程必须经过工厂逻辑,可以改用规范允许的公开静态无参 provider() 方法,并让它返回服务类型的实现。
PHP 文件上传如何同时限制扩展名、MIME 与文件大小
- 上一篇
- PHP 文件上传如何同时限制扩展名、MIME 与文件大小
- 下一篇
- html/template 与 text/template 的转义边界有什么不同
-
- 文章 · java教程 | 3小时前 | Java · 取消 · CompletableFuture · 重试 ·
- Java HTTP Client 实现带取消与重试的异步请求
- 212浏览 收藏
-
- 文章 · java教程 | 1天前 | Java · JVM · JVM Native Memory Tracking NMT jcmd 原生内存 JVM内存排查
- JVM 原生内存上涨但堆稳定,怎样用 NMT 分类定位
- 414浏览 收藏
-
- 文章 · java教程 | 1天前 | Java · 性能优化 · Stream · Java教程 · Java Stream Spliterator 并行流 parallelStream Stream副作用
- Stream 并行化前先判断什么:数据规模、拆分与副作用
- 405浏览 收藏
-
- 文章 · java教程 | 1天前 | Java教程 · sealed interface Java密封类 模式匹配switch 支付结果 穷尽检查
- 密封类建模支付结果:穷尽分支与扩展边界
- 270浏览 收藏
-
- 文章 · java教程 | 1天前 | 数据校验 · api设计 · Java教程 · 参数校验 Java record API DTO Jakarta Validation 紧凑规范构造器 跨字段校验
- Java Record 作为 API DTO 时,校验逻辑放在哪里
- 370浏览 收藏
-
- 文章 · java教程 | 1天前 | 线程池 · 异常处理 · 并发编程 · Java教程 · CompletableFuture · 异步任务 completablefuture allOf Handle 结果汇总 CompletionException
- CompletableFuture 组合独立任务:allOf 结果汇总与失败归属
- 482浏览 收藏
-
- 文章 · java教程 | 1天前 |
- StructuredTaskScope 如何表达并发任务的共同生命周期
- 425浏览 收藏
-
- 文章 · java教程 | 1天前 | 并发编程 · Java教程 · java arena MemorySegment WrongThreadException FFM API
- Java MemorySegment 怎么限制跨线程访问范围
- 132浏览 收藏
-
- 文章 · java教程 | 1天前 | Java · Java 24 Java Class-File API CodeTransform ClassTransform CodeAttribute
- Java Class-File API 怎么转换方法代码属性
- 199浏览 收藏
-
- 前端进阶之JavaScript设计模式
- 设计模式是开发人员在软件开发过程中面临一般问题时的解决方案,代表了最佳的实践。本课程的主打内容包括JS常见设计模式以及具体应用场景,打造一站式知识长龙服务,适合有JS基础的同学学习。
- 543次学习
-
- GO语言核心编程课程
- 本课程采用真实案例,全面具体可落地,从理论到实践,一步一步将GO核心编程技术、编程思想、底层实现融会贯通,使学习者贴近时代脉搏,做IT互联网时代的弄潮儿。
- 516次学习
-
- 简单聊聊mysql8与网络通信
- 如有问题加微信:Le-studyg;在课程中,我们将首先介绍MySQL8的新特性,包括性能优化、安全增强、新数据类型等,帮助学生快速熟悉MySQL8的最新功能。接着,我们将深入解析MySQL的网络通信机制,包括协议、连接管理、数据传输等,让
- 500次学习
-
- JavaScript正则表达式基础与实战
- 在任何一门编程语言中,正则表达式,都是一项重要的知识,它提供了高效的字符串匹配与捕获机制,可以极大的简化程序设计。
- 487次学习
-
- 从零制作响应式网站—Grid布局
- 本系列教程将展示从零制作一个假想的网络科技公司官网,分为导航,轮播,关于我们,成功案例,服务流程,团队介绍,数据部分,公司动态,底部信息等内容区块。网站整体采用CSSGrid布局,支持响应式,有流畅过渡和展现动画。
- 485次学习
-
- PubMedQA
- 深入了解PubMedQA生物医学问答数据集,涵盖其核心功能、使用方法及在临床决策、药物研发等场景的应用,助力提升NLP模型性能。
- 378次使用
-
- H2O EvalGPT
- H2O EvalGPT是H2O.ai推出的开源LLM评估平台,提供详细的大模型性能排行榜、行业特定基准测试及A/B测试功能,助您快速选择最适合项目的高性能大语言模型。
- 448次使用
-
- LMArena
- LMArena是加州大学伯克利分校推出的AI模型匿名评测平台。通过盲测投票机制,用户可对比不同大模型回答并生成实时排行榜,助力开发者优化模型及用户选择最佳AI工具。
- 457次使用
-
- HELM
- 深入了解斯坦福推出的HELM(Holistic Evaluation of Language Models)大模型评测体系。本文解析其核心功能、安装配置步骤及应用场景,涵盖准确性、公平性、鲁棒性等多维度指标,助力开发者全面优化语言模型性能。
- 400次使用
-
- MMBench
- MMBench是由上海人工智能实验室等机构联合推出的多模态基准测试平台,提供细粒度能力评估、大规模数据集及VLMEvalKit工具。本文详细介绍其核心功能、安装使用方法及应用场景,助力开发者全面评估多模态模型性能。
- 227次使用
-
- 用 go.work 同时开发两个模块并保持各自发布独立
- 2026-10-07 201浏览
-
- Spring Boot 开虚拟线程后吞吐没上去?先查这 5 个生产坑
- 2026-06-02 239浏览
-
- JFR 排查 Spring Boot 慢接口:别急着加缓存,先抓一段 Flight Recording
- 2026-06-02 126浏览
-
- CompletableFuture 异步接口卡死复盘:别让 commonPool 背锅到凌晨
- 2026-06-02 191浏览
-
- MyBatis N+1 查询实战:列表接口 1 秒变 8 秒,别只怪数据库
- 2026-06-02 116浏览

