
经典模式系列写到第11篇轮到享元模式了。说实话这是一个我早年觉得“只活在面试题里”的模式直到在某后台系统里被内存问题逼着重新认识它。当时系统要做权限目录渲染每个用户打开页面都会生成上千个菜单节点对象每个节点还带着相同的图标路径、样式配置和跳转模板结果几百个用户同时在线堆内存被重复对象塞得满满当当GC一顿狂跑接口响应直接变慢。后来把“不变的那部分”抽出来共享内存占用立刻降了一个数量级代码也没复杂多少。从那以后我就明白享元模式不是背概念用的它是真正能救项目性能的设计工具。这篇就把我对享元模式的理解、实现方法、高频场景和踩过的坑完整聊一遍希望能帮到正在为对象数量头疼的人。1. 为什么每个成熟系统到最后都会惦记享元模式1.1 内存账单重复对象到底有多贵先算一笔粗糙的账。一个普通Java对象不算实际业务字段光是对象头和引用对齐就有十几字节的固定开销。如果你有三千个菜单节点每个节点都要持有一个字符串图标路径、一个样式对象、一个URL模板而这些内容在节点之间完全一样那就等于把相同的数据复制了三千份。我习惯用一个很生活化的类比学校图书馆只有一本通用教材结果每个学生都自己复印了一整本放在书包里。占用的是每个人的书包空间但这些内容明明可以共用一本。享元模式做的事情就是让这些学生去图书馆借阅同一本教材而不是各自复印。放到代码里就是让多个对象共享同一个不可变实例省掉重复存储的成本。更重要的是这些重复对象还伴随着创建开销。每次new一个对象都要分配内存、执行构造器、建立引用关系。当对象数量从几百涨到几万几十万GC的压力会成倍放大。这就是为什么很多系统在早期功能简单时感受不到问题一旦数据规模上来第一个优化点往往就是“减少重复对象的创建”而享元模式正是针对这个场景的经典解法。1.2 一句话说清享元模式共享细粒度对象享元模式的经典定义是运用共享技术有效地支持大量细粒度对象。拆开来看有三个关键词。第一是“大量”说明这个模式解决的是对象数量爆炸的问题不是所有场景都需要第二是“细粒度”意思是这些对象往往很小单独看没什么成本但数量一大就会压垮内存第三是“共享”这是核心手段让相同的对象实例被多个地方复用。很多初学者会把享元模式理解成“缓存”这不算错但不够精确。缓存的目的是提高访问速度而享元的目的是减少对象数量和内存占用。它更像“相同内容只存一份其他地方引用这一份”。如果一份内容被一万个地方引用那这一万处就不需要各自持有一份完整数据只需要持有引用或者通过某种方式查找到共享实例即可。1.3 语言层面其实早就用了享元思想你很可能已经用过享元模式只是没意识到。Java里的String常量池就是典型的共享思想多个字符串字面量内容相同可能指向同一个字符串对象。Integer的缓存机制也类似[-128, 127]范围内的整数会自动复用缓存对象而不是每次装箱都创建新对象。我印象很深的一次排查是看到用户频繁创建大量相同的小对象导致堆内存上涨。后来我做了个实验把一部分对象改成复用固定实例再跑一次压测内存曲线立刻平缓了很多。语言底层的这些优化本质上就是在告诉我们当数据不变而且会被大量复用时共享是性价比极高的选择。理解了这一点再回头看享元模式就会觉得它不是一个孤立的模式而是整个软件工程里“复用不可变状态”这个朴素思想的一种工程化表达。2. 内部状态和外部状态享元模式的核心分界线2.1 什么是内部状态可以安全共享的部分内部状态是存储在享元对象内部、不随环境变化而变化、可以被多个调用方安全共享的属性。它通常是对象本质上不可变的部分比如围棋棋子的颜色。一颗黑子不管放在棋盘哪个位置它始终是黑子这个“黑”就是内部状态。在代码里内部状态一般被设计成final字段在创建时确定之后不再修改。为什么要强调“不可变”因为共享意味着多个调用方同时使用同一个实例。如果这个实例的字段会被某个调用方改掉其他调用方看到的数据就会出问题。所以内部状态必须稳定、只读才能让所有人放心地共用同一个对象。内部状态还是享元对象的“身份标识”。两个外部状态不同的使用场景如果内部状态相同就可以共用同一个享元对象如果内部状态不同就不能共用。比如文本编辑器里字符“A”可以是内部状态之一但字号、颜色是否算内部状态要看系统里这些组合是否有限。如果有限就可以把“A16号红色”作为一个组合键来共享如果无限那这些就不适合放进内部状态。2.2 什么是外部状态必须从外部传入的上下文外部状态是随调用场景变化、不能共享、必须由客户端在使用时传入的状态。继续用围棋举例棋子的颜色可以共享但棋子所在的位置(x, y)是每颗棋子独一无二的必须在使用时单独指定。如果把位置也存进共享对象那这个对象就不再是“共享的”因为每颗棋子的位置都不同等于又回到了一对象一位置的老路上。外部状态的存在是享元模式能保持共享的关键。所有差异都通过方法参数传入共享对象本身只负责处理内部状态对应的固定行为。调用方拿到享元对象后把上下文信息传进去就能得到完整的结果也不会污染共享对象本身。我记得有个很经典的比喻享元对象像一枚印章印章本身的图案是内部状态盖章的位置和纸张是外部状态。同一枚章可以盖在不同的地方章还是那枚章但每张纸上的结果是独立的。这个比喻基本就解释了享元模式的运作方式。2.3 状态划分的判断清单在实际设计时我通常用三个问题来判断一个属性到底应该放在内部还是外部这个属性在系统里是否只有有限几种取值如果只有有限几种它才有成为内部状态的资格如果是用户ID、时间戳这种无限增长的值直接排除。这个属性是否会被多个调用方同时使用并且要求不能因人而异如果所有调用方都希望看到相同的值它适合内部共享。这个属性是否会随着调用环境改变如果会它一定是外部状态必须由调用方传入。我举一个权限菜单的例子。菜单的图标路径和跳转URL在所有用户看来都是一样的取值种类也很有限所以是内部状态。而“当前用户是否有权限看到这个节点”这个结果不仅取决于菜单还取决于登录用户所以它是外部状态应该在渲染时根据用户信息实时判断绝不能写进共享菜单对象里。只要你把内部状态和外部状态这条线划清楚享元模式的代码写起来就非常顺畅这条线一旦模糊后面必然会出现各种数据错乱和线程安全问题。3. 手写一个享元工厂从接口设计到测试验证3.1 接口与具体享元类的设计先看一个最经典实例围棋棋盘上有大量黑白棋子我们用享元模式来管理棋子的对象。定义一个通用的棋子接口接口方法接收外部状态x和y。public interface Chess { void draw(int x, int y); }黑色棋子类只关心自己的内部状态也就是颜色。位置通过方法参数传入不会存进字段。public class BlackChess implements Chess { private final String color black; Override public void draw(int x, int y) { System.out.println(color chess at ( x , y )); } }白色棋子类结构完全一致只是颜色字段不同。两个类的核心特点是内部状态是固定的、不会变的方法参数才代表每颗棋子的独特位置。这里有个容易被忽略的设计点接口方法签名里的x和y就是外部状态的入口。如果你在BlackChess里定义一个setPosition(x, y)那就等于把外部状态装进了共享对象后面一定会踩坑。正确的做法是用方法参数传递而不是用字段保存。3.2 工厂缓存、并发与key设计享元模式通常需要一个工厂来管理共享实例避免调用方自己重复创建。工厂内部用一个Map保存已有的享元对象key是内部状态的组合value是对应的共享实例。代码如下import java.util.Map; import java.util.concurrent.ConcurrentHashMap; public class ChessFactory { private static final MapString, Chess CACHE new ConcurrentHashMap(); public static Chess getChess(String color) { return CACHE.computeIfAbsent(color, c - { if (black.equals(c)) { return new BlackChess(); } else if (white.equals(c)) { return new WhiteChess(); } throw new IllegalArgumentException(unsupported color: c); }); } }为什么用ConcurrentHashMap而不是普通HashMap因为享元工厂经常在多线程环境下被调用如果两个线程同时创建同一种颜色的棋子使用普通HashMap可能产生重复对象甚至出现并发安全风险。Java 8 的computeIfAbsent是原子操作可以在同一个key不存在时才创建对象避免重复实例。key的设计也很有讲究。这里只有颜色一个维度所以直接使用颜色字符串即可。如果内部状态有多个维度比如“字体字号颜色”就需要封装一个组合key类或者用List、字符串拼接等方式保证唯一性。不管用哪种方式key必须能稳定唯一地表达内部状态组合这是工厂正确性的基础。3.3 客户端使用方式与共享验证客户端只需要通过工厂获取对象不需要关心对象是否被创建过。相同颜色的棋子拿到的应该是同一个对象。public class Client { public static void main(String[] args) { Chess black1 ChessFactory.getChess(black); Chess black2 ChessFactory.getChess(black); Chess white1 ChessFactory.getChess(white); System.out.println(black1 black2); // true证明共享生效 System.out.println(black1 white1); // false内部状态不同所以不同 black1.draw(3, 3); black2.draw(10, 10); white1.draw(5, 5); } }这段代码输出里最重要的一行是black1 black2。因为getChess两次传入相同颜色工厂返回了同一个实例所以引用相等。这是验证享元模式是否生效最直接的手段。我在项目里验证享元收益时还会在工厂里加一个简单的实例计数器记录每种内部状态被创建了几次。比如正常情况下“black”只应该被创建一次但draw方法被调用了一万次。这样就能非常直观地看到创建成本被摊薄了共享对象被大量复用了。如果计数显示同一个key被创建了几百次那说明工厂的共享逻辑没有生效多半是并发创建或者key设计出了问题。4. 真实业务里的三个高频落地场景4.1 文本编辑器里的字符对象富文本编辑器是享元模式的经典战场。一个文档页面上可能有成千上万个字符如果每个字符都单独存储完整的字体信息、渲染样式和字形数据内存开销会非常夸张。所以常见的做法是让每个字符对象共享它的字符编码和字形数据把位置、字号、颜色这些随排版变化的信息作为外部状态传入渲染引擎。比如文档里有三百个“A”这三百个“A”其实只需要一个共享的字符对象它的内部状态就是字符编码A。每个“A”具体显示在页面哪个坐标、多大字号、什么颜色都通过渲染方法的参数传进去。这样的设计让字符对象数量从“字符总数”降到了“字符种类数”节省的内存非常可观。当然如果字号和颜色的组合种类很多可以把“编码字号颜色”一起作为内部状态组合形成一个有限集合的享元池。只要组合种类是有限的这个方案就成立如果允许任意颜色任意字号无限组合那享元的收益就会被稀释这时候要回到外部状态去思考。4.2 游戏粒子与地图元素游戏开发里粒子系统是另一个典型场景。想象一场战斗画面里同时出现几千个弹幕粒子每个粒子都有位置、速度、剩余生命但它们的纹理、网格模型、碰撞规则完全相同。如果把每个粒子的纹理都单独加载一份到内存任何设备都会被直接压垮。正确的设计是让粒子纹理作为内部状态存放在一个共享的粒子模板对象里而每个活跃粒子只需要保存自己的位置、速度和生命值这些外部状态。渲染每一帧的时候把粒子的实时属性传给模板对象的更新方法再绘制出来。这样即使同时有五千个粒子在屏幕上飞真正加载的纹理资源可能只有几十种。地图元素也是同一个道理。比如一个副本区域里有几百个相同的装饰箱子这些箱子共享同一个模型和贴图但每个箱子的坐标可能不同。共享模型、外部传入坐标就能大幅减少资源占用。做游戏优化的同学对这套思路一定不陌生只不过在游戏引擎里它经常被叫做“资源共享”或“实例化渲染”本质上和享元模式是通的。4.3 后台系统里的菜单与权限节点回到我最早遇到的那个场景后台管理系统的菜单树。菜单树里的节点其实由两部分组成一部分是菜单本身的元数据比如标题、图标路径、排序值、路由地址这些对所有用户都一样是有限的另一部分是当前用户的权限状态也就是“这个用户能不能看到这个菜单”这部分因人而异是动态的。如果按照“每个用户生成一棵完整菜单树”的思路实现用户一多极端情况下会创建出“用户数乘以菜单数”个节点对象每个节点还要重复持有图标路径和路由模板。改成享元模式后菜单元数据只保留一份所有用户共用同一棵树上的共享节点渲染时再结合当前用户权限过滤出可见节点或者把权限状态作为参数传进节点的方法里。对象数量直接从“用户数乘菜单数”降到了“菜单数”。权限场景特别能说明一个问题不能因为某个状态“很重要”就塞进共享对象。用户权限恰恰是最大的外部状态来源它和菜单元数据根本不属于同一个生命周期必须在调用入口动态判断而不是把每个用户的权限结果都固化在共享节点里。5. 踩坑实录享元模式最容易被用错的几个地方5.1 外部状态写进共享对象一次样式互串的排查链路这是我印象最深的一次排错经历。现象是后台系统里用户A打开页面看到的菜单样式居然带着用户B的个性化配置而且不是每次都复现偶发性很强。一开始我怀疑是缓存失效问题检查了一圈缓存发现没有命中问题。后来去看GC日志和堆转储发现内存里某种菜单节点对象的数量远小于理论值这说明共享确实生效了但问题也恰恰出在共享上。再往下查发现这个共享菜单类里有一个userStyle字段每次请求到达时代码都会执行类似menu.setUserStyle(user.getStyle())的操作。因为菜单对象是共享的多线程并发时后写入的userStyle覆盖了前一个请求写入的值导致B的样式被A看到。这就是典型的把外部状态写进了共享对象。修复方案并不复杂把userStyle字段从共享对象里去掉改成渲染方法的一个参数render(UserContext context)每次渲染时根据传入的用户上下文去取样式这样共享对象里不再保存任何跟具体用户相关的字段问题立刻消失。修复后我又做了一轮并发压测同样的操作路径下再也没有出现样式互串。这个案例给了我一个很深的教训判断一个字段能不能放进共享对象只要问一句“它会不会因为使用者不同而不同”。会就放外面。5.2 可变内部状态与线程安全与外部状态混淆类似的问题是内部状态本身没有设计成不可变。有些同事会把一个内部状态字段声明成非final并且在工厂返回对象后允许调用方通过setter修改。一旦两个线程拿到同一个共享对象并修改了这个字段那本质上就是把外部状态偷渡进来了只是换了一种方式。我的建议是享元类里的内部状态字段全部用final修饰并且不提供任何修改方法。这样编译器级别就能杜绝误操作。如果你确实需要共享一个可变对象比如一个状态需要所有调用方同步看到并同步修改那这属于线程安全治理范畴应该用锁或原子类解决但这就已经不是享元模式的设计目标了。还有一种情况是内部状态本身不可变但方法内部有临时可变数据。因为Java方法栈上的变量是线程私有的所以这种临时数据不会互相干扰不需要担心。真正要盯住的是对象字段尤其是那些暴露了setter的字段。5.3 缓存失控与内存泄漏享元工厂的Map如果无限增长问题也很严重。尤其有的人为了图省事把用户ID、请求时间这种无限变化的字段拼进key里结果每个用户都创建一个共享对象对象数量不但没有减少反而全被工厂缓存持有变成了变相的内存泄漏。我见过一个实例系统想缓存数据库查询结果把查询条件整个拼成字符串当key其中包含用户ID和当前时间。用户一多缓存对象只增不减堆内存直接撑爆。正确的做法是key只能由有限取值的内部状态构成比如枚举、类型、配置标识。如果key无限增长就要回头检查是不是把外部状态塞进了内部状态。如果确实需要清理缓存可以考虑使用WeakHashMap或者给工厂增加清理逻辑。但要注意WeakHashMap依赖弱引用可能被GC提前回收导致共享效果大打折扣。我的建议是优先从key设计上避免无限增长缓存治理只是兜底手段。5.4 不是所有系统都适合享元模式有时候硬套享元反而得不偿失。如果整个系统里某种对象总共就几十个内存完全没压力引入工厂、封装内部状态反而增加了代码理解成本。又或者外部状态特别多每次调用都要组装一大串参数代码很快就会变成参数地狱维护起来非常痛苦。我判断是否使用享元模式会先看两个硬指标一是对象数量是否达到“大量”级别二是不变的内部状态是否占据对象体积的主要部分。如果数量不大或者每次调用的大部分数据都在变化那还不如老老实实创建新对象。设计模式的价值在于解决问题而不是给代码“镶金边”用不上的模式写得再漂亮也只是负担。6. 和单例、原型、对象池的关系别再傻傻分不清6.1 一张表看懂四种模式的边界单例、原型、对象池和享元模式经常被人放在一起讨论它们确实都和“对象创建”有关但目的和实现方式有明显区别。我整理了一张表模式核心理念实例数量状态特点典型场景单例模式全局只有一个实例全局唯一可变或不可变但共享一个状态配置中心、日志组件原型模式通过复制已有对象创建新对象可创建多个新实例新对象拥有独立状态复杂对象的快速拷贝对象池模式复用对象实例用后归还固定或动态上限的池对象状态可变需要清理状态数据库连接、线程池享元模式相同内部状态共享同一实例每个内部状态组合一个实例内部状态不可变外部状态外部传入大量相似对象这张表最核心的区别在“状态”这一列。单例是全局共享一份状态不管谁调用都看到同一个对象状态原型是复制出独立实例互不影响对象池允许对象状态被反复修改但用完后要归还并清理状态享元则要求内部状态稳定不变差异靠外部状态传入。6.2 什么时候该选享元什么时候该选对象池对象池和享元模式是最常被混淆的。比如数据库连接池每个连接对象内部有socket、事务状态这些可变信息用完必须归还并重置否则连接就脏了。这种场景适合对象池而不适合享元因为连接对象的状态既会变化也不能被不同业务无差别共享。反过来如果一个对象的状态天生不会变而且你希望多个调用方看到同样的一份内容那享元模式更合适。比如报表系统里的样式模板同一个模板可以被无数报表引用模板内容不会因为引用它的报表不同而改变这就是典型的享元场景。还有一种组合用法对象池负责复用那些“创建成本高但状态可变”的对象享元模式负责共享那些“不变的内容”。比如连接池里每个连接都要持有连接参数和驱动元数据这些参数本身不变可以先做成享元连接对象本身的状态则用池化来管理。两套思想并不冲突反而可以互相配合。6.3 我的实际使用标准最后分享一下我在真实项目里使用享元模式的判断标准。我一般就问三个问题第一对象的数量是不是大到了需要优化内存或GC的程度第二这些对象是否有一部分信息是相同且不变的第三变化的部分能不能通过方法参数方便地传入。三个问题都满足我就放心设计工厂任何一个不满足我就果断放弃避免过度设计。实际写代码时我还会额外做一件小事在享元类旁边的注释里写清楚“哪些是内部状态、哪些是外部状态”。这样后来接手代码的人看一眼注释就知道什么能加、什么不能加能省掉大量排查时间。这个习惯看起来不起眼但真的帮我在团队里避免过好几次因为乱加字段导致的数据串扰问题。享元模式本身不复杂复杂的是守住那条内部状态和外部状态的分界线只要守住它这模式就是一把非常趁手的性能优化利器。