13|享元:共享稳定状态,把变化留在外部
配套代码:GitHub 仓库 · 本篇完整源码 · 行为测试。使用 JDK 25 与 Gradle,包名为
com.hanserwei.patterns.flyweight。
系列导航:Java 25 设计模式学习指南
一页排版里有许多字形,每个字形都有字符和坐标,但大量字形使用相同字体。若每个字形都重复持有一份复杂字体样式,内存可能主要消耗在重复信息上。
享元先把状态拆开:字体样式可以共享,字符和坐标属于一次具体绘制。共享对象应该避免保存调用者传入的坐标,否则后绘制的字形会污染先前字形。
从问题提炼设计意图
享元共享大量细粒度对象中的内在状态,把随上下文变化的外在状态交给调用方保存,并在操作时传入。
对象职责与协作关系
| 示例角色 | 职责 |
|---|---|
TextStyle |
享元,保存不可变字体信息 |
StyleFactory |
享元工厂,按键复用样式对象 |
Glyph |
上下文,保存字符和坐标等外在状态 |
Demo |
装配共享样式与多个独立上下文 |
classDiagram
StyleFactory o-- TextStyle : shares by font
Glyph --> TextStyle : shared style
Demo ..> StyleFactory : requests
代码思路:变化应该落在哪个对象上
TextStyle 只保存 font。draw 的 symbol 和 x 都是参数,不会写入字段,因此一个样式对象可以服务多个不同位置的字形。
StyleFactory 用字体名作为缓存键,computeIfAbsent 在首次请求时创建对象,以后同键请求返回同一引用。这里比较对象身份具有意义,因为享元的目标确实包含共享,而不只是两个对象的值相等。
Glyph 保存自己的字符和 positionX,同时引用共享 TextStyle。draw 时把这些外在状态传给样式。测试先绘制 A,再绘制 B,再绘制 A,确认 B 的位置没有进入共享样式而覆盖 A。
关键实现与独立运行
配套仓库中的包名是 com.hanserwei.patterns.flyweight,源码目录为 src/main/java/com/hanserwei/patterns/flyweight/。仓库地址统一见系列导航。以下展示关键文件的完整内容;其余角色和测试在同一仓库中,每个顶级类型各占一个文件。
TextStyle.java:
package com.hanserwei.patterns.flyweight;
import java.util.Objects;
/** 可以被多个字形共享的不可变内在状态. */
public final class TextStyle {
/** 该样式使用的字体名称. */
private final String font;
/** 创建指定字体样式,仅允许同包工厂直接调用. */
TextStyle(String font) {
this.font = Objects.requireNonNull(font, "font");
}
/** 接收外在坐标并生成绘制说明,不保存坐标. */
public String draw(char symbol, int x) {
return font + ":" + symbol + "@" + x;
}
}
StyleFactory.java:
package com.hanserwei.patterns.flyweight;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
/** 在单线程学习场景中按字体复用样式. */
public final class StyleFactory {
/** 字体到共享样式的缓存;生命周期由工厂实例控制. */
private final Map<String, TextStyle> styles = new HashMap<>();
/** 返回相同字体的共享对象,首次请求时创建. */
public TextStyle get(String font) {
Objects.requireNonNull(font, "font");
return styles.computeIfAbsent(font, TextStyle::new);
}
}
Glyph.java:
package com.hanserwei.patterns.flyweight;
import java.util.Objects;
/** 保存非共享字符与坐标,并引用共享样式的上下文. */
public final class Glyph {
/** 当前字符. */
private final char symbol;
/** 当前横坐标,是外在状态. */
private final int positionX;
/** 与其他字形共享的样式. */
private final TextStyle style;
/** 装配外在状态和共享样式. */
public Glyph(char symbol, int positionX, TextStyle style) {
this.symbol = symbol;
this.positionX = positionX;
this.style = Objects.requireNonNull(style, "style");
}
/** 把当前外在状态传入共享对象完成绘制. */
public String draw() {
return style.draw(symbol, positionX);
}
}
Demo.java 展示调用方如何装配这些对象:
package com.hanserwei.patterns.flyweight;
/** 演示本章对象的装配方式和可观察结果. */
public final class Demo {
/** 禁止实例化演示入口. */
private Demo() {}
/** 运行独立示例;args 为未使用的命令行参数. */
public static void main(String[] args) {
StyleFactory factory = new StyleFactory();
System.out.println(new Glyph('A', 10, factory.get("Serif")).draw());
System.out.println(new Glyph('B', 20, factory.get("Serif")).draw());
}
}
在配套代码仓库根目录运行;Windows 使用 gradlew.bat 替换 ./gradlew:
./gradlew runFlyweight
./gradlew test --tests 'com.hanserwei.patterns.flyweight.PatternTest'
示例的业务输出如下,省略 Gradle 自身的任务提示:
Serif:A@10
Serif:B@20
用测试确认模式的行为
相同字体复用同一对象,不同字体得到不同对象;不同字形坐标始终独立。该测试证明共享结构,不等于完成内存收益基准测试。
对应测试位于 src/test/java/com/hanserwei/patterns/flyweight/PatternTest.java。建议先运行现有测试,再改动一个协作环节,观察哪个断言能够发现问题。
常见用法
- 文档排版、地图标记和游戏场景中有大量重复样式。
- 语法树或词法符号中存在可安全共享的不可变描述。
- 经过测量,重复内在状态确实构成显著内存成本。
适用边界与容易踩的坑
共享必须先有可靠的键。如果样式还包含字号、颜色和粗体,键只使用字体名就会错误复用不兼容状态;应把所有影响内在状态的维度纳入不可变键。
缓存也会占内存。工厂长期存活、键无限增长时,享元可能变成泄漏来源,需要容量、淘汰或生命周期设计。不要在没有测量时先制造一个永久全局缓存。
本例的 HashMap 工厂仅面向单线程;多线程共享时需要并发容器或同步,而且享元自身仍应保持不可变。用并发 map 只保证缓存操作的并发语义,不会自动让其中的可变对象安全。
与相近模式比较
缓存代理通常是为了省掉昂贵访问,并保持目标接口;享元关注内存共享和内外状态划分。对象池还涉及可变对象的借还与重置,不能直接等同于不可变享元。
动手练习
加入字号和颜色,设计完整样式键,并测试不同字号不能误共享。创建大量字形后比较“每个字形都新建样式”和“通过工厂共享”两种对象数量,说明何时共享成本可能超过节省。
系列导航:Java 25 设计模式学习指南