设计模式——抽象工厂:产品族的一致性
抽象工厂常被描述成「创建一族对象」的接口,可是创建不值钱:
new、Supplier、Map查表、switch都能做。
值钱的那部分在编译期:一个族少实现一个产品,javac 直接拒绝编译;族与产品的对应关系写进接口,不用靠人记。
本文用 Button/Checkbox 两套主题、三种写法,量 100 万次创建的耗时与类加载数量,并贴出 javac 与运行期的输出。
一、产品族的一致性是谁的责任
两套主题,两个产品。每个主题要同时提供 Button 和 Checkbox,同一个界面里的两个控件必须来自同一套主题。
classDiagram
direction TB
class GuiFactory {
<<interface>>
+button() Button
+checkbox() Checkbox
}
class MacFactory
class WinFactory
class Button {
<<interface>>
}
class Checkbox {
<<interface>>
}
class MacButton
class MacCheckbox
class WinButton
class WinCheckbox
GuiFactory <|.. MacFactory
GuiFactory <|.. WinFactory
Button <|.. MacButton
Button <|.. WinButton
Checkbox <|.. MacCheckbox
Checkbox <|.. WinCheckbox
MacFactory ..> MacButton
MacFactory ..> MacCheckbox
WinFactory ..> WinButton
WinFactory ..> WinCheckbox
代码里的「族」就是接口上的方法集合。GuiFactory 每多一个方法,所有实现类的义务同时增加一份。这是抽象工厂唯一的结构性特征:把「必须成套」写成「必须全部实现」。
三个失效点
一致性会在三个地方漏掉,各自需要的保护不同。
族缺产品。WinFactory 实现了 button(),checkbox() 没填。代码能跑,直到有人切到 Win 主题并创建一个复选框。
新增族没补全分支。产品没变,主题从两套变成三套。所有与主题有关的判断、映射、分支都要补一份。缺陷出现在新增的那一刻,测试却只覆盖老主题。
跨族混用。界面上出现一个 Mac 按钮和一个 Windows 复选框。三个产品各自合法,组合起来丑陋。这一条在 Java 的类型系统里默认挡不住,Button 和 Checkbox 是两个独立类型,把不同族的实例放在一起,编译器不报错。要让类型系统挡住它,产品的类型必须携带族参数(Button<MacTheme>),代价是所有签名都变长。JDK 自己的选择是放弃这一层保护,java.sql 里的 Statement 和 Connection 之间就没有这种类型约束(第六节)。
二、实验设计与局限
三种写法,外加一个不抽象的基线:
1 | /* A:抽象工厂接口 + 两个实现类 */ |
三种写法的调用点形状:
| 写法 | 调用点 | 每次调用做的事 |
|---|---|---|
| direct(基线) | new MacButton() |
一次 new |
| A 抽象工厂 | f.button() |
一次 invokeinterface + 一次 new |
| B sealed+switch | button(theme) |
一次 invokestatic + invokedynamic 类型分派 + 一次 new |
| C 查表 | reg.button("MAC") |
一次 Map.get + 一次 Supplier.get + 两次 checkcast + 一次 new |
测量方法
- 机器:Apple M1 Pro,macOS Darwin 25.6.0
- JDK:
21.0.8+12-LTS-250与25+37-LTS-3491,都是 arm64 - 每轮创建 100 万个 Button 加 100 万个 Checkbox,交替两个族;每种写法单起一个 JVM,3 轮预热丢弃,7 轮计时取中位数;整套跑两遍,第二遍顺序反转
- 两种对象生存期分开测:
esc把每个对象写进静态数组,sum跳过这次写入。循环代码相同,对象数量与分配字节数相同,差的是一次数组存储和它的写屏障 - 计时期间持全局锁
/tmp/pattern-bench.lock,同一时刻本机只有一个基准在跑 - 类加载与分配字节数是计数类指标,不受 CPU 争用影响,不持锁
- JVM 参数:
-Xms512m -Xmx512m -Xmn384m,新增区放大到 384 MB,压掉年轻代回收在计时轮里的抖动
本机没有 JMH。这是单机单线程微基准,结论只取量级,不取小数点后两位。
三、实测一:创建耗时
单位是「每次迭代」的纳秒数。一次迭代创建一个 Button 加一个 Checkbox,两个对象。每种写法单起一个 JVM,7 轮计时各取中位数;整套跑两遍,第二遍顺序反转,用来观察机器状态漂移。
对象逃逸(esc,把对象写进静态数组):
| 写法 | JDK 21 第 1 遍 | JDK 21 第 2 遍 | JDK 25 第 1 遍 | JDK 25 第 2 遍 | 相对 direct |
|---|---|---|---|---|---|
| direct(基线) | 9.31 | 9.68 | 6.72 | 7.20 | — |
| A 抽象工厂 | 9.23 | 9.31 | 7.85 | 8.88 | −4% ~ +24% |
| B sealed+switch | 10.36 | 9.74 | 8.42 | 8.14 | +1% ~ +25% |
| C 查表 | 11.51 | 11.67 | 11.02 | 11.26 | +21% ~ +64% |
不做数组写入(sum,同一份循环代码,两个对象照常创建):
| 写法 | JDK 21 第 1 遍 | JDK 21 第 2 遍 | JDK 25 第 1 遍 | JDK 25 第 2 遍 | 相对 direct |
|---|---|---|---|---|---|
| direct(基线) | 4.89 | 4.73 | 4.64 | 4.94 | — |
| A 抽象工厂 | 6.49 | 6.01 | 6.13 | 5.92 | +20% ~ +33% |
| B sealed+switch | 5.03 | 5.25 | 5.22 | 5.05 | +2% ~ +13% |
| C 查表 | 9.45 | 9.24 | 9.03 | 8.85 | +79% ~ +95% |
数字说明的三件事
分配是共同的底。 四种写法、两种生存期、八次运行,每轮分配的字节数都是 32,000,000,等于 200 万个对象乘 16 字节。ThreadMXBean.getThreadAllocatedBytes 不区分写法,一次迭代里两次分配的成本对所有写法相同。GC 计数全程为 0:384 MB 的新生代吃掉每轮 32 MB 的垃圾,10 轮里一次回收都没触发,垃圾回收没有进入这段测量。
esc 与 sum 的差来自数组写入。 两种模式的循环代码相同,sum 只跳过两次数组写入。这个跳过让 direct 从 9.31 掉到 4.89(JDK 21),省下的时间里包含写屏障的代价。sum 也没有让逃逸分析消掉对象:数组写入的分支在编译产物里存在,两种模式下的分配字节数一样。
去掉写入之后,分派成本的排序才干净。 sum 模式下两个 JDK、两遍测量的排序完全一致:direct < switch < af < map。查表约是基线的两倍,抽象工厂比基线多 20% 到 33%,sealed+switch 只多 2% 到 13%。esc 模式里 af 与 direct 的差在 −4% 到 +24% 之间摇摆,两遍之间的漂移(direct 自己就有 4% 到 7% 的波动)与它同量级,读不出稳定结论。
这些差怎么用
一次创建的成本落在 5 纳秒到 12 纳秒之间。抽象工厂的 invokeinterface 在 JDK 21 上没有可测的额外成本(9.23 对 9.31),在 JDK 25 上多 1.1 到 1.7 纳秒。代价在查表:Map.get 加 Supplier.get 两次接口调用、一次哈希探测、两次 checkcast,把创建成本推到基线的两倍。
翻倍是相对量,换算成绝对量是每次迭代多 4 纳秒。只有每秒百万次创建的场景才看得见这个差别;界面控件每秒创建几千次,整个进程的生命里都测不到。拿性能当理由选或不选抽象工厂,两个方向都站不住:省不下什么,也赔不了什么。它的成本和收益在编译期和类加载上,第四节和第五节量的是那两项。
四、实测二:类加载数量
同一个程序用 -Xlog:class+load:file=... 记录每一次类加载,跑完后去掉隐藏类名里的地址再比对。基线是 direct。
| JDK | 写法 | class+load 行数 |
去重后类名数 | 相对基线新增 |
|---|---|---|---|---|
| 21 | direct | 969 | 936 | 0 |
| 21 | A 抽象工厂 | 971 | 938 | 2 |
| 21 | B sealed+switch | 1052 | 972 | 36 |
| 21 | C 查表 | 974 | 938 | 2 |
| 25 | direct | 994 | 978 | 0 |
| 25 | A 抽象工厂 | 996 | 980 | 2 |
| 25 | B sealed+switch | 1045 | 1027 | 49 |
| 25 | C 查表 | 999 | 980 | 2 |
新增的是哪些类,比数量更有信息量。
A 新增 2 个:bench.Bench$MacFactory、bench.Bench$WinFactory。抽象工厂在运行期不引入机制,多出来的就是两个普通类。
C 新增 2 个:bench.Bench$Registry、bench.Bench$Registry$$Lambda。后者是 MacButton::new 这类方法引用的载体,四个方法引用共享同一个隐藏类。
B 新增 36 个(JDK 21)到 49 个(JDK 25):sealed 的 switch 不是编译期消解的 tableswitch。看字节码:
1 | public static bench.Bench$Button button(bench.Bench$Theme); |
类型分派走 invokedynamic,绑定到 SwitchBootstraps.typeSwitch。这个 bootstrapper 在第一次执行时生成一个隐藏类来承载分派逻辑。JDK 25 的源码里能读到生成过程,java.base/java/lang/runtime/SwitchBootstraps.java:
1 | byte[] classBytes = ClassFile.of(ClassFile.StackMapsOption.DROP_STACK_MAPS).build( |
类名由 typeSwitchClassName(caller.lookupClass()) 算出(:737),跟在宿主类后面,就是日志里的 Bench$$TypeSwitch。这个文件在顶部导入了 java.lang.classfile.ClassFile、CodeBuilder、StackMapFrameInfo(:29-53),生成工具是 JEP 484 的 Class-File API。
1 | [0.060s][info][class,load] bench.Bench$$TypeSwitch/0x0000180001043000 source: bench.Bench |
两个条目对应两个调用点:button() 一个,checkbox() 一个。同一个语言构造,两个 JDK 用了两套机器。JDK 21 的日志里没有 TypeSwitch 这个隐藏类,多出来的 36 个类集中在 java.lang.invoke(MethodHandleImpl$*、BoundMethodHandle$Species_I、ClassValue 等,从 83 个涨到 99 个);JDK 25 的日志里多出两个隐藏类条目和 14 个 java.lang.classfile.* 类。
JDK 21 的 SwitchBootstraps 不生成字节码,它把分派逻辑拼成一串方法句柄:
1 | MethodHandle target = createMethodHandleSwitch(lookup, labels); // java.base/java/lang/runtime/SwitchBootstraps.java:158 |
JDK 25 换了做法,改成用 Class-File API 造一个类再定义成隐藏类(SwitchBootstraps.java:737-749,见上)。这背后是 JEP 484(Class-File API,JDK 24 定为标准);JDK 25 的核心库自己就在用这套 API,direct 基线日志里名字带 classfile 的类有 114 个(含 jdk.internal.classfile.impl.*),switch 运行后涨到 140 个。
graph TD
S["switch (theme) 两个调用点"] --> I["invokedynamic typeSwitch"]
I --> J21["JDK 21: 组合方法句柄<br/>新增 36 个类,不生成类"]
I --> J25["JDK 25: Class-File API 生成隐藏类<br/>Bench$$TypeSwitch,新增 49 个类"]
A["抽象工厂接口 f.button()"] --> A2["invokeinterface<br/>新增 2 个实现类"]
C["Map 查表"] --> C2["Map.get + Supplier.get<br/>新增 1 个 lambda 载体类"]
这些类只影响启动期的加载量。几十个类在启动预算里通常不算事,switch 换来的编译期检查在第五节,那里才是取舍发生的地方。
分配字节数
每轮 200 万个对象,两种生存期、四种写法下都是 32,000,000 字节,即每个对象 16 字节。分派方式、族的映射方式都不会改变这个数:创建对象的成本是分配器的事,与谁在调用无关。这也解释了第三节的时间数据:分配与数组写入占到一半以上,分派方式的差别在剩余部分里。
五、javac 到底挡住了什么
三种写法各制造一个缺陷,看 javac 与运行期的反应。
A:族缺产品
1 | static class WinFactory implements GuiFactory { |
1 | $ javac -cp out25 -d cls src/broken/BrokenAF.java |
报错点名了缺失的方法和它所属的接口。同一份源码用 JDK 21 编译,输出一字不差(中文环境下是「WinFactory不是抽象的, 并且未覆盖GuiFactory中的抽象方法checkbox()」)。缺陷在编译期被拒收。GuiFactory 的存在让「每个族都必须提供全部产品」成了编译器可以检查的义务。
B:新增族没补全
1 | sealed interface Theme permits Mac, Win, Linux {} |
1 | $ javac -cp out25 -d cls src/broken/BrokenSwitch.java |
覆盖性检查来自 sealed 类型(JEP 409,JDK 17)与模式匹配 switch(JEP 441,JDK 21):permits 列表是封闭的,编译器知道 Linux 没被覆盖。JDK 21 与 JDK 25 输出一致。
C:查表少登记一格
1 | static final Map<String, Supplier<Bench.Checkbox>> CHECKBOXES = |
编译通过。运行:
1 | $ java -cp out25:cls broken.BrokenMap |
第一行打印成功,第二行炸掉。同一个缺陷,A 在 javac 阶段被拒收,C 走到运行期才暴露,且只在 Win 主题的复选框被创建时暴露。JEP 358(Helpful NullPointerExceptions,JDK 14)让这条消息带上了「谁返回了 null」,但它改变不了暴露时机。
三个维度,三种保护
| 失效点 | A 抽象工厂 | B sealed+switch | C 查表 |
|---|---|---|---|
| 族缺产品 | 编译期报错,点名方法 | 不能表达这种缺陷(没有「产品的集合」这个单位) | 运行期 NPE,且只在对应路径上 |
| 新增族 | 不报错:新增族只是新增一个实现类,老代码不受影响 | 编译期报错,点名每个没补的 switch | 静默多一个错键或漏一个键,看你怎么写 |
| 跨族混用 | 挡不住 | 挡不住 | 挡不住 |
B 在「族缺产品」这一行给不出保护:button(t) 和 checkbox(t) 是两个独立函数,缺了 checkbox 函数,调用方当然编译不过;可一旦函数存在,函数体内没有「产品的集合」可供核对。B 的保护方向与 A 正交:A 咬住产品维(新增产品时,所有族一起被点名),B 咬住族维(新增族时,所有 switch 一起被点名)。选哪个取决于哪个维度会变。
C 在两个维度上都没有保护。它的优势在别处:登记表是数据,可以来自配置文件、数据库、插件目录,而 A 和 B 的映射关系写死在代码里。
混族这一条要另想办法
Button 与 Checkbox 是两个独立类型,把 Mac 按钮和 Win 复选框放进同一个列表,编译器不报错。类型系统只在两种值必须落进同一个类型位置时才介入,能拦的地方有限:
1 | interface Widget<F extends Theme> { } |
1 | BrokenMixed.java:18: error: incompatible types: inference variable E has incompatible bounds |
报错的地方是容器边界。两个变量各自声明时不报错,一旦要求它们落进同一个 List<Widget<Mac>>,编译器才拒绝。
代价是所有产品类型都带一个类型参数,签名变长,通配符 Widget<?> 到处出现。实际项目里更省力的做法是把族收敛成一个对象:一次创建带主题的面板,控件的创建藏在面板内部,外部代码拿不到单个产品,也就没有机会把两族拼在一起。这条路线放弃了逐个创建产品的能力,换来的是界面上不可能出现混族。桥接在两个维度独立变化时走的是同一条路。
已发布的接口加一个产品
A 的保护有边界:它只对同时参与编译的代码生效。接口发布之后往 GuiFactory 加 slider(),对着旧接口编译好的第三方实现类加载时不会报错,调用新方法时炸。
写一个最小验证。v1 接口只有一个方法,AddProductImpl 对着它编译并「发布」;随后接口加上 checkbox(),旧实现的 class 不重编译,用新接口编译的调用方去调新方法:
1 | $ java -cp out25/v2:out25/impl:out25/main spi.Main |
JDK 21 与 JDK 25 输出一致。javac 的强制力止于本人编译的那批源码,产品维扩展对第三方实现来说是运行期故障。
JDK 自己给出的解法是默认实现。SelectorProvider 里的六个创建方法都是抽象的(:163-226),而 JDK 15 追加的两个 ProtocolFamily 变体是具体方法:
1 | public SocketChannel openSocketChannel(ProtocolFamily family) throws IOException { // :312 |
两个方法都标着 @since 15(:310、:334)。已有的 provider 实现不需要改动,加载正常;不支持 protocol family 的实现被调用时抛 UnsupportedOperationException。新产品的默认行为是一句「我不支持」,而不是 AbstractMethodError。这是给已有实现留缓坡的加法,用「调用时失败」换「加载时失败」。
六、JDK 源码里的产品族
抽象工厂在 JDK 里比想象中少。
java.nio.channels.spi.SelectorProvider:一个干净的抽象工厂
java.base/java/nio/channels/spi/SelectorProvider.java:163-226 有六个抽象创建方法,返回五类产品:
1 | public abstract DatagramChannel openDatagramChannel() // :163 |
选中哪个实现由 provider() 决定(:151),查找顺序写在 Holder 里(:82-88):系统属性 java.nio.channels.spi.SelectorProvider → ServiceLoader.findFirst() → sun.nio.ch.DefaultSelectorProvider.get()。这是「抽象工厂 + SPI」的完整形态。
族一致性的保证写在实现类里,java.base/sun/nio/ch/SelectorProviderImpl.java:
1 | public abstract class SelectorProviderImpl extends SelectorProvider { |
每个产品在构造时拿到创建它的 provider(this)。这是族一致性的运行期实现:产品与工厂互相持有,混族的代价是配置错误而不是类型错误。
骨架实现把六个方法砍成一个
SelectorProviderImpl 还有一层意义。它把六个抽象方法实现了五个,只留 openSelector() 抽象(:68)。于是 macOS 的实现可以只有 42 行,java.base/sun/nio/ch/KQueueSelectorProvider.java 全文:
1 | public class KQueueSelectorProvider extends SelectorProviderImpl { |
抽象工厂的六个方法对一个新平台的全部义务收敛成一行。JDK 用骨架实现(抽象类提供默认实现)来降低抽象工厂的实现成本,这是抽象工厂在 JDK 里能存活的原因之一。第三方实现者的义务从一个族缩到一个方法。
java.sql:工厂就是产品自己
java.sql 里有一个完整的族:Statement、PreparedStatement、CallableStatement、DatabaseMetaData。创建它们的责任落在 Connection 上,java.sql/java/sql/Connection.java:
1 | Statement createStatement() throws SQLException; // :105 |
Connection 上的产品按重载分四类:createStatement 三个(:105、:532、:811)、prepareStatement 六个(:139、:567、:853、:940、:988、:1036)、prepareCall 三个(:172、:601、:893),加上 getMetaData()(:317)。工厂长在 Connection 上,连接自己也是产品之一。族的一致性由「同一个驱动实现全部接口」来保证,编译器不会因为 MySQL 的 Connection 返回了一个 PostgreSQL 的 Statement 而报错:类型上它们都是 java.sql.Statement。
选择发生在更外面:java.sql/java/sql/DriverManager.java:248 遍历已注册驱动,:613 逐个调用 aDriver.driver.connect(url, info),Driver.connect 是接口方法(java.sql/java/sql/Driver.java:91)。这一段是工厂方法 + 注册表,不是抽象工厂。
Charset:一致性靠构造器绑定
java.base/java/nio/charset/Charset.java 里有两个抽象工厂方法:
1 | public abstract CharsetDecoder newDecoder(); // :797 |
配套的静态入口是 Charset.forName(:530),它从标准字符集与 CharsetProvider 里找对象,CharsetProvider(java.base/java/nio/charset/spi/CharsetProvider.java:99、:112)只有两个方法:
1 | public abstract Iterator<Charset> charsets(); |
一个产品族,但 SPI 接口只暴露「给我一个 Charset」。decoder 与 encoder 的族归属落在构造器上:CharsetDecoder 的构造函数要求传入 Charset(CharsetDecoder.java:231-234),并把它存进 private final Charset charset(:140),charset()(:245)可以取回。一致性在运行期绑定并留了回指,编译期不参与。
诚实结论
JDK 里形态完整的抽象工厂只有 SelectorProvider 一个。其余候选分别是产品兼工厂(Connection)、工厂方法 + SPI(Charset、ToolProvider.findFirst,java.base/java/util/spi/ToolProvider.java:179)、注册表(DriverManager)。原因可以推断:JDK 的 SPI 接口要留给第三方实现,接口越大实现成本越高,所以接口偏向单个产品,多产品族交给抽象类做骨架实现。这与第五节的取舍一致:抽象工厂的实现成本全部落在「必须补齐所有方法」上,JDK 用抽象类把这份成本降到最低,并没有取消它。
七、结论
什么时候用
- 产品必须成套出现,跨产品的约束比单个产品重要。JDBC 连接与语句、选择器与通道、字符集与编解码器都是这种结构。
- 族的数量固定、产品的数量会增长。往
GuiFactory加一个Slider,编译器会点名所有没补的族;这个方向上的可扩展性是抽象工厂买到的。 - 族是部署或插件单元。一个驱动、一个 provider 对应一个族,加载哪个族由配置或
ServiceLoader决定,产品永远成套出现。
什么时候不用
- 只有一个产品,抽象工厂退化成一个方法,直接用工厂方法或
Supplier更省(见工厂方法)。 - 族会频繁新增。新增族要改的是所有 switch 和所有映射,
sealed+ switch 在这个方向上有编译器兜底,抽象工厂没有。 - 只是为了隐藏
new。查表写法能创建、能配置、能在运行期换实现,但在族缺产品时给的是运行期 NPE。没有编译期约束的抽象,剩下的只是间接层。 - 产品之间没有一致性要求。两个控件各管各的,抽象工厂就是多写一个接口。这个场景在桥接里是两个独立维度,在享元里是共享不可变对象,都不需要族的概念。
一条判断顺序
graph TD
Q1["产品之间有成套约束吗?<br/>Button 与 Checkbox 必须同族"] -->|没有| F1["各建各的:new / Supplier / 工厂方法"]
Q1 -->|有| Q2["哪个维度会变?"]
Q2 -->|产品增加<br/>族基本不动| Q3["抽象工厂接口<br/>新增产品 → 所有族编译报错"]
Q2 -->|族增加<br/>产品基本不动| Q4["sealed + switch<br/>新增族 → 所有 switch 编译报错"]
Q2 -->|两者都会变| Q5["先按更常变的一侧选<br/>另一侧靠测试与显式校验兜底"]
Q3 --> Q6["实现成本:每个族一个类<br/>用骨架实现把默认实现下沉"]
Q4 --> Q6
Q6 --> Q7["失败停在哪?<br/>编译期 > 启动期校验 > 运行期"]
顺序不能反:先问成套约束,再问变化方向,最后才谈实现形式。倒过来从「用什么模式」出发,得到的只是一层没有约束力的间接。
总结
- 四种写法(direct 基线、抽象工厂、sealed+switch、Map 查表)、两个 JDK、100 万次迭代一轮、7 轮取中位数、整套跑两遍反转顺序。一次迭代创建两个对象。
- 分配字节数与写法无关:八次运行每轮都是 32,000,000 字节,即每个对象 16 字节;384 MB 新生代下 10 轮里 GC 计数为 0。
- 创建耗时落在每次迭代 5 到 12 纳秒。去掉数组写入后排序稳定:direct < sealed+switch < 抽象工厂 < 查表,两个 JDK、两遍一致。查表约是基线的两倍(+79% 到 +95%),抽象工厂 +20% 到 +33%,sealed+switch +2% 到 +13%。
esc模式里两遍间的漂移就有 4% 到 7%,抽象工厂与 sealed+switch 的差值被漂移盖住一部分,只有查表的惩罚稳定超出。 - 抽象工厂的
invokeinterface在 JDK 21 上没有可测的额外成本(9.23 对 9.31),在 JDK 25 上多 1.1 到 1.7 纳秒。更贵的是查表:Map.get加Supplier.get两次接口调用、一次哈希探测、两次checkcast,把创建成本推到基线的两倍。 - 类加载(相对 direct 基线):抽象工厂 +2 个类,查表 +2 个类(含一个方法引用载体隐藏类),sealed+switch +36(JDK 21)到 +49(JDK 25)个类。
- sealed+switch 的类加载来自
invokedynamic typeSwitch:JDK 25 用 JEP 484 的 Class-File API 生成隐藏类Bench$$TypeSwitch(每调用点一个,源码在SwitchBootstraps.java:737-749);JDK 21 不生成类,改为组合方法句柄,多出的 36 个类集中在java.lang.invoke。 WinFactory少实现一个产品:error: WinFactory is not abstract and does not override abstract method checkbox() in GuiFactory,JDK 21 与 25 输出一致,报错点名缺失的方法。sealed多一个族:error: the switch expression does not cover all possible input values,来自 JEP 409 的封闭性与 JEP 441 的覆盖性检查。Map少登记一格:编译通过,运行到该路径时NullPointerException: Cannot invoke "java.util.function.Supplier.get()" because the return value of "java.util.Map.get(Object)" is null。- 已发布接口加一个产品:旧实现类加载正常,调用新方法时
AbstractMethodError,两个 JDK 输出一致。JDK 自己用默认实现(SelectorProvider.java:312-315,@since 15)避开这个坑。 - 抽象工厂护住产品维(新增产品时所有族被点名),sealed+switch 护住族维(新增族时所有 switch 被点名),查表两个维度都没有编译期保护。跨族混用三种写法都挡不住,泛型参数化只能在容器边界上拦住。
- JDK 里形态完整的抽象工厂只有
SelectorProvider(六个抽象创建方法,SelectorProvider.java:163-226);SelectorProviderImpl用骨架实现把义务砍到只剩openSelector()(:68),macOS 的KQueueSelectorProvider全文 42 行。java.sql里工厂就是产品自己(Connection.java:105/139/172/317),Charset的族一致性靠构造器绑定(CharsetDecoder.java:231-247)。
参考资料
- SelectorProvider (Java SE 25)
- SelectorProvider.openSocketChannel(ProtocolFamily) (Java SE 25)
- java.nio.channels.spi 包说明
- Connection (Java SE 25)
- Driver (Java SE 25)
- DriverManager (Java SE 25)
- Charset (Java SE 25)
- CharsetProvider (Java SE 25)
- ToolProvider (Java SE 25)
- ServiceLoader (Java SE 25)
- JEP 409: Sealed Classes
- JEP 441: Pattern Matching for switch
- JEP 484: Class-File API
- JEP 358: Helpful NullPointerExceptions
系列索引:设计模式系列








