所谓 “享元”,顾名思义就是被共享的单元。享元模式的意图是复用对象,节省内存,前提是享元对象是不可变对象。

当一个系统中存在大量重复对象的时候,如果这些重复的对象是不可变对象,我们就可以利用享元模式将对象设计成享元,在内存中只保留一份实例,供多处代码引用。这样可以减少内存中对象的数量,起到节省内存的目的。

实际上,不仅仅相同对象可以设计成享元,对于相似对象,我们也可以将这些对象中相同的部分(字段)提取出来,设计成享元,让这些大量相似对象引用这些享元。

定义中的 “不可变对象” 指的是,一旦通过构造函数初始化完成之后,它的状态(对象的成员变量或者属性)就不会再被修改了。所以,不可变对象不能暴露任何 set() 等修改内部状态的方法。之所以要求享元是不可变对象,那是因为它会被多处代码共享使用,避免一处代码对享元进行了修改,影响到其他使用它的代码。

1 享元模式 UML

图片来源于《Android 源码设计模式解析与实战》

举个 🌰

过年回家买火车票是一件很困难的事,无数人用刷票插件软件在向服务端发出请求,对于每一个请求服务器都必须做出应答。

在用户设置好出发地和目的地之后,每次请求都返回一个查询的车票结果。那么当数以万计的人不间断在请求数据时,如果每次都重新创建一个查询的车票结果,那么必然会造成大量重复对象的创建、销毁,使得 GC 任务繁重、内存占用率高居不下。

而这类问题通过享元模式就能够得到很好地改善,从城市 A 到城市 B 的车辆是有限的,车上的铺位也就是硬卧、硬卧、坐票 3 种。我们将这些可以公用的对象缓存起来,在用户查询时优先使用缓存,如果没有缓存则重新创建。这样就将成千上万的对象变为了可选择的有限数量。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
public interface Ticket {
void showTicketInfo(String bunk);
}

public class TrainTicket implements Ticket {
private String from;
private String to;

public TrainTicket(String from, String to) {
this.from = from;
this.to = to;
}

@Override
public void showTicketInfo(String bunk) {
int price = new Random().nextInt(300);
System.out.println("购买 从 " + from + " 到 " + to + " 的 " + bunk + " 火车票,价格:" + price);
}
}

public class TicketFactory {
static Map<String, Ticket> sTringTicketMap = new ConcurrentHashMap<>();

public static Ticket getTicket(String from, String to) {
String key = from + "-" + to;
if (sTringTicketMap.containsKey(key)) {
System.out.println("使用缓存 ==>" + key);
return sTringTicketMap.get(key);
} else {
System.out.println("创建对象 ==>" + key);
Ticket ticket = new TrainTicket(from, to);
sTringTicketMap.put(key, ticket);
return ticket;
}
}
}

2 简单实现

举个 🌰

假设我们在开发一个棋牌游戏(比如象棋)。一个游戏厅中有成千上万个“房间”,每个房间对应一个棋局。棋局要保存每个棋子的数据,比如:棋子类型(将、相、士、炮等)、棋子颜色(红方、黑方)、棋子在棋局中的位置。利用这些数据,我们就能显示一个完整的棋盘给玩家。

为了记录每个房间当前的棋局情况,我们需要给每个房间都创建一个 ChessBoard 棋局对象。因为游戏大厅中有成千上万的房间(实际上,百万人同时在线的游戏大厅也有很多),那保存这么多棋局对象就会消耗大量的内存。

这个时候,享元模式就可以派上用场了。像刚刚的实现方式,在内存中会有大量的相似对象。这些相似对象的 id、text、color 都是相同的,唯独 positionX、positionY 不同。实际上,我们可以将棋子的 id、text、color 属性拆分出来,设计成独立的类,并且作为享元供多个棋盘复用。这样,棋盘只需要记录每个棋子的位置信息就可以了。

棋盘上旗子颜色和字都是固定的,只需要全部添加,并且缓存使用就好。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
public class ChessPieceUnit {
private int id;
private String text;
private Color color;

public ChessPieceUnit(int id, String text, Color color) {
this.id = id;
this.text = text;
this.color = color;
}

public static enum Color {RED, BLACK} // ...省略其他属性和getter/setter方法...
}

public class ChessPiece {
private ChessPieceUnit chessPieceUnit;
private int positionX;
private int positionY;

public ChessPiece(ChessPieceUnit chessPieceUnit, int positionX, int positionY) {
this.chessPieceUnit = chessPieceUnit;
this.positionX = positionX;
this.positionY = positionX;
}
}

public class ChessPieceUnitFactory {
private static final Map<Integer, ChessPieceUnit> pieces = new HashMap<>();

static {
pieces.put(1, new ChessPieceUnit(1, "車", ChessPieceUnit.Color.BLACK));
pieces.put(2, new ChessPieceUnit(2, "馬", ChessPieceUnit.Color.BLACK));
//...省略摆放其他棋子的代码...
}

public static ChessPieceUnit getChessPiece(int chessPieceId) {
return pieces.get(chessPieceId);
}
}

我们利用工厂类来缓存 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
2
3
4
5
6
Integer i1 = 56;
Integer i2 = 56;
Integer i3 = 129;
Integer i4 = 129;
System.out.println(i1 == i2);
System.out.println(i3 == i4);

要正确地分析上面的代码,我们需要弄清楚下面两个问题:

  • 如何判定两个 Java 对象是否相等(也就代码中的 “==” 操作符的含义)?
  • 什么是自动装箱(Autoboxing)和自动拆箱(Unboxing)?

所谓的自动装箱,就是自动将基本数据类型转换为包装器类型。所谓的自动拆箱,也就是自动将包装器类型转化为基本数据类型。

1
2
Integer i = 56; //自动装箱
int j = i; //自动拆箱

数值 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
2
3
4
5
public static Integer valueOf(int i) {
if (i >= IntegerCache.low && i <= IntegerCache.high)
return IntegerCache.cache[i + (-IntegerCache.low)];
return new Integer(i);
}

这里的 IntegerCache 相当于享元对象的工厂类,只不过名字不叫 xxxFactory 而已。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
/**
* Cache to support the object identity semantics of autoboxing for values between
* -128 and 127 (inclusive) as required by JLS.
*
* The cache is initialized on first usage. The size of the cache
* may be controlled by the {@code -XX:AutoBoxCacheMax=<size>} option.
* During VM initialization, java.lang.Integer.IntegerCache.high property
* may be set and saved in the private system properties in the
* sun.misc.VM class.
*/
private static class IntegerCache {
static final int low = -128;
static final int high;
static final Integer cache[];

static {
// high value may be configured by property
int h = 127;
String integerCacheHighPropValue =
sun.misc.VM.getSavedProperty("java.lang.Integer.IntegerCache.high");
if (integerCacheHighPropValue != null) {
try {
int i = parseInt(integerCacheHighPropValue);
i = Math.max(i, 127);
// Maximum array size is Integer.MAX_VALUE
h = Math.min(i, Integer.MAX_VALUE - (-low) -1);
} catch( NumberFormatException nfe) {
// If the property cannot be parsed into an int, ignore it.
}
}
high = h;

cache = new Integer[(high - low) + 1];
int j = low;
for(int k = 0; k < cache.length; k++)
cache[k] = new Integer(j++);

// range [-128, 127] must be interned (JLS7 5.1.7)
assert IntegerCache.high >= 127;
}

private IntegerCache() {}
}

为什么 IntegerCache 只缓存 -128 到 127 之间的整型值呢?

在 IntegerCache 的代码实现中,当这个类被加载的时候,缓存的享元对象会被集中一次性创建好。毕竟整型值太多了,我们不可能在 IntegerCache 类中预先创建好所有的整型值,这样既占用太多内存,也使得加载 IntegerCache 类的时间过长。所以,我们只能选择缓存对于大部分应用来说最常用的整型值,也就是一个字节的大小(-128 到 127 之间的数据)。

如何修改这个值呢?

JDK 提供了方法来让我们可以自定义缓存的最大值,但没有提供设置最小值的方法。这里只有 idea 的设置参数。Android Studio 还没有找到相关的设置方法,我试过了再 studio64.exe.vmoptions 中设置,并且执行了 Invalidate Caches/Restart... 但是没有效果。

希望哪位大佬指点一下 ~

1
2
3
4
//方法一:
-Djava.lang.Integer.IntegerCache.high=255
//方法二:
-XX:AutoBoxCacheMax=255

除了 Integer 类型之外,其他包装器类型,比如 Long、Short、Byte 等,也都利用了享元模式来缓存 -128 到 127 之间的数据。具体查看对应的 valueOf() 方法。

在我们平时的开发中,对于下面这样三种创建整型对象的方式,我们优先使用后两种。

1
2
3
Integer a = new Integer(123);
Integer a = 123;
Integer a = Integer.valueOf(123);

第一种创建方式并不会使用到 IntegerCache,而后面两种创建方法可以利用 IntegerCache 缓存,返回共享的对象,以达到节省内存的目的。

4.2 String

1
2
3
4
5
6
String s1 = "abc";
String s2 = "abc";
String s3 = new String("abc");

System.out.println(s1 == s2);
System.out.println(s1 == s3);

上面代码的运行结果是:一个 true,一个 false。

String 类利用享元模式来复用相同的字符串常量。JVM 会专门开辟一块存储区来存储字符串常量,这块存储区叫作 “字符串常量池”。

String 类的享元模式的设计,跟 Integer 类稍微有些不同。Integer 类中要共享的对象,是在类加载的时候,就集中一次性创建好的。但是,对于字符串来说,我们没法事先知道要共享哪些字符串常量,所以没办法事先创建好,只能在某个字符串常量第一次被用到的时候,存储到常量池中,当之后再用到的时候,直接引用常量池中已经存在的即可,就不需要再重新创建了。

5 Android 源码中的享元(Handler)

万万没想到,很不常用的享元模式,竟然在我们经常使用的 Handler 中用到了!说起 Handler ,一般都是在耗时操作的时候才会使用 Handler ,比如更新 UI,大家肯定听过一句话,UI 不能够在子线程中更新。这原本就是一个伪命题,因为并不是 UI 不可以在子线程更新,而是 UI 不可以在不是它的创建线程里进行更新。只是绝大多数情况下 UI 都是从 UI 线程中创建的,因此,在其他线程更新时会抛出异常。

Android UI 线程更新UI也会崩溃???

这里插入一个小小的知识点,然后回到我们的猪脚 ^(* ̄(oo) ̄)^ handler 中。

我们在通过 Handler 中传递消息时会传递一个 Runnable,Runnable 会被包装到一个 Message 对象中,然后再投递到 UI 线程的消息队列。具体看 post 代码

1
2
3
4
5
6
7
8
9
10
public final boolean post(Runnable r)
{
return sendMessageDelayed(getPostMessage(r), 0);
}

private static Message getPostMessage(Runnable r) {
Message m = Message.obtain();
m.callback = r;
return m;
}

这里可以看到,Message 并不是 new 出来的,这个 obtain 有点蹊跷 ~ 这里只研究和享元模式相关的部分,其他部分看下面扩展 ~

1
2
3
4
5
6
7
8
9
10
11
12
13
public static Message obtain() {
synchronized (sPoolSync) {
if (sPool != null) {
Message m = sPool;
sPool = m.next;
m.next = null;// 从sPool中取出一个 Message 对象,并消息链表断开(单独的一个节点)
m.flags = 0; // clear in-use flag
sPoolSize--; //消息池的可用大小进行减1操作
return m;
}
}
return new Message();// 当消息池为空时,直接创建Message对象
}

这里可以看到,在 mPool 为 null 时才会去重新 new 一个 Message ,那我们只需要知道这个 sPoolSync 和 sPool 是什么,就可以知道这里的缓存是怎么做的了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/**
*
* Defines a message containing a description and arbitrary data object that can be
* sent to a {@link Handler}. This object contains two extra int fields and an
* extra object field that allow you to not do allocations in many cases.
*
* <p class="note">While the constructor of Message is public, the best way to get
* one of these is to call {@link #obtain Message.obtain()} or one of the
* {@link Handler#obtainMessage Handler.obtainMessage()} methods, which will pull
* them from a pool of recycled objects.</p>
*/
public final class Message implements Parcelable {
public static final Object sPoolSync = new Object();
private static Message sPool;
private static int sPoolSize = 0;

首先 Message 文档第一段的意思就是介绍了一下这个 Message 类的字段,以及说明 Message 对象是被发送到 Handler 的,对于我们来说作用不大。

第二段的意思是建议我们使用 Message 的 obtain 方法获取 Message 对象,而不是通过 Message 的构造函数,因为 obtain 方法会从被回收的对象池中获取 Message 对象。

然后再看看关键的字段,sPoolSync 是一个普通的 Object 对象,它的作用就是用于在获取 Message 对象时进行同步锁。再看 sPool 居然是一个 Message 对象,居然不是消息池之类的东西,既然它命名为 sPool 不可能是有名无实吧。原来还有一个比较总要的字段

1
2
// sometimes we store linked lists of these things
Message next;

原来 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
public void recycle() {
// 判断是否该消息还在使用
if (isInUse()) {
if (gCheckRecycle) {
throw new IllegalStateException("This message cannot be recycled because it "
+ "is still in use.");
}
return;
}
// 清空状态,并且将消息添加到消息池中
recycleUnchecked();
}

//对于不再使用的消息,加入到消息池
void recycleUnchecked() {
//清空消息状态,设置该消息 in - use flag
flags = FLAG_IN_USE;
what = 0;
arg1 = 0;
arg2 = 0;
obj = null;
replyTo = null;
sendingUid = -1;
when = 0;
target = null;
callback = null;
data = null;
// 回收消息到消息池中
synchronized (sPoolSync) {
if (sPoolSize < MAX_POOL_SIZE) { //当消息池没有满时,将Message对象加入消息池
next = sPool;
sPool = this;
sPoolSize++;//消息池的可用大小进行加1操作
}
}
}

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消息机制-Handler(native篇)

Android消息机制3-Handler(实战)

感谢

设计模式之美

《Android 源码设计模式解析与实战》

以及上文中的链接