设计模式修炼之路 ⑫ 享元模式——共享细粒度对象
共享细粒度对象、内部状态与外部状态的切分、Integer 缓存与字符串常量池
目录
上一篇外观模式给它留了句实在话:前面几位省的都是"代码的复杂度",它省的是"货真价实的内存"。今天正主登场:享元模式(Flyweight Pattern),GoF 23 人名单正牌选手、结构型阵营只剩它和代理压轴。Flyweight 是个自造词——fly(飞)+ weight(重量),合起来"轻如飞絮":同样的对象要造一万份?留一份共享,其余全指过来。它回答的问题朴素到心酸:对象太多,内存不够用了,怎么办?
一、什么是享元模式?
先看定义:
运用共享技术有效地支持大量细粒度的对象。
翻译成人话:当系统里出现海量小对象、而它们大部分内容彼此相同时,把"相同的部分"抽出来做成有限几个共享对象,"不同的部分"在调用时再传进去。一万个对象瘦身成 N 个对象加一万份参数,内存从"万级"降到"常数级"。
生活类比是共享单车停车场——不对,单车会被骑走,更准确的类比是景区地图上的印章:盖一百个"到此一游",不存在一百枚印章,只有一枚,盖一百次;每次位置不同(你手按哪),印章本身不变。印章是共享的享元,位置是随用随传的参数。
它有四个角色:
- Flyweight(享元接口):本篇里 GoPiece 的公开方法 place(x, y);
- ConcreteFlyweight(具体享元):真正被共享的对象,只装内部状态,本篇里是黑棋、白棋两个实例;
- FlyweightFactory(享元工厂):按内部状态缓存享元,保证"同一状态全 JVM 一份",本篇里是 PieceFactory;
- UnsharedConcreteFlyweight(非共享享元):不参与共享的享元,本篇用不上,知道有这一角色即可。
而全模式的灵魂,是它对状态的这刀切分:
- 内部状态(Intrinsic):不随环境变化、可以共享的部分——围棋里棋子的颜色和纹理。黑棋的纹理和另一颗黑棋的纹理一模一样,没理由存两份;
- 外部状态(Extrinsic):随环境变化、无法共享的部分——围棋里棋子的坐标。(3,3) 的黑棋和 (15,15) 的黑棋,差异全在坐标上。
切完之后享元就成型了:内部状态装进对象、全 JVM 一份;外部状态从对象里赶出去、每次调用当参数传入。上一篇外观是"把流程从客户端收进门面",这一篇是"把变化从对象里赶出去"——两位干的是同一门手艺:把"多"变成"一"。
二、享元模式的优缺点
优点
- 内存立竿见影:对象数量从"实例数"降到"内部状态组合数"。一盘围棋 300 颗子,棋子对象只需要 2 个——省的不是 30%,是 99%;
- 创建与 GC 压力同步下降:对象不重复造了,年轻代的分配速率和垃圾回收频率跟着降——高并发场景里这往往比省内存本身还值钱;
- 结构稳定:享元不可变,天生线程安全,并发共享零加锁。
缺点
- 设计成本前置:哪些字段能共享、哪些必须外置,得先想清楚——切错一刀(把可变字段留在享元里),多个"逻辑调用方"就会通过同一对象互相踩脚;
- API 变啰嗦:外部状态从字段变成参数,原来
piece.place()一个字都不用传,现在piece.place(x, y)——每个调用点都得多背一份"本该对象自己记的事"; - 生命周期常驻:工厂的缓存通常静态常驻,内部状态种类一旦膨胀(比如键设计不当导致缓存无限增长),享元池自己反而变成内存泄漏点。
三、享元模式的应用场景
- 包装类缓存(JDK):Integer、Long、Short、Byte、Character、Boolean 的 valueOf 全走缓存——Integer 缓存 -128 到 127,Boolean 就 TRUE/FALSE 两个。这是 JDK 官方实装的最大规模享元池,第五节专门解剖。
- 字符串常量池(JVM):
"hello"字面量全 JVM 存一份,两个类里写同一个字符串,指向的是同一块内存——你从第一天写 Java 起就在用享元。 - 枚举:JVM 保证每个枚举常量全 JVM 只实例化一次,天然享元——所以枚举天生适合当"种类"用(单例模式那篇聊过它还是最硬的单例写法)。
- POI 的 CellStyle:用 Apache POI 写过 Excel 的都撞过那堵墙——每个单元格 new 一个 CellStyle,写到几万行报"样式表超限",官方文档明晃晃写着"请复用样式对象"。CellStyle 就是被 Excel 格式限定了数量的享元。
- 棋牌类游戏:一副斗地主 54 张牌,服务端管一万桌,"黑桃 A"这个对象需要一万份吗?一份就够,哪桌出了它、压在谁的出牌序列里,全是外部状态。
- 地图 POI / GUI 图标:十万个餐馆图标,样式按品类就几十种——图标是享元,经纬度是外部状态。
两个高频误认,先纠个偏:
- 数据库连接池、线程池不是享元,是对象池(Object Pool)。分水岭在"共享方式":享元是同一个对象被并发共享、且不可变(黑白棋被全盘同时引用);对象池是同一个对象被轮流独占、且可变(连接借走用完还回来,池里同一时刻只有一个线程在用它)。一个像公共雕塑,一个像共享充电宝。
- 单例也不是享元。单例是"整个类只要一个实例",享元是"每种内部状态一个实例"——享元池里通常躺着好几个对象(黑白两颗棋),池子本身就是个"按状态分组的批量单例"。
什么时候该用:对象数量大(万级起步)、且大部分内部状态重复、且重复部分是大头(内存大头在纹理不在坐标)——三条全中再上享元;只有一万个十几字节的 POJO?GC 表示毫无压力,别折腾。
四、享元模式的实现方式
示例代码在 design-pattern 项目的 flyweight 包里:
design-pattern/src/main/java
└── flyweight // 享元模式
├── BadPiece.java // 反面教材:每颗棋子自带颜色+纹理+坐标
├── GoPiece.java // 享元:只装内部状态(颜色、纹理),坐标外部传入
├── PieceFactory.java // 享元工厂:按颜色缓存,全 JVM 一份
└── FlyweightTest.java // 测试入口
场景:围棋。棋子有颜色(黑/白)、每颗棋子渲染要一份 1MB 的纹理、还要落在某个坐标上。先看"一颗棋子一个对象"的下场,再看享元怎么救。
1. 反面教材:每颗棋子一份纹理
最直觉的建模:落一子 new 一颗棋子,颜色、纹理、坐标全塞对象里:
// 反面教材:每颗棋子一个对象,颜色、纹理、坐标全塞在对象里
public class BadPiece {
private final String color;
// 模拟渲染纹理:真正的内存大户(实际项目里可能是图片、字形、网格模型)
private final int[] texture = new int[256 * 1024]; // 1MB
private final int x;
private final int y;
public BadPiece(String color, int x, int y) {
this.color = color;
this.x = x;
this.y = y;
}
public void place() {
System.out.println("[BadPiece] " + color + "棋落在 (" + x + ", " + y + "),自带一份 1MB 纹理");
}
}
模拟下十手棋:
List<BadPiece> board = new ArrayList<>();
for (int i = 0; i < 10; i++) {
BadPiece piece = new BadPiece(i % 2 == 0 ? "黑" : "白", i, i * 2);
board.add(piece);
piece.place();
}
System.out.println("10 手棋 = " + board.size() + " 个对象 = 10 份 1MB 纹理");
System.out.println("一盘 300 手的棋就是 300 个对象、300MB 纹理——而所有黑棋的纹理,本来就一模一样");
运行结果:
===== 反面教材:每颗棋子自带一份纹理 =====
[BadPiece] 黑棋落在 (0, 0),自带一份 1MB 纹理
[BadPiece] 白棋落在 (1, 2),自带一份 1MB 纹理
[BadPiece] 黑棋落在 (2, 4),自带一份 1MB 纹理
[BadPiece] 白棋落在 (3, 6),自带一份 1MB 纹理
[BadPiece] 黑棋落在 (4, 8),自带一份 1MB 纹理
[BadPiece] 白棋落在 (5, 10),自带一份 1MB 纹理
[BadPiece] 黑棋落在 (6, 12),自带一份 1MB 纹理
[BadPiece] 白棋落在 (7, 14),自带一份 1MB 纹理
[BadPiece] 黑棋落在 (8, 16),自带一份 1MB 纹理
[BadPiece] 白棋落在 (9, 18),自带一份 1MB 纹理
10 手棋 = 10 个对象 = 10 份 1MB 纹理
一盘 300 手的棋就是 300 个对象、300MB 纹理——而所有黑棋的纹理,本来就一模一样
能跑,但账没法看:
- 重复度触目惊心:十个对象里其实只有两种内容——黑棋、白棋。颜色加纹理只有 2 种组合,却造了 10 个对象、10 份纹理;一盘棋 300 颗子,300MB 纹理里 298MB 是纯重复;
- 真正的差异被淹没了:十颗棋子彼此唯一不同的是坐标——几个字节的信息,却拖着 1MB 的车身;
- 对象数随对局线性膨胀:每落一子堆一个对象,一场多桌并发的棋牌室,内存曲线直接跟着在线人数起飞。
病根只有一个:"不变的 1MB"和"多变的几个字节"被塞进了同一个对象,重复的部分跟着实例数一起翻倍。
2. 享元版:一份纹理,全盘共享
先动刀切状态——纹理和颜色留下(内部状态),坐标赶出去(外部状态):
// 享元:颜色和纹理是内部状态(不变、全盘共享),坐标是外部状态(用时传入)
public class GoPiece {
private final String color;
// 纹理只需两份:黑一份、白一份,全盘所有同色棋子共享这同一份
private final int[] texture = new int[256 * 1024]; // 1MB
public GoPiece(String color) {
this.color = color;
}
// 坐标 x、y 是外部状态:每次落子由调用方传入,享元对象自己不保存
public void place(int x, int y) {
System.out.println("[GoPiece] " + color + "棋落在 (" + x + ", " + y + "),纹理共享全盘同一份");
}
}
注意两个细节:字段全是 final——享元被全盘共享,必须不可变,哪个调用方手贱改了纹理,全盘棋子跟着变脸;place 的参数 x、y——每颗棋子落哪由调用方告诉它,对象自己"失忆"了。
再配工厂,保证"同一颜色全 JVM 一份":
// 享元工厂:按内部状态(颜色)缓存享元,保证每种颜色全 JVM 只有一份
public class PieceFactory {
private static final Map<String, GoPiece> CACHE = new HashMap<>();
public static GoPiece get(String color) {
GoPiece piece = CACHE.get(color);
if (piece == null) {
piece = new GoPiece(color);
CACHE.put(color, piece);
System.out.println("[工厂] 第一次要" + color + "棋,创建并缓存(此后全盘复用)");
}
return piece;
}
}
同样的四手棋:
// 黑白各只有一份,坐标作为外部状态在调用时传入
PieceFactory.get("黑").place(3, 3);
PieceFactory.get("白").place(15, 15);
PieceFactory.get("黑").place(16, 3);
PieceFactory.get("白").place(2, 2);
GoPiece black1 = PieceFactory.get("黑");
GoPiece black2 = PieceFactory.get("黑");
System.out.println("两次取到的黑棋是同一个对象吗?" + (black1 == black2));
System.out.println("全盘 300 手也一样:对象数永远 2 个,纹理永远 2MB");
运行结果:
===== 正解:享元模式 =====
[工厂] 第一次要黑棋,创建并缓存(此后全盘复用)
[GoPiece] 黑棋落在 (3, 3),纹理共享全盘同一份
[工厂] 第一次要白棋,创建并缓存(此后全盘复用)
[GoPiece] 白棋落在 (15, 15),纹理共享全盘同一份
[GoPiece] 黑棋落在 (16, 3),纹理共享全盘同一份
[GoPiece] 白棋落在 (2, 2),纹理共享全盘同一份
两次取到的黑棋是同一个对象吗?true
全盘 300 手也一样:对象数永远 2 个,纹理永远 2MB
对照反面教材逐条验收:
- 对象数与落子数彻底脱钩:反面教材 300 手 300 个对象,正解 300 手还是 2 个——内存从 O(手数) 降到 O(颜色数),这正是定义里"支持大量细粒度对象"的实现方式:大量对象被"一份享元 + 每次调用的外部状态"撑起来了;
black1 == black2为 true——工厂真的只发同一份对象,连"=="都验明正身;- 谁该记住坐标?棋盘。真实实现里落子记录是棋盘上的一个
List<Placement>(每条只存颜色引用 + 坐标),棋盘管外部状态、享元管内部状态,职责各归其位; - 纹理这种重资源,从"随对象生"变成"随类别生"——加载一次,全盘受益,连加载耗时都省了。
五、Integer 缓存与字符串常量池:JDK 里的享元
理论讲完,去看这套机制在 JDK 里的样子——先看一段让无数人怀疑人生的代码:
Integer a = 127;
Integer b = 127;
Integer c = 128;
Integer d = 128;
System.out.println(a == b); // true
System.out.println(c == d); // false
同一个写法,127 相等、128 不等,玄机全在 Integer.valueOf 的源码里:
public static Integer valueOf(int i) {
if (i >= IntegerCache.low && i <= IntegerCache.high)
return IntegerCache.cache[i + (-IntegerCache.low)]; // 命中缓存,返回共享对象
return new Integer(i); // 超范围,每次真 new
}
IntegerCache 是个静态内部类,类加载时就把 -128 到 127 这 256 个 Integer 提前 new 好、放进数组——一个提前造好的享元池,PieceFactory 需要时才创建,IntegerCache 是"先造好等你来取",思路一脉相承。Integer a = 127 的自动装箱编译成 Integer.valueOf(127),命中缓存,a 和 b 拿到的是同一个对象,所以 == 为 true;128 超出范围,c 和 d 各自 new,== 自然 false。
这就是各大编码规范反复强调"包装类比较必须用 equals"的技术根源——享元缓存的存在,让 == 的结果跟数值范围挂钩,这种语义陷阱只能靠规范绕开。顺带两个冷知识:缓存上限可以用 -XX:AutoBoxCacheMax=<size> 调大(下限 -128 动不了);Long、Short、Byte、Character 也有各自的缓存,只是范围写死、不能调。
字符串那边是另一副景象。String s = "hello" 的字面量进字符串常量池,两个类里写同一个字面量,指向池里同一份:
String s1 = "享元";
String s2 = "享元";
System.out.println(s1 == s2); // true:常量池里就一份
System.out.println(s1 == new String("享元")); // false:new 出来的不走池
System.out.println(s1 == new String("享元").intern()); // true:intern() 把它送回池里对齐
和 IntegerCache 比,常量池是"运行时也能进进出出的享元池"——intern() 就是手动登记处:池里有就返回池里那份,没有就登记当前这份。字符串不可变(String 全字段 final),恰好满足享元"共享必须不可变"的铁律——JDK 这两套享元,一套提前造、一套随时进,把"对象复用"做进了语言的地基。
写在最后
回顾这一篇:享元模式把对象劈成两半——内部状态(不变、共享)留在对象里,外部状态(多变)赶出去做参数,配一个工厂保证"同一状态全 JVM 一份",海量对象瘦身成有限几个;反面教材"一颗棋子一份纹理"300MB 的账单,享元两行字抹平;包装类缓存、字符串常量池、枚举、POI 的 CellStyle、棋牌服务端的牌对象,全是它的地盘;享元 vs 对象池的分水岭是"并发共享的不可变对象"还是"轮流独占的可变对象"——连接池线程池都不算享元。
下一篇 ⑬ 代理模式,结构型最后一位压轴出场。它的 UML 和装饰器几乎一个模子:同样实现同一个接口、同样手里攥着一个同类对象——区别全在意图上:装饰器回答"怎么给它加功能",代理回答"怎么替它管住访问"。懒加载、权限校验、远程调用,还有 JDK 动态代理、CGLIB、Spring AOP 这整座大厦的地基,全是这一篇的内容。