抽象工厂常被描述成「创建一族对象」的接口,可是创建不值钱:newSupplierMap 查表、switch 都能做。
值钱的那部分在编译期:一个族少实现一个产品,javac 直接拒绝编译;族与产品的对应关系写进接口,不用靠人记。
本文用 Button/Checkbox 两套主题、三种写法,量 100 万次创建的耗时与类加载数量,并贴出 javac 与运行期的输出。

一、产品族的一致性是谁的责任

两套主题,两个产品。每个主题要同时提供 Button 和 Checkbox,同一个界面里的两个控件必须来自同一套主题。

代码里的「族」就是接口上的方法集合。GuiFactory 每多一个方法,所有实现类的义务同时增加一份。这是抽象工厂唯一的结构性特征:把「必须成套」写成「必须全部实现」。

三个失效点

一致性会在三个地方漏掉,各自需要的保护不同。

族缺产品WinFactory 实现了 button()checkbox() 没填。代码能跑,直到有人切到 Win 主题并创建一个复选框。

新增族没补全分支。产品没变,主题从两套变成三套。所有与主题有关的判断、映射、分支都要补一份。缺陷出现在新增的那一刻,测试却只覆盖老主题。

跨族混用。界面上出现一个 Mac 按钮和一个 Windows 复选框。三个产品各自合法,组合起来丑陋。这一条在 Java 的类型系统里默认挡不住,ButtonCheckbox 是两个独立类型,把不同族的实例放在一起,编译器不报错。要让类型系统挡住它,产品的类型必须携带族参数(Button<MacTheme>),代价是所有签名都变长。JDK 自己的选择是放弃这一层保护,java.sql 里的 StatementConnection 之间就没有这种类型约束(第六节)。

二、实验设计与局限

三种写法,外加一个不抽象的基线:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/* A:抽象工厂接口 + 两个实现类 */
interface GuiFactory { Button button(); Checkbox checkbox(); }
final class MacFactory implements GuiFactory {
public Button button() { return new MacButton(); }
public Checkbox checkbox() { return new MacCheckbox(); }
}

/* B:sealed 配置 + switch(两个调用点各自 switch 一遍) */
sealed interface Theme permits Mac, Win {}
record Mac() implements Theme {} record Win() implements Theme {}
static Button button(Theme t) {
return switch (t) { case Mac m -> new MacButton(); case Win w -> new WinButton(); };
}
static Checkbox checkbox(Theme t) {
return switch (t) { case Mac m -> new MacCheckbox(); case Win w -> new WinCheckbox(); };
}

/* C:Map<String, Supplier> 查表 */
static final Map<String, Supplier<Button>> BUTTONS =
Map.of("MAC", MacButton::new, "WIN", WinButton::new);
public Button button(String theme) { return BUTTONS.get(theme).get(); }

三种写法的调用点形状:

写法 调用点 每次调用做的事
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-25025+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 轮里一次回收都没触发,垃圾回收没有进入这段测量。

escsum 的差来自数组写入。 两种模式的循环代码相同,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.getSupplier.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$MacFactorybench.Bench$WinFactory。抽象工厂在运行期不引入机制,多出来的就是两个普通类。

C 新增 2 个bench.Bench$Registrybench.Bench$Registry$$Lambda。后者是 MacButton::new 这类方法引用的载体,四个方法引用共享同一个隐藏类。

B 新增 36 个(JDK 21)到 49 个(JDK 25)sealed 的 switch 不是编译期消解的 tableswitch。看字节码:

1
2
3
4
5
6
7
8
9
10
11
12
13
public static bench.Bench$Button button(bench.Bench$Theme);
0: aload_0
2: invokestatic #7 // Method java/util/Objects.requireNonNull:(Ljava/lang/Object;)Ljava/lang/Object;
11: invokedynamic #13, 0 // InvokeDynamic #0:typeSwitch:(Lbench/Bench$Theme;I)I
16: lookupswitch { // 2
0: 54
1: 69
default: 44
}
44: new #17 // class java/lang/MatchException
54: aload_1
55: checkcast #22 // class bench/Bench$Mac
59: new #24 // class bench/Bench$MacButton

类型分派走 invokedynamic,绑定到 SwitchBootstraps.typeSwitch。这个 bootstrapper 在第一次执行时生成一个隐藏类来承载分派逻辑。JDK 25 的源码里能读到生成过程,java.base/java/lang/runtime/SwitchBootstraps.java

1
2
3
4
5
6
7
8
byte[] classBytes = ClassFile.of(ClassFile.StackMapsOption.DROP_STACK_MAPS).build(
ConstantUtils.binaryNameToDesc(typeSwitchClassName(caller.lookupClass())), // :737-738
clb -> {
clb.withFlags(AccessFlag.FINAL, AccessFlag.SUPER, AccessFlag.SYNTHETIC) // :739
.withMethodBody("typeSwitch", ...);
});
MethodHandles.Lookup lookup;
lookup = caller.defineHiddenClass(classBytes, true, NESTMATE, STRONG); // :749

类名由 typeSwitchClassName(caller.lookupClass()) 算出(:737),跟在宿主类后面,就是日志里的 Bench$$TypeSwitch。这个文件在顶部导入了 java.lang.classfile.ClassFileCodeBuilderStackMapFrameInfo:29-53),生成工具是 JEP 484 的 Class-File API。

1
2
[0.060s][info][class,load] bench.Bench$$TypeSwitch/0x0000180001043000 source: bench.Bench
[0.060s][info][class,load] bench.Bench$$TypeSwitch/0x0000180001043438 source: bench.Bench

两个条目对应两个调用点:button() 一个,checkbox() 一个。同一个语言构造,两个 JDK 用了两套机器。JDK 21 的日志里没有 TypeSwitch 这个隐藏类,多出来的 36 个类集中在 java.lang.invokeMethodHandleImpl$*BoundMethodHandle$Species_IClassValue 等,从 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 个。

这些类只影响启动期的加载量。几十个类在启动预算里通常不算事,switch 换来的编译期检查在第五节,那里才是取舍发生的地方。

分配字节数

每轮 200 万个对象,两种生存期、四种写法下都是 32,000,000 字节,即每个对象 16 字节。分派方式、族的映射方式都不会改变这个数:创建对象的成本是分配器的事,与谁在调用无关。这也解释了第三节的时间数据:分配与数组写入占到一半以上,分派方式的差别在剩余部分里。

五、javac 到底挡住了什么

三种写法各制造一个缺陷,看 javac 与运行期的反应。

A:族缺产品

1
2
3
4
static class WinFactory implements GuiFactory {
public Button button() { return new WinButton(); }
/* checkbox() 没写 */
}
1
2
3
4
5
$ javac -cp out25 -d cls src/broken/BrokenAF.java
/tmp/AbstractFactory/src/broken/BrokenAF.java:10: error: WinFactory is not abstract and does not override abstract method checkbox() in GuiFactory
static class WinFactory implements GuiFactory {
^
1 error

报错点名了缺失的方法和它所属的接口。同一份源码用 JDK 21 编译,输出一字不差(中文环境下是「WinFactory不是抽象的, 并且未覆盖GuiFactory中的抽象方法checkbox()」)。缺陷在编译期被拒收。GuiFactory 的存在让「每个族都必须提供全部产品」成了编译器可以检查的义务。

B:新增族没补全

1
2
3
4
5
6
7
8
sealed interface Theme permits Mac, Win, Linux {}
record Linux() implements Theme {}
static Button button(Theme t) {
return switch (t) {
case Mac m -> new MacButton();
case Win w -> new WinButton();
};
}
1
2
3
4
5
$ javac -cp out25 -d cls src/broken/BrokenSwitch.java
/tmp/AbstractFactory/src/broken/BrokenSwitch.java:16: error: the switch expression does not cover all possible input values
return switch (t) {
^
1 error

覆盖性检查来自 sealed 类型(JEP 409,JDK 17)与模式匹配 switch(JEP 441,JDK 21):permits 列表是封闭的,编译器知道 Linux 没被覆盖。JDK 21 与 JDK 25 输出一致。

C:查表少登记一格

1
2
static final Map<String, Supplier<Bench.Checkbox>> CHECKBOXES =
Map.of("MAC", Bench.MacCheckbox::new); /* WIN 忘了登记 */

编译通过。运行:

1
2
3
4
$ java -cp out25:cls broken.BrokenMap
button(WIN) -> WinButton
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "java.util.function.Supplier.get()" because the return value of "java.util.Map.get(Object)" is null
at broken.BrokenMap.main(BrokenMap.java:17)

第一行打印成功,第二行炸掉。同一个缺陷,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 的映射关系写死在代码里。

混族这一条要另想办法

ButtonCheckbox 是两个独立类型,把 Mac 按钮和 Win 复选框放进同一个列表,编译器不报错。类型系统只在两种值必须落进同一个类型位置时才介入,能拦的地方有限:

1
2
3
4
5
interface Widget<F extends Theme> { }
interface Button<F extends Theme> extends Widget<F> { }
interface GuiFactory<F extends Theme> { Button<F> button(); Checkbox<F> checkbox(); }

List<Widget<Mac>> row = List.of(macButton(), winCheckbox());
1
2
3
4
5
6
BrokenMixed.java:18: error: incompatible types: inference variable E has incompatible bounds
List<Widget<Mac>> row = List.of(macButton(), winCheckbox());
^
equality constraints: Widget<Mac>
lower bounds: Checkbox<Win>,Button<Mac>
1 error

报错的地方是容器边界。两个变量各自声明时不报错,一旦要求它们落进同一个 List<Widget<Mac>>,编译器才拒绝。

代价是所有产品类型都带一个类型参数,签名变长,通配符 Widget<?> 到处出现。实际项目里更省力的做法是把族收敛成一个对象:一次创建带主题的面板,控件的创建藏在面板内部,外部代码拿不到单个产品,也就没有机会把两族拼在一起。这条路线放弃了逐个创建产品的能力,换来的是界面上不可能出现混族。桥接在两个维度独立变化时走的是同一条路。

已发布的接口加一个产品

A 的保护有边界:它只对同时参与编译的代码生效。接口发布之后往 GuiFactoryslider(),对着旧接口编译好的第三方实现类加载时不会报错,调用新方法时炸。

写一个最小验证。v1 接口只有一个方法,AddProductImpl 对着它编译并「发布」;随后接口加上 checkbox(),旧实现的 class 不重编译,用新接口编译的调用方去调新方法:

1
2
3
4
$ java -cp out25/v2:out25/impl:out25/main spi.Main
button() -> win
Exception in thread "main" java.lang.AbstractMethodError: Receiver class spi.AddProductImpl does not define or inherit an implementation of the resolved method 'abstract java.lang.String checkbox()' of interface spi.AddProduct.
at spi.Main.main(Main.java:6)

JDK 21 与 JDK 25 输出一致。javac 的强制力止于本人编译的那批源码,产品维扩展对第三方实现来说是运行期故障。

JDK 自己给出的解法是默认实现。SelectorProvider 里的六个创建方法都是抽象的(:163-226),而 JDK 15 追加的两个 ProtocolFamily 变体是具体方法:

1
2
3
4
5
6
7
8
public SocketChannel openSocketChannel(ProtocolFamily family) throws IOException {   // :312
Objects.requireNonNull(family);
throw new UnsupportedOperationException("Protocol family not supported");
}
public ServerSocketChannel openServerSocketChannel(ProtocolFamily family) throws IOException { // :336
Objects.requireNonNull(family);
throw new UnsupportedOperationException("Protocol family not supported");
}

两个方法都标着 @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
2
3
4
5
6
public abstract DatagramChannel openDatagramChannel()               // :163
public abstract DatagramChannel openDatagramChannel(ProtocolFamily) // :181
public abstract Pipe openPipe() // :192
public abstract AbstractSelector openSelector() // :203
public abstract ServerSocketChannel openServerSocketChannel() // :214
public abstract SocketChannel openSocketChannel() // :225

选中哪个实现由 provider() 决定(:151),查找顺序写在 Holder 里(:82-88):系统属性 java.nio.channels.spi.SelectorProviderServiceLoader.findFirst()sun.nio.ch.DefaultSelectorProvider.get()。这是「抽象工厂 + SPI」的完整形态。

族一致性的保证写在实现类里,java.base/sun/nio/ch/SelectorProviderImpl.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public abstract class SelectorProviderImpl extends SelectorProvider {
public DatagramChannel openDatagramChannel() throws IOException {
return new DatagramChannelImpl(this, /*interruptible*/true); // :46
}
public Pipe openPipe() throws IOException {
return new PipeImpl(this); // :64
}
public abstract AbstractSelector openSelector() throws IOException; // :68
public ServerSocketChannel openServerSocketChannel() throws IOException {
return new ServerSocketChannelImpl(this); // :72
}
public SocketChannel openSocketChannel() throws IOException {
return new SocketChannelImpl(this); // :77
}

每个产品在构造时拿到创建它的 provider(this)。这是族一致性的运行期实现:产品与工厂互相持有,混族的代价是配置错误而不是类型错误。

骨架实现把六个方法砍成一个

SelectorProviderImpl 还有一层意义。它把六个抽象方法实现了五个,只留 openSelector() 抽象(:68)。于是 macOS 的实现可以只有 42 行,java.base/sun/nio/ch/KQueueSelectorProvider.java 全文:

1
2
3
4
5
6
7
8
public class KQueueSelectorProvider extends SelectorProviderImpl {
public AbstractSelector openSelector() throws IOException {
return new KQueueSelectorImpl(this);
}
public Channel inheritedChannel() throws IOException {
return InheritedChannel.getChannel();
}
}

抽象工厂的六个方法对一个新平台的全部义务收敛成一行。JDK 用骨架实现(抽象类提供默认实现)来降低抽象工厂的实现成本,这是抽象工厂在 JDK 里能存活的原因之一。第三方实现者的义务从一个族缩到一个方法。

java.sql:工厂就是产品自己

java.sql 里有一个完整的族:StatementPreparedStatementCallableStatementDatabaseMetaData。创建它们的责任落在 Connection 上,java.sql/java/sql/Connection.java

1
2
3
4
Statement createStatement() throws SQLException;                     // :105
PreparedStatement prepareStatement(String sql) throws SQLException; // :139
CallableStatement prepareCall(String sql) throws SQLException; // :172
DatabaseMetaData getMetaData() throws SQLException; // :317

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
2
public abstract CharsetDecoder newDecoder();   // :797
public abstract CharsetEncoder newEncoder(); // :807

配套的静态入口是 Charset.forName:530),它从标准字符集与 CharsetProvider 里找对象,CharsetProviderjava.base/java/nio/charset/spi/CharsetProvider.java:99:112)只有两个方法:

1
2
public abstract Iterator<Charset> charsets();
public abstract Charset charsetForName(String charsetName);

一个产品族,但 SPI 接口只暴露「给我一个 Charset」。decoder 与 encoder 的族归属落在构造器上:CharsetDecoder 的构造函数要求传入 Charset(CharsetDecoder.java:231-234),并把它存进 private final Charset charset:140),charset():245)可以取回。一致性在运行期绑定并留了回指,编译期不参与。

诚实结论

JDK 里形态完整的抽象工厂只有 SelectorProvider 一个。其余候选分别是产品兼工厂(Connection)、工厂方法 + SPI(CharsetToolProvider.findFirstjava.base/java/util/spi/ToolProvider.java:179)、注册表(DriverManager)。原因可以推断:JDK 的 SPI 接口要留给第三方实现,接口越大实现成本越高,所以接口偏向单个产品,多产品族交给抽象类做骨架实现。这与第五节的取舍一致:抽象工厂的实现成本全部落在「必须补齐所有方法」上,JDK 用抽象类把这份成本降到最低,并没有取消它。

七、结论

什么时候用

  • 产品必须成套出现,跨产品的约束比单个产品重要。JDBC 连接与语句、选择器与通道、字符集与编解码器都是这种结构。
  • 族的数量固定、产品的数量会增长。往 GuiFactory 加一个 Slider,编译器会点名所有没补的族;这个方向上的可扩展性是抽象工厂买到的。
  • 族是部署或插件单元。一个驱动、一个 provider 对应一个族,加载哪个族由配置或 ServiceLoader 决定,产品永远成套出现。

什么时候不用

  • 只有一个产品,抽象工厂退化成一个方法,直接用工厂方法或 Supplier 更省(见工厂方法)。
  • 族会频繁新增。新增族要改的是所有 switch 和所有映射,sealed + switch 在这个方向上有编译器兜底,抽象工厂没有。
  • 只是为了隐藏 new。查表写法能创建、能配置、能在运行期换实现,但在族缺产品时给的是运行期 NPE。没有编译期约束的抽象,剩下的只是间接层。
  • 产品之间没有一致性要求。两个控件各管各的,抽象工厂就是多写一个接口。这个场景在桥接里是两个独立维度,在享元里是共享不可变对象,都不需要族的概念。

一条判断顺序

顺序不能反:先问成套约束,再问变化方向,最后才谈实现形式。倒过来从「用什么模式」出发,得到的只是一层没有约束力的间接。

总结

  • 四种写法(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.getSupplier.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)。

参考资料

系列索引:设计模式系列