如何手写一个享元模式?Java 代码怎么实现?
简化版
手写享元模式一般分三步:定义享元接口,把可共享的内部状态放入具体享元类,再用享元工厂缓存对象。客户端从工厂获取享元,并在调用时传入外部状态。
详细版
以“游戏地图树木渲染”为例:
TreeType是享元对象,保存树种、颜色、纹理等内部状态。TreeFactory是享元工厂,根据树种 key 复用TreeType。Tree或地图对象保存坐标等外部状态。- 渲染时调用
treeType.draw(x, y)。
这样即使地图上有 10 万棵树,也只需要少量 TreeType 实例。
完整版教学
一、定义享元对象
享元对象保存内部状态:
class TreeType {
private final String name;
private final String color;
private final String texture;
public TreeType(String name, String color, String texture) {
this.name = name;
this.color = color;
this.texture = texture;
}
public void draw(int x, int y) {
System.out.println("在 (" + x + "," + y + ") 绘制 "
+ name + ",颜色:" + color + ",纹理:" + texture);
}
}
这里 name、color、texture 是大量树木可共享的信息。
二、编写享元工厂
享元工厂负责缓存和复用:
class TreeFactory {
private static final Map<String, TreeType> CACHE = new HashMap<>();
public static TreeType getTreeType(String name, String color, String texture) {
String key = name + ":" + color + ":" + texture;
return CACHE.computeIfAbsent(key, k -> new TreeType(name, color, texture));
}
public static int size() {
return CACHE.size();
}
}
同样的树种、颜色和纹理只创建一次。后续请求直接返回缓存中的享元对象。
三、定义外部状态对象
外部状态可以放在普通对象中:
class Tree {
private final int x;
private final int y;
private final TreeType type;
public Tree(int x, int y, TreeType type) {
this.x = x;
this.y = y;
this.type = type;
}
public void draw() {
type.draw(x, y);
}
}
Tree 不是享元对象,它代表地图上的一棵具体树,保存坐标。TreeType 才是享元。
四、客户端使用
public class FlyweightDemo {
public static void main(String[] args) {
List<Tree> trees = new ArrayList<>();
TreeType pine = TreeFactory.getTreeType("松树", "绿色", "pine.png");
TreeType oak = TreeFactory.getTreeType("橡树", "深绿", "oak.png");
trees.add(new Tree(10, 20, pine));
trees.add(new Tree(30, 40, pine));
trees.add(new Tree(50, 60, oak));
for (Tree tree : trees) {
tree.draw();
}
System.out.println("享元对象数量:" + TreeFactory.size());
}
}
这里有 3 棵树,但只有 2 个树类型享元对象。
五、为什么不是把 Tree 全部共享
地图上的每棵 Tree 坐标不同,不能全部共享。真正重复的是 TreeType。
如果把 Tree 也做成共享对象,就会遇到坐标覆盖问题。享元模式不是盲目共享所有对象,而是共享对象中可复用的部分。
六、并发环境怎么处理
如果享元工厂会被多线程访问,HashMap 不是线程安全的。可以改用 ConcurrentHashMap:
class SafeTreeFactory {
private static final ConcurrentHashMap<String, TreeType> CACHE = new ConcurrentHashMap<>();
public static TreeType getTreeType(String name, String color, String texture) {
String key = name + ":" + color + ":" + texture;
return CACHE.computeIfAbsent(key, k -> new TreeType(name, color, texture));
}
}
同时,享元对象内部状态尽量保持不可变,避免共享后被并发修改。
七、常见误区与追问
实现时应把外部状态放在方法参数,而不是写回共享对象。假设1000棵树只有3种纹理,工厂最多创建3个 TreeType,每棵树的位置 x、y 保存在场景节点;绘制时调用 type.draw(x,y)。若 draw 修改 type 的坐标字段,并发绘制就会互相污染。
| 检查维度 | 判定依据 |
|---|---|
| TreeType | 纹理、颜色等内部状态 |
| Tree | 坐标等每个实例独有的外部状态 |
1000 Tree -> 3 shared TreeType
记忆钩子:享元对象带“模板”,调用现场带“位置”。
- 误区:享元类必须实现 clone。 共享依赖复用同一实例,不需要通过克隆创建副本。
- 追问:外部状态应该由谁保存? 通常由上下文对象、集合记录或调用者计算后传入。
- 误区:享元接口可以暴露 setter。 可变 setter 会破坏共享安全,优先构造后不可变。
- 追问:工厂为何返回接口而非具体类? 可隐藏共享策略,并允许非共享的 ConcreteFlyweight。
- 追问:如何估算节省量? 比较 N 个完整对象与 N 个外部状态加 K 个共享内部对象的总内存。
八、加强记忆
手写享元模式记住四步:找重复状态,做成不可变享元;建工厂,用 Map 缓存;外部状态留给客户端;调用时把外部状态传给享元方法。