享元模式
本文内容
前言
上一篇文章讲的组合模式,主要应用在数据能表示成树形结构,所以不太常用。
而本篇文章要讲的 享元模式,也不太常用,因为它的使用场景也比较特殊。不过在 Java 中,你经常使用的 String、Integer 都使用到了享元模式。
1. 什么是享元模式
享元,顾名思义就是 被共享的单元,它的意图是 复用对象,节省内存。对象要复用,前提肯定是 不可变对象。
“不可变对象” 指的是 对象一旦通过构造函数初始化后,它的状态(对象的成员变量或属性)就不会再被修改了。
具体来说,当系统中存在 大量重复 且 不可变 的对象时,就可以使用 享元模式 将对象设计成享元,在内存中只保存一份实例,供多处代码引用。这样便可以减少内存中的对象数量,起到节省内存的目的。
对于 相似的对象,其实也可以 将对象中相同的部分(字段)提取出来,设计成享元,让大量相似的对象引用这些享元。
2. 如何实现享元模式
下面用一个象棋游戏来看看享元模式的具体代码实现。
在象棋游戏中,一个游戏厅有成千上万个 “房间”,每个房间对应一个棋局。棋局中要保存每个棋子的数据,如:棋子类型(将、相、士等)、棋子颜色(红、黑)、棋子的位置(x, y)。
利用上面这些数据,就可以将一个完整的棋盘显示出来,具体代码如下:
public class ChessPiece { //棋子
private int id;
private String text;
private Color color;
private int positionX;
private int positionY;
public static enum Color {
RED, BLACK
}
// ...省略其他属性和构造器、getter/setter 方法...
}
public class ChessBoard { //棋局
private Map<Integer, ChessPiece> chessPieces = new HashMap<>();
public ChessBoard() {
init();
}
private void init() {
chessPieces.put(1, new ChessPiece(1, "車", ChessPiece.Color.BLACK, 0, 0));
chessPieces.put(2, new ChessPiece(2, "馬", ChessPiece.Color.BLACK, 0, 1));
//...省略摆放其他棋子的代码...
}
public void move(int chessPieceId, int toPositionX, int toPositionY) {
//...省略...
}
}
为了记录每个房间的棋局情况,需要给每个房间都创建一个 ChessBoard 棋局对象,所以当房间比较多的时候,就会 消耗大量的内存。
这时候就可以使用享元模式了,因为其实这些棋局对象是相似的,这些对象中很多属性都是相同的,比如 id、text、color,唯独 positionX、positionY 不同。所以可以把相同的属性拆分出来,设计成独立的类,作为享元供多个期盼复用。
具体代码实现如下:
// 享元类,创建对象后便不能改变对象,所以不提供 setter 方法
public class ChessPieceUnit {
private int id;
private String text;
private Color color;
public static enum Color {
RED, BLACK
}
// ...省略其他属性和构造器、getter方法...
}
// 提供一个ChessPieceUnit 工厂类,用来获取享元
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);
}
}
public class ChessPiece {
private ChessPieceUnit chessPieceUnit;
private int positionX;
private int positionY;
// 省略构造器、getter、setter方法
}
public class ChessBoard {
private Map<Integer, ChessPiece> chessPieces = new HashMap<>();
public ChessBoard() {
init();
}
private void init() {
// 通过 ChessPieceUnitFactory 来获取享元
chessPieces.put(1, new ChessPiece(
ChessPieceUnitFactory.getChessPiece(1), 0,0));
chessPieces.put(2, new ChessPiece(
ChessPieceUnitFactory.getChessPiece(2), 1,0));
//...省略摆放其他棋子的代码...
}
public void move(int chessPieceId, int toPositionX, int toPositionY) {
//...省略...
}
}
我们使用 工厂类来缓存 ChessPieceUnit 信息(id、text、color),通过工厂类获取到的 ChessPieceUnit 就是享元。
因为在一个棋盘中,只有 30 个不同的棋子,所以所有的 ChessBoard 对象都可以共享这 30 个 ChessPieceUnit 对象。在使用享元模式之前,记录 1 万个棋局,需要创建 30 万(30×1 万)个棋子的 ChessPieceUnit 对象。利用享元模式,只需要创建 30 个享元对象供所有棋局共享使用,大大节省了内存。
所以,享元模式的代码实现主要是:通过工厂模式,在工厂类中通过一个 Map 来缓存已经创建过的享元,来达到复用的目的,从而节省了内存空间。
3. 享元模式 vs 单例、缓存、池化技术
好像享元、单例、缓存、对象池,都有点对象复用或共享的意思,那么它们有啥区别呢?
享元模式和单例的区别
单例模式中,一个类只能创建一个对象,而享元模式中,一个类可以创建多个对象,每个对象被多处代码引用共享。
可以发现,享元模式有点类似与单例的变体,多例。不过它们的 设计意图是完全不同的:
- 享元模式是为了对象复用,节省内存;
- 多例模式是为了限制对象的个数。
享元模式和缓存的区别
在 享元模式 中,我们一般通过工厂类来 “缓存” 已经创建好的对象,这里的 “缓存” 其实是 “存储” 的意思。
而我们 平时说的缓存,例如CPU 缓存、Redis 缓存等,主要是为了 提高访问速度,而非复用。
享元模式和池化技术的区别
池化技术常见的有连接池、线程池等,也是为了复用,那它们和享元模式有什么区别呢?
虽然都是复用,但是它们的 复用其实是不同的概念:
- 池化技术 的复用,指的是 重复使用,主要目的是 节省时间(比如线程池节省了线程创建和销毁的时间)。在任意时刻,每个连接(连接池)或线程(线程池)并不会被多处使用,而是被一个使用者独占,使用完后放回池中,再由其他使用者重复利用;
- 享元模式 中的复用,指的是 共享使用,在享元的整个声明周期中,都是被所有使用者共享的,主要目的是节省空间。
4. 享元模式在 Java 中的应用
4.1 享元模式在 Integer 中的应用
先给你抛一道面试题:
Integer i1 = 56;
Integer i2 = 56;
Integer i3 = 129;
Integer i4 = 129;
System.out.println(i1 == i2);
System.out.println(i3 == i4);
我们知道,Java 中 == 号是比较对象的地址值是否相等,所以答案是 false、false 吗?显然没有这么简单。
这就涉及到 Integer 的缓存机制 了,其实 在创建 Integer 对象时,如果对象的值在 -128 到 127 之间,则会先从 IntegerCache 类中获取对象,否则才会新创建对象。
所以其实上面的答案是 true,false。
而这就是一种 享元模式,这里的 IntegerCache 就相当于前面讲的享元对象的工厂类,只是名字不叫 xxxFactory 而已。IntegerCache 是 Integer 的静态内部类,源码如下:
/**
* 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
* jdk.internal.misc.VM class.
*/
private static class IntegerCache {
static final int low = -128;
static final int high;
static final Integer[] cache;
static Integer[] archivedCache;
static {
// high value may be configured by property
int h = 127;
String integerCacheHighPropValue =
VM.getSavedProperty("java.lang.Integer.IntegerCache.high");
if (integerCacheHighPropValue != null) {
try {
h = Math.max(parseInt(integerCacheHighPropValue), 127);
// Maximum array size is Integer.MAX_VALUE
h = Math.min(h, Integer.MAX_VALUE - (-low) -1);
} catch( NumberFormatException nfe) {
// If the property cannot be parsed into an int, ignore it.
}
}
high = h;
// Load IntegerCache.archivedCache from archive, if possible
CDS.initializeFromArchive(IntegerCache.class);
int size = (high - low) + 1;
// Use the archived cache if it exists and is large enough
if (archivedCache == null || size > archivedCache.length) {
Integer[] c = new Integer[size];
int j = low;
for(int i = 0; i < c.length; i++) {
c[i] = new Integer(j++);
}
archivedCache = c;
}
cache = archivedCache;
// range [-128, 127] must be interned (JLS7 5.1.7)
assert IntegerCache.high >= 127;
}
private IntegerCache() {}
}
通过查看源码可知,当使用到 IntegerCache 时,就会在类加载时将缓存的享元对象一次性创建好。这也是为什么只提供 -128 到 127 之间的值,如果范围太大,那么就需要创建很多的享元对象,占用了太多的内存,也使得该类的加载时间过长。所以 JDK 只选择缓存了最常用的,也就是一个字节的大小(-128 到 127 之间的数据)。
JDK 也提供了自定义调节缓存的最大值,通过
-Djava.lang.Integer.IntegerCache.high=255
或-XX:AutoBoxCacheMax=255
参数调节,不过没有提供设置最小值的方法。
当你掌握了 IntegerCache 后,面试官又抛给你一道题:
Integer n1 = 56;
Integer n2 = 56;
Integer n3 = new Integer(56);
Integer n4 = new Integer(56);
System.out.println(n1 == n2);
System.out.println(n3 == n4);
如果你觉得答案是 true,true 的话,那面试官可能就直接叫你回去等通知了。
我们回过头去看看 IntegerCache 类的注释:
/**
* Cache to support the object identity semantics of autoboxing for values between
* -128 and 127 (inclusive) as required by JLS.
*
* ......
*/
可以发现,这个缓存只对范围在 -128 到 127 之间,且通过自动装箱 autoboxing 的对象创建时,才会起作用。
关于 自动拆装箱,其实就是 Java 提供的一种语法糖:
- 当直接将基本数据类型变量赋值给包装类型变量时,就会发生自动装箱,实际上就是调用
Integer.valueOf()
方法; - 当直接将包装类型变量赋值给基本数据类型变量时,就会发生自动拆箱,实际上就是调用
包装类型变量.intValue()
方法。
所以在上面的面试题中,n1 和 n2 都是自动装箱,所以 n1、n2 直接复用了 IntegerCache 中的对象(56 的对象值在类加载的时候就已经添加到缓存了)。而 n3 和 n4 直接通过 new 来创建对象,没有使用自动装箱,自然就使用不到 IntegerCache 了。
Integer 装箱的源码如下:
public static Integer valueOf(int i) {
// 如果值在范围内,则从 IntegerCache 中获取
if (i >= IntegerCache.low && i <= IntegerCache.high)
return IntegerCache.cache[i + (-IntegerCache.low)];
return new Integer(i);
}
所以,我们在开发中使用 Integer 时,最好都使用自动装箱(或者自己显示调用 Integer.valueOf()
)来创建对象,这样可以大大节省内存。
在 JDK 9 中已经将包装类型的构造器设置为 Deprecated 了,彻底弃用了。
实际上,除了 Integer 之外,其他的包装类型比如 Long、Short、Byte 等,也都利用享元模式来缓存 -128 到 127 之间的数据,实现方式都和 Integer 类似。
4.2 享元模式在 String 中的应用
同样,先给你抛一道面试题:
String s1 = "AruNi";
String s2 = "AruNi";
String s3 = new String("AruNi");
System.out.println(s1 == s2);
System.out.println(s1 == s3);
答案是 true,false。和 Integer 类的设计思路类似,String 利用享元模式来复用相同的字符串常量(代码中的 "AruNi"
),JVM 会专门开辟一块存储区来存储字符串常量,也就是 常量池。
不过,String 类的享元模式和 Integer 类还是有点 区别:
- Integer 中的享元,是在类加载的时候就一次性创建好;
- String 常量池中的享元,是在字符串常量第一次被使用时,才放到常量池中,之后就可以复用这个字符串常量了。
原因也很好理解,因为对于字符串来说,没法事先知道要共享哪些字符串常量,所以只能在被使用时才添加了。
关于字符串常量池,其实还有很多内容,涉及到 JVM,这里就不过多讲解了,可以看我的另一篇文章:String 类。
5. 总结
享元,顾名思义就是 被共享的单元,它的意图是 复用对象,节省内存。对象要复用,前提肯定是 不可变对象。
享元模式的代码实现主要是通过工厂模式,在工厂类中通过一个 Map(或 List,看具体场景)来缓存已经创建好的享元对象,以达到复用的目的。
其实很多设计模式的代码实现都大差不差,但 设计意图是截然不同的,主要是看要解决什么问题。
这里再简单的概括下 享元模式和单例、缓存、池化技术的区别:
- 享元模式是为了对象复用,节省内存,而且享元对象是多个使用者共享的;
- 单例模式是为了保证对象的全局唯一,就算是多例,也是要限制对象的个数的;
- 缓存是为了提高访问速度,而非复用;
- 池化技术中的复用,是为了节省时间(不为对象创建和销毁花费太多的时间),而且池化技术中的线程/连接是被使用者独占的。
最后讲到了享元模式在 Java Integer、String 中的应用:
Integer 的缓存机制,其实是 在创建 Integer 对象时,如果对象的值在 -128 到 127 之间,则会先从 IntegerCache 类中获取对象,否则才会新创建对象;
需要注意的是,只有在使用自动装箱的时候,才会使用缓存。而且 IntegerCache 在使用到时,会在类加载的时候就初始化好缓存。
String 的缓存机制主要是通过字符串常量池来提供的,当使用到字符串时,会先尝试从字符串常量池中获取,获取不到才会创建对象,然后将对象放入字符串常量池中。
其实,IntegerCache 也可以像字符串常量池那样,在使用到时才创建然后放入缓存中,一个简单的思路是使用 WeakHashMap。
使用弱引用是为了让外部没有变量引用对象时,可以让这些对象被 GC 回收掉,而不至于浪费内存。