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

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

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

定义中的 “不可变对象” 指的是,一旦通过构造函数初始化完成之后,它的状态(对象的成员变量或者属性)就不会再被修改了。所以,不可变对象不能暴露任何 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 棋局对象。因为游戏大厅中有成千上万的房间(实际上,百万人同时在线的游戏大厅也有很多),那保存这么多棋局对象就会消耗大量的内存。

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

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

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 信息(也就是 idtextcolor)。通过工厂类获取到的 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 对象并且赋值给 i1i2i3i4 这四个变量。根据刚刚的讲解,i1i2 尽管存储的数值相同,都是 56,但是指向不同的 Integer 对象,所以通过 “==” 来判定是否相同的时候,会返回 false。同理,i3==i4 判定语句也会返回 false

以为这样就完了吗?上面的分析还是不对,答案并非是两个 false,而是一个 true,一个 false
这正是因为 Integer 用到了享元模式来复用对象,才导致了这样的运行结果。当我们通过自动装箱,也就是调用 valueOf() 来创建 Integer 对象的时候,如果要创建的 Integer 对象的值在 -128127 之间,会从 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 只缓存 -128127 之间的整型值呢?

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

如何修改这个值呢?

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

希望哪位大佬指点一下 ~

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

除了 Integer 类型之外,其他包装器类型,比如 LongShortByte 等,也都利用了享元模式来缓存 -128127 之间的数据。具体查看对应的 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 中传递消息时会传递一个 RunnableRunnable 会被包装到一个 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对象
}

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

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 的,对于我们来说作用不大。

第二段的意思是建议我们使用 Messageobtain 方法获取 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 ,最后一个可用的 Messagenext 则为空。这样一来,所有可用的 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 ,表明该消息已被使用,这个 flagobtain 函数中会被置为 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 源码设计模式解析与实战》

以及上文中的链接