☰
享元模式实战:用对象共享根治内存膨胀与性能瓶颈
2026/10/19 3:35:45 网站建设 项目流程

经典模式系列写到第11篇,轮到享元模式了。说实话,这是一个我早年觉得“只活在面试题里”的模式,直到在某后台系统里被内存问题逼着重新认识它。当时系统要做权限目录渲染,每个用户打开页面都会生成上千个菜单节点对象,每个节点还带着相同的图标路径、样式配置和跳转模板,结果几百个用户同时在线,堆内存被重复对象塞得满满当当,GC一顿狂跑,接口响应直接变慢。后来把“不变的那部分”抽出来共享,内存占用立刻降了一个数量级,代码也没复杂多少。从那以后我就明白,享元模式不是背概念用的,它是真正能救项目性能的设计工具。这篇就把我对享元模式的理解、实现方法、高频场景和踩过的坑完整聊一遍,希望能帮到正在为对象数量头疼的人。

1. 为什么每个成熟系统到最后都会惦记享元模式

1.1 内存账单:重复对象到底有多贵

先算一笔粗糙的账。一个普通Java对象,不算实际业务字段,光是对象头和引用对齐,就有十几字节的固定开销。如果你有三千个菜单节点,每个节点都要持有一个字符串图标路径、一个样式对象、一个URL模板,而这些内容在节点之间完全一样,那就等于把相同的数据复制了三千份。

我习惯用一个很生活化的类比:学校图书馆只有一本通用教材,结果每个学生都自己复印了一整本放在书包里。占用的是每个人的书包空间,但这些内容明明可以共用一本。享元模式做的事情就是让这些学生去图书馆借阅同一本教材,而不是各自复印。放到代码里,就是让多个对象共享同一个不可变实例,省掉重复存储的成本。

更重要的是,这些重复对象还伴随着创建开销。每次new一个对象,都要分配内存、执行构造器、建立引用关系。当对象数量从几百涨到几万几十万,GC的压力会成倍放大。这就是为什么很多系统在早期功能简单时感受不到问题,一旦数据规模上来,第一个优化点往往就是“减少重复对象的创建”,而享元模式正是针对这个场景的经典解法。

1.2 一句话说清享元模式:共享细粒度对象

享元模式的经典定义是:运用共享技术有效地支持大量细粒度对象。拆开来看有三个关键词。第一是“大量”,说明这个模式解决的是对象数量爆炸的问题,不是所有场景都需要;第二是“细粒度”,意思是这些对象往往很小,单独看没什么成本,但数量一大就会压垮内存;第三是“共享”,这是核心手段,让相同的对象实例被多个地方复用。

很多初学者会把享元模式理解成“缓存”,这不算错,但不够精确。缓存的目的是提高访问速度,而享元的目的是减少对象数量和内存占用。它更像“相同内容只存一份,其他地方引用这一份”。如果一份内容被一万个地方引用,那这一万处就不需要各自持有一份完整数据,只需要持有引用或者通过某种方式查找到共享实例即可。

1.3 语言层面其实早就用了享元思想

你很可能已经用过享元模式,只是没意识到。Java里的String常量池就是典型的共享思想:多个字符串字面量内容相同,可能指向同一个字符串对象。Integer的缓存机制也类似,[-128, 127]范围内的整数会自动复用缓存对象,而不是每次装箱都创建新对象。

我印象很深的一次排查,是看到用户频繁创建大量相同的小对象,导致堆内存上涨。后来我做了个实验,把一部分对象改成复用固定实例,再跑一次压测,内存曲线立刻平缓了很多。语言底层的这些优化本质上就是在告诉我们:当数据不变而且会被大量复用时,共享是性价比极高的选择。理解了这一点,再回头看享元模式,就会觉得它不是一个孤立的模式,而是整个软件工程里“复用不可变状态”这个朴素思想的一种工程化表达。

2. 内部状态和外部状态:享元模式的核心分界线

2.1 什么是内部状态:可以安全共享的部分

内部状态,是存储在享元对象内部、不随环境变化而变化、可以被多个调用方安全共享的属性。它通常是对象本质上不可变的部分,比如围棋棋子的颜色。一颗黑子不管放在棋盘哪个位置,它始终是黑子,这个“黑”就是内部状态。在代码里,内部状态一般被设计成final字段,在创建时确定,之后不再修改。

为什么要强调“不可变”?因为共享意味着多个调用方同时使用同一个实例。如果这个实例的字段会被某个调用方改掉,其他调用方看到的数据就会出问题。所以内部状态必须稳定、只读,才能让所有人放心地共用同一个对象。

内部状态还是享元对象的“身份标识”。两个外部状态不同的使用场景,如果内部状态相同,就可以共用同一个享元对象;如果内部状态不同,就不能共用。比如文本编辑器里,字符“A”可以是内部状态之一,但字号、颜色是否算内部状态,要看系统里这些组合是否有限。如果有限,就可以把“A+16号+红色”作为一个组合键来共享;如果无限,那这些就不适合放进内部状态。

2.2 什么是外部状态:必须从外部传入的上下文

外部状态,是随调用场景变化、不能共享、必须由客户端在使用时传入的状态。继续用围棋举例,棋子的颜色可以共享,但棋子所在的位置(x, y)是每颗棋子独一无二的,必须在使用时单独指定。如果把位置也存进共享对象,那这个对象就不再是“共享的”,因为每颗棋子的位置都不同,等于又回到了一对象一位置的老路上。

外部状态的存在,是享元模式能保持共享的关键。所有差异都通过方法参数传入,共享对象本身只负责处理内部状态对应的固定行为。调用方拿到享元对象后,把上下文信息传进去,就能得到完整的结果,也不会污染共享对象本身。

我记得有个很经典的比喻:享元对象像一枚印章,印章本身的图案是内部状态,盖章的位置和纸张是外部状态。同一枚章可以盖在不同的地方,章还是那枚章,但每张纸上的结果是独立的。这个比喻基本就解释了享元模式的运作方式。

2.3 状态划分的判断清单

在实际设计时,我通常用三个问题来判断一个属性到底应该放在内部还是外部:

  1. 这个属性在系统里是否只有有限几种取值?如果只有有限几种,它才有成为内部状态的资格;如果是用户ID、时间戳这种无限增长的值,直接排除。
  2. 这个属性是否会被多个调用方同时使用,并且要求不能因人而异?如果所有调用方都希望看到相同的值,它适合内部共享。
  3. 这个属性是否会随着调用环境改变?如果会,它一定是外部状态,必须由调用方传入。

我举一个权限菜单的例子。菜单的图标路径和跳转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 Map<String, 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的程度;第二,这些对象是否有一部分信息是相同且不变的;第三,变化的部分能不能通过方法参数方便地传入。三个问题都满足,我就放心设计工厂;任何一个不满足,我就果断放弃,避免过度设计。

实际写代码时,我还会额外做一件小事:在享元类旁边的注释里写清楚“哪些是内部状态、哪些是外部状态”。这样后来接手代码的人看一眼注释就知道什么能加、什么不能加,能省掉大量排查时间。这个习惯看起来不起眼,但真的帮我在团队里避免过好几次因为乱加字段导致的数据串扰问题。享元模式本身不复杂,复杂的是守住那条内部状态和外部状态的分界线,只要守住它,这模式就是一把非常趁手的性能优化利器。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询