密封类跨模块循环依赖问题:Java 25 下的解决方案
Java 25 的密封类(sealed class)无疑是提升代码可控性的利器,能让类层次结构更清晰、更易管理。但一旦涉及跨模块场景,问题就会变得复杂——当父类声明在模块 A,而它的 permits 子类却散落在模块 B、C 里时,稍有不慎,循环模块依赖这个老问题就会卷土重来。
举一个具体场景:模块 A 定义了一个 sealed interface Shape permits Circle,而 Circle 类却在模块 B 中。偏偏模块 B 又需要依赖模块 A(比如为了引用 Shape),于是 A → B → A 的闭环就形成了。编译期直接抛出“模块读取互斥”的报错。

循环依赖的常见表现
在编译或模块解析阶段,错误信息通常非常直白:
module X reads module Y, and module Y reads module X——典型的循环读取提示class Circle is not visible: module B does not export package com.example.shape.impl to module A——可见性被模块边界阻塞permits list contains type from module not declared in requires——permits 列表中的类型来自一个未在 requires 里声明的模块
这些报错本质上指向同一个根源:密封父类与它的实现子类之间,形成了跨模块的双向依赖关系。
解耦思路:分离许可声明与实现归属
核心策略不是让模块 B 也 require 模块 A——那样只会让问题更复杂。关键是避免密封父类直接引用子类所在的模块。这里整理几种主流的落地方式。
第一招:将密封接口/类提升至公共基础模块
实战中最推荐的做法。可以新建一个最小化的 api 模块——比如 shape-api,只存放 sealed interface Shape 及其 permits 列表(允许留空或用占位符)。各个实现模块(如 shape-circle、shape-rect)只需 requires 这个 API 模块,不再反向依赖。模块间的依赖链变成单向的,循环自然消失。
第二招:用 opens 替代 exports 实现反射友好的开放
如果模块结构实在无法调整,可以在父类所在模块的 module-info.java 中这样写:
opens com.example.shape to com.example.circle;
注意这里使用的是 opens 而非 exports。这个区别很关键:opens 不会参与编译期的模块图拓扑检查,但运行时 JVM 加载时对子类的可见性要求依然能满足。相当于在保留原结构的前提下,绕开了编译阶段对模块循环的严格检查。
第三招:延迟许可绑定——服务加载器 + 密封骨架
这是最灵活但也最“软”的方式。把 permits 列表留空或设为一个占位类型(比如 permits PlaceholderImpl)。实际子类通过 ServiceLoader.load(Shape.class) 在运行时动态注册。然后调用 Class.isSealedExhaustive() 验证所有合法实现是否都已加载。这个方案的代价是失去了编译期的穷尽性检查,但换来了模块间的彻底解耦。
模块声明示例:以方案一为例
假设我们把 Shape 接口放在 shape-api 模块中:
模块 shape-api 的 module-info.java:
module shape.api {
exports com.example.shape;
}
模块 shape-circle 的 module-info.java:
module shape.circle {
requires shape.api;
provides com.example.shape.Shape with com.example.circle.Circle;
}
此时 Shape 接口的声明是:
public sealed interface Shape permits Circle, Rectangle, Triangle { }
注意,Circle 类虽然在 shape-circle 模块中,但 shape-api 不 require 任何实现模块,环就是这样被打破的。
注意事项与常见陷阱
permits列表里写的类名,必须是已编译可解析的类型。不能使用尚未编译的源码类。IDE 或构建工具(比如 Maven)有时会因为编译顺序问题误报循环依赖。建议采用多模块聚合项目统一编译,可以避免很多无意义的问题。- 尽量不要在
permits中混用不同模块的类——除非所有涉及模块都明确requires密封类所在模块,并且该模块已经exports了对应包。 - Java 25 新增的
Class.getSealedHierarchy()方法,可以在启动时扫描验证所有许可子类是否都正常加载。可以借此实现一个模块健康检查的小工具,非常实用。
