当前位置: 首页 > news >正文

GoF设计模式——享元模式

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

image

前言

为什么需要享元模式?

假设要做一个文字处理软件,一篇 10 万字的文档,每个字符都有字体、字号、颜色等格式属性。如果每个字符都独立存储一份格式对象,就要创建 10 万个格式对象——其中大量对象的属性完全相同(比如正文都是"宋体、12号、黑色"),内存直接爆掉。

// 每个字符一个格式对象:10万个对象,大量重复
class CharFormat {String font;int size;String color;
}

游戏开发也是一样:场景中可能有 10000 棵树,每棵树的类型只有 3 种(橡树、松树、枫树)。如果每棵树都创建一个完整的类型对象,9997 个对象都是浪费——同类树木的颜色、纹理、耐旱度完全相同,只有坐标不同。

这种"大量对象的属性可以分为'相同的'和'不同的'两部分"的矛盾,就是享元模式要解决的问题。

概念

享元模式(Flyweight Pattern)是一种结构型设计模式,核心思想是通过共享相同对象来减少内存占用

名字的含义

"享元"这个名字拆开来看:

  • = 共享(share),多个上下文共用同一个对象实例
  • = 元素(element),被共享的那个对象本身

英文名 Flyweight 来自拳击术语"蝇量级"(最轻量级),强调效果——通过共享,大量对象变得"轻量"。中文名强调机制(共享),英文名强调效果(变轻),合在一起概括了这个模式的全貌。

内部状态与外部状态

享元模式的精髓在于区分两种状态:

  • 内部状态:存储在享元对象内部,对所有上下文都相同,不可变。例如公司公章的图案和文字——刻好之后就不会变了。
  • 外部状态:依赖上下文、可能变化的部分,不存储在享元对象内部,由客户端在使用时传入。例如盖章时合同上需要盖章的位置——每份合同不同。

举个例子:公司只有一个公章(内部状态固定),但可以盖在无数份合同的不同位置(外部状态变化)。不需要为每份合同刻一个新章——这就是享元模式"共享"的本质。

角色

享元模式包括以下四个角色:

  • 享元接口 Flyweight:所有具体享元类的共享接口,包含接受外部状态的方法。
  • 具体享元类 ConcreteFlyweight:实现享元接口,存储内部状态。
  • 享元工厂 FlyweightFactory:创建并管理享元对象池,当用户请求时,提供已创建的实例或新建一个。
  • 客户端 Client:维护外部状态,在使用享元对象时将外部状态传入。
classDiagramdirection BTclass Flyweight {<<interface>>+operation(extrinsicState)}class ConcreteFlyweight {-intrinsicState+operation(extrinsicState)}class FlyweightFactory {-pool: Map+getFlyweight(key): Flyweight}class Client {-extrinsicState}ConcreteFlyweight ..|> Flyweight : 实现FlyweightFactory o--> Flyweight : 创建和管理Client ..> FlyweightFactory : 获取享元Client ..> Flyweight : 使用享元

图中各类之间的关系: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 保证同一种图标只创建一次,成千上万个标记点共享同一个图标对象。

技术交流 & 更多原创内容,关注公众号:咖啡八杯

http://www.jsqmd.com/news/1026216/

相关文章:

  • MainsailOS深度解析:高性能3D打印控制系统的架构设计与实战应用
  • 换了电脑或重装系统后Git推送失败?快速恢复Gitee/SSH访问权限的3个关键步骤
  • 嵌入式系统内存映射与U-Boot配置:从QorIQ处理器到启动部署实战
  • 老博会上,这款手机为何让AI老人“活”了?——ibbot青春版,一个让你家的token自己“长”出来的AI编程执行器
  • 避坑指南:Lattice Radiant 2023.2安装后破解失败?别急着卸载,先检查这个隐藏的‘前任’
  • 北京本地高价回收生肖邮票纪念币,老邮册工艺品上门收 - 深鉴新闻
  • 博客标题缺失导致内容生成失败的典型原因分析
  • 2026亲测推荐:901树脂实践案例分享 - 资讯快报
  • SolidWorks第四部分_直接实体建模特征10_移动面操作
  • 2026福州高端别墅电梯推荐品牌测评:十大权威排行榜与优选推荐 - 资讯快报
  • 英雄联盟回放播放终极指南:如何使用ROFL-Player轻松观看历史比赛
  • QQ机器人-Astrbot搭配NapCat框架插件文件发送问题 - windows99
  • Python ezdxf:7天掌握DXF文件处理的完整指南
  • 【技术干货】OpenRouter Fusion复合API实战:多模型协同调用如何突破单模型性能瓶颈
  • 2026年建筑护栏围栏生产厂家推荐:从工程集采到庭院定制,如何找到靠谱的供应商 - 资讯快报
  • 3个技术突破:Path of Building PoE2如何解决流放之路2角色构建的复杂性问题
  • 2026福州大平层装修公司哪家靠谱?最新排行榜与避坑指南 - 资讯快报
  • Selenium vs Puppeteer vs Playwright:三大网页爬虫与AI自动化框架全面对比(2026)
  • 901环氧乙烯基酯公司亲测推荐 - 资讯快报
  • 2026佛山装修公司权威综合实力TOP1:星艺装饰(佛山全域直营) - 广东科技观察
  • MCU功能安全自测试:IEC 60730标准下的CPU与RAM测试实战
  • 终极指南:通过AES密钥逆向工程实现《鸣潮》游戏模组开发
  • 六年软件测试实战:从找Bug到质量守门人的认知跃迁
  • 2026年6月最新山东超和龙山腾食品官方公布唯一联系方式 - 资讯快报
  • 2026年湖南建筑护栏工程供应商选购指南:从本土龙头到全国布局 - 资讯快报
  • 【Java架构_API服务-01_一次性讲解清楚接口服务中到底什么是P99和P9999】
  • 面试逆袭攻略:Java面试常见陷阱与应对策略
  • 单词背了很多,英文文章还是读不懂?
  • 企业级智能问数平台:从架构设计到实战落地的全流程解析
  • 程序员技术护城河构建指南:从原理拆解到AI工程化