什么是享元模式?应用场景有哪些?
结论先行:享元(Flyweight)把对象里的可共享部分抽出来复用,叫内部状态(intrinsic state);把每个实例独有的部分在调用时传进去,叫外部状态(extrinsic state)。它换的不是"对象变少",而是"重复数据变少"——一万个坐标点共用一份图标样式,而不是各存一份。
1. 内部状态与外部状态
| 维度 | 内部状态(Intrinsic) | 外部状态(Extrinsic) |
|---|---|---|
| 归属 | 对象自身,可被多方共享 | 每个使用场景独有 |
| 是否可变 | 必须不可变 | 每次调用可不同 |
| 存放位置 | 享元池(工厂缓存) | 调用方/上下文 |
| 例子 | 字体名、字号、颜色、图标 URL | 坐标、时间、用户 ID |
| 数量级 | 少(有限组合) | 多(随业务线性增长) |
判据很直接:能按有限组合枚举的放内部,随每次使用变化的放外部。
2. 结构:池 + 引用
┌─────────────────────────────┐
Client ─────────►│ FlyweightFactory │
│ │ pool: Map<key, Flyweight> │
│ │ get(key): 有则复用,无则建 │
│ └──────────────┬──────────────┘
│ │ 返回共享引用
│ ┌──────────────▼──────────────┐
│ │ Flyweight(不可变) │
│ │ color / size / icon │
│ └─────────────────────────────┘
│
└─ 调用时自带外部状态:draw(x, y) ← 坐标不进享元
关键:外部状态绝不出现在享元对象的字段里,否则共享就失效了。
3. 代码:地图标注点
// 内部状态:样式,同类标注共用一份
final class PinStyle {
final String color;
final int size;
final String icon;
PinStyle(String color, int size, String icon) {
this.color = color; this.size = size; this.icon = icon;
}
// 只读、无 setter,保证共享安全
}
// 享元工厂:按 key 复用,避免重复创建
final class PinStyleFactory {
private static final Map<String, PinStyle> POOL = new ConcurrentHashMap<>();
static PinStyle of(String color, int size, String icon) {
String key = color + "|" + size + "|" + icon;
return POOL.computeIfAbsent(key, k -> new PinStyle(color, size, icon));
}
}
// 外部状态:每个标注自己的坐标
final class Pin {
private final PinStyle style; // 共享
private final int x, y; // 独有
Pin(String color, int size, String icon, int x, int y) {
this.style = PinStyleFactory.of(color, size, icon);
this.x = x; this.y = y;
}
void render() {
System.out.println("draw " + style.icon + " at (" + x + "," + y + ")");
}
}
// 10 万个标注,样式只有 3 种 → 样式对象只有 3 个
4. Java 里现成的享元
| 场景 | 共享什么 | 说明 |
|---|---|---|
String 常量池 | 字面量字符串 | 相同字面量指向同一对象,== 为 true |
Integer.valueOf(-128~127) | 小整数对象 | 超出范围每次 new,所以 == 比较会翻车 |
Boolean.TRUE/FALSE | 布尔常量 | 天然享元 |
| 枚举 | 每个常量 | JVM 保证唯一实例 |
| 线程池 / 连接池 | 可复用资源 | 池化思想,与享元同源不同目的 |
| 字体、图元、图标 | 渲染资源 | GUI/游戏里最常见的用法 |
| 游戏子弹、粒子 | 纹理与模型 | 上千粒子共用一个纹理 |
5. 与单例、对象池的区别
| 对比项 | 享元 | 单例 | 对象池 |
|---|---|---|---|
| 实例数量 | 多份(按 key 分组) | 一份 | 固定若干份 |
| 目的 | 减少重复数据 | 全局唯一访问点 | 复用昂贵资源(连接、线程) |
| 是否独占 | 共享,可并发读 | 共享 | 独占,用完归还 |
| 生命周期 | 与池同长 | 与进程同长 | 借出/归还循环 |
6. 代价与坑
- 共享对象必须不可变:一旦有人改了共享的
PinStyle.size,所有标注一起变形,且并发下没有任何保护。 - 池要有边界:
ConcurrentHashMap无上限时,key 空间随参数组合爆炸,本质是另一种内存泄漏。做法是限定枚举组合、加 LRU 上限,或改用弱引用(WeakHashMap)。 - 不是所有对象都值得共享:只有"对象数量巨大 + 内部状态重复率高"时收益才明显;十个对象共享没意义,反而多一层查表。
7. 常见追问
Q:new String("abc") 创建几个对象?
两个:常量池里的 "abc"(若首次出现)和堆上一个新的 String。后者不共享,所以 new String("abc") == "abc" 为 false,equals 为 true。
Q:Integer a = 127, b = 127; a == b 为什么 true,128 就 false?
valueOf 对 -128~127 走缓存(享元),超出范围直接 new。所以包装类型比较一律用 equals。
Q:享元能减少 GC 压力吗? 能减少存活对象数量,但共享对象一旦进入老年代就长期存活,属于"用常驻内存换分配频率",要结合池大小评估。
小结
- 享元 = 拆内部状态(共享、不可变)+ 外部状态(调用时传入)。
- 核心收益是内存与创建开销,前提是"对象多、重复率高"。
- 三大坑:共享对象被改、池无上限、参数组合爆炸。
- Java 里 String 常量池、Integer 缓存、枚举都是现成例子,面试可以直接引用。