享元模式实战:内部与外部状态拆分解决大量对象内存问题
2026/9/8 12:47:31 网站建设 项目流程

1. 先搞清楚享元模式到底解决什么问题

每次讲到享元模式,我都会先问一个问题:如果你的系统里有几十万个对象要创建,内存还能扛得住吗?别觉得夸张,我在做游戏客户端的时候,遇到过一局战斗里要渲染几千个外形相同的小兵,如果每个小兵都new一个独立对象,光对象头就够内存喝一壶了。享元模式就是专门解决这类“大量相似对象”场景的方案。

享元模式的核心思想一句话就能说清楚:把对象的公共部分抽出来共享,把每个对象独有的部分放到外面传进去。这个“公共部分”叫内部状态,“独有部分”叫外部状态。工厂负责维护一个共享池,你要对象的时候先去池子里找,找到就复用,找不到才创建。

听起来很简单,但很多人在实际写代码时很容易把内部状态和外部状态搞混,结果共享的对象出现数据串扰,比不用享元模式还糟。这一篇我就把享元模式从概念、结构、源码案例到实战坑点完整拆一遍。无论你是应付设计模式期末考试、做大作业,还是想在Android源码或Java源码里找找它的影子,这篇都适合你。

2. 从五子棋棋盘看透享元模式的核心结构

2.1 一个最直观的场景:棋盘上的落子

假设你要写一个五子棋游戏。棋盘是15x15的网格,一盘棋最多会有225个落子点。如果你给每个落子点都创建一个棋子对象,一局棋下来就有几百个对象。但如果同时在线一万局,那就是几百万个对象,内存直接爆炸。

用享元模式怎么设计?棋子本身有颜色(黑或白)和形状(圆形棋子),这些对所有黑棋、白棋来说都是一样的,属于内部状态。但棋子在棋盘上的位置(第几行第几列)每一手都不同,这属于外部状态

于是你只需要维护两个对象:一个黑棋实例、一个白棋实例。所有落子操作都通过这两个共享实例来完成,位置信息由外部调用方传入。这样一来,无论系统里有多少局棋在进行,内存里始终只有两个棋子对象,内存占用降了几个数量级。

这就是享元模式的典型应用场景:对象数量多、对象之间存在大量相同属性、这些相同属性可以剥离出来共享

2.2 内部状态与外部状态:区分标准只有一个

判断一个属性该放内部还是外部,标准其实只有一条:这个属性是不是跟对象的具体使用场景无关

内部状态是对象固有的、可共享的,存储在每个享元对象内部,不会随环境变化而变化。比如棋子的颜色、文字对象的字体和字号、图形对象的颜色和样式。外部状态是对象在使用时依赖的上下文信息,由客户端维护,在使用时传给享元对象。比如棋子的位置、文字的内容、图形在画布上的坐标。

我见过很多人在这上面栽跟头。有人把棋子的位置也存进了享元对象里,结果两个棋局共用一个黑棋实例时,后落子的棋局把前一个棋局的位置覆盖了,棋盘上出现了幽灵棋子。这就是典型的内部状态设计失误。

一个稳妥的判断方法是:如果有两个不同场景在使用同一个享元对象,而这个对象的某个属性在两个场景下应该不同,那这个属性就必须是外部状态。反之,如果所有场景下这个属性的值都完全一致,才能考虑放进内部状态。

2.3 享元工厂:对象复用的大管家

享元工厂负责管理共享池,对外提供获取享元对象的接口,通常使用一个HashMap来保存已创建的对象。调用方要对象时,工厂先查map,有就直接返回,没有就创建并放入map再返回。

public class ChessPieceFactory { private static final Map<String, 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 Map<String, 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里最容易联想到享元模式的是TextViewspan处理、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 ConcurrentHashMap<String, ChessPiece> POOL = new ConcurrentHashMap<>(); public static ChessPiece getChessPiece(String color) { // computeIfAbsent是原子操作,避免重复创建 return POOL.computeIfAbsent(color, key -> new ConcreteChessPiece(key)); } }

这段代码里computeIfAbsent是关键。它是原子操作,多线程同时请求同一个key时,只会执行一次创建逻辑,其他线程等待结果返回。相比先getput的写法,省去了手动加锁的麻烦,也避免了重复创建对象的竞态条件。

6.4 大作业和面试里怎么体现你对享元模式的理解

如果你是学生要交设计模式大作业,或者准备面试,光讲概念和代码是不够的。想拿高分,必须讲到这三点:

  • 能清晰说出内部状态和外部状态的划分原则,并举出实际例子。
  • 能讲出享元模式在JDK源码(String、Integer)中的具体体现。
  • 能提到享元模式的风险点,包括线程安全、对象共享的数据干扰。

面试官如果追问“享元模式有什么缺点”,不要只会说“有线程安全问题”。更深层的回答是:享元模式让代码的复杂度提高了,因为它把对象的状态拆成了两部分,阅读代码的人需要同时关注享元对象和外部传入的状态,才能完整理解一个业务行为。另外,共享对象的调试比较痛苦,你无法直接从对象状态判断它在哪个场景下工作,必须追踪外部状态的传递路径。

7. 我在实战项目里用享元模式的经验与避坑记录

我最早在工作中使用享元模式,是在一个文档编辑器项目里。当时要处理一个十万字以上的长文档,每个字符都封装成对象,里面有字体、颜色、大小等几十个属性。最初的实现是每个字符单独占一个对象引用,结果内存峰值直接冲到了1GB以上,编辑一次都要卡顿几秒钟。

后来重构用享元模式,把字体、颜色、大小等样式剥离成内部状态,把字符内容作为外部状态传入渲染方法。因为文档里用到的样式组合其实只有十几种,最后内存占用从1GB多降到不到200MB,编辑操作流畅了很多。那次重构让我深刻体会到,享元模式在大规模细粒度对象的场景里真的是救命的方案。

重构过程中也踩过不少坑。最开始我图省事,把字符内容也放在了享元对象里,想着反正每次渲染前都设置一遍内容。结果两个线程同时渲染不同字符时,内容互相覆盖,屏幕上出现乱码。排查了很久才发现是内部状态被意外修改了。这个教训让我彻底理解了为什么内部状态必须不可变。

再说一个小技巧:享元工厂的命名很影响代码可读性。我习惯在工厂类名上加上“Pool”或“Factory”字样,方法名用getobtain而不是create,因为“get”暗示了有就取、没有才建的语义,而create容易让调用方误以为每次都会新建对象。这个细节不算什么大道理,但对维护代码的人来说,一个准确的方法名能省去很多不必要的猜测。

根据我的经验,享元模式在项目里不会是那种“必须用”的模式,但它一旦用对地方,收益是数量级的提升。关键在于识别场景、抓好内部状态与外部状态的边界,以及始终把共享对象的安全性放在第一位。如果你现在正被大量相似对象的内存问题困扰,不妨先别看复杂的缓存框架,试试享元模式,很可能一行工厂代码就解决了大半个问题。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询