享元回答的问题:对象太多、内存扛不住时怎么办。它的核心只有一句话——把状态拆成「可以共享的内部状态」和「不能共享的外部状态」,共享前者、外置后者。本文讲清内部/外部状态这条分界线、工厂池与线程安全、JDK 里
Integer缓存和String常量池这两个现成的享元,最后把享元与对象池这对最容易混的概念彻底分开。
一、问题的起点:一百万个字符,一百万个对象
文本编辑器是最典型的例子。如果每个字符都建一个对象,并且把字体、字号、位置、颜色全都塞进对象里:
/** 没有享元:每个字符一个对象,各自带着完全相同的字体信息 */
class CharObject {
char ch;
String font; // 一百万个字符 → 一百万个一模一样的 "Noto Sans SC"
int size; // 同样重复了一百万遍
int x, y; // 位置:每个字符都不同
String color; // 颜色:同一段文字里其实也大量重复
}
问题很清楚:一百万字符里,真正不同的「字形 + 字体 + 字号」组合可能只有几十种,但每个字符对象都重复存了一遍。 每个对象至少几十字节,一百万字符就是几十 MB 的额外开销——而这些字段里,绝大多数值是完全相同的。

二、结构:四个角色与那个「池」
享元的结构里最关键的不是那个享元类,而是工厂里的那个池:

- Flyweight(抽象享元):
Glyph,对外暴露的操作接口。注意它的方法签名里带着外部状态(x, y, color)——这是享元最重要的设计信号。 - ConcreteFlyweight(具体享元):
CharGlyph,只存内部状态(字符、字体、字号),且这些字段必须是final。 - FlyweightFactory(享元工厂):
GlyphFactory,持有一个Map池,按「内部状态的组合」做 key,有就返回旧的,没有才创建。 - Client(客户端):持有外部状态(每个字符的坐标、颜色),在调用时传进去。
两个容易漏掉的点:
- 享元类里不能有外部状态字段。一旦存了坐标,这个对象就只能给一个字符用,"共享"立刻失效。
- 工厂必须保证"同一个 key 只创建一个对象"。否则池里出现多个相同实例,共享是假的(第五节讲并发下怎么做)。
三、手写一个字符享元
下面是可直接运行的单文件版本(Java 8+):
import java.util.HashMap;
import java.util.Map;
/** Flyweight:只保存内部状态(字符、字体、字号),字段全部 final */
final class Glyph {
private final char ch;
private final String font;
private final int size;
Glyph(char ch, String font, int size) {
this.ch = ch;
this.font = font;
this.size = size;
}
/** 外部状态(坐标、颜色)由客户端传入,享元自己不保存 */
void draw(int x, int y, String color) {
System.out.println("绘制 '" + ch + "' " + font + "/" + size + "px "
+ "于 (" + x + "," + y + ") 颜色 " + color);
}
}
/** FlyweightFactory:池 + 复用,key 由内部状态的组合构成 */
class GlyphFactory {
private final Map<String, Glyph> pool = new HashMap<>();
Glyph get(char ch, String font, int size) {
String key = ch + "|" + font + "|" + size;
Glyph glyph = pool.get(key);
if (glyph == null) { // 池里没有才创建
glyph = new Glyph(ch, font, size);
pool.put(key, glyph);
}
return glyph; // 池里已有就直接复用
}
int poolSize() {
return pool.size();
}
}
public class FlyweightDemo {
public static void main(String[] args) {
GlyphFactory factory = new GlyphFactory();
String text = "享元模式享元模式享元模式享元模式"; // 16 个字符,只有 4 种不同的字
for (int i = 0; i < text.length(); i++) {
// 内部状态交给工厂复用;外部状态(位置、颜色)每次传入
Glyph glyph = factory.get(text.charAt(i), "Noto Sans SC", 16);
glyph.draw(i * 16, 100, "#333333");
}
System.out.println("字符数:" + text.length());
System.out.println("实际创建的 Glyph 对象:" + factory.poolSize());
}
}
输出(节选):
绘制 '享' Noto Sans SC/16px 于 (0,100) 颜色 #333333
绘制 '元' Noto Sans SC/16px 于 (16,100) 颜色 #333333
绘制 '模' Noto Sans SC/16px 于 (32,100) 颜色 #333333
绘制 '式' Noto Sans SC/16px 于 (48,100) 颜色 #333333
绘制 '享' Noto Sans SC/16px 于 (64,100) 颜色 #333333
...
字符数:16
实际创建的 Glyph 对象:4
一次 get() 在工厂内部是这样走的:

三点值得注意:
- 16 次绘制只创建了 4 个对象——同样一个字,在不同位置画,用的是同一个实例。
draw的参数就是外部状态:坐标和颜色每次不同,所以它们不能进对象,只能当参数。- 如果后面再画 100 万字,只要字符和字体不新增,池的大小仍然是 4——这就是"支持大量细粒度对象"的含义。
四、核心:内部状态与外部状态
享元能不能成立,全看这条分界线画得对不对。

| 内部状态 | 外部状态 | |
|---|---|---|
| 是否随场景变化 | 不变 | 每次可能不同 |
| 存在哪 | 享元对象里(final 字段) | 客户端 / 调用参数里 |
| 能否共享 | 能,且必须共享 | 不能共享 |
| 典型例子 | 字符、字体、字号、图的网格模型、棋子的颜色 | 坐标、颜色、缩放、棋子的位置 |
判断方法很朴素:问一句"这个值在同一个享元的不同使用场景里会变吗"——不变的是内部状态,会变的是外部状态。
还有一个容易被忽略、但决定成败的细节:外部状态放在哪。正确做法是把它集中存成一张紧凑的「位置表」(比如 int[] x、int[] y,或一个 Position 数组),而不是"一个字符一个对象、对象里挂着享元引用"——后者虽然共享了字形,但每个字符仍然是一个对象,内存收益会大打折扣。享元真正省下来的内存,来自「少量享元 + 一张扁平的外部状态表」这个组合。
两个坑:
- 把会变的状态放进享元 → 共享失效,还会出现"A 改一处、B 跟着变"的串数据问题;
- 把不变的状态也外置 → 每次调用都要传一堆参数,享元退化成"无状态的工具类",白折腾。
五、工厂与线程安全
工厂的 get 必须是"同一个 key 只返回同一个实例"。单线程下 HashMap 够了,但在 Web 应用里,这个池会被多个请求线程同时访问,必须换成并发容器:
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
class GlyphFactory {
// 并发场景:computeIfAbsent 保证 key 不存在时才创建,且整个过程是原子的
private final ConcurrentMap<String, Glyph> pool = new ConcurrentHashMap<>();
Glyph get(char ch, String font, int size) {
String key = ch + "|" + font + "|" + size;
return pool.computeIfAbsent(key, k -> new Glyph(ch, font, size));
}
}
另外两点工程实践:
- 池要有上限:如果 key 的组合会无限增长(比如颜色、样式也进了 key),池会变成内存泄漏点,需要设上限或改成弱引用(
WeakHashMap)。 - 享元对象不可变:所有字段
final、不提供 setter、不暴露可变集合——这是共享的前提,不是风格偏好。
六、JDK 与框架里的享元
① Integer 缓存 —— 最贴近日常的享元:Integer.valueOf() 对 -128 ~ 127 复用同一批对象,所以这个范围内 == 会返回 true:
Integer a = Integer.valueOf(127);
Integer b = Integer.valueOf(127);
System.out.println(a == b); // true:命中缓存,同一个对象
Integer c = Integer.valueOf(128);
Integer d = Integer.valueOf(128);
System.out.println(c == d); // false:超出缓存范围,各自新建
Integer e = new Integer(127); // 显式 new 一定绕开缓存(Java 9 起已废弃)
System.out.println(a == e); // false

② String 常量池 —— 字面量字符串自动入池、intern() 手动入池;两个内容相同的字面量指向同一个对象。判断字符串相等永远用 equals,== 比较的是"是不是同一个享元"。
③ 其他 —— Boolean.TRUE / FALSE、Character 缓存(0~127)、枚举实例(天然的单例享元)、游戏引擎里共享的网格/材质。
判断标准:对象里是否只有"不可变的、重复率极高的"状态,并且被大量复用。
七、享元 vs 对象池 vs 单例
这是两个最容易被混的概念。享元共享的是"数据",对象池复用(独占)的是"实例":
| 享元 Flyweight | 对象池 Object Pool | 单例 Singleton | |
|---|---|---|---|
| 共享/复用的对象 | 不可变的内部状态 | 可变、有状态的对象实例 | 唯一的全局实例 |
| 同一时刻的使用者 | 多个(可同时读) | 只有一个(借出期间独占) | 多个(但要注意线程安全) |
| 解决的问题 | 内存占用(对象太多) | 创建/销毁成本(连接、线程昂贵) | 全局唯一性 / 受控访问 |
| 典型例子 | Integer 缓存、字符字形、瓦片地图 | 数据库连接池、线程池 | 配置中心、日志器 |
| 归还 | 不需要归还 | 必须归还 | 不存在归还 |
一句话记忆:享元是"很多人同时看同一张地图",对象池是"一支笔借给一个人用完再还"。
顺带澄清一个常见的误认:享元也不是缓存。缓存关心的是「结果算过一次就存起来」,key 通常是一次请求或一次计算的输入,并且会过期、会淘汰;享元关心的是「同一个不可变对象被大量地方同时引用」,在对象的生命周期内通常一直有效。
八、什么时候不要用享元
- 对象数量本来就不多:为了几十个对象搞一套池 + 内外状态拆分,是纯粹的过度设计。
- 状态无法清晰分离内外:如果每个对象的状态都随着场景变化,那没有可共享的部分。
- 需要频繁修改共享数据:享元的前提是不可变;一旦要改,共享就会变成一致性地狱。
- key 的组合会无限增长:池会变成内存泄漏点,除非有上限或弱引用策略。
九、结构型七个:一张表理清
| 模式 | 一句话意图 | 结构特征 | 典型场景 |
|---|---|---|---|
| 代理 Proxy | 控制对单个对象的访问 | 一对一,接口不变 | 权限、远程调用、延迟加载 |
| 适配器 Adapter | 让接口对不上的双方协作 | 一对一,接口转换 | 接第三方 SDK、老系统兼容 |
| 装饰者 Decorator | 不改源码叠加职责 | 一层套一个,接口不变 | IO 流、加缓存/日志/加密 |
| 桥接 Bridge | 分离两个独立变化的维度 | 两棵树 + 一座桥 | JDBC、跨平台 GUI |
| 外观 Facade | 简化一组子系统的调用 | 多对一,统一入口 | 下单编排、日志门面 |
| 组合 Composite | 表示部分-整体的层次结构 | 一对多自聚合,多叉树 | 组织架构、菜单、文件系统 |
| 享元 Flyweight | 共享大量细粒度对象 | 池 + 内外状态分离 | Integer 缓存、字形、瓦片 |
评论