享元模式(Flyweight)在 GoF 23 个模式里使用频率不高,但它解决的问题非常具体:当系统里需要大量"细粒度对象"时,如何避免内存爆炸。Java 的 Integer.valueOf、String 池、字体渲染、游戏里的粒子系统,都在悄悄使用它。这篇文章把享元从原理讲到实战,讲清楚"内部状态 vs 外部状态"这个核心区分,以及它在不可变对象大行其道的今天为什么仍然重要。
问题:海量相似对象的内存压力
想象一个文档编辑器,要渲染一本书 —— 假设有 50 万个字符,每个字符是一个 Character 对象,带 char value、font、size、color、style 等字段:
class Character {
char ch;
String font;
int size;
String color;
boolean bold;
boolean italic;
int x, y; // 位置
}
// 50 万个字符,每个对象至少几十字节,总共几十 MB —— 内存吃不消
仔细观察:50 万个对象里,绝大多数字符值是重复的(英文只有 128 种,中文常用 3000 种);字体/字号/颜色样式大多数页面只用 3-5 种。如果把这些"会重复的部分"提取出来共享,内存能省下 90%+。
享元的核心:内部状态 vs 外部状态
享元模式的精髓是把对象状态分成两部分:
- 内部状态(Intrinsic):可以共享的、不依赖上下文的部分。例:字符的 ch、font、size、color。
- 外部状态(Extrinsic):不能共享的、每次使用都不同的部分。例:字符在文档里的位置 x、y。
把内部状态封装成不可变对象,做成一个共享池;外部状态由客户端在使用时传入。这样 50 万个字符可能只对应几百个不同的内部状态实例。
标准结构
// 1. 享元接口(可选,Flyweight 本身就是个值对象时可以不要)
interface CharacterFlyweight {
void render(int x, int y); // 外部状态作为参数传入
}
// 2. 具体享元:内部状态都是 final,实例不可变
class ConcreteCharacter implements CharacterFlyweight {
private final char ch;
private final String font;
private final int size;
private final String color;
public ConcreteCharacter(char ch, String font, int size, String color) {
this.ch = ch; this.font = font; this.size = size; this.color = color;
}
public void render(int x, int y) {
System.out.printf("'%c' at (%d, %d), %s %dpt %s%n", ch, x, y, font, size, color);
}
@Override
public boolean equals(Object o) { ... } // 内部状态相同就是同一对象
@Override
public int hashCode() { ... }
}
// 3. 享元工厂:维护池,保证相同 key 的请求返回同一个享元实例
class CharacterFactory {
private static final Map<String, ConcreteCharacter> pool = new HashMap<>();
public static ConcreteCharacter get(char ch, String font, int size, String color) {
String key = ch + "|" + font + "|" + size + "|" + color;
return pool.computeIfAbsent(key,
k -> new ConcreteCharacter(ch, font, size, color));
}
public static int poolSize() { return pool.size(); }
}
// 4. 客户端使用
class Document {
private List<PlacedChar> chars = new ArrayList<>(); // 只存"享元 + 位置"
public void add(char ch, String font, int size, String color, int x, int y) {
chars.add(new PlacedChar(
CharacterFactory.get(ch, font, size, color), // 共享
x, y // 外部状态
));
}
public void render() {
for (PlacedChar pc : chars) pc.flyweight.render(pc.x, pc.y);
}
}
record PlacedChar(ConcreteCharacter flyweight, int x, int y) {}
结果:Document 里有 50 万个 PlacedChar(每个只占两个 int + 一个引用,非常小),但 CharacterFactory 池里可能只有几百个 ConcreteCharacter 实例。内存占用从 50 万 × 大对象 降到 50 万 × 小对象 + 几百 × 大对象。
实战 1:Java Integer 缓存
Integer a = Integer.valueOf(100);
Integer b = Integer.valueOf(100);
System.out.println(a == b); // true —— 同一对象
Integer c = Integer.valueOf(200);
Integer d = Integer.valueOf(200);
System.out.println(c == d); // false —— 不同对象
原因:Java 内置了一个 Integer 缓存,默认范围 -128 到 127。这个范围内的 valueOf 返回缓存对象;超出就 new 一个新的。这是享元模式在 JDK 标准库的内置实现。
同样的还有 Boolean.TRUE / Boolean.FALSE、Long.valueOf(也有缓存)、String.intern()(字符串池)。这就是为什么"包装类型用 valueOf 而不是 new"是个良好习惯 —— 它让你的代码顺势享受 JDK 内置的享元优化。
实战 2:字符串池
所有字符串字面量都自动放进字符串池(intern pool):
String a = "hello";
String b = "hello";
System.out.println(a == b); // true —— 同一对象,来自字符串池
String c = new String("hello"); // 强制 new,不走池
System.out.println(a == c); // false
String d = c.intern(); // 显式加入/查找池
System.out.println(a == d); // true
字符串池是跨整个 JVM 共享的享元 —— 任何代码里出现的 "hello" 字面量都指向同一个对象。这是为什么 Java 启动时类加载会把大量字符串放进池,内存中重复字符串大幅减少。
但要注意:过度 intern 会让 PermGen / Metaspace 撑爆。如果你 intern 大量用户输入的字符串,池会无限增长。规则:只 intern 那些"可控、有限、复用频繁"的字符串。
实战 3:游戏里的粒子系统
射击游戏里,玩家发射 1000 颗子弹,每颗都是一个 Bullet 对象:
class Bullet {
String texture; // 子弹贴图(几 KB)
String sound; // 命中音效
double speed;
int damage;
// ... 内部状态
// 外部状态:位置、方向、当前飞行时间
Vec2 position;
Vec2 direction;
double age;
}
1000 颗"霰弹枪子弹"内部状态完全一样,只是位置不同。用享元拆分:
class BulletType { // 享元
final String texture;
final String sound;
final double speed;
final int damage;
}
class BulletInstance { // 外部状态 + 享元引用
BulletType type;
Vec2 position;
Vec2 direction;
double age;
void update(double dt) {
position.add(direction.mul(type.speed * dt));
age += dt;
}
}
// 类型池
Map<String, BulletType> types = Map.of(
"shotgun", new BulletType("shotgun.png", "shotgun.wav", 800, 5),
"rifle", new BulletType("rifle.png", "rifle.wav", 1200, 15),
"rocket", new BulletType("rocket.png", "rocket.wav", 400, 100)
);
// 创建 1000 颗
List<BulletInstance> bullets = new ArrayList<>();
for (int i = 0; i < 1000; i++) {
BulletInstance b = new BulletInstance();
b.type = types.get("shotgun"); // 共享同一个 BulletType
b.position = new Vec2(x, y);
b.direction = randomDir();
bullets.add(b);
}
1000 颗子弹只对应 3 个 BulletType 实例(几 KB 重的资源只加载 3 份),每颗 BulletInstance 只占几十字节。这种设计让游戏可以同屏渲染数万粒子。
实战 4:字体渲染
字体引擎里,每个字形(glyph)是一组矢量信息或位图,加载一次后被所有"在文档里出现的这个字"共享。这是享元的极致应用 —— 一份字形数据,被显示成千上万次。Skia / FreeType / DirectWrite 这些字体引擎内部都有 glyph cache。
享元 + 不可变 = 默认安全
享元对象必须是不可变的 —— 一旦被多个客户端共享,任何修改都会影响所有用它的地方。所以:
- 所有字段 final。
- 没有 setter。
- 构造时初始化完毕,之后只能读不能改。
这正好和现代 Java / Kotlin / Rust 推崇的"不可变设计"完美契合。事实上,所有 immutable 对象天然适合做享元—— 你只要加个池就能享元化。
享元工厂:线程安全的池
享元池如果被多个线程同时访问,要保证 get 的原子性。否则两个线程同时查 "key X 在池里吗",都发现不在,都各自 new 了一个,池里有了两份"享元",共享就被破坏。
// 错:非线程安全
Map<String, Flyweight> pool = new HashMap<>();
Flyweight get(String key) {
Flyweight f = pool.get(key);
if (f == null) { f = new Flyweight(...); pool.put(key, f); }
return f;
}
// 对 1:ConcurrentHashMap.computeIfAbsent —— 原子且高效
Map<String, Flyweight> pool = new ConcurrentHashMap<>();
Flyweight get(String key) {
return pool.computeIfAbsent(key, k -> new Flyweight(...));
}
// 对 2:synchronized —— 简单但每次访问都锁
synchronized Flyweight get(String key) { ... }
实战推荐 ConcurrentHashMap + computeIfAbsent,既线程安全又对读优化。
享元和缓存的区别
很多人觉得享元就是"加个缓存",但概念上有差别:
- 缓存:为了加速,数据可以丢(缓存失效后重新计算)。
- 享元:为了共享,数据是"权威的"(同样输入永远是同一对象)。
缓存可以有 TTL、有淘汰策略;享元池通常不淘汰(或只在内存极紧张时清理)。但实际工程里两者经常混合 —— "带 LRU 淘汰的享元池"既享元又控内存。
享元 vs 单例
都涉及"对象数量受限",但:
- 单例:全局只有一个实例,无论参数。
- 享元:每种"内部状态"只有一个实例,实例数等于不同内部状态的种类数。
单例是"享元 with key=空"的退化形态。
各语言里的享元
Python:函数式实现
from functools import lru_cache
# lru_cache 直接把函数变成享元工厂
@lru_cache(maxsize=None)
def get_character(ch, font, size, color):
return Character(ch, font, size, color)
# 调用:同样参数返回同一对象
a = get_character('a', 'Arial', 14, 'black')
b = get_character('a', 'Arial', 14, 'black')
assert a is b
JavaScript:Map + WeakMap
const pool = new Map();
function getCharacter(ch, font, size, color) {
const key = `${ch}|${font}|${size}|${color}`;
if (!pool.has(key)) pool.set(key, Object.freeze({ ch, font, size, color }));
return pool.get(key);
}
// 如果 key 是对象,且希望"key 没人引用时享元也能被 GC",用 WeakMap
const wpool = new WeakMap();
享元的代价
1. 代码变复杂
原本一个类,现在要拆成"内部状态类 + 外部状态类 + 工厂",且使用时要区分什么是内部什么是外部。除非真有大量重复对象,否则别上享元。100 个对象的场景不值得。
2. 不能修改
共享对象不能改,意味着字段被外部需要"个性化"修改的就不能放进内部状态。设计时要想清楚什么字段真的"普遍"。
3. 池的内存管理
享元池不释放会持续增长,尤其是用户输入参与 key 时(每个不同输入就增加一个享元)。需要:
- 限制 key 的取值范围(枚举/有限集合)。
- 给池加 LRU 或定期清理。
- 用 SoftReference / WeakReference 让 GC 在内存紧张时回收享元。
识别享元场景
清单(满足越多越值得用享元):
- 系统中有大量相似对象(数千到数百万级)。
- 这些对象的大部分状态可以共享。
- 剩余独有状态相对小。
- 对象本身不需要被修改(或修改可以接受"换一个享元")。
- 对象身份不重要(
==比较意义不大)。
常见坑
坑 1:把可变字段放进内部状态。 享元实例被多处共享,任何一处修改影响全部。规则:内部状态必须 final + immutable。
坑 2:hashCode/equals 不一致。 享元池靠 key 找实例,如果 key 的 hashCode 不稳定(基于可变字段),查找会失败,池会有重复对象。key 用所有内部状态的不可变值。
坑 3:把"看起来重复的"硬享元化。 100 个不同的 User 对象虽然字段名相同,但每个 User 的字段值都不同 —— 不存在共享空间,享元没用。值得享元的是"种类有限、实例无限"的对象。
坑 4:池泄漏。 池里的享元永远不释放,如果 key 是用户输入(可能是任意字符串),池会无限增长直到 OOM。要么限制 key 集合,要么加淘汰。
写在最后
享元模式的核心是"识别重复,提取共享"。它在内存敏感的场景(游戏、文档处理、大数据缓存、嵌入式)是必备工具,在常规业务里反而少见。但它的思想 —— 把对象状态分成"可共享"和"个性化"两部分 —— 是一种通用的"识别冗余"能力,你在做任何系统设计时都会无意识地用到。
下次面对一个"我要建几万个小对象"的需求,先停一秒,问:这些对象之间有多少字段是重复的?重复部分能不能共享?能的话,享元会让你的内存占用骤降几个量级,而代码改动不大。这是 GoF 模式里 ROI 最隐蔽但最高的一个 —— 用对了,它默默地把你从内存崩溃边缘救回来。
—— 别看了 · 2026