本文是【GoF设计模式】系列第12篇,更多内容欢迎关注公众号:咖啡八杯

前言
为什么需要享元模式?
假设要做一个文字处理软件,一篇 10 万字的文档,每个字符都有字体、字号、颜色等格式属性。如果每个字符都独立存储一份格式对象,就要创建 10 万个格式对象——其中大量对象的属性完全相同(比如正文都是"宋体、12号、黑色"),内存直接爆掉。
// 每个字符一个格式对象:10万个对象,大量重复
class CharFormat {String font;int size;String color;
}
游戏开发也是一样:场景中可能有 10000 棵树,每棵树的类型只有 3 种(橡树、松树、枫树)。如果每棵树都创建一个完整的类型对象,9997 个对象都是浪费——同类树木的颜色、纹理、耐旱度完全相同,只有坐标不同。
这种"大量对象的属性可以分为'相同的'和'不同的'两部分"的矛盾,就是享元模式要解决的问题。
概念
享元模式(Flyweight Pattern)是一种结构型设计模式,核心思想是通过共享相同对象来减少内存占用。
名字的含义
"享元"这个名字拆开来看:
- 享 = 共享(share),多个上下文共用同一个对象实例
- 元 = 元素(element),被共享的那个对象本身
英文名 Flyweight 来自拳击术语"蝇量级"(最轻量级),强调效果——通过共享,大量对象变得"轻量"。中文名强调机制(共享),英文名强调效果(变轻),合在一起概括了这个模式的全貌。
内部状态与外部状态
享元模式的精髓在于区分两种状态:
- 内部状态:存储在享元对象内部,对所有上下文都相同,不可变。例如公司公章的图案和文字——刻好之后就不会变了。
- 外部状态:依赖上下文、可能变化的部分,不存储在享元对象内部,由客户端在使用时传入。例如盖章时合同上需要盖章的位置——每份合同不同。
举个例子:公司只有一个公章(内部状态固定),但可以盖在无数份合同的不同位置(外部状态变化)。不需要为每份合同刻一个新章——这就是享元模式"共享"的本质。
角色
享元模式包括以下四个角色:
- 享元接口 Flyweight:所有具体享元类的共享接口,包含接受外部状态的方法。
- 具体享元类 ConcreteFlyweight:实现享元接口,存储内部状态。
- 享元工厂 FlyweightFactory:创建并管理享元对象池,当用户请求时,提供已创建的实例或新建一个。
- 客户端 Client:维护外部状态,在使用享元对象时将外部状态传入。
图中各类之间的关系:FlyweightFactory 依赖 Flyweight 接口创建和管理对象,ConcreteFlyweight 实现了 Flyweight 接口并持有内部状态,Client 依赖 FlyweightFactory 获取享元对象、同时依赖 Flyweight 接口使用享元对象——外部状态由 Client 自己维护,不体现为 Flyweight 的字段。
实现
标准实现
享元工厂维护一个对象池,按内部状态标识查找已有对象——未找到则创建并放入池中,找到则直接返回。
// 享元接口
interface Flyweight {void operation(String extrinsicState);
}// 具体享元类
class ConcreteFlyweight implements Flyweight {private String intrinsicState;public ConcreteFlyweight(String intrinsicState) {this.intrinsicState = intrinsicState;}public void operation(String extrinsicState) {System.out.println("内部状态: " + intrinsicState+ ", 外部状态: " + extrinsicState);}
}// 享元工厂
class FlyweightFactory {private Map<String, Flyweight> pool = new HashMap<>();public Flyweight getFlyweight(String key) {if (!pool.containsKey(key)) {pool.put(key, new ConcreteFlyweight(key));}return pool.get(key);}
}// 客户端
public class Client {public static void main(String[] args) {FlyweightFactory factory = new FlyweightFactory();Flyweight f1 = factory.getFlyweight("X");f1.operation("First");Flyweight f2 = factory.getFlyweight("X");f2.operation("Second");System.out.println(f1 == f2); // true,同一个实例}
}
引入一个例子:「公司只有一个公章(内部状态固定),但可以盖在无数份合同的不同位置(外部状态变化)。不需要为每份合同刻一个新章——这就是享元模式"共享"的本质。」
// 公章(享元)
class Seal {private String companyName; // 内部状态:公司名称private String pattern; // 内部状态:公章图案public Seal(String companyName, String pattern) {this.companyName = companyName;this.pattern = pattern;}public void stamp(String contractName, int x, int y) {// 在合同的 (x, y) 位置盖章System.out.println("在《" + contractName + "》的(" + x+ "," + y + ")位置盖章:" + companyName + " " + pattern);}
}// 公章工厂 —— 相当于"公章管理处"
class SealFactory {private Map<String, Seal> pool = new HashMap<>();public Seal getSeal(String companyName) {if (!pool.containsKey(companyName)) {// 实际项目中 pattern 也应作为参数传入,此处简化pool.put(companyName, new Seal(companyName, "五角星"));}return pool.get(companyName);}
}// 公司(客户端)—— 相当于"盖章的人"
class Company {public static void main(String[] args) {SealFactory factory = new SealFactory();// 公司只有一枚公章(内部状态固定)Seal seal1 = factory.getSeal("阿里巴巴");Seal seal2 = factory.getSeal("阿里巴巴");System.out.println(seal1 == seal2); // true,同一枚公章// 同一枚公章盖在不同合同的不同位置(外部状态变化)seal1.stamp("采购合同", 100, 200); // 位置(100,200)seal2.stamp("销售合同", 150, 300); // 位置(150,300)seal1.stamp("劳动合同", 80, 150); // 位置(80,150)// 三份合同用的是同一枚公章对象,不需要为每份合同刻一个新章}
}
关键点:Seal 的公司名称和图案是内部状态(像公章上刻好的字),创建后永不改变;合同名称和盖章位置是外部状态(像盖在哪份合同的哪个位置),由客户端调用 stamp() 时传入。公司只需要一枚公章,就能盖无数份合同——这就是享元模式"共享"的本质。
享元工厂与缓存
享元就像图书馆里的一本书被多人同时借阅——每个人翻到不同页码(外部状态),但书本身(内部状态)只有一本。缓存就像把借过的书复印一份存起来,下次不用再借——省的是"借"的功夫,不是"书"的数量。
享元工厂里确实有个 Map 存着对象,看起来很像缓存,但二者的目的和机制完全不同:
| 对比维度 | 享元模式 | 缓存 |
|---|---|---|
| 核心目的 | 共享对象实例,节省内存 | 存储计算结果,避免重复计算/查询 |
| 关键机制 | 分离内部状态和外部状态 | 键值对存储 + 淘汰策略 |
| 返回结果 | 同一个对象实例(多处同时持有同一引用) | 可能是新对象、副本或同一引用 |
| 管理策略 | 创建后常驻,通常不淘汰 | 有 LRU/TTL 等淘汰策略 |
一句话区分:享元共享的是"对象本身",缓存存储的是"计算结果"。 享元能做到的事——让同一个对象同时被多个上下文使用,每次传入不同的外部状态——缓存做不到,因为缓存不关心内部/外部状态的分离。享元工厂本质上用了缓存的思想来存储对象,但享元多了内部/外部状态的分离和外部状态的参数化传递,这是缓存不具备的。
对象状态能分离为内外两类 → 享元(节省内存);不能分离,只是想避免重复计算 → 缓存(减少计算)。
总结
享元模式本质上是分离内部状态和外部状态,通过共享内部状态相同的对象来减少内存占用。
什么时候用:
- 系统中有大量相似对象,且对象的属性可以分为"相同的"和"不同的"两部分
- 内存占用是瓶颈,需要优化对象数量
- 对象的内部状态不可变,可以安全共享
什么时候不用:
- 对象数量本来就不多,共享没有意义
- 对象内部状态各不相同,无法共享
- 内部状态需要频繁修改,享元对象必须不可变
简单记忆:
享元分内外,共享省内存。内部不可变,外部传参用。
相似模式区分
| 模式 | 接口关系 | 核心意图 | 典型场景 |
|---|---|---|---|
| 享元 | 工厂按key管理享元对象池 | 共享内部状态相同的对象,节省内存 | 文本格式、游戏纹理、地图图标 |
| 单例 | 全局静态方法返回同一实例 | 确保全局只有一个实例 | 配置管理、日志记录 |
| 缓存 | 键值对存储 + 淘汰策略 | 存储计算结果,避免重复计算 | 数据库查询、API调用 |
口诀对比:享元省内存,单例保唯一,缓存减计算。
享元 vs 单例
| 维度 | 享元模式 | 单例模式 |
|---|---|---|
| 核心意图 | 共享大量相似对象,节省内存 | 确保全局只有一个实例 |
| 结构差异 | 多个实例(每种内部状态一个),工厂管理对象池 | 一个实例(全局唯一),静态方法返回 |
| 关注点 | 对象状态分离为内外两类,内部状态不可变 | 实例唯一性,可以有可变状态 |
| 典型场景 | 文本格式、游戏纹理、地图图标 | 配置管理、日志记录、数据库连接池 |
逐步区分法:
- 需要限制实例数量为唯一一个 → 单例
- 需要同一个对象实例被多处共享,对象状态可分离为内外两类 → 享元
记忆口诀:"单例一人独享,享元多人共享。"
练习题目
游戏场景 - 树木渲染
题目描述:在一个开放世界游戏中,场景中有大量树木需要渲染。树木分为三种类型:
| 类型 | 颜色 | 纹理 | 耐旱度 |
|---|---|---|---|
| OAK(橡树) | Green | Rough | 3 |
| PINE(松树) | DarkGreen | Smooth | 5 |
| MAPLE(枫树) | Red | Rough | 2 |
颜色、纹理、耐旱度是同类树木共有的内部状态,而每棵树的坐标 (x, y) 是外部状态。请使用享元模式实现树木渲染系统,使相同类型的树木共享同一个享元对象。不用享元模式的话,10000 棵树就要创建 10000 个 TreeType 对象——同类的 9997 个都是重复的,只有坐标不同,颜色/纹理完全相同,内存直接爆了。
输入描述:多行,每行一个种植命令,格式为:
树木类型 x y
输出描述:对于每个种植命令:
- 若该类型的享元对象首次创建,先输出:
Creating [类型]: color=[颜色], texture=[纹理], droughtTolerance=[耐旱度] - 然后输出:
[类型] planted at (x, y) - 所有命令处理完毕后输出:
Total trees planted: N
Flyweight objects created: M
输入示例:
OAK 10 20
PINE 30 40
OAK 15 25
MAPLE 5 15
PINE 50 60
OAK 10 20
输出示例:
Creating OAK: color=Green, texture=Rough, droughtTolerance=3
OAK planted at (10, 20)
Creating PINE: color=DarkGreen, texture=Smooth, droughtTolerance=5
PINE planted at (30, 40)
OAK planted at (15, 25)
Creating MAPLE: color=Red, texture=Rough, droughtTolerance=2
MAPLE planted at (5, 15)
PINE planted at (50, 60)
OAK planted at (10, 20)
Total trees planted: 6
Flyweight objects created: 3
解题思路:树类型(颜色、纹理、耐旱度)是内部状态——同一类树的这些属性完全一样,只需创建一个共享对象。坐标是外部状态——每棵树的位置不同,由客户端调用 display(x, y) 时传入。享元工厂按类型名管理对象池,首次遇到某类型时创建并打印创建信息,之后再遇到直接返回已有对象。不用享元模式,6 棵树就要 new 6 次 TreeType;用了享元,只需要 new 3 次。
import java.util.*;public class Main {public static void main(String[] args) {Scanner sc = new Scanner(System.in);TreeFactory factory = new TreeFactory();int totalPlanted = 0;while (sc.hasNext()) {String typeName = sc.next();int x = sc.nextInt();int y = sc.nextInt();Tree tree = factory.getTree(typeName);tree.display(x, y);totalPlanted++;}System.out.println("Total trees planted: " + totalPlanted);System.out.println("Flyweight objects created: "+ factory.getPoolSize());sc.close();}
}interface Tree {void display(int x, int y);
}class TreeType implements Tree {private String type; // typeName 既是池的 key,也是享元的内部状态private String color;private String texture;private int droughtTolerance;public TreeType(String type, String color, String texture,int droughtTolerance) {this.type = type;this.color = color;this.texture = texture;this.droughtTolerance = droughtTolerance;}public String getColor() { return color; }public String getTexture() { return texture; }public int getDroughtTolerance() { return droughtTolerance; }public void display(int x, int y) {System.out.println(type + " planted at (" + x + "," + y + ")");}
}class TreeFactory {private Map<String, Tree> pool = new HashMap<>();public Tree getTree(String typeName) {if (!pool.containsKey(typeName)) {TreeType tree = createTreeType(typeName);pool.put(typeName, tree);System.out.println("Creating " + typeName + ": color="+ tree.getColor() + ", texture=" + tree.getTexture()+ ", droughtTolerance="+ tree.getDroughtTolerance());}return pool.get(typeName);}private TreeType createTreeType(String typeName) {if ("OAK".equals(typeName)) {return new TreeType("OAK", "Green", "Rough", 3);} else if ("PINE".equals(typeName)) {return new TreeType("PINE", "DarkGreen", "Smooth", 5);} else { // MAPLEreturn new TreeType("MAPLE", "Red", "Rough", 2);}}public int getPoolSize() {return pool.size();}
}
扩展:实际项目中的享元模式
Java String 常量池
JVM 中的字符串常量池是享元模式最经典的实现。内容相同的字符串字面量在常量池中只存一份,所有引用指向同一个对象。
String s1 = "hello";
String s2 = "hello";
System.out.println(s1 == s2); // true,同一个对象String s3 = new String("hello");
String s4 = s3.intern(); // 手动放入常量池,返回池中的引用
System.out.println(s1 == s4); // true
关键点:字符串的内容就是内部状态(不可变),intern() 相当于享元工厂的 getFlyweight() 方法——池中有则返回已有实例,没有则放入再返回。
Java Integer 缓存
Integer.valueOf() 对 -128 到 127 范围内的整数做了享元缓存,避免频繁装箱时创建大量重复的小整数对象。
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,超出缓存范围,各自创建新对象
关键点:IntegerCache 就是享元工厂,[-128, 127] 范围内的 Integer 对象就是具体享元,整数值本身就是内部状态(不可变)。这是 JDK 源码中可以直接查阅的享元模式实例——打开 java.lang.Integer 就能看到 IntegerCache 内部类。
游戏中的纹理共享
同一个纹理文件被场景中成百上千个粒子或模型引用,如果每个对象都加载一份纹理数据,内存直接爆掉。游戏引擎用享元模式让同类对象共享纹理,每个对象只维护自己的位置、旋转等外部状态。
// 纹理(享元)
class Texture {private byte[] imageData; // 内部状态:纹理数据public Texture(String path) {this.imageData = loadImage(path); // 只加载一次}public void render(int x, int y) { /* 渲染逻辑 */ }
}// 纹理工厂
class TextureFactory {private Map<String, Texture> pool = new HashMap<>();public Texture getTexture(String path) {if (!pool.containsKey(path)) {pool.put(path, new Texture(path));}return pool.get(path);}
}// 粒子(外部状态由粒子自己维护)
class Particle {private Texture texture; // 共享的享元对象private int x, y; // 外部状态:坐标public void draw() {texture.render(x, y); // 传入外部状态}
}
关键点:TextureFactory 保证同一个纹理路径只加载一次,10000 个同类粒子共享同一份纹理数据。
地图标记图标共享
地图上可能有成千上万个标记点(加油站、餐厅、景点),同一类型的标记使用相同图标。每个标记点共享一个图标对象,各自维护自己的经纬度坐标。高德、百度等地图 SDK 内部大量使用类似机制管理标记图标。
// 地图图标(享元)
class MapIcon {private String iconFile; // 内部状态:图标文件public MapIcon(String file) {this.iconFile = file;}public void render(double lat, double lng) { /* 渲染逻辑 */ }
}// 图标工厂
class MapIconFactory {private Map<String, MapIcon> pool = new HashMap<>();public MapIcon getIcon(String type) {if (!pool.containsKey(type)) {pool.put(type, new MapIcon(type + ".png"));}return pool.get(type);}
}// 标记点(外部状态由标记点自己维护)
class MapMarker {private MapIcon icon; // 共享的享元对象private double lat, lng; // 外部状态:经纬度public void draw() {icon.render(lat, lng); // 传入外部状态}
}
关键点:MapIconFactory 保证同一种图标只创建一次,成千上万个标记点共享同一个图标对象。
技术交流 & 更多原创内容,关注公众号:咖啡八杯
