享元模式:用对象共享大幅优化内存的Java设计模式实战

发布时间:2026/10/21 6:47:42
享元模式:用对象共享大幅优化内存的Java设计模式实战 先把结论放在前面享元模式是结构型设计模式里最“抠门”的一个它的核心就一句话把对象里可以共享的部分抽出来让多个地方复用而不是每次都 new 一个新对象。所谓“享元”英文叫 Flyweight意思是“轻量级”像羽毛一样轻。它解决的问题也很简单当系统里对象数量巨大、内存快撑不住的时候通过共享内部状态来大幅减少对象个数。适合的场景包括文本编辑器里的字符格式、游戏里的粒子特效、围棋棋盘上的棋子、报表系统里的单元格样式等等。这篇文章我会从一组实际场景出发拆解享元模式的内部状态和外部状态给你一份可以直接抄作业的 Java 骨架代码再算一笔内存账最后把踩过的坑和排查经验一并交代清楚。不管你是刚开始学设计模式还是在项目里已经用到了但说不清原理看完都可以直接上手。1. 先说结论享元模式是干嘛的1.1 一个内存被吃满的真实场景我先描述一个很常见的问题。假设你在做一个带格式的文本编辑器要显示一篇几万字的文章。最朴素的设计是每个字符都是一个对象对象里保存字符本身、字体、字号、颜色、加粗、斜体等属性。看起来逻辑清楚但内存很快就不够用了。我来算一笔账一个字符对象如果包含 6 个字段每个字段按 Java 对象头加引用粗算 20 到 30 字节一万个字符就是 20 万到 30 万字节还能接受但如果是几十万字再加上一套复杂的样式属性内存直接翻好几倍GC 也频繁得吓人。你仔细观察就会发现文章里大量字符的字体、字号、颜色其实是重复的。比如正文都是“宋体 14 号 黑色”标题都是“黑体 18 号 深蓝色”。真正不一样的只是字符的 Unicode 编码以及它在页面上的 x、y 坐标。把每个字符都做成一个独立对象等于把同样一份“样式信息”复制了几万份。这时候你就需要享元模式了。享元模式的思路很直接把“哪些信息是对象内部共有的”和“哪些信息是每次使用时才传入的”分开前者做成共享对象后者由客户端在使用时提供。这样原本几万个重复对象最后可能只剩几十个共享对象加几万条轻量记录。1.2 内部状态与外部状态共享的核心分界线享元模式能不能用好关键看你能不能分清两类状态。内部状态Intrinsic State存储在享元对象内部被多个上下文共享的信息。它不会随着使用场景变化而变化所以线程安全要求高的时候内部状态最好设计成不可变的。外部状态Extrinsic State依赖具体使用场景、不能共享的信息。它不属于享元对象而是由客户端保存或者作为参数在调用方法时传进去。拿围棋举例。棋盘上有 19×19 共 361 个交叉点每个点可以放黑子、白子或空。如果给每个位置创建一个棋子对象需要 361 个对象。但如果棋子对象的“颜色”是内部状态那就只需要两个对象一个黑色棋子的共享对象一个白色棋子的共享对象。位置坐标 (x, y) 就是外部状态由客户端在下棋时传入或者保存在一个二维数组里。这两种状态的分界线一旦模糊很容易写出有并发隐患的代码。比如你图省事把坐标放到共享对象里那么在多线程下同时落子就会出现 A 线程把坐标改了B 线程还在用旧坐标的错乱。记住一条原则享元对象本身尽量做不可变对象所有变化的数据都走外部状态。2. 经典结构拆解四个角色各自干什么2.1 角色定义与职责边界享元模式的类结构并不复杂主要涉及四个角色Flyweight抽象享元定义享元对象的接口通常声明一个接收外部状态的方法比如void draw(int x, int y)。ConcreteFlyweight具体享元实现抽象享元内部持有内部状态并实现方法。这是真正被共享的对象。FlyweightFactory享元工厂负责创建和管理享元对象。通常用 Map 做池子key 是内部状态的标识value 是享元对象。客户端请求时如果池子里有就直接返回没有才创建。Client客户端维护外部状态调用享元对象的方法时把外部状态传进去。工厂角色是整个模式的核心。它会保证同一个内部状态只会创建一个享元对象。这里有个习惯叫“先查再建”和缓存设计的逻辑一模一样。这里要注意一个容易忽略的点GoF 原书里还有一个 UnsharedConcreteFlyweight非共享具体享元。它的存在是为了说明并不是所有 Flyweight 子类都需要共享。有些对象虽然也用享元接口但某些场景下不适合共享比如边界的特殊处理对象可以直接创建。实际项目中很少单独特意去写这个角色但理解它有助于避免“为了共享而共享”的僵化设计。2.2 一个最简的享元模式骨架代码我先给一个最精简的骨架方便你看懂整体流程。// 抽象享元 public interface Flyweight { void operation(String extrinsicState); } // 具体享元内部状态是 type public class ConcreteFlyweight implements Flyweight { private final String type; public ConcreteFlyweight(String type) { this.type type; } Override public void operation(String extrinsicState) { System.out.println(内部状态 type 外部状态 extrinsicState); } } // 享元工厂 public class FlyweightFactory { private final MapString, Flyweight pool new HashMap(); public Flyweight getFlyweight(String type) { // 先查再建 Flyweight flyweight pool.get(type); if (flyweight null) { flyweight new ConcreteFlyweight(type); pool.put(type, flyweight); } return flyweight; } public int poolSize() { return pool.size(); } } // 客户端 public class Client { public static void main(String[] args) { FlyweightFactory factory new FlyweightFactory(); Flyweight a1 factory.getFlyweight(A); Flyweight a2 factory.getFlyweight(A); Flyweight b1 factory.getFlyweight(B); System.out.println(a1 a2 ? (a1 a2)); // true a1.operation(坐标1); a2.operation(坐标2); b1.operation(坐标3); } }这段代码里有几个值得留意的细节。pool用HashMap就够了因为享元对象创建之后一般不会修改 key。type字段加final是为了保证内部状态不可变。getFlyweight方法里用的是简单的“查不到再创建”没有加锁这在单线程里没问题但多线程环境下面会专门说怎么处理。3. 实战案例围棋棋盘和文本编辑器3.1 围棋棋子共享黑白两种颜色我拿围棋来写一个接近真实场景的案例。棋盘的坐标用一个二维数组维护每个格子存一个“坐标到棋子”的映射关系或者直接把落子记录保存起来。棋子对象本身不存坐标坐标由客户端传入。// 棋子颜色作为享元内部状态 public enum PieceType { BLACK, WHITE } // 抽象享元 public interface Piece { void place(int x, int y); } // 具体享元 public class PieceImpl implements Piece { private final PieceType type; public PieceImpl(PieceType type) { this.type type; } Override public void place(int x, int y) { System.out.println(在( x , y )落下一枚 type 棋子); } } // 享元工厂 public class PieceFactory { private static final MapPieceType, Piece POOL new EnumMap(PieceType.class); public static Piece getPiece(PieceType type) { // computeIfAbsent 等价于先查再建 return POOL.computeIfAbsent(type, PieceImpl::new); } public static int poolSize() { return POOL.size(); } } // 客户端自己维护外部状态落子记录 public class GameBoard { private final int size; private final MapString, Piece pieces new HashMap(); public GameBoard(int size) { this.size size; } public void place(PieceType type, int x, int y) { String key x , y; Piece piece PieceFactory.getPiece(type); pieces.put(key, piece); piece.place(x, y); } public int pieceCount() { return pieces.size(); } public static void main(String[] args) { GameBoard board new GameBoard(19); board.place(PieceType.BLACK, 3, 3); board.place(PieceType.WHITE, 3, 15); board.place(PieceType.BLACK, 9, 9); System.out.println(棋盘上的落子数 board.pieceCount()); // 3 System.out.println(共享的棋子对象数 PieceFactory.poolSize()); // 2 } }这里最直观的效果是不管棋盘上有多少个落子点PieceFactory池里永远只有 2 个棋子对象。如果不用享元模式361 个位置都各自 new 一个棋子对象那就是 361 个对象。内存差别在对象数量少的时候不明显一旦场景放大到百万级别差别就是几 MB 和几百 MB 的差距。这个案例也验证了一个原则外部状态一定要由客户端保存。这里用MapString, Piece保存了“位置 - 棋子享元对象”的映射下次要判断某位置有没有棋子时直接查 Map 就行不用遍历所有棋子。3.2 文本编辑器共享字符样式含内存计算示例文本编辑器是 GoF 经典案例我在这里把它改造成可运行示例并补一个内存账。假设文档中有段落文字每个字符包含三个要素字符内容字符串、样式 ID、坐标位置。样式就是字体、字号、颜色这些它们适合做内部状态字符内容和坐标是外部状态。// 享元字符样式 public class CharacterStyle { private final String font; private final int size; private final int color; public CharacterStyle(String font, int size, int color) { this.font font; this.size size; this.color color; } // getter 省略 } // 享元工厂 public class CharacterStyleFactory { private static final MapString, CharacterStyle POOL new HashMap(); public static CharacterStyle getStyle(String font, int size, int color) { String key font _ size _ color; return POOL.computeIfAbsent(key, k - new CharacterStyle(font, size, color)); } public static int poolSize() { return POOL.size(); } } // 客户端记录每个字符保存内容、样式引用、坐标 public class CharacterRecord { private final char value; private final int x; private final int y; private final CharacterStyle style; public CharacterRecord(char value, int x, int y, CharacterStyle style) { this.value value; this.x x; this.y y; this.style style; } public void display() { System.out.println(字符 value 在坐标( x , y )样式 style); } }假设文档有 10 万字只有 5 种字体组合标题黑体 18 号、正文宋体 14 号、注释楷体 12 号、强调黑体 14 号红色、链接宋体 14 号蓝色。不用享元每个字符都内嵌一个样式对象需要 10 万份样式对象。每个样式对象粗略按 4 个引用加对象头算 60 字节就是 600 万字节约 6 MB。用享元工厂池里最多 5 个样式对象其它字符只保存一个“样式引用”。引用按 8 字节算10 万字符也就 80 万字节加上 5 个样式对象可以忽略不计。省下来的内存超过 85%。如果你做的不是文档而是大屏可视化项目里的海量图表节点或者游戏里成千上万的粒子这种省内存的效果会更明显。当然字符对象本身还有 value、坐标等字段这部分省不掉因为它是外部状态。享元模式不是把所有对象都共享掉而是把能共享的那部分共享掉。4. 和容易混淆的技术与模式划清界限4.1 享元模式 vs 单例模式网上很多人把享元工厂和单例搞混因为两者都强调“只有一个对象”。区别其实很明显。单例模式保证一个类在整个 JVM 里只有一个实例它关注的是“全局访问点”主要用在配置类、连接管理类等。享元模式关注的是“多个内部状态不同的对象共享”工厂池里通常有多个对象每个对应一种内部状态。比如围棋的享元工厂里包含黑白两个对象文本样式池里包含多种字体样式对象。如果把享元工厂做成单例那是另一种设计取向表示“全局只有一个享元池”。这可以结合用不矛盾。但不要把单例模式本身等同于享元模式。换个角度理解单例是“一个类只有一种实例”享元是“一类对象池化按需取用”。4.2 享元模式 vs 对象池 vs 缓存这里必须展开讲因为很多人把享元模式直接等同于“对象池”这其实是个很常见的误解。对象池如数据库连接池、线程池解决的问题是“创建对象太贵用完回收复用”。对象池里的对象复用时内部状态通常会被“清空”或“重置”比如连接池里的连接从池子里取出后新的使用者会重新设置参数。对象池更关注生命周期管理和资源复用。享元模式解决的问题是“共享同一个对象给大量上下文使用”。享元对象不会因为被复用而改变内部状态它是纯粹共享的不需要重置。对象池强调“这个对象现在归谁用”享元强调“这个对象所有人都能用”。缓存的概念更宽缓存的目的是“减少重复计算或重复拉取”比如缓存接口返回值、缓存菜单树不要求对象内部状态不可变。享元可以借助缓存的思想实现但享元更强调结构上的共享而不是临时存储。我做项目时见过有人用享元模式去管理数据库连接结果连接被多个线程同时使用状态场全乱了。正确的做法是连接这种“使用后需要归还且不能共享”的资源用对象池字符样式、棋子颜色、地形材质这类纯粹的只读数据用享元。画一张简单的对照表。对比维度享元模式对象池缓存核心问题减少对象数量复用创建昂贵的资源减少重复计算/IO对象是否可变内部状态不可变可以重置通常可变共享方式多个上下文同时共享一次只有一个使用者按 key 存取典型例子字符样式、棋子数据库连接池、线程池Redis 缓存接口数据4.3 享元模式 vs 原型模式还有一个容易混的是原型模式。原型模式的核心是通过复制现有对象来创建新对象解决的也是“创建对象成本高”的问题但它创建出来的每个副本都是独立对象改一个不影响另一个。享元模式则是尽量让大家用同一个对象根本不要复制。有些场景两者混合使用先用享元模式共享模板对象然后基于模板clone出新对象再修改差异部分。这属于组合应用但你要清楚每一步到底用的是哪个模式的能力。5. 实操中的坑与排查技巧5.1 外部状态污染并发环境下最隐蔽的 bug这是享元模式里翻车率最高的问题。因为客户端调用享元方法时往往会把外部状态作为参数传进去。如果你在享元对象内部用一个公共字段临时保存外部状态然后多个线程同时调用就会出现“这个线程传的是坐标 A掉的却是刚才那个线程传的坐标 B”的现象。举个例子下面的做法是典型的错误示范public class BadPiece implements Piece { private PieceType type; private int x; private int y; public void place(int x, int y) { this.x x; this.y y; // 模拟耗时操作 try { Thread.sleep(50); } catch (InterruptedException e) { } System.out.println(落子( this.x , this.y )); } }两个线程分别调用piece.place(1,1)和piece.place(2,2)你以为每个线程都会输出自己传的坐标实际上第二个线程可能会把第一个线程的x、y覆盖掉或者第一个线程在 sleep 之后拿到的this.x已经变成了 2。解决办法很简单外部状态永远不要写到享元对象的字段里。要么在方法参数里直接用局部变量要么把外部状态封装成独立的对象由客户端持有。这也是我反复强调的“享元对象要不可变”的原因。5.2 工厂池里的享元对象一直不释放怎么办享元工厂一般用 Map 保存对象引用这会让对象一直停留在内存里。如果内部状态种类非常庞大比如样式 key 有上亿种组合池子本身就会变成内存黑洞。我在一个报表项目里遇到过样式 key 由字体、字号、颜色、边框粗细、对齐方式等 8 个字段拼接而成理论上组合数是几十亿实际运行过程中确实出现了上百万种组合池子内存飙升。遇到这种情况你要做两件事。第一限制内部状态的粒度。不要把所有字段都塞进 key只把真正高频复用的字段作为共享维度。比如报表里 90% 的单元格用的是那 5 套默认样式那就只共享这 5 套剩下的可以走普通对象。第二给池子加容量上限或者淘汰策略。比如用LinkedHashMap的removeEldestEntry实现 LRU 淘汰或者定期清理不再使用的享元对象。但要注意享元对象一旦被多个客户端引用清理时有可能导致引用失效。稳妥做法是只清理那些没有外部引用、只存在于工厂池里的对象。5.3 什么时候别用享元模式享元模式不是所有场景都合适。如果你发现以下信号就别硬上。内部状态根本没有重复。比如系统里每个字符串样式都是唯一的共享率极低那池子越大浪费越多。外部状态非常复杂。如果每次调用都要拼接一大坨外部状态参数代码可读性会直线下降甚至比直接创建新对象更难受。对象本身的体积不大。一个对象只有两三个字段创建成本几乎为零此时用享元模式省下的内存非常有限但引入的工厂、池化逻辑反而增加了复杂度。状态经常修改。享元模式强调共享如果同一个对象的状态经常被改共享就会变成灾难。我见过不少同行在刚开始学享元模式时激动不已想把所有对象都池化结果代码出现一堆“伪共享”和并发问题。正确的姿势是先做内存分析确认对象数量和重复度都够高再决定引入享元模式。6. 我的落地经验与后续扩展6.1 在真实项目里怎么判断该不该用我在实际项目里判断要不要用享元模式通常按三步走。第一步看 GC 日志和内存快照。如果一堆同类型对象占据了大量堆内存而且这些对象里有很多重复的字段值说明有共享潜力。比如某个对象有 20 个字段其中 15 个字段在 80% 的实例里完全一样。第二步看对象的生命周期。共享对象一般生命周期很长或者至少存在一个稳定的调用周期。如果是短命对象频繁创建销毁可以考虑对象池而不是享元。第三步看访问方式。如果对象会被多个线程同时只读访问那就很适合做享元如果要写就要重新考虑。还有一个很实用的习惯先用一个简单的 Map 工厂承接共享逻辑不急着引入复杂的框架。当你能用几十行代码讲清楚“内部状态是什么、外部状态是什么、池子怎么管理”的时候说明你已经想清楚了如果连这个结构都想不明白那大概率不应该用享元模式。6.2 享元模式和不可变对象的搭配实践最近几年我在新项目里写享元模式时会更倾向于把享元对象实现成不可变对象也就是所有字段用final修饰只提供 getter不提供 setter。这样做有三个好处。第一省心。共享对象不可变客户端拿到手就不用担心被别人修改相对容易保证一致性。第二安全。享元对象本身不保存外部状态所有变化的数据都通过方法参数传入或由客户端保存并发调用时天然安全。第三更好的可测试性。不可变对象在单元测试里不需要准备复杂的 mock 环境直接校验构造结果就行。有人会问那内部状态如果需要变怎么办比如字体样式池里某个样式要改颜色。我的做法是不改原对象而是创建一个新的样式对象并放进池子用版本号或新 key 区分。旧对象仍被旧字符引用不会受影响。这其实是不可变对象与版本替换策略的组合能有效避免“改了一个引用全部字符跟着变”的事故。6.3 后续可以怎么扩展享元模式本身很小但和很多现代技术能无缝结合。在游戏开发里粒子系统常用享元模式管理爆炸效果、弹道特效内部状态是纹理和材质外部状态是位置和速度。在低代码平台里字段属性模板、权限模板都可以做成享元前端渲染时引用同一个模板对象。在大数据处理里如果频繁创建相同配置的批次任务也可以抽象出共享的配置对象减少堆外内存和序列化体积。另外享元模式和缓存中间件的结合也很常见。如果应用是多实例部署享元工厂的本地 Map 只能共享单实例内的对象跨实例时可以把 key 对应的配置对象放在 Redis 里本地再套一层轻量缓存。这样可以降低 DB 压力同时保持对象的可共享性。最后再分享一个小技巧。真正用好享元模式不是比谁代码里写了几个设计模式类而是看你能不能做到“心里有数”面对一个需求你清楚地知道哪些对象可以共享、哪些不能共享、共享的边界在哪。我见过最优雅的享元实现核心工厂类只有三十行代码读起来却非常通透。设计模式是工具不是表演能把对象数量砍到极致同时不牺牲可读性和安全性就是你理解和掌握这个模式最好的证明。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询