享元模式实战:用共享对象解决内存爆炸与GC压力
1. 从一次内存告警说起享元模式到底解决了什么问题内存告警一天内接连触发翻了一下GC日志发现同一批小对象被反复创建了上万次而它们的属性几乎一模一样。这是我在某个项目性能排查时最直观的现场。很多人第一反应是“对象分配太多太快”但真正的问题往往藏得更深不是单个对象太大而是大量对象里包含了大段完全相同的数据却被当成独立个体对待。设计模式里的享元模式Flyweight就是专门治这类病的它通过共享把重复数据只保留一份让大量对象“瘦身”成极少数实例同时通过外部参数维持每个使用场景的差异。这篇内容适合几类人看一是写图形、编辑器、游戏这类需要大量渲染对象的开发者二是面试前被“内部状态外部状态”绕晕的求职者三是做性能优化时想给系统减负、又不希望引入复杂缓存框架的人。我会从真实的业务场景出发把这个模式为什么存在、四个角色怎么分工、代码怎么落地、坑在哪里一次讲清楚。1.1 一个真实到不能再真实的场景棋盘上的“对象爆炸”先还原一下当时的场景。某个棋牌类平台需要展示历史对局每局棋可能有两三百手落子每个落子在前端展示时都要对应一个棋子对象。当时版本的代码非常直白每次落子就new一个棋子ChessPiece piece new ChessPiece(color, x, y, style);一局棋几百个对象看不出问题可一旦对局列表页同时加载上千局历史棋局内存里瞬间多出几十万个棋子对象。每一个棋子都带着颜色、坐标、纹理、动效参数大部分字段的值还几乎一样——黑色棋子无非坐标不同白色棋子也一样。这就好比为了展示一万张同样式的工作证每张证都完整复制了一张背景模板而不是让所有人共用一块背景板。GC日志里old区持续上涨Full GC频率明显偏高。我最初以为是数据库查询返回的数据量太大后来定位到是对象创建路径上没有任何复用机制。这里的核心矛盾在于对象的“种类”极少但“数量”极大。黑白两色棋子撑死了就两种但代码把每一种都复制成了成千上万份。这种“重复状态极高、独有状态极低”的场景正是享元模式的天然适用区。1.2 享元模式的核心思路把状态拆成“共享”和“独有”两部分享元模式解决问题的思路一句话概括就是把对象的状态分成两类能共享的全部共享不能共享的等用的时候再传进来。第一类叫内部状态Intrinsic State。它存储在享元对象内部对所有使用场景完全一致不会随环境变化。还是拿棋子举例颜色就是内部状态黑色棋子永远都是黑色。第二类叫外部状态Extrinsic State。它不属于享元对象本身而是随着使用场景变化的必须由客户端在使用时传入。比如棋子的坐标同样是黑色棋子这一步落在(0, 0)下一步落在(1, 5)坐标显然不能被所有场景共享。这两类状态是享元模式的灵魂。一个对象创建之后内部状态保持不变外部状态通过方法参数传递进去从而让同一个共享对象兼顾无数个不同的位置。打个比方就像工厂里用同一块模具冲压不同的工件模具本身是共享资源但每个工件最终压在哪里、何时压出来取决于工序自己的参数。模具不会因为多压了一个工件而变化工件之间的差异全部由外部参数决定。代码层面的体现也很简单。传统做法把坐标写进对象构造参数享元做法则把坐标放到方法的入参里// 传统方式每个坐标都创建一个对象 new ChessPiece(black, 0, 1); // 享元方式同一个黑色棋子坐标通过display方法传入 blackPiece.display(0, 1);对照着看享元对象的构造参数里只保留颜色这类固有数据调用时再把坐标这类外部数据传进去。这样就实现了“一个共享的黑棋子可以表示任意位置的黑色棋子”的效果。1.3 缓存和对象池不是享元先搞清楚它们到底有什么区别不少人会把享元模式和缓存、对象池混为一谈它们确实像但解决问题的方式不一样用错场合反而会引入不必要的复杂度。缓存的对象通常是完整创建的缓存的是“计算结果”重点在避免重复计算。对象池的管理重点在复用生命周期连接池、线程池都是典型代表它们限制对象数量把用完的对象回收回来继续给下一个人用。而享元模式的重点在状态拆分它不关心对象的创建消耗它的核心是让不同场景共享同一个对象通过外部参数区分场景。对象池里的对象使用期间通常被“独占”享元对象则同时被多个场景引用。举一个更直观的区别。一个线程池里有10个线程同一时刻某个线程只能被一个任务使用享元模式里同一个棋子对象可以在A棋局里显示为(1,1)也可以在B棋局里显示为(9,9)两个调用之间并不需要排队。外部状态只存在于方法调用栈上根本不会互相覆盖这也是享元模式区别于对象池的重要特征。把这三个概念分清楚之后选型就很自然了场景里存在大量“属性高度相似”的对象优先考虑享元场景里对象创建和销毁开销大、需要限制实例数优先考虑对象池场景里计算结果昂贵且需要反复使用优先考虑缓存。实际项目中它们经常组合出现但先分清楚谁在解决什么问题才不会写着写着变成四不像。2. 四个角色一次理清享元模式的结构与职责划分享元模式的类结构并不复杂但四个角色每个都有明确的规矩少一个或者职责放错位置整个模式就会变形。这四个角色分别对应抽象享元、具体享元、享元工厂和客户端。2.1 抽象享元客户端唯一可见的门面抽象享元一般是接口或者抽象类它定义了外部状态如何传进来、业务行为如何暴露。在棋牌场景里抽象享元接口长这样public interface ChessPiece { void display(int x, int y); }接口的方法参数就是外部状态的入口。客户端只依赖这个抽象接口不需要知道具体享元类到底存了哪些内部状态。这样做的根本目的是隔离变化将来棋子的内部状态从“颜色”扩展成“颜色材质”只要接口不变化客户端代码一行都不用改。这里有个很容易被忽略的设计细节抽象享元方法的参数应当尽量精简且语义清晰。把坐标设计成int x, int y没问题但如果你发现一个方法需要传五六个参数说明外部状态被拆得不够好或者内部状态分类有误。参数越多客户端出错的可能性越高也意味着你很可能把本该属于内部状态的东西错误地放到了外部。2.2 具体享元类内部状态的正确归宿具体享元实现了抽象接口内部状态作为它的字段存在。这些字段必须是不可变的这是享元模式里非常关键的一条纪律。继续看棋子的实现public class ConcreteChessPiece implements ChessPiece { private final String color; private final String texture; public ConcreteChessPiece(String color, String texture) { this.color color; this.texture texture; } Override public void display(int x, int y) { System.out.println(在( x , y )放置 color 棋子纹理 texture 对象散列 System.identityHashCode(this)); } }字段用了final修饰构造时一次性赋值后面就再也不能改。这是共享对象的安全底线。如果具体享元内部状态允许被改那么同一个黑色棋子一旦被某个调用方改成白色所有用到它的棋局全会变成白色这就是所谓的“共享状态污染”比内存泄漏还要隐蔽。当你在具体享元类里写setter的那一瞬间享元模式已经开始崩坏了。内部状态的选择也有讲究。它必须是在所有使用场景中稳定且相同的特性。颜色、字体、图标、背景纹理都适合做内部状态而坐标、尺寸、旋转角度这类随场景变化的必须放在方法参数或者由客户端临时持有。2.3 享元工厂唯一实例从哪里来享元工厂负责维护内部状态到具体享元实例的映射关系。它通常使用一个容器保存已有的享元对象调用方请求时先查容器命中就直接返回未命中才创建新对象并放入容器。这是整个模式“共享”的落点。public class ChessPieceFactory { private static final MapString, ChessPiece POOL new ConcurrentHashMap(); private ChessPieceFactory() { } public static ChessPiece getChessPiece(String color) { return POOL.computeIfAbsent(color, ConcreteChessPiece::new); } public static int poolSize() { return POOL.size(); } }工厂里有几个细节值得单独说。第一容器要选对。初学者喜欢用HashMapString, ChessPiece但享元工厂往往是多线程共享的静态对象HashMap在并发写入下轻则重复创建重则在旧版本JDK里会出现环链死循环。ConcurrentHashMap配合computeIfAbsent是当前最稳妥的组合因为computeIfAbsent在键不存在时原子完成“计算并放入”的动作既避免重复实例化也避免性能损耗。第二构造方法要私有化。享元工厂作为全局入口不需要实例化私有构造器能防止误用new创建多个工厂破坏单例语义。第三工厂的key应该如何设计。key必须是内部状态的总和。如果内部状态包含颜色和纹理两个字段key就要用“颜色纹理”的组合不能只按颜色区分否则不同纹理的棋子会被错误地共享。2.4 客户端只允许读写外部状态客户端是直接使用享元对象的角色。它依赖抽象享元接口并在每次调用时把外部状态作为参数传入。客户端要记住一件事它只能使用外部状态不能尝试修改内部状态。看一段客户端代码public class Client { public void play() { ChessPiece black ChessPieceFactory.getChessPiece(black); black.display(1, 1); ChessPiece blackAgain ChessPieceFactory.getChessPiece(black); blackAgain.display(5, 5); } }两次调用获取到的其实是同一个实例但因为在display里传入了不同的坐标表现上就像有了两个不同的棋子。这就是“用共享对象承载差异化表现”的完整链路。客户端还有一层隐含责任它需要确保外部状态在使用期间不会被并发修改。这点放到后面“踩坑”部分详细展开。3. 手写一个可复用的示例棋牌对战场景完整落地理论部分说完了接下来完整走一遍代码。我选棋牌场景因为它能自然体现出内部状态和外部状态的边界而且可读性强。整个示例不依赖任何框架直接复制就能运行。3.1 场景建模为什么选棋牌作为示例棋牌场景有三个天然特性恰好能对准享元模式三个要求。一是种类少。棋子只有黑色和白色或者加上不同纹理的几种变体类型数量可控适合放工厂缓存。二是使用量大。一场对局几十上百手平台要同时承载大量对局对象总量非常可观。三是状态天然可分。棋子的“颜色”是共享的固有属性棋子的“坐标”是随落子点变化的临时属性两者边界清晰。把棋牌场景跑通之后你可以很自然地把这套代码迁移到文本编辑器字符渲染、粒子特效生成等场景。3.2 代码实现定义享元接口、具体享元和工厂代码我拆成三块来讲对应三个文件结构清晰一些。第一块是抽象享元接口// ChessPiece.java public interface ChessPiece { void display(int x, int y); }第二块是具体享元类// ConcreteChessPiece.java public class ConcreteChessPiece implements ChessPiece { private final String color; private final String texture; public ConcreteChessPiece(String color, String texture) { this.color color; this.texture texture; } Override public void display(int x, int y) { System.out.println(在( x , y )放置 color 棋子纹理 texture 对象散列 System.identityHashCode(this)); } }第三块是享元工厂// ChessPieceFactory.java import java.util.Map; import java.util.concurrent.ConcurrentHashMap; public class ChessPieceFactory { private static final MapString, ChessPiece POOL new ConcurrentHashMap(); private ChessPieceFactory() { } public static ChessPiece getChessPiece(String color, String texture) { String key color : texture; // 第一次进入时创建后面全部复用 return POOL.computeIfAbsent(key, k - new ConcreteChessPiece(color, texture)); } public static int poolSize() { return POOL.size(); } }我在这里把纹理也作为内部状态加入了key。实际项目中内部状态可能有多个用字符串拼接作为key虽然直观但如果是复杂组合可以定义不可变的key对象并正确重写equals和hashCode。这一点在下文“踩坑”部分还会强调。3.3 代码实现客户端如何创建棋子和对比差异第四块是模拟对局的主程序既演示使用方式也顺便展示对象数量对比// FlyweightDemo.java public class FlyweightDemo { public static void main(String[] args) { // 模拟一局黑白双方轮流落子 for (int i 0; i 10; i) { ChessPiece black ChessPieceFactory.getChessPiece(black, standard); black.display(i, i); ChessPiece white ChessPieceFactory.getChessPiece(white, standard); white.display(i, i 1); } System.out.println(享元池中的棋子对象数量 ChessPieceFactory.poolSize()); System.out.println(实际上这一局共落子 20 手); } }运行结果大致如下在(0,0)放置black棋子纹理standard对象散列1212114429 在(0,1)放置white棋子纹理standard对象散列577489663 ... 享元池中的棋子对象数量2 实际上这一局共落子 20 手注意观察输出的对象散列值黑色棋子每次输出都是同一个散列白色棋子也都是同一个散列。打印20行但实际创建的对象只有2个。为了对比更直观你可以把display里的输出换成HashMap或列表收集所有落子数据通过(color, x, y)三元组保存这样棋局的历史记录就完整了而且内存里依然只占两个棋子对象的空间。3.4 运行结果与内存对比数据不会骗人用一个极端点的数据来对比。假设平台需要加载10000次落子记录普通实现每次创建完整棋子对象享元实现则只创建共享的棋子种类对象。我用表格整理一下区别对比项普通创建享元模式落子次数1000010000创建对象数100002原始对象引用存储量10000次new2次new坐标数据存储每个对象都存由客户端当场传入内存占用量级数百KB起步几乎可以忽略GC压力大量对象待回收几乎无新增对象这里的内存占用没有写死数值因为不同字段设计的棋子对象大小不同。但量级差异非常明显同样承载10000个落子展示普通模式要向JVM申请大量对象头与字段空间而享元模式只需要两个常驻对象。坐标数据本身是临时传入的用完就没了不会长期占用堆空间。实际项目里还隐藏着一个更值得关注的收益GC压力降低。大量短生命周期小对象的创建和回收会推高Minor GC频率而享元模式把“反复创建”变成“复用一个常驻对象”对象数量从成千上万降到个位数GC负担骤减。那次性能优化之后我重点看的指标就是GC频率和堆占用改善非常明显。3.5 从示例到真实系统的扩展当内部状态变多怎么办真实系统不会像示例里只有颜色和纹理这么简单。棋子还可能带投影效果、棋子材质、是否为特殊标记等属性。只要这些属性在同类棋子中保持一致就都应当作为内部状态放进具体享元类里并纳入工厂的key计算。这里有一个容易混淆的点同一种棋子在不同模式下外观不同比如有普通模式、阴影模式、高亮模式。这种情况下模式属性应该算内部状态还是外部状态答案是如果同一个棋子对象需要在多个模式下复用模式就必须是外部状态如果每个模式都是独立创建且固定不变模式就可以是内部状态。判断标准只有一个——这个属性是否在对象的整个生命周期内对所有使用场景保持不变。会变的一律滚出内部状态。4. 新手最容易踩的4个坑外部状态、线程安全与工厂初始化按我经验来看理解概念并不难难在落地时踩坑。以下4个坑是我在不同项目里反复见过的每一个都值得单独拿出来讲。4.1 内部状态被“污染”一个setter引发的连环事故某次项目里有人在具体享元类上加了一个setTexture(String texture)方法。他本意是想让同一个棋子对象在特定场景下更换纹理结果就是某一场对局临时把纹理改成了“阴影金色”之后所有棋局里的黑色棋子全部变成了阴影金色。因为他改的是共享对象影响范围是全局的。排查这种问题有时候很费劲因为代码逻辑没有报错只是展示结果诡异。从根上说具体享元类的内部状态必须不可变字段加final、去除所有setter、不要返回可变的内部引用。如果确实需要在某些场景用不同纹理那么纹理就不是内部状态请你把它作为外部状态传进display方法。注意内部状态不可变是享元模式的基本纪律。任何想通过修改共享对象来区分场景的尝试都是在制造全局污染事故。4.2 外部状态被并发覆盖坐标错乱的隐藏原因享元对象的内部状态安全了外部状态还有另一层风险。假设客户端代码写成这样public class Client { private int x; private int y; public void update(int deltaX, int deltaY) { this.x deltaX; this.y deltaY; ChessPiece piece ChessPieceFactory.getChessPiece(black, standard); piece.display(x, y); } }如果Client被多个线程共享调用x和y就成了共享可变变量A线程刚把坐标改成(3,3)B线程立刻改成(5,5)A线程调display时看到的坐标很可能已经不是自己的了。坐标的外部状态被“串线”棋局显示就会错乱。正确做法是外部状态只存在于方法内部或者调用栈本地。每次调用都新建局部变量或者直接使用局部参数传出不让它跨线程共享。示例代码里的display(i, i)就是典型的局部传参方式这种写法天然线程安全。外部状态一旦想“缓存”到成员变量里就要回到并发控制的老话题复杂度立刻上来。4.3 工厂初始化不重视并发HashMap与computeIfAbsent的分水岭很多享元工厂的早期版本长这样private static final MapString, ChessPiece POOL new HashMap(); public static ChessPiece getChessPiece(String key) { if (!POOL.containsKey(key)) { POOL.put(key, new ConcreteChessPiece(key)); } return POOL.get(key); }这段代码在单线程下没问题多线程并发下会出现两个问题一是多个线程同时发现containsKey为false各自创建了新的实例重复创建让享元失去意义二是HashMap在并发扩容时可能产生环链一旦程序卡死查出来的往往都是这类老代码。如果你用的是JDK 8及以上直接用computeIfAbsent一行解决问题。它保证同一个key只会执行一次创建函数而且返回的一定是同一个实例。如果因为历史原因必须兼容老版本JDK那就给整个方法加synchronized或者用双检锁加volatile。在共享对象的全局入口上并发的安全性不是可选项是基本盘。4.4 key设计随意导致错误共享equals和hashCode必须同步处理好当内部状态变成两个以上字段时工厂的key设计就很重要。比如“color texture”作为组合key比简单颜色key准确。更严谨的做法是定义一个key类public final class PieceType { private final String color; private final String texture; public PieceType(String color, String texture) { this.color color; this.texture texture; } Override public boolean equals(Object o) { if (this o) return true; if (!(o instanceof PieceType)) return false; PieceType that (PieceType) o; return color.equals(that.color) texture.equals(that.texture); } Override public int hashCode() { return color.hashCode() * 31 texture.hashCode(); } }只要你创建了key类并且把它放进HashMap或者ConcurrentHashMapequals和hashCode就必须同时重写否则同样的内部状态组合可能被当作不同key导致共享失败。这是HashMap相关代码最经典的坑放在享元工厂里也不例外。5. JDK和框架里的享元影子从Integer缓存到连接池如果只知道怎么写示例还不足以说明你在实战中能识别这个模式。JDK和主流框架里有大量享元思想的默默应用能从它们身上看到这个模式在真实工程里的生命力。5.1 IntegerCache与LongCache包装类型里的享元Integer.valueOf(int i)是JDK里最典型的享元应用。调用它时如果参数在-128到127之间不会新建对象而是直接从IntegerCache里返回缓存的实例。这意味着Integer a Integer.valueOf(127); Integer b Integer.valueOf(127); System.out.println(a b); // true同一个对象 Integer c Integer.valueOf(200); Integer d Integer.valueOf(200); System.out.println(c d); // false超过缓存范围各是各的对象IntegerCache就是享元工厂-128到127这些“小整数值”就是内部状态Integer对象本身是共享的。这也是为什么很多人强调“比较Integer尽量用equals不要用”因为的结果受缓存边界影响。类似机制的还有LongCache、ShortCache、ByteCache以及天然只有两个实例的Boolean。5.2 字符串常量池与intern最容易被忽略的共享点字符串是享元思想的一个特殊变体。Java的字符串常量池保证了相同内容的字面量字符串在大多数情况下指向同一个内部对象String.intern()更是主动把字符串放入常量池的接口。这本质上是用“内容”作为key共享“字符串对象”。但要注意字符串的享元并不是绝对的。通过new String(abc)创建的字符串不一定在常量池里所以比较String时依然应该用equals而不是。这从侧面说明了一个工程结论享元模式提供的是共享机制但共享范围、共享边界都需要根据场景和实现方式仔细确认。5.3 线程池与连接池池化思想与享元模式的关系我经常被问到线程池是不是享元模式。严格来说它不是线程池属于对象池思想线程对象被复用但每个线程在同一时刻只能执行一个任务它不具备“同时被多个场景共享”的能力。但两者在“避免重复创建”这一点上是亲戚。真正接近享元思想的是数据库连接池在某种程度上的变体连接池预创建一批连接调用方获取时拿到一个连接使用完还回池里。它的核心理念是把“创建昂贵资源”由每次重复变成一次性建设这和享元模式“复用有限对象”的动机一致。不过连接对象通常不强调内部状态而享元模式的重心恰恰在状态拆分。理解它们相似但不同的关系能帮助你在实际项目里准确选型不会一看到复用场景就盲目套模式。6. 什么时候该用、什么时候别硬凑选择边界与判断方法设计模式最大的误用就是“手里拿着锤子看什么都像钉子”。享元模式虽然好用但它的应用场景有明确边界不满足条件时硬套只会增加代码复杂度降低可读性。6.1 三个特征缺一不可大量对象、重复状态、有限组合我判断一个场景是否适合享元模式会依次过三个筛子。第一对象数量是否足够多。多是一个相对概念几百上千个对象通常不需要优化几十万上百万对象才值得引入模式。如果对象总量本就很小加一个工厂和共享映射反而让代码变重。第二对象内部是否存在大量重复状态。如果每个对象的每个字段都不同就没有什么可共享的。享元只解决“重复存在”的问题不负责把“独特的对象”变得省内存。第三内部状态的组合是否有限。有限是关键因为工厂本质上是缓存这些有限的组合。如果内部状态组合有无限多种工厂的缓存就会无限膨胀最终变成一个内存泄漏隐患。这种情况下你应该重新思考状态到底该怎么划分。三个条件同时满足时享元模式能给你带来实打实的好处对象数量锐减、GC压力下降、内存占用降低。三个条件缺一个收益都会大打折扣。6.2 反例分析状态全在外部时硬套享元会怎样假设你维护一个报表导出模块每次导出都会生成一个包含成千上万行数据的报表对象而每一行的数据内容几乎都不同。这种场景没有“重复的内部状态”硬套享元模式的结果是你会把大量行数据作为外部状态传入共享对象结果共享对象本身几乎什么都不做所有差异全在调用方手里。这样既没有省内存还把代码搞得异常复杂。另一个反例是对象数量本来就不大的场景。比如配置信息类项目里就那么几个实例重复创建的成本完全可忽略。这时候加享元工厂相当于用一套间接层换来了几乎为零的收益属于纯粹增加盲目的“模式感”。模式是解决问题的工具不是用来装饰代码的标签。6.3 速查表三秒判断要不要用享元模式我把判断逻辑整理成一张速查表你可以在方案评审时直接参考检查项适用信号不适用信号对象数量大量、高频创建少量低频内部状态重复度高存在大量相同数据低每个对象几乎都不同内部状态组合有限且稳定无限或动态增长外部状态是否可控可拆出且传参成本低外部状态太多太杂拆分复杂共享对象安全要求可以保证内部不可变无法保证内部状态不被修改如果表格里前两行就有不适用信号我建议直接放弃享元模式转向普通创建或缓存策略。如果五条全部符合这个模式能给你带来很明显的收益。我自己的体会是享元模式在真实项目里很少单独出现它总是和工厂、单例、缓存思想组合使用。有一次接手一个老系统处理大量图形节点渲染原先每次刷新都new一遍节点对象稍微扩大了数据量就触发卡顿。我把节点按“类型”拆分内部状态把“坐标和旋转角”全部改成调用时传入仅仅改动了核心渲染路径内存占用就降了一个量级刷新也顺了。这个模式的美妙之处在于它没有引入任何复杂的中间件只是换了数据组织的视角就解决了真实存在的性能问题。你可以先从小场景入手比如把示例代码跑通再找一个对象创建密集的业务点做一次重构实验亲自感受一下“两个对象扛起一万次调用”的体感。