1. 享元模式到底在解决什么头疼事
先说一个我早年遇到的真实场景,某游戏项目的UI界面同时要渲染几千个士兵单位,每个单位有完整的模型、贴图引用、音效句柄、行为配置。功能倒是没啥问题,就是内存涨得离谱,低端设备直接闪退。后来排查发现,几千个士兵其实只有几种兵种类型,绝大多数字段是重复的,真正不同的只是坐标、血量、朝向这几个值。当时第一反应就是“这么多重复对象能不能共享一份”,这就是享元模式的核心动机,用共享消除大量细粒度对象的重复开销。
这个模式属于结构型,因为它处理的是对象之间的组织关系,关注如何用更少的对象支撑更多的逻辑实例。它解决的核心问题是:当系统存在大量相似对象,并且这些对象的大部分属性可以共享时,如何避免每个逻辑实例都独立持有完整数据。拿上面的游戏来说,几千士兵共享同一套模型和配置,只需要各自维护坐标和状态,内存占用直接下降一个数量级。
用一句话概括享元模式:把对象的属性拆成内部状态和外部状态,内部状态共享,外部状态由客户端传入或在运行时维护。内部状态就是对象身上不变的、可复用的那部分,比如士兵的兵种、武器模型、动画资源;外部状态是会变化的、不该放进共享对象的那部分,比如坐标、当前血量、朝向。享元对象本身只保存内部状态,外部状态在使用时通过参数传进去。
适合读这篇内容的人很明确:想让系统在大量相似对象场景下更省内存的开发者,或者准备面试、想搞清楚各类设计模式在实际项目里怎么落地的同学。这篇博文会从模式结构、实现步骤、踩坑实录、JDK源码里的影子几个维度展开,最后还会聊几个我实际项目中摸索出来的演进路线。
2. 享元模式的结构与关键角色拆解
2.1 四个角色各司其职
先看最朴素但最直接的类图视角,享元模式包含四个角色:抽象享元接口、具体享元类、享元工厂、客户端。抽象享元接口定义业务方法,方法的参数往往就是外部状态;具体享元类实现这个接口,内部保存内部状态,业务方法根据传入的外部状态完成完整操作;享元工厂负责创建和管理享元对象,内部通常有一个缓存容器,比如HashMap,客户端要对象时先查缓存,命中就返回已有对象,没命中就创建新对象并放入缓存。
这里有个很关键的点,享元工厂是模式能否生效的枢纽。如果客户端绕过工厂,直接new具体享元类,共享就无从谈起。我在项目里甚至会把具体享元类的构造方法设成包级私有或者私有,让外部只能通过工厂获取,从语法层面堵死绕路可能。这属于强制约束,比靠团队约定靠谱得多。
外部状态则完全脱离享元对象自身。举个例子,一个文本编辑器里,字符是享元对象,字符本身的字形、字体、颜色配置可以共享,但字符在文档中的位置、行号不能存在享元对象里,否则两个相同字符换位置就产生冲突。运行时的状态必须由客户端在调用时传入,或者保存在一个独立的外部数据结构中。
2.2 内部状态与外部状态的划分标准
这个划分是享元模式最容易翻车的点,我总结了一套判断标准:变化频率。一个属性如果绝大多数实例的取值都相同,并且几乎不会改变,优先考虑放内部;如果不同实例差异明显,或者同一个逻辑实例运行过程中这个值会频繁变化,放外部。
再细化一点,内部状态的对象在多线程环境下被多个线程同时调用,所以它必须是线程安全的,最稳妥的做法就是不可变。构造时把所有字段初始化完成,后续没有任何setter,所有字段用final修饰。这样既保证了共享安全,也让享元对象的生命周期管理变得简单,反正没人改它,放缓存里多久都不会出问题。
外部状态则没有这个要求,它跟随客户端线程走。比如棋类游戏里,棋子外观可以共享,但棋子在棋盘上的位置是外部状态。实现时通常把外部状态作为方法参数传入,或者用一个独立的容器,像Map、数组,按业务维度去索引。别忘了这种外部状态容器也需要注意并发访问问题。
2.3 不等同于单纯缓存
网上很多人把享元模式简单理解成“缓存一下,复用对象”,这个理解不够准确。缓存复用是手段,但享元模式还成立的前提是:必须能区分内部状态和外部状态,并且对象具备可共享的语义。如果两个逻辑上不同的对象共享同一份数据会出错,那就不符合享元的使用前提。
举个例子,线程池也算复用对象,但它不叫享元模式,因为线程没有内部/外部状态之分,线程之间是独立的执行体。享元对象则是“同一个对象可以代表多个逻辑实例”,靠外部状态来区分。逻辑实例数量大于物理对象数量,这才是享元模式的核心特征。抓住这个特征,你就不会把享元模式用成一锅粥。
3. 一个可以从零复用的享元实现范式
3.1 场景设定与代码落位
我用一个很典型的小案例来演示完整实现:模拟场景里的树木渲染。场景中可能有几万棵树,但树种只有几种,Oak、Pine、Birch。每棵树不需要各自持有一份完整的纹理、材质、模型数据,这些属于内部状态,可以共享;树的位置、大小、健康状况是外部状态,随实例变化。
先建一个抽象接口,所有树都要能“渲染”,渲染时需要外部状态参与。代码如下:
public interface Tree { void render(int x, int y, int size, String health); }然后是具体享元类。每棵树在创建时被指定一个类型,这个类型决定了它的纹理和模型。类内部保存内部状态type、texture、model,并且这些字段不允许修改。
public final class TreeType implements Tree { private final String type; private final String texture; private final String model; public TreeType(String type, String texture, String model) { this.type = type; this.texture = texture; this.model = model; } @Override public void render(int x, int y, int size, String health) { System.out.printf("绘制%s树,位置(%d,%d),尺寸%d,状态:%s,贴图:%s%n", type, x, y, size, health, texture); // 实际项目里这里会用model+texture走一次真正的渲染管线 } }3.2 工厂缓存与获取逻辑
工厂是享元模式里的“调度员”。这里我直接用一个线程安全的ConcurrentHashMap作为缓存容器,因为真实项目中多线程渲染很常见,HashMap在并发下扩容可能形成死链,教训很惨痛。
public final class TreeFactory { private static final Map<String, TreeType> CACHE = new ConcurrentHashMap<>(); private TreeFactory() { } public static TreeType getTreeType(String type) { return CACHE.computeIfAbsent(type, key -> { String texture = loadTexture(key); String model = loadModel(key); return new TreeType(key, texture, model); }); } private static String loadTexture(String type) { // 模拟从磁盘加载,这里只做个耗时演示 return "textures/" + type + ".png"; } private static String loadModel(String type) { return "models/" + type + ".obj"; } }注意computeIfAbsent的写法,它是原子操作,避免了“先get再put”之间的竞态条件,同一时刻多个线程请求相同type时不会重复创建享元对象。Java 8之后的ConcurrentHashMap的computeIfAbsent方法在保证原子性的前提下,性能也比用putIfAbsent包一层要好。
客户端使用时,外部状态完全由调用方维护。比如场景记录所有树实例时,只存坐标和尺寸等可变值,树类型去工厂取共享对象:
public class ForestScene { private record TreePlacement(TreeType type, int x, int y, int size, String health) { } private final List<TreePlacement> placements = new ArrayList<>(); public void plantTree(int x, int y, int size, String health, String type) { TreeType treeType = TreeFactory.getTreeType(type); placements.add(new TreePlacement(treeType, x, y, size, health)); } public void drawAll() { for (TreePlacement placement : placements) { placement.type().render(placement.x(), placement.y(), placement.size(), placement.health()); } } }这样整个场景中,无论种多少棵树,真正创建的TreeType对象只有三种。内存节省是非常直观的,尤其当texture和model这两个引用指向的底层资源有几十MB时,几千个对象复用后节省的是成百上千MB的量级。
3.3 为什么这个范式是“标准答案”
这个写法把享元模式的几个最佳实践经验都融合进去了:享元类用final修饰且字段不可变,保证线程安全;工厂用计算型缓存,避免并发重复创建;外部状态用record或独立对象包装,和共享对象解耦;客户端代码不用感知工厂内部的共享逻辑,对调用方透明。四件事各自独立,任何一个拆掉都会出问题。
我在实际项目里见过有人只做了一半,享元类做成了可变对象,为了图省事直接在render方法里写状态。结果两个线程同时渲染时,一个线程刚设置好坐标,另一个线程就把它覆盖了。屏幕上的树全部错位,排查了半天才发现是共享对象被改了。记住,享元对象一旦可写,共享就变成灾难。
4. 实现享元时必须盯死的五个细节
4.1 内部状态必须不可变
这个前面已经反复强调,但值得用一段专门的篇幅说明原因。共享对象的本质是“一个物理对象,多个逻辑视角”。如果物理对象的数据是可变的,那么一个逻辑视角改了数据,所有其他逻辑视角全部跟着变。所谓共享,共享的是不变的那部分语义。
实践中的具体约束是:所有内部字段设成private final;不提供setter;在构造器里完成所有初始化;如果一个字段是引用类型,尽量用不可变类型,比如String、基本类型包装类、或者自己再套一层Collections.unmodifiableList。如果确实需要一个可变引用,那不用犹豫,这个字段根本不是内部状态,应该拿到外部去。
4.2 工厂的缓存容器要扛住并发
多数业务系统逃不过多线程。工厂里最常见的两个并发问题是:重复创建和缓存失效。重复创建最多浪费一点内存,缓存失效则可能导致不同线程拿到不同对象,享元模式名存实亡。
稳妥的做法是使用ConcurrentHashMap,并且用computeIfAbsent这类原子API。不要自己写“先查缓存、再创建、再放入”的三步逻辑。这个顺序在并发下必出问题,即使双检锁也容易因为指令重排翻车。如果是低并发、单线程的客户端程序,用普通HashMap就够了,但养成用并发容器的习惯没有坏处。
4.3 equals与hashCode的选择
享元对象存在缓存里,以内部状态作为键。如果内部状态有几个维度,可以用组合键,比如一个内部类或者直接拼接一个字符串。更通用一点的做法是:享元类根据内部状态实现equals和hashCode。这样即使不用工厂的缓存,也能通过HashMap天然去重。
这里涉及一个更大的设计问题,享元对象之间到底算“相同”还是“不同”?从内部状态角度看,两个Oak类型的享元对象应该相等,因为它们共享一切数据;从外部状态角度看,它们又分属不同树。所以equals和hashCode只基于内部状态计算,外部状态不得参与,否则HashMap查询都成问题。
4.4 清理与内存回收
享元工厂的缓存如果无限增长,确实存在内存泄漏隐患,但大部分场景下不用过度焦虑,因为享元对象本身就是为了“常驻复用”而存在的,数量通常等于类型的数量,而类型数量一般可控。真正需要警惕的是缓存里的键不可变,如果键是可变对象,哈希值变化后整个缓存就废了。
如果类型数量真的会无限扩张,比如根据用户输入动态生成鱼池里的鱼种,那就需要引入淘汰策略。简单方式是用带过期时间的缓存,比如Guava的CacheBuilder或者Caffeine;复杂一点可以按引用计数,当外部没有逻辑实例引用某一类型时,从工厂中移除。这个需求在标准享元模式讨论中不多见,但大型服务端项目中很常见。
4.5 日志与调试的关键点位
享元对象共享后,调试难度会上升。因为报错时看到的对象地址可能是几十个逻辑实例共用的同一个物理对象,你很难直接区分出到底是哪个业务实例出了问题。我的习惯是:在工厂的创建和缓存命中处打关键日志,创建时记录新类型,命中时静默;在外部状态包装类中保留一个业务ID,比如树ID、棋子ID。这样排查问题时可以快速通过ID定位是哪个逻辑实例,再通过类型定位到哪个共享对象。
还有一个很务实的技巧,在享元类中加一个只读的description方法,返回内部状态的摘要。这样即使在IDE里直接查看对象,也能快速知道这是哪个类型的共享实体,不需要一层层展开所有字段。这个习惯在大型项目里能省很多时间。
5. 常见问题与排查技巧实录
5.1 问题速查表
| 现象 | 可能原因 | 处理方式 |
|---|---|---|
| 修改一个对象数据,所有对象一起变 | 可变对象被共享,外部状态误存入内部 | 把变化字段移到方法参数或外部容器,内部字段全部final |
| 不同线程拿到不同享元对象 | 工厂缓存没有保证原子性 | 换用ConcurrentHashMap + computeIfAbsent |
| 缓存永不命中,内存暴涨 | 键对象hashCode不稳定 | 检查键是否可变,字段是否final,是否依赖于外部状态 |
| 对象时增时减,清理困难 | 被误回收或缓存膨胀 | 检查是否持有强引用,需要淘汰策略时引入过期缓存 |
| 业务版本升级后旧数据被复用 | 内部状态缺少版本标识 | 在享元的内部状态中增加版本号作为区分 |
5.2 一次真实的内存排查经历
某次我负责的系统里,有个报表模块动态生成几十万个折线图上的点位对象。每个点位包含样式信息,而样式只有十来种。结果内存占用非常高。当时的代码是用一个普通类存储点位坐标和样式细节,再把几千个样式字符串各自存一遍,字符串本身在Java里会驻留,栈上还有几万个数组,最终吃掉几百兆。
我介入后做的事很简单:把样式抽成享元对象,工厂按样式名缓存,点位只存x、y值和样式的一个引用。改造完以后,几十万点位的内存占用从四百多兆降到六十兆左右,而且还顺带提升了渲染性能,因为相同样式的点位在绘制时能走批量渲染通道。那次经历让我意识到,享元模式优化内存的效果往往是数量级的,不是百分之二三十的提升,而是直接降一个量级。
5.3 排查手法:先看共享对象是否被意外修改
定位享元模式的bug,第一步永远是一行关键词:这个字段到底该不该是共享的。我会在代码里搜享元类的setter,只要出现setter,就非常可疑。虽然setter本身不一定是错误,但如果setter修改的是内部状态字段,它一定是错误。
然后观察外部状态是否被放进了享元构造参数。有时候开发图方便,把位置坐标也传给享元对象,虽然被共享的数据多了,但对象复用率直线下降。内存问题没解决,反而引入了状态污染。正确的姿势是:构造参数只放真正的内部状态,外部状态一律走方法参数。
6. JDK里那些你没注意过的享元影子
6.1 包装类的缓存机制
Integer、Long、Short这些包装类里隐藏着一个非常经典的享元实现。Integer.valueOf(int)在-128到127的范围内,直接返回缓存数组里的对象,超出范围才new新的Integer。这就是享元模式在语言底层的应用,避免频繁创建常见数值的包装对象。
这里有个细节值得留意,Integer内部状态就是一个int值,外部状态没有,所以它是最纯粹的享元:一个对象对应一个数值逻辑实例。正因为这个缓存,两个Integer用==比较时很容易出现“大部分时候相等,超范围后反而不等”的迷惑行为。所以写代码时永远用equals或intValue()比较数值,不要用==比较包装类,这是我踩过的最典型的Java基础坑之一。
6.2 String常量池与字符串驻留
字符串常量池也常被拿来和享元模式类比。凡是双引号直接写的字符串字面量,JVM会在常量池里复用,相同内容的字符串字面量指向同一个底层字符数组。不过String的驻留机制比纯享元模式复杂一些,它有编译期优化、运行期intern方法等细节,不完全等同于业务代码里的享元工厂。
另一个值得一提的点是,String类本身就是不可变的,这恰好符合享元对象的要求。如果String是可变的,常量池里的字符串被改了一下,其他所有引用它的变量全都会受影响。正因为不可变,JVM才敢大胆复用。我们在业务代码里设计享元对象时,应该默认走这个思路。
6.3 线程池、连接池不算享元
很多人会问,线程池、连接池、对象池算不算享元模式?严格来说不算。享元模式的核心衡量标准是“内部状态是否可共享”,线程和连接没有“内部状态共享”这一说,它们各自有独立的内存栈、独立连接通道,池化只是为了复用创建成本,而不是让同一份数据支撑多个逻辑实例。
不过从广义的资源复用角度看,池化和享元经常搭配出现。比如一个系统中的享元对象如果创建成本很高,可以先通过对象池技术来复用这些享元对象的内存分配。它们不是替代关系,而是组合关系。理解这个边界,面试时才能把问题答得既有深度又准确。
7. 享元模式与相近设计模式的边界
7.1 和单例模式的区别
单例模式保证一个类只有一个实例,享元模式一个类可以有多个实例。单例类的内部状态就是类级别的全局状态,享元类的内部状态要按类型区分。所以享元工厂本质上是“每个类型一个单例”的容器。
组合使用很常见:享元工厂本身就可以设计成单例模式,比如上面的TreeFactory使用静态方法,本质上是全局唯一的入口。把这两个模式配合使用并不矛盾,一个管工厂的唯一性,一个管业务对象的复用性。
7.2 和对象池模式的区别
对象池的核心特征是“从池中借出,用完归还”,对象的生命周期在客户端和池之间来回流转。享元模式的核心特征是“永远共享”,客户端只是获取共享对象引用,根本不需要归还。线程池、数据库连接池属于前者,文本编辑器里的字符属于后者。
这个区别在实际工程中很重要,因为借用归还引入了额外的资源管理负担,用不好容易造成池泄漏、对象状态残留。享元模式则更轻量,只要保证内部状态不可变,客户端拿到引用后随便用,用完也不需要通知工厂。选型时想清楚这一点,能少写很多复杂代码。
7.3 和原型模式、外观模式的对比
原型模式关心对象如何被复制,享元模式关心对象如何被共享。复制和共享的差异在于:复制会产生新物理对象,共享不会。但两者可以联合使用,当享元工厂需要创建一个新的内部状态类型时,可以先通过原型模式快速克隆一个原型对象,再修改差异字段。
外观模式则是给子系统提供统一入口,它和享元模式的层次不同。外观关注的是接口简化,享元关注的是内存优化。但享元工厂本身就像一个微型外观,把复杂的内存复用逻辑封装起来,对客户端只暴露getTreeType()这样的简单方法。理解这个层次感,能帮你写出更自然的架构。
8. 实际项目里的享元模式演进路线
8.1 初级阶段:只做对象复用
第一个阶段是把它当缓存用。系统里类型数量有限、对象创建频繁但种类固定,直接引入享元工厂分担创建压力,让对象缓存起来。这一阶段最明显的收益是代码结构更清晰了,外部状态和内部状态被强制分离。很多业务代码虽然没意识到自己在用享元模式,但写出来的缓存工厂其实已经是它的雏形。
需要提醒的是,初阶段千万不要为了用模式而用模式。如果系统里对象数量本来就不大,强行抽象享元会平白增加复杂度。判断依据很简单:实际运行时对象数量是否与类型数量成数量级差距,比如几千个实例确实集中到几十个甚至几个类型上。满足这个条件,才值得动手重构。
8.2 进阶阶段:外置状态管理与批量操作
第二阶段是把外部状态的管理也结构化。外部状态不应该零散地散落在业务代码里,而是用一个独立的数据结构来组织和索引。比如游戏引擎里的组件系统,经常用Eintity-Component模式,把位置、血量、朝向这些变化数据存进专门组件,而共享的模型资源由引擎级缓存管理。
这个阶段的另一个关键词是“批量操作”,多个共享同一类型的逻辑实例,在渲染和计算上天然具备批处理潜力。比如所有松树共享同一纹理,渲染时就能合并成一次批次调用,减少状态切换开销。这种优化在图形引擎、游戏开发领域非常常见。
8.3 成熟阶段:按需缓存、可观测、自动清理
成熟阶段的享元体系会具备三个特征:按需加载、可观测、生命周期管理。按需加载意味着内部状态里有重资源时,第一次访问某个类型才真正加载资源,而不是工厂初始化时全量加载。可观测意味着你能清楚知道当前有多少共享对象、每个对象被多少逻辑实例引用,这需要引入引用计数或者监控埋点。
生命周期管理处理的是“类型数量无限增长”的问题,比如用户自定义模板生成文档时,模板类型可能成千上万,不可能全部永久缓存,必须清理不常用的类型。这时候可以切换到Caffeine这类支持LRU、过期策略的缓存框架,或者自建引用计数清理任务。这个阶段的享元模式,已经从一个简单复用工具成长为一个独立的资源管理子系统。
8.4 结合领域驱动设计的落地思路
在领域驱动设计里,享元模式特别适合那些“值对象”和“领域服务”的场景。值对象以值相等性为区分标准,天然适合做成享元;领域服务如果无状态,也能使用享元语义共享。但要注意,聚合根和实体有独立的生命周期,不适合直接享元化,会把状态搞乱。
一个实践技巧是:对聚合根和实体,保持普通对象语义;对值对象、资源配置、字典数据、领域内频繁变化的上下文数据,大胆应用享元。配合依赖注入容器,把这些享元对象注册成单例,就有了一个很干净的架构。我还习惯在架构文档里专门标注哪些对象是享元、哪些不是,避免后续维护者误用,把可变状态塞进共享对象里。
9. 最后分享一个重组后的小技巧
在做享元模式改造时,我习惯先画一张“状态分布清单”,把对象的所有属性列成表格,逐个标记为内部或外部。标记的原则是问三个问题:这个属性在所有实例中是否一样?这个属性会不会在运行期改变?这个属性是否跟特定逻辑实例强绑定?只有三个问题都指向“共享”,才敢放进内部状态。
这套方法帮我避免过很多次拍脑袋的决策。有一次某个类里一共11个属性,直觉上感觉只有2个能共享,画完清单后发现其实是7个能共享,只是命名上它们看起来像业务数据。可见享元模式的难点不在于写代码,而在于状态划分。划分对了,代码结构自然就出来了。