走进 Gang of Four 设计模式:享元模式

走进 Gang of Four 设计模式:享元模式
走进 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: 框架中的典型应用JDKInteger.valueOf(int i)内部有IntegerCache缓存 -128 到 127 之间的 Integer 对象。String常量池也是极典型的享元应用。SpringSpring 的 Bean 容器默认 Singleton 作用域管理着各种单例 Bean通过beanName在 Map 中复用对象。MyBatisSqlNode的共享元数据SQL 解析后的结构树被缓存复用。TomcatStringCache字符串缓存复用常用的 HTTP 头部、方法名等字符串。Q13: 最小可运行版本五子棋棋子importjava.util.HashMap;importjava.util.Map;// 1. 抽象享元interfaceChessPiece{voiddraw(intx,inty);// x, y 是外部状态}// 2. 具体享元只包含内部状态classConcreteChessPieceimplementsChessPiece{privatefinalStringcolor;// 内部状态颜色publicConcreteChessPiece(Stringcolor){this.colorcolor;}Overridepublicvoiddraw(intx,inty){System.out.println(在坐标 (x, y) 放置了一颗 [color] 棋子);}}// 3. 享元工厂classChessPieceFactory{privatestaticfinalMapString,ChessPiecepoolnewHashMap();publicstaticChessPiecegetChessPiece(Stringcolor){if(!pool.containsKey(color)){pool.put(color,newConcreteChessPiece(color));System.out.println( 创建了新的 [color] 棋子对象 );}returnpool.get(color);}}// 4. 客户端publicclassMain{publicstaticvoidmain(String[]args){ChessPieceblack1ChessPieceFactory.getChessPiece(黑色);black1.draw(1,1);ChessPiecewhite1ChessPieceFactory.getChessPiece(白色);white1.draw(2,2);ChessPieceblack2ChessPieceFactory.getChessPiece(黑色);black2.draw(3,3);ChessPiecewhite2ChessPieceFactory.getChessPiece(白色);white2.draw(4,4);System.out.println(black1 black2: (black1black2));// true}}Q14: 识别重构信号内存监控异常通过 Profiler 发现某种简单对象的实例数达到数十万甚至数百万占用了大部分堆内存。代码中存在大量重复数据发现这些对象中有 80% 的属性字段全都是一模一样的。Q15: 现代改进Java 16 Recordpublic record ChessPiece(String color) { public void draw(int x, int y) { ... } }函数式工厂ComputeIfAbsentpublicclassChessFactory{privatestaticfinalMapString,ChessPiecepoolnewConcurrentHashMap();publicstaticChessPieceget(Stringcolor){returnpool.computeIfAbsent(color,ConcreteChessPiece::new);}}微服务/分布式视角在微服务架构中单机的享元扩展成了分布式缓存/元数据服务。海量订单中重复的商品详情静态元数据通过 Redis 缓存或在 BFF 层进行数据聚合拉取核心思想依然是剥离外部动态状态共享静态内部状态。2. 框架源码实战分析2.1 JDK Integer.valueOf 与 String 常量池Integer.valueOf 源码publicstaticIntegervalueOf(inti){if(iIntegerCache.lowiIntegerCache.high)returnIntegerCache.cache[i(-IntegerCache.low)];returnnewInteger(i);}IntegerCache 享元池privatestaticclassIntegerCache{staticfinalintlow-128;staticfinalinthigh;staticfinalInteger[]cache;static{inth127;highh;cachenewInteger[(high-low)1];intjlow;for(intk0;kcache.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. */privatefinalMapString,ObjectsingletonObjectsnewConcurrentHashMap(256);AbstractBeanFactory.doGetBean获取单例 BeanprotectedTTdoGetBean(Stringname,NullableClassTrequiredType,...){// 1. 尝试从单例享元池中直接获取ObjectsharedInstancegetSingleton(beanName);if(sharedInstance!nullargsnull){beangetObjectForBeanInstance(sharedInstance,name,beanName,null);}else{// 2. 享元池里没有创建并缓存if(isSingleton(beanName)){sharedInstancegetSingleton(beanName,()-createBean(beanName,mbd,args));beangetObjectForBeanInstance(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[][]bcCachenull;protectedstaticCharEntry[][]ccCachenull;publicstaticStringtoString(ByteChunkbc){if(!cacheEnabled){returnbc.toStringInternal();// 没开启缓存直接 new String}inthashbc.getHash();intindexfindClosest(bc,bcCache[hashaccessCount]);if(index0){returnbcCache[hashaccessCount][index].name;// 命中缓存直接返回}else{Stringvaluebc.toStringInternal();// 高频访问才加入享元池returnvalue;}}}内部状态ByteEntry.name已经转换好的String对象。外部状态网络中源源不断流进来的原始 TCP 字节流ByteChunk。应用价值Tomcat 每秒处理数万次请求Connection: keep-alive、Accept-Language等常见 HTTP 头文本在内存中只保留极少数的单例其余全部复用引用对压榨 JVM 内存、减少 GC 停顿起到决定性作用。四者对比总结框架/组件享元工厂享元池内部状态外部状态JDK IntegerInteger类本身IntegerCache.cache数组-128~127的int值无单纯享元SpringBeanFactorysingletonObjectsMapBean 方法体、静态配置业务方法的具体入参MyBatisXMLScriptBuilderDynamicSqlSource树结构XML 静态文本和 OGNL 表达式DynamicContext参数绑定TomcatStringCache类bcCache/ccCache数组GET,Host等字符串原始 TCP 字节流ByteChunk3. 深度追问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数字缓存、SpringsingletonObjectsBean 池、MyBatisSqlNodeSQL 语义共享、TomcatStringCacheHTTP 头部复用现代演进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.