享元模式实战:内部与外部状态拆分解决大量对象内存问题
1. 先搞清楚享元模式到底解决什么问题每次讲到享元模式我都会先问一个问题如果你的系统里有几十万个对象要创建内存还能扛得住吗别觉得夸张我在做游戏客户端的时候遇到过一局战斗里要渲染几千个外形相同的小兵如果每个小兵都new一个独立对象光对象头就够内存喝一壶了。享元模式就是专门解决这类“大量相似对象”场景的方案。享元模式的核心思想一句话就能说清楚把对象的公共部分抽出来共享把每个对象独有的部分放到外面传进去。这个“公共部分”叫内部状态“独有部分”叫外部状态。工厂负责维护一个共享池你要对象的时候先去池子里找找到就复用找不到才创建。听起来很简单但很多人在实际写代码时很容易把内部状态和外部状态搞混结果共享的对象出现数据串扰比不用享元模式还糟。这一篇我就把享元模式从概念、结构、源码案例到实战坑点完整拆一遍。无论你是应付设计模式期末考试、做大作业还是想在Android源码或Java源码里找找它的影子这篇都适合你。2. 从五子棋棋盘看透享元模式的核心结构2.1 一个最直观的场景棋盘上的落子假设你要写一个五子棋游戏。棋盘是15x15的网格一盘棋最多会有225个落子点。如果你给每个落子点都创建一个棋子对象一局棋下来就有几百个对象。但如果同时在线一万局那就是几百万个对象内存直接爆炸。用享元模式怎么设计棋子本身有颜色黑或白和形状圆形棋子这些对所有黑棋、白棋来说都是一样的属于内部状态。但棋子在棋盘上的位置第几行第几列每一手都不同这属于外部状态。于是你只需要维护两个对象一个黑棋实例、一个白棋实例。所有落子操作都通过这两个共享实例来完成位置信息由外部调用方传入。这样一来无论系统里有多少局棋在进行内存里始终只有两个棋子对象内存占用降了几个数量级。这就是享元模式的典型应用场景对象数量多、对象之间存在大量相同属性、这些相同属性可以剥离出来共享。2.2 内部状态与外部状态区分标准只有一个判断一个属性该放内部还是外部标准其实只有一条这个属性是不是跟对象的具体使用场景无关。内部状态是对象固有的、可共享的存储在每个享元对象内部不会随环境变化而变化。比如棋子的颜色、文字对象的字体和字号、图形对象的颜色和样式。外部状态是对象在使用时依赖的上下文信息由客户端维护在使用时传给享元对象。比如棋子的位置、文字的内容、图形在画布上的坐标。我见过很多人在这上面栽跟头。有人把棋子的位置也存进了享元对象里结果两个棋局共用一个黑棋实例时后落子的棋局把前一个棋局的位置覆盖了棋盘上出现了幽灵棋子。这就是典型的内部状态设计失误。一个稳妥的判断方法是如果有两个不同场景在使用同一个享元对象而这个对象的某个属性在两个场景下应该不同那这个属性就必须是外部状态。反之如果所有场景下这个属性的值都完全一致才能考虑放进内部状态。2.3 享元工厂对象复用的大管家享元工厂负责管理共享池对外提供获取享元对象的接口通常使用一个HashMap来保存已创建的对象。调用方要对象时工厂先查map有就直接返回没有就创建并放入map再返回。public class ChessPieceFactory { private static final MapString, ChessPiece PIECE_POOL new HashMap(); public static ChessPiece getChessPiece(String color) { ChessPiece piece PIECE_POOL.get(color); if (piece null) { piece new ChessPiece(color); PIECE_POOL.put(color, piece); System.out.println(创建了 color 棋子 piece); } else { System.out.println(复用已有 color 棋子 piece); } return piece; } }这个工厂就是享元模式的门面它的存在让调用方不关心对象是从池里复用的还是新建的只需要拿到一个可用的对象即可。这种写法还有一个额外的好处实现了对象的延迟创建第一次用到某种颜色时才真正创建后续全部复用。3. 手写一个完整的享元模式代码示例3.1 五子棋落子的完整实现光说不练假把式我把上面的五子棋例子写成完整的Java代码。这个代码可以直接拿去当设计模式大作业的参考结构清晰注释完整。// 1. 抽象享元接口 public interface ChessPiece { void place(int x, int y); // 落子x和y是外部状态 } // 2. 具体享元类 public class ConcreteChessPiece implements ChessPiece { private final String color; // 内部状态颜色一经创建不可变 public ConcreteChessPiece(String color) { this.color color; // 模拟耗时耗资源的初始化过程 try { Thread.sleep(10); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(创建 color 棋子对象); } Override public void place(int x, int y) { System.out.println(color 棋落在( x , y )); } } // 3. 享元工厂 public class ChessPieceFactory { private static final MapString, ChessPiece POOL new HashMap(); public static ChessPiece getChessPiece(String color) { ChessPiece piece POOL.get(color); if (piece null) { piece new ConcreteChessPiece(color); POOL.put(color, piece); } return piece; } public static int getPoolSize() { return POOL.size(); } } // 4. 客户端使用 public class Client { public static void main(String[] args) { ChessPiece black1 ChessPieceFactory.getChessPiece(黑); ChessPiece black2 ChessPieceFactory.getChessPiece(黑); ChessPiece white1 ChessPieceFactory.getChessPiece(白); System.out.println(black1和black2是否为同一对象 (black1 black2)); black1.place(3, 5); black2.place(7, 8); white1.place(1, 2); System.out.println(享元池中对象数量 ChessPieceFactory.getPoolSize()); } }运行结果会显示创建黑棋子对象时只创建一次第二次取黑棋时直接复用black1 black2返回true池子里最终只有两个对象。这就是享元模式的精华所在。3.2 代码里的关键设计决策我用final修饰了color字段这是故意为之。内部状态一旦创建就不允许被修改否则多个线程共享同一个对象时一个线程改了颜色其他线程拿到的对象就不是原来的样子了这是享元模式的大忌。place(int x, int y)方法接收的坐标就是外部状态。每次调用时外部传入用完即弃不会残留在对象内部。这样做的好处是一个享元对象可以在任意位置“落子”只要调用时传入对应的坐标完全不影响其他调用方。Thread.sleep(10)是我故意加上的模拟耗时操作。在真实的项目里创建对象的成本可能非常高比如要加载图片资源、初始化网络连接、读取配置文件等。享元模式的价值就是把这个高成本初始化过程只执行一次后续无穷次复用。3.3 用类图理解享元模式的三个角色如果画UML类图享元模式就三个角色Flyweight抽象享元声明业务方法方法参数用于接收外部状态。对应例子里的ChessPiece接口。ConcreteFlyweight具体享元实现抽象享元内部保存内部状态。对应ConcreteChessPiece类。FlyweightFactory享元工厂维护享元池负责创建和管理享元对象。对应ChessPieceFactory类。画大作业类图的时候把这三个类之间的关系画清楚连线标上“创建”和“复用”老师一看就明白你掌握了享元模式的核心。工厂和具体享元之间是依赖关系客户端依赖于工厂接口这三个依赖方向不要画反了。4. 源码里的享元模式Java和Android中随处可见4.1 String常量池你天天在用却没察觉Java里的String就是享元模式最经典的实现。字符串常量池维护了一组字符串对象当你用双引号直接声明字符串时JVM会先检查常量池里有没有内容相同的字符串有就直接返回池中的引用没有才新建。String a hello; String b hello; System.out.println(a b); // true两个引用指向同一个对象这个机制就是享元模式。字符串对象的内容是内部状态常量池是享元工厂。正因如此Java官方才反复强调字符串比较必须用equals()因为两个内容相同的字符串变量可能指向同一个对象也可能指向不同对象比如用new String(hello)创建时。4.2 Integer缓存-128到127的小秘密另一个典型是Integer的缓存机制。用valueOf()创建Integer对象时如果数值在-128到127之间JVM会直接从缓存里取现成的对象超出这个范围才新建。Integer i1 Integer.valueOf(100); Integer i2 Integer.valueOf(100); System.out.println(i1 i2); // true Integer i3 Integer.valueOf(200); Integer i4 Integer.valueOf(200); System.out.println(i3 i4); // false这个缓存上限可以通过JVM参数-XX:AutoBoxCacheMax调整。理解了享元模式你就明白为什么Java官方这样做小整数在业务系统中出现的频率极高缓存起来能省大量的对象创建开销。写面试题时如果遇到“为什么128不等于128”的问题本质就是在考享元模式。4.3 Android源码中的享元思想Android里最容易联想到享元模式的是TextView的span处理、MessagePool消息池以及Bitmap的复用。以Bitmap为例频繁创建和销毁Bitmap对象会导致内存抖动官方推荐的BitmapFactory.Options.inBitmap就是复用机制本质上也是享元思想的变异应用。另一个典型是Handler消息池。Message对象通过obtain()方法获取用完放进池子里回收复用避免每次发送消息都new一个新对象。这个设计在高频UI更新场景下尤其重要如果每次sendMessage都新建Message主线程的消息处理会频繁触发GC出现掉帧卡顿。4.4 数据库连接池享元模式的企业级应用数据库连接池也是享元模式的最佳实践。建立数据库连接是一个非常昂贵的操作通常需要几十毫秒甚至更久。连接池在初始化时创建一批连接对象放进池中每次需要连接时从池中取出一个用完后归还而不是销毁。这正是享元模式的核心思想连接对象本身就是可复用的享元不同的数据库操作通过传入不同的SQL语句外部状态来产生不同的行为。在JDBC中Connection对象内部维护的数据库连接是内部状态而每次执行时传入的PreparedStatement参数则是外部状态。5. 不同于单例享元模式和单例模式的区别要搞清很多初学者会把享元模式和单例模式搞混因为它们都是“只创建少量对象”。最直白的区别是单例模式是一个类只有一个实例享元模式是一类相似对象中相同部分只有一个实例但从整体来看享元模式可以有多个不同内部状态的实例。比如五子棋例子里棋子的总类型只有黑和白两种所以黑棋一个实例、白棋一个实例这看起来像单例。但如果你有红黑白三种颜色的棋子享元池里就有三个实例。单例模式不管什么情况都只有一个。从关注点来看单例模式关注的是“全局唯一访问点”常用于配置类、线程池等需要全局共享的场景。享元模式关注的是“如何减少对象创建开销”常用于大量细粒度对象的场景。两者的出发点和应用范围完全不同。还有一点容易被忽略单例模式的实现要求构造函数私有享元模式则通过工厂来限制直接new并不强制要求构造函数私有。实际开发中为了让调用方无法绕过工厂有经验的开发者会把具体享元类的构造函数设为包私有或私有从而强制走工厂获取对象。6. 写好享元模式的关键内存存储、线程安全与场景权衡6.1 什么时候应该果断使用享元模式不是所有场景都适合用享元模式用错了反而增加代码复杂度。我的判断标准有三条系统中存在大量相似对象内存占用成为瓶颈。这些对象的大部分属性可以剥离为内部状态。对象被复用的频率高且不依赖业务场景的独特性。典型场景包括文本编辑器中的字符对象、游戏中的粒子效果、地图应用中的地标图标、权限系统中的角色对象。这些对象动辄成千上万但属性和类别的数量有限非常适合享元模式。6.2 内部状态的线程安全性几乎人人踩坑用享元模式时线程安全是绕不开的话题。如果多个线程共享同一个享元对象而内部状态是可变的就会出现数据竞争。比如棋子颜色字段如果是非final的线程A把黑棋改成白棋线程B拿到的就是改过的棋子。两个安全措施你可以直接抄内部状态字段全部声明为final一经初始化就不可变。外部状态由客户端传入方法内部只读取不修改共享字段。如果确实需要对内部状态做修改那就需要加锁或者使用原子类。但我在实际项目里一般不建议这么做内部状态应该是只读的所有可变数据都走外部状态传参这样最安全、最省心。6.3 享元池的大小和回收策略享元对象池不是越大越好。池太大空闲对象占用内存池太小命中率低频繁创建新对象。在设计享元工厂时可以根据业务估算上限加上缓存淘汰策略。在Java企业应用里直接复用现成的缓存框架往往比手写HashMap锁更可靠。比如用ConcurrentHashMap代替普通的HashMap保证线程安全或者引入LRU算法淘汰不常用的享元对象。public class CacheFlyweightFactory { // 使用ConcurrentHashMap保证并发安全 private static final ConcurrentHashMapString, ChessPiece POOL new ConcurrentHashMap(); public static ChessPiece getChessPiece(String color) { // computeIfAbsent是原子操作避免重复创建 return POOL.computeIfAbsent(color, key - new ConcreteChessPiece(key)); } }这段代码里computeIfAbsent是关键。它是原子操作多线程同时请求同一个key时只会执行一次创建逻辑其他线程等待结果返回。相比先get再put的写法省去了手动加锁的麻烦也避免了重复创建对象的竞态条件。6.4 大作业和面试里怎么体现你对享元模式的理解如果你是学生要交设计模式大作业或者准备面试光讲概念和代码是不够的。想拿高分必须讲到这三点能清晰说出内部状态和外部状态的划分原则并举出实际例子。能讲出享元模式在JDK源码String、Integer中的具体体现。能提到享元模式的风险点包括线程安全、对象共享的数据干扰。面试官如果追问“享元模式有什么缺点”不要只会说“有线程安全问题”。更深层的回答是享元模式让代码的复杂度提高了因为它把对象的状态拆成了两部分阅读代码的人需要同时关注享元对象和外部传入的状态才能完整理解一个业务行为。另外共享对象的调试比较痛苦你无法直接从对象状态判断它在哪个场景下工作必须追踪外部状态的传递路径。7. 我在实战项目里用享元模式的经验与避坑记录我最早在工作中使用享元模式是在一个文档编辑器项目里。当时要处理一个十万字以上的长文档每个字符都封装成对象里面有字体、颜色、大小等几十个属性。最初的实现是每个字符单独占一个对象引用结果内存峰值直接冲到了1GB以上编辑一次都要卡顿几秒钟。后来重构用享元模式把字体、颜色、大小等样式剥离成内部状态把字符内容作为外部状态传入渲染方法。因为文档里用到的样式组合其实只有十几种最后内存占用从1GB多降到不到200MB编辑操作流畅了很多。那次重构让我深刻体会到享元模式在大规模细粒度对象的场景里真的是救命的方案。重构过程中也踩过不少坑。最开始我图省事把字符内容也放在了享元对象里想着反正每次渲染前都设置一遍内容。结果两个线程同时渲染不同字符时内容互相覆盖屏幕上出现乱码。排查了很久才发现是内部状态被意外修改了。这个教训让我彻底理解了为什么内部状态必须不可变。再说一个小技巧享元工厂的命名很影响代码可读性。我习惯在工厂类名上加上“Pool”或“Factory”字样方法名用get或obtain而不是create因为“get”暗示了有就取、没有才建的语义而create容易让调用方误以为每次都会新建对象。这个细节不算什么大道理但对维护代码的人来说一个准确的方法名能省去很多不必要的猜测。根据我的经验享元模式在项目里不会是那种“必须用”的模式但它一旦用对地方收益是数量级的提升。关键在于识别场景、抓好内部状态与外部状态的边界以及始终把共享对象的安全性放在第一位。如果你现在正被大量相似对象的内存问题困扰不妨先别看复杂的缓存框架试试享元模式很可能一行工厂代码就解决了大半个问题。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →