14. 享元模式:定义、结构、实现(内部状态、外部状态)、优缺点、应用场景
享元模式,这个名字听起来有点抽象。说白了,它就是「共享对象」。
你想想看,我们写代码的时候,是不是经常创建大量相似的对象?每个对象都占内存,对象一多,内存就扛不住了。享元模式就是来解决这个问题的——把那些可以共享的部分抽出来,大家共用。
我记得有一次做游戏开发,地图上要显示成千上万棵树。每棵树都有位置、颜色、纹理。如果每棵树都创建一个独立对象,内存直接爆炸。后来我用享元模式重构,只保留了几种树模型,位置信息单独存。嗯,效果立竿见影。
14.1 什么是享元模式
享元模式属于结构型设计模式。它的核心思想是:通过共享技术,有效支持大量细粒度对象的复用。
它把对象的属性分成了两类:
- 内部状态(Intrinsic State):可以共享的、不变的部分。比如树的纹理、颜色。
- 外部状态(Extrinsic State):不能共享的、随环境变化的部分。比如树的位置坐标。
内部状态存在享元对象内部,外部状态由客户端维护,使用时传入。
核心要点:享元模式的关键在于「分离」——把不变的部分和变化的部分分开。不变的部分共享,变化的部分外部传入。
14.2 结构图
先看一张图,帮你快速建立整体认知。
这张图展示了享元模式的经典结构。工厂负责创建和管理享元对象,客户端通过工厂获取享元,然后传入外部状态来使用。
14.3 内部状态 vs 外部状态
这是享元模式最核心的概念。我见过不少开发者在这上面栽跟头。
| 对比维度 | 内部状态 | 外部状态 |
|---|---|---|
| 存储位置 | 享元对象内部 | 客户端维护 |
| 是否可变 | 不可变(通常 final) | 可变 |
| 是否共享 | 共享 | 不共享 |
| 典型例子 | 字体、颜色、纹理 | 坐标、大小、角度 |
| 内存影响 | 节省内存(共享) | 占用内存(每个对象一份) |
我的经验:判断一个属性该放内部还是外部,有个简单方法——问自己:「这个属性会不会随着使用场景变化?」如果会,那就是外部状态。比如树的纹理不会变,但树的位置会变。
14.4 代码实现
我们用一个文本编辑器的例子来说明。假设我们要显示大量字符,每个字符都有字体、大小、颜色和位置。
// 1. 享元接口
public interface CharacterFlyweight {
void display(int x, int y); // 外部状态:位置
}
// 2. 具体享元 - 内部状态共享
public class ConcreteCharacter implements CharacterFlyweight {
private final char character; // 内部状态
private final String font; // 内部状态
private final int size; // 内部状态
private final String color; // 内部状态
public ConcreteCharacter(char character, String font, int size, String color) {
this.character = character;
this.font = font;
this.size = size;
this.color = color;
}
@Override
public void display(int x, int y) {
System.out.println("字符: " + character +
", 字体: " + font +
", 大小: " + size +
", 颜色: " + color +
", 位置: (" + x + ", " + y + ")");
}
}
// 3. 享元工厂
public class CharacterFactory {
private final Map<String, CharacterFlyweight> pool = new HashMap<>();
public CharacterFlyweight getCharacter(char c, String font, int size, String color) {
String key = c + "-" + font + "-" + size + "-" + color;
CharacterFlyweight flyweight = pool.get(key);
if (flyweight == null) {
flyweight = new ConcreteCharacter(c, font, size, color);
pool.put(key, flyweight);
System.out.println("创建新享元: " + key);
}
return flyweight;
}
public int getPoolSize() {
return pool.size();
}
}
// 4. 客户端使用
public class Client {
public static void main(String[] args) {
CharacterFactory factory = new CharacterFactory();
// 显示大量字符
String text = "Hello World";
for (int i = 0; i < text.length(); i++) {
char c = text.charAt(i);
// 内部状态相同(字体、大小、颜色)的字符共享同一个对象
CharacterFlyweight flyweight = factory.getCharacter(c, "Arial", 12, "black");
flyweight.display(i * 10, 100); // 外部状态:位置
}
System.out.println("享元池大小: " + factory.getPoolSize());
// 输出:享元池大小: 8(因为 'l' 重复了3次,'o' 重复了2次)
}
}
关键点:享元工厂内部维护了一个对象池。当请求享元时,先检查池中是否存在,存在则复用,不存在则创建。这就是「共享」的实现机制。
14.5 优缺点
优点
- 大幅减少内存占用:共享内部状态,避免重复创建。我在项目中遇到过,用享元模式后内存占用降低了70%以上。
- 提升性能:对象少了,GC压力也小了,创建和销毁的开销都降低。
- 逻辑清晰:把不变和变化分离,代码结构更干净。
缺点
- 增加了复杂度:需要区分内部状态和外部状态,设计上要多花心思。
- 外部状态管理麻烦:客户端需要维护外部状态,如果外部状态很多,反而增加了内存开销。
- 线程安全问题:如果享元对象被多个线程共享,内部状态必须是不可变的,否则会有并发问题。
避坑指南:我曾经在一个项目中,把「位置」也放到了内部状态里,结果所有树都长在同一个地方。嗯,调试了半天才发现问题。记住:内部状态必须是不变的、可共享的。任何随环境变化的东西,都别放进去。
14.6 应用场景
享元模式不是万能的,它适合特定场景。我个人总结了几类典型应用:
- 大量相似对象:比如游戏中的子弹、粒子效果、地图上的树木。
- 字符串常量池:Java中的String常量池就是享元模式的经典实现。
- 数据库连接池:连接对象复用,避免频繁创建和销毁。
- 缓存系统:缓存热点数据,减少重复计算。
- 编辑器中的字符渲染:每个字符的字体、颜色可以共享,位置单独存储。
判断是否该用享元模式,有个简单标准:如果系统中存在大量细粒度对象,且它们大部分属性相同,只有少数属性不同,那就该考虑享元模式了。
我的建议:不要为了用模式而用模式。如果对象数量不多(比如几百个),享元模式带来的复杂度可能得不偿失。我一般会在对象数量超过一万时,才认真考虑享元模式。
好了,享元模式就讲到这里。记住三个关键词:共享、内部状态、外部状态。理解透了,你就能在合适的场景下用好它。
公众号:蓝海资料掘金营,微信deep3321