
1. 别等内存爆了才想起享元模式先看一个真实翻车现场先说个我早年踩过的坑。当时做一个模拟类的图形渲染项目地图上要摆几万棵形态各异的树。我一开始的思路很直接每棵树就是一个对象树有品种、颜色、纹理还有坐标、高度、生长状态。几万个对象new出来功能倒是一切正常但跑起来内存曲线直线往上飙GC频繁到界面开始掉帧最后直接在日志里看到了OutOfMemoryError。当时我第一反应是是不是某处把数据重复加载了查了一遍发现不是。真正的问题是——这几万个对象里树的纹理、颜色、品种这些字段其实只有几十种组合。也就是说我创建了成千上万个完全相同的字段副本每份都占着独立的内存。这就是典型的对象数量大、但对象种类极少的场景享元模式(Flyweight)就是专门治这个病的把对象里那些可以共享的、不变的部分抽出来让一堆对象共同引用同一份数据用共享对象对抗内存爆炸。这篇文章我不打算给你教科书式的定义复读。我会按我当时排查、重构、测试的完整路径来讲先搞清楚内存是为什么爆掉的再拆解享元模式的核心机制然后给一套能直接抄走的Java实现连C版本的差异一起讲最后聊聊什么时候该用它、什么时候加了反而添乱。如果你正被内存问题折磨或者在准备设计模式相关的面试和考试这些内容应该比单纯背定义有用得多。2. 享元模式到底在解决什么问题从对象数量和对象种类的矛盾说起2.1 一个容易被忽略的直觉重复本身不是问题无法共享的重复才是很多人对享元模式的第一印象是把相同的对象合并成一个这个说法其实不够准确。准确地说享元模式解决的是大量对象携带大量重复数据的问题而且它要求这些重复数据本身具备不可变性——意思是这些数据一旦确定就不会再变。我举个例子你就明白了。还是说树。假设你有10000棵树树种只有5种。如果没有享元模式每棵树对象里都要存一份纹理图片的引用、颜色值、名称字符串10000份这些字段就会占用大量内存。但仔细想想这10000棵树里属于同一种树的那几千棵它们存的纹理、颜色、名称是不是完全一样既然一样为什么不能共享同一份理论上当然可以但有一个前提这些字段一旦共享任何一棵树都不能去修改它。因为你一改所有共享这棵树的同品种树木全部跟着变了那系统就乱套了。所以享元模式的核心约束就是——被共享的属性必须只读不允许被修改。这里还有一个很多人忽略的点不是所有重复数据都能共享。如果每棵树的坐标、高度、生长状态都不一样那这些数据就不能共享只能由每个对象自己持有。这就是享元模式里最关键的拆分逻辑把对象的属性分成大家一样的和各自不一样的两类。2.2 用一个表格看清享元模式的角色划分为了让你后面读代码时不蒙圈先把几个关键角色理清楚。享元模式里通常有四个角色角色职责对应树场景Flyweight抽象享元定义共享对象的接口TreeType接口暴露渲染方法ConcreteFlyweight具体享元实现共享对象存储内蕴状态具体某种树的类型对象FlyweightFactory享元工厂管理共享对象池保证重复键复用TreeFactory按品种返回树类型Client客户端持有外部状态组合享元对象地图上的每一棵树这种角色划分不是用来背的它是理解谁该创建对象、谁该持有对象的分工逻辑。工厂负责保证共享客户端负责把共享的和不共享的组合起来。2.3 内蕴状态 vs 外蕴状态这是享元模式的灵魂在正式动手写代码之前最需要搞明白的就是这两个概念。内蕴状态Intrinsic State存储在享元对象内部所有共享该对象的客户端看到的都是同一份数据且不会变化。比如树的品种、纹理、颜色。外蕴状态Extrinsic State由客户端对象自己持有不属于享元对象本身随场景变化而变化。比如树的坐标、高度、朝向。我踩过的一个坑是一开始分不清哪些属性该放哪边结果把树的生长状态也放进了享元对象里。表面上看着没问题结果同一品种的树全部同时生长数据直接串了。后来才彻底想明白一个判断标准如果这个字段的值在系统运行期间对所有使用者都必须保持一致那就是内蕴状态如果不同的使用者需要看到不同的值那它一定是外蕴状态。撑不住的时候就反问一句如果几百个对象共享一个享元这个字段会不会互相干扰会它就是外蕴的不会才有资格做内蕴的。3. 用Java手写一套能用能测的享元模式实现这部分我用地图上的树这个例子来讲因为它的逻辑直观、场景又真实。完整的代码结构分三层享元类、享元工厂、客户端。3.1 第一步定义享元接口和具体享元类享元接口的核心动作是渲染。注意渲染动作需要用到外部状态——也就是坐标和高度——所以接口方法要把外部状态作为参数传进来而不是从内部字段去读。// 抽象享元定义渲染逻辑接收外部状态 public interface TreeType { void draw(int x, int y, int height); }然后实现具体享元类这里面只放内蕴状态。品种名、颜色、纹理图片引用一旦构造完成就不允许修改所以我用了final字段也刻意不提供setter。// 具体享元共享的对象只容纳内蕴状态 public final class TreeTypeImpl implements TreeType { private final String name; // 树种名称 private final String color; // 叶子颜色 private final String texture; // 纹理图片路径 public TreeTypeImpl(String name, String color, String texture) { this.name name; this.color color; this.texture texture; } Override public void draw(int x, int y, int height) { // 渲染时内蕴状态自己提供外蕴状态由参数传入 System.out.printf(绘制[%s]于(%d, %d)高度%d%n, name, x, y, height); } Override public String toString() { return TreeType{ name , color , texture }; } }这段代码看着简单但有几个细节值得你留意final修饰类、final修饰字段是刻意为之。这能从根本上防止有人后续不小心给享元对象加setter破坏不变性。接口方法draw接收坐标和高度这是外部状态通过方法参数传入的标准姿势你在很多框架源码里都能看到类似的设计。3.2 第二步享元工厂共享对象的守门员工厂是享元模式里最重要的角色。它的职责用一个词概括就是按需去重同样的键值请求来了直接返回已有的对象新组合出现了才创建一个新对象。最经典的实现方式是HashMap做缓存。import java.util.HashMap; import java.util.Map; // 享元工厂负责创建和管理共享对象 public class TreeFactory { private static final MapString, TreeType pool new HashMap(); public static TreeType getTreeType(String name, String color, String texture) { // 将三个内蕴状态组合成缓存key String key name | color | texture; TreeType type pool.get(key); if (type null) { type new TreeTypeImpl(name, color, texture); pool.put(key, type); System.out.println(创建新的树类型: type); } else { System.out.println(复用已有树类型: type); } return type; } public static int getPoolSize() { return pool.size(); } }这个工厂有几点你可以参考key用内蕴状态拼接而成。拼接时用|分隔是为了避免红||绿和红|绿这类拼接歧义。如果真的担心这种边界情况可以把key封装成一个专门的类重写equals和hashCode。pool用了静态Map本质上这是一个全局缓存。注意它不是线程安全的并发场景下要做同步我后面会专门讲。工厂完全不感知外部状态。它只管理树种类型不关心树长在哪里、多高。3.3 第三步客户端把内蕴状态和外蕴状态组合起来真正的每棵树不是直接在客户端new一个几十KB的完整对象而是持有两部分一个指向享元对象的引用加上自身的外部状态。我一般会把外部状态封装成一个轻量的Tree类。// 客户端对象持有外部状态引用享元对象 public class Tree { private final int x; private final int y; private final int height; private final TreeType type; // 共享的享元引用 public Tree(int x, int y, int height, TreeType type) { this.x x; this.y y; this.height height; this.type type; } public void draw() { type.draw(x, y, height); } }再写一个森林类来模拟批量种植import java.util.ArrayList; import java.util.List; public class Forest { private final ListTree trees new ArrayList(); public void plantTree(int x, int y, int height, String name, String color, String texture) { TreeType type TreeFactory.getTreeType(name, color, texture); Tree tree new Tree(x, y, height, type); trees.add(tree); } public void drawAll() { for (Tree tree : trees) { tree.draw(); } } public int getTreeCount() { return trees.size(); } }写个测试类验证一下效果public class FlyweightDemo { public static void main(String[] args) { Forest forest new Forest(); // 模拟种10000棵树只有5种品种组合 for (int i 0; i 10000; i) { int typeIndex i % 5; switch (typeIndex) { case 0 - forest.plantTree(i % 100, i % 100, 10, 橡树, 深绿, oak.png); case 1 - forest.plantTree(i % 100, i % 100, 20, 白桦, 浅绿, birch.png); case 2 - forest.plantTree(i % 100, i % 100, 15, 松树, 墨绿, pine.png); case 3 - forest.plantTree(i % 100, i % 100, 12, 枫树, 火红, maple.png); case 4 - forest.plantTree(i % 100, i % 100, 18, 柳树, 青绿, willow.png); } } System.out.println(树的总数量: forest.getTreeCount()); System.out.println(实际创建的享元对象数量: TreeFactory.getPoolSize()); forest.drawAll(); } }跑起来后你会看到输出里只有5个创建新的树类型后面全是复用已有树类型。10000棵树真实的共享对象只有5个。这就是享元模式最直观的收益证明。3.4 C实现的核心差异内存所有权要搞清楚很多人在网上问C怎么实现享元模式这里单独说一下。C比Java多一个绕不开的问题内存所有权。Java里对象由GC统一回收C里你得自己决定谁负责释放。我推荐的做法是工厂用std::unique_ptr持有享元对象然后以原始指针或const引用的方式交给客户端。客户端只借用、不拥有这样就杜绝了悬挂指针和双重释放的问题。#include iostream #include memory #include string #include unordered_map class TreeType { public: virtual ~TreeType() default; virtual void draw(int x, int y, int height) const 0; }; class TreeTypeImpl : public TreeType { private: std::string name; std::string color; std::string texture; public: TreeTypeImpl(std::string n, std::string c, std::string t) : name(std::move(n)), color(std::move(c)), texture(std::move(t)) {} void draw(int x, int y, int height) const override { std::cout 绘制[ name ]于( x , y )高度 height std::endl; } }; class TreeFactory { private: static std::unordered_mapstd::string, std::unique_ptrTreeType pool; public: static const TreeType* getTreeType(const std::string name, const std::string color, const std::string texture) { std::string key name | color | texture; auto it pool.find(key); if (it ! pool.end()) { return it-second.get(); } auto type std::make_uniqueTreeTypeImpl(name, color, texture); const TreeType* ptr type.get(); pool[key] std::move(type); return ptr; } static size_t getPoolSize() { return pool.size(); } }; std::unordered_mapstd::string, std::unique_ptrTreeType TreeFactory::pool;客户端持有的是const TreeType*这本身就传达了一个信息这个指针指向的对象你只能读不能改。这种写法从类型系统层面就保证了享元对象的不变性比纯靠编程规范约束更可靠。4. 享元工厂设计的几个细节不是写个HashMap那么简单工厂是享元模式最容易出问题的地方。很多人的实现能跑但经不起并发和性能测试。这里我把自己实际排查过的几个问题梳理一下。4.1 线程安全高并发下工厂会变成瓶颈前面那个TreeFactory用的是普通HashMap单线程环境下完全没问题。但一旦多个线程同时种树HashMap的并发写问题就会暴露轻则丢数据重则死循环。处理方案按并发级别有不同选择方案适用场景注意点方法加synchronized低频调用实现简单但所有线程串行高并发时吞吐下降ConcurrentHashMap配合computeIfAbsent中高并发原子性有保证性能好但key计算可能重复执行双重检查锁 HashMap读多写少需要volatile实现稍复杂我个人最推荐的是ConcurrentHashMapcomputeIfAbsent。它把查缓存、不存在则创建、放入缓存三步合并成原子操作代码量极少性能也很好。但要留意computeIfAbsent的映射函数里如果做很重的创建操作并发下可能被多次调用虽然最终只有一个生效。如果创建享元对象的成本极高建议在函数内部再加一层检查或者用putIfAbsent搭配显式的二次get。// 线程安全的工厂实现片段 public class TreeFactoryThreadSafe { private static final MapString, TreeType pool new ConcurrentHashMap(); public static TreeType getTreeType(String name, String color, String texture) { String key name | color | texture; // computeIfAbsent 保证原子性不存在时才执行创建 return pool.computeIfAbsent(key, k - new TreeTypeImpl(name, color, texture)); } }4.2 预创建还是懒创建两种策略的取舍工厂拿到一个key时可以预先创建好所有享元对象放在池子里也可以等到第一次被请求时才创建。这个取舍主要看两件事创建成本和业务场景。如果享元对象的创建成本很高比如要加载纹理图片、做IO而且系统启动时能预知所有种类那预创建就很有价值——把初始化成本摊到启动阶段运行期完全无创建开销。但如果种类非常多是动态的、无法穷举预创建就不现实必须懒创建。实际项目里懒创建更常见因为大多数场景下你不可能预知运行期会出现多少种组合而且预先创建一堆用不到的共享对象本身就是浪费。我遇到过一个反面案例有人为了省事把上千种组合全部预创建结果启动时间从2秒涨到15秒而实际上运行期只用了其中几十种。懒创建的思路更贴合真实需求——用到了才创建用不到就不占内存。4.3 享元对象要不要清理对象池和缓存的边界在哪这是很容易踩的理解误区。享元模式的核心目标就是常驻内存、持续复用。如果频繁地把享元对象移出缓存那工厂的复用价值就打折扣了甚至可能出现不断创建、不断销毁的抖动。除非你有极强的内存压力否则不建议给享元工厂加清缓存逻辑。但有一种情况例外如果内蕴状态的组合种类实在太多多到缓存本身成为内存压力来源那就有必要做淘汰策略。这时候享元模式其实已经演变成了一个带策略的缓存需要引入LRU之类的淘汰算法。做这个决定之前我建议你先重新审视一遍状态划分是否合理——很多时候是因为你把本该属于外蕴的字段错放进了内蕴状态导致组合数爆炸。先修正划分比加缓存淘汰策略更治本。4.4 key的设计别让字符串拼接坑了你用字符串拼接做key是最常见的做法但有两点务必注意。第一拼接分隔符选一个不会出现在字段值里的字符否则A|B和A |B会产生歧义。第二如果字段很多每次都做字符串拼接会产生大量临时对象对性能和内存都不友好。更好的做法是定义一个key类内部持有各字段引用重写equals和hashCode。这样不仅消除了拼接开销也让代码语义更清晰。// 更合理的key设计 public record TreeTypeKey(String name, String color, String texture) { }用record定义key类一行搞定不可变性和equals/hashCode。配合ConcurrentHashMapTreeTypeKey, TreeType工厂代码会变得干净很多。5. 享元模式和其他模式的区别这几条边界你最好记住面试和考试里最常问的一个问题是享元模式和单例、对象池、缓存有什么区别。说不清这条边界的人往往是把模式混着用最后代码变成一团乱麻。5.1 享元 vs 单例一个池子和一个名额的区别单例模式保证一个类只有唯一实例整个系统全局就这一个。享元模式是一个受控的多实例同一类共享对象可以有多个实例只是相同key的实例只存在一个。管理树的例子中5个享元对象是同时并存的单例模式就不可能做到这种效果。如果你的工厂缓存里永远只有一个key那工厂退化成单例的容器但只要key范围是有多个它就不是单例。我见过有人把享元工厂整个实现成单例然后说这就是享元单例组合这没问题但你要理解——单例的是工厂不是享元对象。5.2 享元 vs 缓存目标不同管理策略也不同缓存的核心目标是通过临时存储加速访问它允许数据被清除、过期、淘汰因为缓存里的数据丢了可以从原始来源重建。享元的核心目标是减少重复对象共享对象不能被随意清除——一旦清了所有引用它的客户端都会出问题。我把这两者看成替身和正主的关系缓存里的东西是可有可无的加速道具享元池里的东西是对象图里不可缺失的组成部分。所以享元工厂通常不做过期淘汰而缓存系统一定会做。5.3 享元 vs 对象池省的是内存还是创建开销对象池Object Pool的核心目的是复用创建成本高的对象省的是时间比如数据库连接池。池里的对象往往是有状态的从池里取出时要初始化归还时要清理每次使用都是独占的。享元模式的核心目的是省内存共享的对象完全无状态、永久可复用。同一时刻可以有无数客户端同时引用同一个享元对象而对象池里的同一个连接对象在某一时刻只能被一个客户端占用。一句话总结对象池说排队用我的对象用完了还回来享元说这个对象你尽管随便引用我不收钱。5.4 和原型模式的简单辨析在准备设计模式相关的期末或面试时大家还容易把享元和原型模式Prototype搞混。原型模式的核心是通过复制现有对象快速创建新对象重点是省创建成本但复制出来的对象仍然是各自独立的数据副本。享元模式的重点是直接共享同一个对象连复制都省了。两者面对的问题相似但答案方向截然相反一个造副本一个不造副本。6. 实战中的真实收益和踩坑经验从几千块到几十块的变化这部分我拿一个实际项目的改造数据来说话顺便给出几个判断原则帮你决定该不该用享元模式。6.1 一个可以复现的对比实验我当年那块地图渲染改造前后的数据是这样的指标重构前重构后对象实例数10000个树对象10000个轻量树对象 5个共享树类型每棵树对象占用约50KB含纹理路径、颜色等约8KB坐标、高度、引用总内存占用约500MB约80MB其中共享部分可忽略创建耗时每次都要加载纹理数据首次加载5次其余命中缓存对比不是重点重点是这个数字怎么估算出来的。你可以用jcmd、JProfiler或者简单的-Xmx调小内存来复现。我是在测试环境把堆内存设成原来的1/4重构前直接OOM重构后稳定运行这就是最简单粗暴的验证方式。6.2 什么时候不要用享元模式享元模式确实好用但它不是万能药。这里列几个我判断不该用的信号内蕴状态太少外蕴状态占大头。如果共享部分只有一两个字段省下的内存非常有限反而增加工厂和引用的复杂度。对象的种类本身就特别多几乎没有重复。如果10000个对象有9000种不同配置享元工厂的缓存命中率极低HashMap本身反而成了新的内存负担。内蕴状态会变化。如果你的共享数据在运行期经常被修改那享元模式天然不适用因为修改会污染所有引用者。这时候你需要考虑的是对象池或者干脆普通创建。代码可读性优先于极致的内存优化。享元模式本质上是用复杂度换空间。如果你的对象数量只有几百而团队里大多数人还不熟悉这个模式强行引入会让维护者困惑也需要谨慎。6.3 常见误区的排查经验我总结过几个团队里反复踩的坑你可以对照自查误把所有字段都当内蕴状态。现象是共享对象数据串了排查的时候翻遍调用链才发现根源在状态划分。判断方法前面讲过用完例去检验多个客户端共享同一个对象时会互相干扰的字段必须外置。只做了共享没做不变性保护。Java里如果不小心把内蕴字段的setter暴露出去运行期可能完全正常等到数据被篡改时bug极其隐蔽。用final、用record、返回不可变对象这些做法都是为了杜绝这类问题。工厂本身成了性能瓶颈。如果工厂内部使用了重量级的同步锁而调用频率又极高那共享省下的内存可能被性能上的损失抵消。选择并发工具时一定要结合真实调用量不能无脑加锁。在分布式环境中自以为用了享元。享元模式的共享是同进程内的共享不同JVM之间不存在天然的享元池。如果业务涉及多节点部署你需要进一步考虑集群级别的共享方案而不是指望单机工厂解决问题。6.4 我个人的使用原则写代码这么多年我逐渐养成了一个使用习惯任何new对象的循环都先停下来问三个问题——这个循环会执行多少次每次创建的对象有哪些字段这些字段里有多少是重复的如果对象数量多到足以吃内存并且重复字段占比明显那享元模式就是值得考虑的候选方案。最后再分享一个实操小技巧测试享元模式是否真的生效最直观的方法不是看感觉而是在工厂里加一个统计方法输出缓存命中次数和池大小。我从一开始做重构就保留了getPoolSize()和日志输出这样不仅自己能确认优化效果后续review代码的人也能一眼看懂设计意图。这个小动作比任何架构文档都更有说服力。