跟其他所有的设计模式类似,享元模式的原理和实现也非常简单。今天,我会通过棋牌游戏的例子来讲解。除此之外,我还会讲到它跟单例、缓存、对象池的区别和联系。并剖析一下享元模式在 Java Integer 中的应用。
享元模式原理与实现
所谓“享元”,顾名思义就是被共享的单元。享元模式的意图是复用对象,节省内存,前提是享元对象是不可变对象。具体来讲,当一个系统中存在大量重复对象的时候,如果这些重复的对象是不可变对象,我们就可以利用享元模式将对象设计成享元,在内存中只保留一份实例,供多处代码引用。这样可以减少内存中对象的数量,起到节省内存的目的。实际上,不仅仅相同对象可以设计成享元,对于相似对象,我们也可以将这些对象中相同的部分(字段)提取出来,设计成享元,让这些大量相似对象引用这些享元。
这里我稍微解释一下,定义中的“不可变对象”指的是,一旦通过构造函数初始化完成之 后,它的状态(对象的成员变量或者属性)就不会再被修改了。所以,不可变对象不能暴露任何 set() 等修改内部状态的方法。之所以要求享元是不可变对象,那是因为它会被多处代码共享使用,避免一处代码对享元进行了修改,影响到其他使用它的代码。
接下来,我们通过一个简单的例子解释一下享元模式。假设我们在开发一个棋牌游戏(比如象棋)。一个游戏厅中有成千上万个“房间”,每个房间对应一个棋局。棋局要保存每个棋子的数据,比如:棋子类型(将、相、士、炮等)、棋子颜色(红方、黑方)、棋子在棋局中的位置。利用这些数据,我们就能显示一个完整的棋盘给玩家。具体的代码如下所示。其中,ChessPiece 类表示棋子,ChessBoard 类表示一个棋局,里面保存了象棋中 30 个棋子的信息。
1 | public class ChessPiece {//棋子 |
为了记录每个房间当前的棋局情况,我们需要给每个房间都创建一个 ChessBoard 棋局对 象。因为游戏大厅中有成千上万的房间(实际上,百万人同时在线的游戏大厅也有很多), 那保存这么多棋局对象就会消耗大量的内存。有没有什么办法来节省内存呢?
这个时候,享元模式就可以派上用场了。像刚刚的实现方式,在内存中会有大量的相似对象。这些相似对象的 id、text、color 都是相同的,唯独 positionX、positionY 不同。实际上,我们可以将棋子的 id、text、color 属性拆分出来,设计成独立的类,并且作为享元供多个棋盘复用。这样,棋盘只需要记录每个棋子的位置信息就可以了。具体的代码实现如下所示:
1 | // 享元类 |
在上面的代码实现中,我们利用工厂类来缓存 ChessPieceUnit 信息(也就是 id、text、 color)。通过工厂类获取到的 ChessPieceUnit 就是享元。所有的 ChessBoard 对象共享这 30 个 ChessPieceUnit 对象(因为象棋中只有 30 个棋子)。在使用享元模式之前,记 录 1 万个棋局,我们要创建 30 万(30*1 万)个棋子的 ChessPieceUnit 对象。利用享元模式,我们只需要创建 30 个享元对象供所有棋局共享使用即可,大大节省了内存。
那享元模式的原理讲完了,我们来总结一下它的代码结构。实际上,它的代码实现非常简单,主要是通过工厂模式,在工厂类中,通过一个 Map 来缓存已经创建过的享元对象,来达到复用的目的。
享元模式 vs 单例、缓存、对象池
在上面的讲解中,我们多次提到“共享”“缓存”“复用”这些字眼,那它跟单例、缓存、 对象池这些概念有什么区别呢?我们来简单对比一下。
我们先来看享元模式跟单例的区别。
在单例模式中,一个类只能创建一个对象,而在享元模式中,一个类可以创建多个对象,每个对象被多处代码引用共享。实际上,享元模式有点类似于之前讲到的单例的变体:多例。
我们前面也多次提到,区别两种设计模式,不能光看代码实现,而是要看设计意图,也就是要解决的问题。尽管从代码实现上来看,享元模式和多例有很多相似之处,但从设计意图上来看,它们是完全不同的。应用享元模式是为了对象复用,节省内存,而应用多例模式是为 了限制对象的个数。
我们再来看享元模式跟缓存的区别。
在享元模式的实现中,我们通过工厂类来“缓存”已经创建好的对象。这里的“缓存”实际上是“存储”的意思,跟我们平时所说的“数据库缓存”“CPU 缓存”“MemCache 缓存”是两回事。我们平时所讲的缓存,主要是为了提高访问效率,而非复用。
最后我们来看享元模式跟对象池的区别。
对象池、连接池(比如数据库连接池)、线程池等也是为了复用,那它们跟享元模式有什么区别呢?
你可能对连接池、线程池比较熟悉,对对象池比较陌生,所以,这里我简单解释一下对象池。像 C++ 这样的编程语言,内存的管理是由程序员负责的。为了避免频繁地进行对象创建和释放导致内存碎片,我们可以预先申请一片连续的内存空间,也就是这里说的对象池。 每次创建对象时,我们从对象池中直接取出一个空闲对象来使用,对象使用完成之后,再放回到对象池中以供后续复用,而非直接释放掉。
虽然对象池、连接池、线程池、享元模式都是为了复用,但是,如果我们再细致地抠一抠“复用”这个字眼的话,对象池、连接池、线程池等池化技术中的“复用”和享元模式中的“复用”实际上是不同的概念。
池化技术中的“复用”可以理解为“重复使用”,主要目的是节省时间(比如从数据库池中 取一个连接,不需要重新创建)。在任意时刻,每一个对象、连接、线程,并不会被多处使用,而是被一个使用者独占,当使用完成之后,放回到池中,再由其他使用者重复利用。享元模式中的“复用”可以理解为“共享使用”,在整个生命周期中,都是被所有使用者共享的,主要目的是节省空间。
享元模式在 Java Integer 中的应用
我们先来看下面这样一段代码。你可以先思考下,这段代码会输出什么样的结果。
1 | Integer i1 = 56; |
答案是一个 true,一个 false。实际上,这正是因为 Integer 用到了享元模式来复用对象,才导致了这样的运行结果。当我们通过自动装箱,也就是调用 valueOf() 来创建 Integer 对象的时候,如果要创建的 Integer 对象的值在 -128 到 127 之间,会从 IntegerCache 类中直接返回,否则才调用 new 方法创建。看代码更加清晰一些,Integer 类的 valueOf() 函 数的具体代码如下所示:
1 | public static Integer valueOf(int i) { |
实际上,这里的 IntegerCache 相当于,我们上面讲的生成享元对象的工厂类,只不 过名字不叫 xxxFactory 而已。我们来看它的具体代码实现。这个类是 Integer 的内部类, 你也可以自行查看 JDK 源码。
1 | /** |
为什么 IntegerCache 只缓存 -128 到 127 之间的整型值呢?
在 IntegerCache 的代码实现中,当这个类被加载的时候,缓存的享元对象会被集中一次性创建好。毕竟整型值太多了,我们不可能在 IntegerCache 类中预先创建好所有的整型值, 这样既占用太多内存,也使得加载 IntegerCache 类的时间过长。所以,我们只能选择缓存对于大部分应用来说最常用的整型值,也就是一个字节的大小(-128 到 127 之间的数据)。
现在,让我们再回到最开始的问题,因为 56 处于 -128 和 127 之间,i1 和 i2 会指向相同的享元对象,所以 i1==i2 返回 true。而 129 大于 127,并不会被缓存,每次都会创建一 个全新的对象,也就是说,i3 和 i4 指向不同的 Integer 对象,所以 i3==i4 返回 false。