所谓 “享元”,顾名思义就是被共享的单元。享元模式的意图是复用对象,节省内存,前提是享元对象是不可变对象。
当一个系统中存在大量重复对象的时候,如果这些重复的对象是不可变对象,我们就可以利用享元模式将对象设计成享元,在内存中只保留一份实例,供多处代码引用。这样可以减少内存中对象的数量,起到节省内存的目的。
实际上,不仅仅相同对象可以设计成享元,对于相似对象,我们也可以将这些对象中相同的部分(字段)提取出来,设计成享元,让这些大量相似对象引用这些享元。
定义中的 “不可变对象” 指的是,一旦通过构造函数初始化完成之后,它的状态(对象的成员变量或者属性)就不会再被修改了。所以,不可变对象不能暴露任何 set() 等修改内部状态的方法。之所以要求享元是不可变对象,那是因为它会被多处代码共享使用,避免一处代码对享元进行了修改,影响到其他使用它的代码。
1 享元模式 UML

举个 🌰
过年回家买火车票是一件很困难的事,无数人用刷票插件软件在向服务端发出请求,对于每一个请求服务器都必须做出应答。
在用户设置好出发地和目的地之后,每次请求都返回一个查询的车票结果。那么当数以万计的人不间断在请求数据时,如果每次都重新创建一个查询的车票结果,那么必然会造成大量重复对象的创建、销毁,使得 GC 任务繁重、内存占用率高居不下。
而这类问题通过享元模式就能够得到很好地改善,从城市 A 到城市 B 的车辆是有限的,车上的铺位也就是硬卧、硬卧、坐票 3 种。我们将这些可以公用的对象缓存起来,在用户查询时优先使用缓存,如果没有缓存则重新创建。这样就将成千上万的对象变为了可选择的有限数量。
1 | public interface Ticket { |
2 简单实现
举个 🌰
假设我们在开发一个棋牌游戏(比如象棋)。一个游戏厅中有成千上万个“房间”,每个房间对应一个棋局。棋局要保存每个棋子的数据,比如:棋子类型(将、相、士、炮等)、棋子颜色(红方、黑方)、棋子在棋局中的位置。利用这些数据,我们就能显示一个完整的棋盘给玩家。
为了记录每个房间当前的棋局情况,我们需要给每个房间都创建一个 ChessBoard 棋局对象。因为游戏大厅中有成千上万的房间(实际上,百万人同时在线的游戏大厅也有很多),那保存这么多棋局对象就会消耗大量的内存。
这个时候,享元模式就可以派上用场了。像刚刚的实现方式,在内存中会有大量的相似对象。这些相似对象的 id、text、color 都是相同的,唯独 positionX、positionY 不同。实际上,我们可以将棋子的 id、text、color 属性拆分出来,设计成独立的类,并且作为享元供多个棋盘复用。这样,棋盘只需要记录每个棋子的位置信息就可以了。
棋盘上旗子颜色和字都是固定的,只需要全部添加,并且缓存使用就好。
1 | public class ChessPieceUnit { |
我们利用工厂类来缓存 ChessPieceUnit 信息(也就是 id、text、color)。通过工厂类获取到的 ChessPieceUnit 就是享元。所有的 ChessBoard 对象共享这 30 个 ChessPieceUnit 对象(因为象棋中只有 30 个棋子)。在使用享元模式之前,记录 1 万个棋局,我们要创建 30 万( 30*1 万)个棋子的 ChessPieceUnit 对象。利用享元模式,我们只需要创建 30 个享元对象供所有棋局共享使用即可,大大节省了内存。
享元模式实际上,它的代码实现非常简单,主要是通过工厂模式,在工厂类中,通过一个 Map 来缓存已经创建过的享元对象,来达到复用的目的。
3 享元模式 vs 单例、缓存、对象池
在上面的讲解中,我们多次提到 “共享” “缓存” “复用” 这些字眼,那它跟单例、缓存、对象池这些概念有什么区别呢?
3.1 享元模式跟单例的区别
在单例模式中,一个类只能创建一个对象,而在享元模式中,一个类可以创建多个对象,每个对象被多处代码引用共享。实际上,享元模式有点类似于之前讲到的单例的变体:多例。
区别两种设计模式,不能光看代码实现,而是要看设计意图,也就是要解决的问题。应用享元模式是为了对象复用,节省内存,而应用多例模式是为了限制对象的个数。
3.2 享元模式跟缓存的区别
在享元模式的实现中,我们通过工厂类来 “缓存” 已经创建好的对象。这里的 “缓存” 实际上是 “存储” 的意思,跟我们平时所说的 “数据库缓存” “CPU 缓存” “MemCache 缓存” 是两回事。我们平时所讲的缓存,主要是为了提高访问效率,而非复用。
3.3 享元模式跟对象池的区别
池化技术中的 “复用” 可以理解为 “重复使用” ,主要目的是节省时间(比如从数据库池中取一个连接,不需要重新创建)。在任意时刻,每一个对象、连接、线程,并不会被多处使用,而是被一个使用者独占,当使用完成之后,放回到池中,再由其他使用者重复利用。享元模式中的“复用”可以理解为 “共享使用”,在整个生命周期中,都是被所有使用者共享的,主要目的是节省空间。
4 享元模式在 Integer、String 中的应用
4.1 Integer
1 | Integer i1 = 56; |
要正确地分析上面的代码,我们需要弄清楚下面两个问题:
- 如何判定两个
Java对象是否相等(也就代码中的 “==” 操作符的含义)? - 什么是自动装箱(
Autoboxing)和自动拆箱(Unboxing)?
所谓的自动装箱,就是自动将基本数据类型转换为包装器类型。所谓的自动拆箱,也就是自动将包装器类型转化为基本数据类型。
1 | Integer i = 56; //自动装箱 |
数值 56 是基本数据类型 int,当赋值给包装器类型(Integer)变量的时候,触发自动装箱操作,创建一个 Integer 类型的对象,并且赋值给变量 i。其底层相当于执行了下面这条语句:
1 | Integer i = 59;底层执行了:Integer i = Integer.valueOf(59); |
当我们通过 “==” 来判定两个对象是否相等的时候,实际上是在判断两个局部变量存储的地址是否相同,换句话说,是在判断两个局部变量是否指向相同的对象。
回头看那段代码,前 4 行赋值语句都会触发自动装箱操作,也就是会创建 Integer 对象并且赋值给 i1、i2、i3、i4 这四个变量。根据刚刚的讲解,i1、i2 尽管存储的数值相同,都是 56,但是指向不同的 Integer 对象,所以通过 “==” 来判定是否相同的时候,会返回 false。同理,i3==i4 判定语句也会返回 false。
以为这样就完了吗?上面的分析还是不对,答案并非是两个 false,而是一个 true,一个 false。
这正是因为 Integer 用到了享元模式来复用对象,才导致了这样的运行结果。当我们通过自动装箱,也就是调用 valueOf() 来创建 Integer 对象的时候,如果要创建的 Integer 对象的值在 -128 到 127 之间,会从 IntegerCache 类中直接返回,否则才调用 new 方法创建。
1 | public static Integer valueOf(int i) { |
这里的 IntegerCache 相当于享元对象的工厂类,只不过名字不叫 xxxFactory 而已。
1 | /** |
为什么 IntegerCache 只缓存 -128 到 127 之间的整型值呢?
在 IntegerCache 的代码实现中,当这个类被加载的时候,缓存的享元对象会被集中一次性创建好。毕竟整型值太多了,我们不可能在 IntegerCache 类中预先创建好所有的整型值,这样既占用太多内存,也使得加载 IntegerCache 类的时间过长。所以,我们只能选择缓存对于大部分应用来说最常用的整型值,也就是一个字节的大小(-128 到 127 之间的数据)。
如何修改这个值呢?
JDK 提供了方法来让我们可以自定义缓存的最大值,但没有提供设置最小值的方法。这里只有 idea 的设置参数。Android Studio 还没有找到相关的设置方法,我试过了再 studio64.exe.vmoptions 中设置,并且执行了 Invalidate Caches/Restart... 但是没有效果。
希望哪位大佬指点一下 ~
1 | //方法一: |
除了 Integer 类型之外,其他包装器类型,比如 Long、Short、Byte 等,也都利用了享元模式来缓存 -128 到 127 之间的数据。具体查看对应的 valueOf() 方法。
在我们平时的开发中,对于下面这样三种创建整型对象的方式,我们优先使用后两种。
1 | Integer a = new Integer(123); |
第一种创建方式并不会使用到 IntegerCache,而后面两种创建方法可以利用 IntegerCache 缓存,返回共享的对象,以达到节省内存的目的。
4.2 String
1 | String s1 = "abc"; |
上面代码的运行结果是:一个 true,一个 false。
String 类利用享元模式来复用相同的字符串常量。JVM 会专门开辟一块存储区来存储字符串常量,这块存储区叫作 “字符串常量池”。
String 类的享元模式的设计,跟 Integer 类稍微有些不同。Integer 类中要共享的对象,是在类加载的时候,就集中一次性创建好的。但是,对于字符串来说,我们没法事先知道要共享哪些字符串常量,所以没办法事先创建好,只能在某个字符串常量第一次被用到的时候,存储到常量池中,当之后再用到的时候,直接引用常量池中已经存在的即可,就不需要再重新创建了。
5 Android 源码中的享元(Handler)
万万没想到,很不常用的享元模式,竟然在我们经常使用的 Handler 中用到了!说起 Handler ,一般都是在耗时操作的时候才会使用 Handler ,比如更新 UI,大家肯定听过一句话,UI 不能够在子线程中更新。这原本就是一个伪命题,因为并不是 UI 不可以在子线程更新,而是 UI 不可以在不是它的创建线程里进行更新。只是绝大多数情况下 UI 都是从 UI 线程中创建的,因此,在其他线程更新时会抛出异常。
这里插入一个小小的知识点,然后回到我们的猪脚 ^(* ̄(oo) ̄)^ handler 中。
我们在通过 Handler 中传递消息时会传递一个 Runnable,Runnable 会被包装到一个 Message 对象中,然后再投递到 UI 线程的消息队列。具体看 post 代码
1 | public final boolean post(Runnable r) |
这里可以看到,Message 并不是 new 出来的,这个 obtain 有点蹊跷 ~ 这里只研究和享元模式相关的部分,其他部分看下面扩展 ~
1 | public static Message obtain() { |
这里可以看到,在 mPool 为 null 时才会去重新 new 一个 Message ,那我们只需要知道这个 sPoolSync 和 sPool 是什么,就可以知道这里的缓存是怎么做的了。
1 | /** |
首先 Message 文档第一段的意思就是介绍了一下这个 Message 类的字段,以及说明 Message 对象是被发送到 Handler 的,对于我们来说作用不大。
第二段的意思是建议我们使用 Message 的 obtain 方法获取 Message 对象,而不是通过 Message 的构造函数,因为 obtain 方法会从被回收的对象池中获取 Message 对象。
然后再看看关键的字段,sPoolSync 是一个普通的 Object 对象,它的作用就是用于在获取 Message 对象时进行同步锁。再看 sPool 居然是一个 Message 对象,居然不是消息池之类的东西,既然它命名为 sPool 不可能是有名无实吧。原来还有一个比较总要的字段
1 | // sometimes we store linked lists of these things |
原来 Message 消息池没有使用 map 这样的容器,使用的是链表!这个 next 就是指向下一个 Message 的。
每个 Message 对象都有一个同类型的 next 字段,这个 next 指向的就是下一个可用的 Message ,最后一个可用的 Message 的 next 则为空。这样一来,所有可用的 Message 对象就通过 next 串连成一个可用的 Message 池。
我们在 obtain 函数中只看到了从链表中获取,并且看到存储。如果消息池链表中没有可用对象的时候,obtain 中则是直接返回一个通过 new 创建的 Message 对象,而且并没有存储到链表中。那么这些 Message 对象什么时候会被放到链表中呢?
“which will pull them from a pool of recycled objects.”,这是 Message 注释中的第二句话末尾的一句话,原来在创建的时候不会把 Message 对象放到池中,在回收(这里的回收并不是指虚拟机回收Message 对象)该对象时才会将该对象添加到链表中。
1 | public void recycle() { |
recycle 函数会将一个 Message 对象回收到一个全局的池中,这个池也就是链表。recycle 函数首先判断该消息是否还在使用,如果还在使用则抛出异常,否则调用 recycleUnchecked 函数处理该消息。
recycleUnchecked 函数中先清空该消息的各字段,并且将 flags 设置为 FLAG_IN_USE ,表明该消息已被使用,这个 flag 在 obtain 函数中会被置为 0 ,这样根据这个 flag 就能够追踪该消息的状态。
然后判断是否要将该消息回收到消息池中,如果池的大小小于 MAX_POOL_SIZE 时,将自身添加到链表的表头。
例如,当链表中还没有元素时,将第一个Message 对象添加到链表中,此时 sPool 为null,next 指向了 sPool ,因此,next 也为 null ,然后 sPool 又指向了 this ,因此,sPool 就指向了当前这个被回收的对象,并且 sPoolSize 加1。相当于当前要回收的 Message (既然是要被回收的,肯定是一个独立的节点) next 指向回收池的头节点,再将 sPool(头指针) 指向当前节点,作为头节点。obtain 取的时候就是将头节点与之后的节点断开,单独取出来使用。
这里的 Message 相当于承担了享元模式中 3 个元素的职责,即是 Flyweight 抽象,又是 ConcreteFlyweight 角色,同时又承担了 FlyweightFactory 管理对象池的职责。因为 Android 应用是事件驱动的,因此,如果通过 new 创建 Message 就会创建大量重复的 Message 对象,导致内存占用率高、频繁 GC 等问题,通过享元模式创建一个大小为 50 的消息池,避免了上述问题的产生,使得这些问题迎刃而解。
Handler 扩展
Android消息机制-Handler(framework篇)
感谢
《Android 源码设计模式解析与实战》
以及上文中的链接







