走进 Gang of Four 设计模式:享元模式
说明:不仅告诉你"怎么用",更告诉你"为什么这样设计"
前置知识:Java 面向对象基础(接口、HashMap、不可变对象)、内存管理基本概念
📊 设计模式分类总览
GoF 23 种设计模式可按两个维度交叉分类:类/对象(处理方式) ×创建型/结构型/行为型(目的)。
| 维度 | 创建型(Creational) | 结构型(Structural) | 行为型(Behavioral) |
|---|---|---|---|
| 类(Class) (通过继承复用) | Factory Method 工厂方法 | Adapter(类适配器) | Interpreter(解释器) Template Method(模板方法) |
| 对象(Object) (通过组合/聚合复用) | Abstract Factory(抽象工厂) Builder(建造者) Prototype(原型) Singleton(单例) | Adapter(对象适配器) Bridge(桥接) Composite(组合) Decorator(装饰) Facade(外观) Flyweight(享元) Proxy(代理) | Chain of Resp.(责任链) Command(命令) Iterator(迭代器) Mediator(中介者) Memento(备忘录) Observer(观察者) State(状态) Strategy(策略) Visitor(访问者) |
📑 目录
- 模式概述 · 15 问深度分析
- 框架源码实战分析
- JDK Integer.valueOf 与 String 常量池
- Spring 单例 Bean 容器
- MyBatis SqlNode 共享元数据
- Tomcat StringCache
- 深度追问:Why / How / Trade-off / Evolution / Modern Practice
- 总结
- 参考文献
1. 模式概述 · 15 问深度分析
Q1: 为什么需要这个模式?它解决了什么问题?
在一些应用中,可能会出现需要创建海量相似对象的情况(例如:文本编辑器中的每一个字符、游戏中的成千上万个小兵、地图上的每一个建筑图标)。
- 解决的问题:解决了由于大量对象的创建而导致的内存溢出(OOM)或内存开销过大、GC 过于频繁导致系统卡顿的问题。
- 根本目的:通过共享技术实现相同或相似对象的重用,大幅减少内存中对象的数量。
Q2: 如果不用这个模式,会有什么缺陷?
- 内存暴涨:如果每一个微小的实例都独立占用内存,内存会迅速被吃光。
- GC 频繁:生命周期短暂的大量对象会频繁触发 JVM 的 Young GC 甚至 Full GC,导致系统出现明显的 STW 卡顿。
- 创建开销大:频繁地
new对象并进行初始化,消耗大量 CPU 资源和时间。
Q3: 核心思想是什么?一句话如何概括?
- 核心思想:将对象的状态划分为内部状态(Internal State)和外部状态(External State)。内部状态可共享,外部状态由客户端传入。
- 一句话概括:运用共享技术有效地支持大量细粒度的对象。
Q4: 包含哪些角色?每个角色的职责是什么?
| 角色 | 职责 |
|---|---|
| Flyweight(抽象享元) | 定义产品的抽象接口,包含需要外部状态的方法 |
| ConcreteFlyweight(具体享元) | 实现抽象接口,保存内部状态(不可变、可共享) |
| FlyweightFactory(享元工厂) | 核心角色,管理享元对象池,检查复用或创建 |
| Client(客户端) | 维护外部状态,调用时传入享元方法 |
Q5: 它们之间如何协作?调用流程是怎样的?
- 客户端需要对象时,不直接
new,而是向FlyweightFactory请求,传入标识(Key)。 FlyweightFactory检查缓存中是否存在该 Key:- 如果有,直接返回。
- 如果没有,创建一个新的
ConcreteFlyweight存入缓存,然后返回。
- 客户端获取享元对象后,在调用其方法时将外部状态作为参数传递进去。享元对象结合自身内部状态和传入的外部状态完成业务逻辑。
Q6: 为什么要这样设计?每个角色存在的意义是什么?
- 把外部状态剥离出来:这是享元模式能成功缩减对象的关键。如果对象自己持有所有状态,它就无法被别人复用。
- 工厂(Factory)存在的意义:它是"共享"的控制中心。没有工厂统一管理,客户端各自
new对象,共享就无从谈起。 - 具体享元存在的意义:它是真正的业务承载者,只保存"不管在什么环境下都不变"的硬核心数据(内部状态)。
Q7: 为什么使用接口、抽象类、组合,而不是其他方式?
- 为什么用接口/抽象类:为了多态性。工厂返回统一的抽象享元类型,客户端不需要知道具体拿到的是哪一个共享实例。
- 为什么用组合(通过参数传入外部状态):外部状态是多变的,如果使用继承,每多一种外部状态就要派生一个子类,导致类爆炸。通过组合/参数传递,把外部状态变成行为的"入参",完美实现解耦。
Q8: 符合哪些面向对象原则?
- 单一职责原则(SRP):享元对象只负责维护内部状态和核心逻辑,工厂只负责对象的生命周期和缓存管理。
- 开闭原则(OCP):增加新的具体享元类型,只需扩展抽象享元并让工厂支持即可。
- 面向接口编程(DIP):客户端依赖的是
Flyweight接口,而不是具体的实现类。
Q9: 优缺点与额外成本
优点:
- 极大减少内存中对象的数量,降低 OOM 风险。
- 外部状态独立,不会相互影响。
局限性/额外成本:
- 系统复杂度显著提高:需要分离内部状态和外部状态,增加了代码理解成本。
- 运行时间成本:每次读取/计算外部状态都需要通过参数传递,属于"用时间换空间"。
Q10: 最适合/不该用的场景
最适合:
- 系统中有大量相同或相似的对象,导致内存开销巨大。
- 对象的大多数状态都可以变为外部状态。
- 典型的如:围棋/象棋游戏、文本渲染引擎、地图 POI 标记。
不应该使用:
- 对象数量很少,或对象之间几乎没有任何共同状态。
- 内存绰绰有余,而系统对执行速度有极高的要求。
Q11: 与其他模式的区别
与单例模式的区别:
- 单例模式是全局只有一个实例。
- 享元模式在工厂里管理着一组/多个不同的对象实例。
与原型模式的区别:
- 原型模式是通过克隆已有的对象来创建全新的独立对象(不共享内存)。
- 享元模式是为了复用同一个对象,减少创建。
与组合模式的联系:
- 享元模式经常和组合模式结合使用。组合模式中的叶子节点如果数量巨大且类似,可以设计为享元对象。
Q12: 框架中的典型应用
- JDK:
Integer.valueOf(int i)内部有IntegerCache,缓存 -128 到 127 之间的 Integer 对象。String常量池也是极典型的享元应用。 - Spring:Spring 的 Bean 容器(默认 Singleton 作用域)管理着各种单例 Bean,通过
beanName在 Map 中复用对象。 - MyBatis:
SqlNode的共享元数据,SQL 解析后的结构树被缓存复用。 - Tomcat:
StringCache(字符串缓存),复用常用的 HTTP 头部、方法名等字符串。
Q13: 最小可运行版本(五子棋棋子)
importjava.util.HashMap;importjava.util.Map;// 1. 抽象享元interfaceChessPiece{voiddraw(intx,inty);// x, y 是外部状态}// 2. 具体享元(只包含内部状态)classConcreteChessPieceimplementsChessPiece{privatefinalStringcolor;// 内部状态:颜色publicConcreteChessPiece(Stringcolor){this.color=color;}@Overridepublicvoiddraw(intx,inty){System.out.println("在坐标 ("+x+", "+y+") 放置了一颗 ["+color+"] 棋子");}}// 3. 享元工厂classChessPieceFactory{privatestaticfinalMap<String,ChessPiece>pool=newHashMap<>();publicstaticChessPiecegetChessPiece(Stringcolor){if(!pool.containsKey(color)){pool.put(color,newConcreteChessPiece(color));System.out.println("== 创建了新的 ["+color+"] 棋子对象 ==");}returnpool.get(color);}}// 4. 客户端publicclassMain{publicstaticvoidmain(String[]args){ChessPieceblack1=ChessPieceFactory.getChessPiece("黑色");black1.draw(1,1);ChessPiecewhite1=ChessPieceFactory.getChessPiece("白色");white1.draw(2,2);ChessPieceblack2=ChessPieceFactory.getChessPiece("黑色");black2.draw(3,3);ChessPiecewhite2=ChessPieceFactory.getChessPiece("白色");white2.draw(4,4);System.out.println("black1 == black2: "+(black1==black2));// true}}Q14: 识别重构信号
- 内存监控异常:通过 Profiler 发现某种简单对象的实例数达到数十万甚至数百万,占用了大部分堆内存。
- 代码中存在大量重复数据:发现这些对象中,有 80% 的属性字段全都是一模一样的。
Q15: 现代改进
Java 16+ Record:public record ChessPiece(String color) { public void draw(int x, int y) { ... } }
函数式工厂(ComputeIfAbsent):
publicclassChessFactory{privatestaticfinalMap<String,ChessPiece>pool=newConcurrentHashMap<>();publicstaticChessPieceget(Stringcolor){returnpool.computeIfAbsent(color,ConcreteChessPiece::new);}}微服务/分布式视角:在微服务架构中,单机的享元扩展成了分布式缓存/元数据服务。海量订单中重复的"商品详情静态元数据",通过 Redis 缓存或在 BFF 层进行数据聚合拉取,核心思想依然是"剥离外部动态状态,共享静态内部状态"。
2. 框架源码实战分析
2.1 JDK Integer.valueOf 与 String 常量池
Integer.valueOf 源码
publicstaticIntegervalueOf(inti){if(i>=IntegerCache.low&&i<=IntegerCache.high)returnIntegerCache.cache[i+(-IntegerCache.low)];returnnewInteger(i);}IntegerCache 享元池:
privatestaticclassIntegerCache{staticfinalintlow=-128;staticfinalinthigh;staticfinalInteger[]cache;static{inth=127;high=h;cache=newInteger[(high-low)+1];intj=low;for(intk=0;k<cache.length;k++)cache[k]=newInteger(j++);}}- 内部状态(共享):
Integer的value属性(private final int value),天然不可变,线程安全。 - 外部状态:无(属于单纯享元模式,核心在于用空间换取高频数字的创建时间与内存开销)。
String 常量池
Java 的String常量池(StringTable)在 JVM 层面是一个固定大小的 HashTable。使用字面量String s = "abc"时,JVM 先去StringTable检查是否存在值为"abc"的引用。存在则直接返回,不存在则创建并存入。
2.2 Spring 单例 Bean 容器
Spring 的单例 Bean 容器从设计初衷上看,其核心逻辑与享元工厂非常相似。
核心源码——DefaultSingletonBeanRegistry:
/** Cache of singleton objects: bean name to bean instance. */privatefinalMap<String,Object>singletonObjects=newConcurrentHashMap<>(256);AbstractBeanFactory.doGetBean获取单例 Bean:
protected<T>TdoGetBean(Stringname,@NullableClass<T>requiredType,...){// 1. 尝试从单例享元池中直接获取ObjectsharedInstance=getSingleton(beanName);if(sharedInstance!=null&&args==null){bean=getObjectForBeanInstance(sharedInstance,name,beanName,null);}else{// 2. 享元池里没有,创建并缓存if(isSingleton(beanName)){sharedInstance=getSingleton(beanName,()->createBean(beanName,mbd,args));bean=getObjectForBeanInstance(sharedInstance,name,beanName,mbd);}}return(T)bean;}- 内部状态(共享):Bean 自身的类结构、无状态的方法、无状态的配置属性。
- 外部状态(非共享):每一次具体业务请求传入的参数。
- 为什么说是变形?享元模式处理大量细粒度对象,Spring Bean 通常是大粒度的。但从"通过
beanName去注册表中获取并复用无状态对象,避免重复创建"的角度来看,两者的底层逻辑完全一致。
2.3 MyBatis SqlNode 共享元数据
MyBatis 的 XML 动态标签在解析后被转换成SqlNode树,这些节点被一次性构建并缓存起来。当多个线程高并发执行同一条动态 SQL 时,大家复用同一个IfSqlNode实例,只需传入各自不同的DynamicContext。
publicclassIfSqlNodeimplementsSqlNode{privatefinalExpressionEvaluatorevaluator;privatefinalStringtest;// 内部状态:表达式文本privatefinalSqlNodecontents;// 内部状态:子 SQL 节点@Overridepublicbooleanapply(DynamicContextcontext){// context 是外部状态:包含了本次调用的实际入参if(evaluator.evaluateBoolean(test,context.getBindings())){contents.apply(context);returntrue;}returnfalse;}}- 内部状态(共享):
test表达式字符串、contents子节点。XML 加载时确定,无论谁调用都不会变。 - 外部状态(通过参数传入):
DynamicContext context,承载本次请求的实际入参和StringBuilder。
2.4 Tomcat StringCache
高并发 HTTP 请求带来大量重复字符串(GET、POST、HTTP/1.1、Host)。Tomcat 设计了StringCache来复用这些字符串。
核心逻辑(简化版):
publicclassStringCache{protectedstaticByteEntry[][]bcCache=null;protectedstaticCharEntry[][]ccCache=null;publicstaticStringtoString(ByteChunkbc){if(!cacheEnabled){returnbc.toStringInternal();// 没开启缓存,直接 new String}inthash=bc.getHash();intindex=findClosest(bc,bcCache[hash&accessCount]);if(index>=0){returnbcCache[hash&accessCount][index].name;// 命中缓存,直接返回}else{Stringvalue=bc.toStringInternal();// 高频访问才加入享元池returnvalue;}}}- 内部状态:
ByteEntry.name(已经转换好的String对象)。 - 外部状态:网络中源源不断流进来的原始 TCP 字节流(
ByteChunk)。 - 应用价值:Tomcat 每秒处理数万次请求,
Connection: keep-alive、Accept-Language等常见 HTTP 头文本在内存中只保留极少数的单例,其余全部复用引用,对压榨 JVM 内存、减少 GC 停顿起到决定性作用。
四者对比总结:
| 框架/组件 | 享元工厂 | 享元池 | 内部状态 | 外部状态 |
|---|---|---|---|---|
| JDK Integer | Integer类本身 | IntegerCache.cache数组 | -128~127的int值 | 无(单纯享元) |
| Spring | BeanFactory | singletonObjectsMap | Bean 方法体、静态配置 | 业务方法的具体入参 |
| MyBatis | XMLScriptBuilder | DynamicSqlSource树结构 | XML 静态文本和 OGNL 表达式 | DynamicContext参数绑定 |
| Tomcat | StringCache类 | bcCache/ccCache数组 | "GET","Host"等字符串 | 原始 TCP 字节流ByteChunk |
3. 深度追问
3.1 Why(为什么):解决哪个根本矛盾?
享元模式解决的是**"业务诉求上无限的细粒度实体对象"与"物理层面上有限的计算机内存空间"之间的根本矛盾。**
面向对象设计鼓励将世界拆解得越细越好(如文档编辑器里每个字符都应是对象),但物理硬件的内存是有限的。享元模式允许我们在逻辑上维持成千上万个独立的细粒度对象,但在物理上只占用极少量的内存。
3.2 How(怎么做):通过哪些对象关系和交互机制?
享元模式的核心机制是状态分离与参数化组合:
A. 状态的彻底剥离:
- 内部状态:存储在享元内部、不会随环境改变而改变的共享部分(如棋子的颜色),作为成员变量存在。
- 外部状态:随环境改变而改变、无法共享的部分(如棋子的 X/Y 坐标),坚决不能作为成员变量。
B. 行为的参数化交互:
客户端在调用享元对象的方法时,必须将外部状态通过方法参数传入。
3.3 Trade-off(代价):牺牲了什么换取什么?
| 换取(收益) | 牺牲(代价) |
|---|---|
| 极大降低内存占用,规避 OOM | 运行时间成本:外部状态变成方法入参,需动态计算/查找 |
| 降低 GC 频率,系统更平稳 | 代码内聚性:逻辑被撕裂在享元和客户端之间 |
| 系统复杂度:引入工厂、缓存、线程安全控制 |
3.4 Evolution(演化):如何逐步演化而来?
- 普通工厂:每次都返回新实例。
- 普通缓存:在工厂里加 Map 缓存,发现相同 Key 就返回同一个。
- 遇到瓶颈:缓存的对象持有特定环境数据,多线程并发时互相覆盖。
- 终极形态(Flyweight):强制把变动的数据从对象中剥离,只缓存纯净的核心。
衍生变体:
- 单纯享元模式:所有享元对象都可共享。
- 复合享元模式:多个单纯享元对象通过组合模式拼接而成,复合享元本身不能共享,但其内部子节点是共享的。
3.5 Modern Practice(现代实践):是否被取代?
被现代技术部分取代/弱化:
- 分布式缓存(Redis):传统单机需要享元省内存,现在大型微服务中分布式缓存承担了"全局享元池"的角色。
- 依赖注入容器:Spring IoC 容器本身就是对开发者透明的享元工厂,不需要自己写
FlyweightFactory。
与现代编程范式完美共生:
- 函数式编程与不可变性:享元模式的内部状态要求"绝对不可变",与函数式编程理念一拍即合。Java 16+ 的
record让编写不可变享元类变得极为简单。 - 无状态架构:现代互联网高并发架构提倡"服务无状态"。API 网关、Controller/Service 的本质都是享元模式思想的无意识应用——对象只有一份(单例),高并发的 HTTP 请求(外部状态)作为参数流经这些无状态的服务节点。
4. 总结
核心要点
- 享元模式通过共享对象减少内存消耗,核心机制是状态分离:内部状态(共享)由享元存储,外部状态(可变)由客户端传入
- 与单例模式的区别:享元是一组对象,单例是一个对象
- 四个工业级应用:
IntegerCache(数字缓存)、SpringsingletonObjects(Bean 池)、MyBatisSqlNode(SQL 语义共享)、TomcatStringCache(HTTP 头部复用) - 现代演进:
record不可变性、ConcurrentHashMap.computeIfAbsent、分布式 Redis 缓存
一句话
享元模式是"分门别类,同类共享"——把对象的相同部分抽出来共享,把不同部分交给客户端自己管理。
5. 参考文献
[1] GAMMA E, HELM R, JOHNSON R, et al. Design Patterns: Elements of Reusable Object-Oriented Software[M]. Boston: Addison-Wesley, 1994.
[2] Oracle. Java Integer / String Source Code[EB/OL]. https://docs.oracle.com/javase/8/docs/api/, 2024.
[3] SPRING. Spring BeanFactory[EB/OL]. https://docs.spring.io/spring-framework/, 2024.
[4] MyBatis. MyBatis 3 SqlNode[EB/OL]. https://github.com/mybatis/mybatis-3, 2024.
[5] Apache Tomcat. Tomcat StringCache[EB/OL]. https://tomcat.apache.org/, 2024.