先把结论放在前面:享元模式是结构型设计模式里最“抠门”的一个,它的核心就一句话:把对象里可以共享的部分抽出来,让多个地方复用,而不是每次都 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 Map<String, 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 Map<PieceType, 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 Map<String, 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 的差距。这个案例也验证了一个原则:外部状态一定要由客户端保存。这里用Map<String, 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 Map<String, 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 压力,同时保持对象的可共享性。最后再分享一个小技巧。真正用好享元模式,不是比谁代码里写了几个设计模式类,而是看你能不能做到“心里有数”:面对一个需求,你清楚地知道哪些对象可以共享、哪些不能共享、共享的边界在哪。我见过最优雅的享元实现,核心工厂类只有三十行代码,读起来却非常通透。设计模式是工具,不是表演,能把对象数量砍到极致,同时不牺牲可读性和安全性,就是你理解和掌握这个模式最好的证明。
先把结论放在前面:享元模式是结构型设计模式里最“抠门”的一个,它的核心就一句话:把对象里可以共享的部分抽出来,让多个地方复用,而不是每次都 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 Map<String, 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 Map<PieceType, 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 Map<String, 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 的差距。这个案例也验证了一个原则:外部状态一定要由客户端保存。这里用Map<String, 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 Map<String, 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 压力,同时保持对象的可共享性。最后再分享一个小技巧。真正用好享元模式,不是比谁代码里写了几个设计模式类,而是看你能不能做到“心里有数”:面对一个需求,你清楚地知道哪些对象可以共享、哪些不能共享、共享的边界在哪。我见过最优雅的享元实现,核心工厂类只有三十行代码,读起来却非常通透。设计模式是工具,不是表演,能把对象数量砍到极致,同时不牺牲可读性和安全性,就是你理解和掌握这个模式最好的证明。