所谓“享元”,顾名思义就是被共享的单元。它是一个不怎么常用的设计模式,享元模式的意图是复用对象,节省内存,前提是享元对象是不可变对象。
当一个系统中存在大量重复对象时,如果这些重复的对象是不可变对象,就可以利用享元模式将对象设计成享元,在内存中只保留一份实例,供多处代码引用。这样可以减少内存中对象的数量,起到节省内存的目的。实际上,不仅仅相同对象可以设计成享元,对于相似对象,我们也可以将这些对象中相同的部分(字段)提取出来,设计成享元,让这些大量相似对象引用这些享元。
这里的“不可变对象”指的是,一旦通过构造函数初始化完成,它的状态(对象的成员变量或者属性)就不会再被修改了。所以,不可变对象不能暴露任何set等修改内部状态的方法。之所以要求享元是不可变对象,是因为它会被多处代码共享使用,避免一处代码对享元进行了修改,影响到其他使用它的代码。
接下来,我们通过一个简单的例子解释一下享元模式。
假设开发一个棋牌游戏(比如象棋)。一个游戏厅中有成千上万个“房间”,每个房间对应一个棋局。棋局要保存每个棋子的数据,比如:棋子类型(将、相、士、炮等)、棋子颜色(红方、黑方)、棋子在棋局中的位置。利用这些数据,我们就能显示一个完整的棋盘给玩家。其中,ChessPiece 类表示棋子,ChessBoard 类表示一个棋局,里面保存了象棋中 30 个棋子的信息。
棋子类
/**
* 类描述:棋子类(享元设计模式-享元类)
*
* @Author crysw
* @Version 1.0
* @Date 2023/12/24 17:02
*/
@Data
public class ChessUnit {
private Long id;
private String text;
private Color color;
public ChessUnit(Long id, String text, Color color) {
this.id = id;
this.text = text;
this.color = color;
}
enum Color {
RED, BLACK
}
}
棋子的享元工厂类,缓存已经创建好的棋子对象,提供了用来获取棋子的方法。
/**
* 类描述:棋子工厂类 (享元工厂)
*
* @Author crysw
* @Version 1.0
* @Date 2023/12/24 17:04
*/
public class ChessUnitFactory {
private static Map<Long, ChessUnit> chessUnitMap = new HashMap<>(64);
static {
chessUnitMap.put(1L, new ChessUnit(1L, "兵", ChessUnit.Color.RED));
chessUnitMap.put(2L, new ChessUnit(2L, "马", ChessUnit.Color.RED));
chessUnitMap.put(3L, new ChessUnit(3L, "炮", ChessUnit.Color.RED));
chessUnitMap.put(4L, new ChessUnit(4L, "将", ChessUnit.Color.RED));
}
/**
* 工厂方法,用来获取棋子
*
* @param id
* @return
*/
public static ChessUnit getChessUnit(Long id) {
return chessUnitMap.get(id);
}
}
描述棋子(包含位置)的单元类
/**
* 类描述:棋子(坐标)类
*
* @Author crysw
* @Version 1.0
* @Date 2023/12/24 17:09
*/
@AllArgsConstructor
@NoArgsConstructor
@Data
public class ChessPiece {
/**
* 棋子
*/
private ChessUnit chessUnit;
/**
* 坐标 x,y
*/
private Position position;
}
/**
* 类描述:棋子坐标类
*
* @Author crysw
* @Version 1.0
* @Date 2023/12/24 17:12
*/
@NoArgsConstructor
@AllArgsConstructor
@Data
@EqualsAndHashCode
public class Position {
private int positionX;
private int positionY;
}
棋盘类,布局各个棋子
@Slf4j
public class ChessBoard {
// 应该持有一套棋子(有具体的坐标)
private Map<Position, ChessPiece> chessPieceMap;
// 初始化棋盘
public ChessBoard() {
// 构造棋牌
this.chessPieceMap = new HashMap<>(64);
Position position1 = new Position(0, 1);
Position position2 = new Position(0, 2);
Position position3 = new Position(0, 3);
Position position4 = new Position(0, 4);
this.chessPieceMap.put(position1, new ChessPiece(ChessUnitFactory.getChessUnit(1L), position1));
this.chessPieceMap.put(position2, new ChessPiece(ChessUnitFactory.getChessUnit(2L), position2));
this.chessPieceMap.put(position3, new ChessPiece(ChessUnitFactory.getChessUnit(3L), position3));
this.chessPieceMap.put(position4, new ChessPiece(ChessUnitFactory.getChessUnit(4L), position4));
}
public void display() {
for (Map.Entry<Position, ChessPiece> entry : chessPieceMap.entrySet()) {
log.info("{}-->{}", entry.getKey(), entry.getValue());
}
}
}
测试用例
/**
* 类描述:享元设计模式测试案例
*
* @Author crysw
* @Version 1.0
* @Date 2023/12/24 17:20
*/
public class FlyweightPatternTest {
@Test
public void test() {
ChessBoard chessBoard = new ChessBoard();
chessBoard.display();
}
}
查看棋盘
[ChessBoard - Position(positionX=0, positionY=1)-->ChessPiece(chessUnit=ChessUnit(id=1, text=兵, color=RED), position=Position(positionX=0, positionY=1))
[ChessBoard - Position(positionX=0, positionY=2)-->ChessPiece(chessUnit=ChessUnit(id=2, text=马, color=RED), position=Position(positionX=0, positionY=2))
[ChessBoard - Position(positionX=0, positionY=3)-->ChessPiece(chessUnit=ChessUnit(id=3, text=炮, color=RED), position=Position(positionX=0, positionY=3))
[ChessBoard - Position(positionX=0, positionY=4)-->ChessPiece(chessUnit=ChessUnit(id=4, text=将, color=RED), position=Position(positionX=0, positionY=4))
先看下面的代码:
Integer i1 = 56;
Integer i2 = 56;
Integer i3 = 129;
Integer i4 = 129;
System.out.println(i1 == i2);
System.out.println(i3 == i4);
通过“==”来判定两个对象是否相等的时候,实际上是在判断两个局部变量存储的地址是否相同,换句话说,是在判断两个局部变量是否指向相同的对象。但是Integer具有自动拆装箱的功能,翻译后的代码:
//自动装箱 int n = i1就是自动拆箱了 >> int n = i1.intValue();
Integer i1 = Integer.valueOf(56);
Integer i2 = Integer.valueOf(56);
Integer i3 = Integer.valueOf(129);
Integer i4 = Integer.valueOf(129);
System.out.println(i1 == i2);
System.out.println(i3 == i4);
前 4 行赋值语句都会触发自动装箱操作,也就是会创建 Integer 对象并且赋值给 i1、i2、i3、i4 这四个变量。根据**==
判断对象引用地址是否相等的特性,i1、i2 尽管存储的数值相同,都是 56,但是指向不同的 Integer 对象,所以通过“**”来判定是否相同的时候,应该会被认为返回 false。同理,i3i4 判定语句也会返回 false。
但实际上,上面的结果是true, false. 这是为什么呢?
因为 Integer 用到了享元模式来复用对象,才导致了这样的运行结果。当我们通过自动装箱,也就是调用 valueOf() 来创建 Integer 对象的时候,如果要创建的 Integer 对象的值在 -128 到 127 之间,会从 IntegerCache 类中直接返回缓存中的对象,范围外的才调用 new 方法创建新的对象。
56在 -128 到 127 范围内,所以比较的是同一个对象引用,比较结果就是true。 129不在这个范围内,就是每次new的新对象,比较两个不同引用指向的不同对象,比较结果就是false。
public static Integer valueOf(int i) {
if (i >= IntegerCache.low && i <= IntegerCache.high)
return IntegerCache.cache[i + (-IntegerCache.low)];
return new Integer(i);
}
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 相当于生成享元对象的工厂类,只不过名字不叫 xxxFactory 而已。它是Integer的静态内部类,当这个类被加载的时候,缓存的享元对象会被一次性创建好。毕竟整型值太多了,我们不可能在 IntegerCache 类中预先创建好所有的整型值,这样既占用太多内存,也使得加载 IntegerCache 类的时间过长。只选择缓存最常用的整型值,也就是一个字节的大小(-128 到 127 之间的数据)。
除了 Integer 类型之外,其他包装类型,比如 Long、Short、Byte 等,也都利用了享元模式来缓存 -128 到 127 之间的数据。
比如,Long 类型对应的LongCache 享元工厂类及 valueOf() 函数代码如下所示:
private static class LongCache {
private LongCache(){}
static final Long cache[] = new Long[-(-128) + 127 + 1];
static {
for(int i = 0; i < cache.length; i++)
cache[i] = new Long(i - 128);
}
}
public static Long valueOf(long l) {
final int offset = 128;
if (l >= -128 && l <= 127) { // will cache
return LongCache.cache[(int)l + offset];
}
return new Long(l);
}
在我们平时的开发中,对于下面这样三种创建整型对象的方式,我们优先使用后两种。
第一种创建方式并不会使用到 IntegerCache,而后面两种创建方法可以利用IntegerCache 缓存,返回共享的对象,以达到节省内存的目的。举一个极端一点的例子,假设程序需要创建 1 万个 -128 到 127 之间的 Integer 对象。使用第一种创建方式,我们需要分配 1 万个 Integer 对象的内存空间;使用后两种创建方式,我们最多只需要分配 256 个 Integer 对象的内存空间。
Integer a = new Integer(123);
Integer a = 123;
Integer a = Integer.valueOf(123);
先来看一段代码,输出的结果会是什么呢?
String s1 = "crysw";
String s2 = "crysw";
String s3 = new String("crysw");
System.out.println(s1 == s2); // true
System.out.println(s1 == s3); // false
运行结果可以看出,String与Integer 类的设计思路相似,String 类利用享元模式来复用相同的字符串常量,JVM会专门开辟一块存储区来存储字符串常量,这块存储区叫作“字符串常量池”。
不过,String 类的享元模式的设计,跟 Integer 类稍微有些不同。Integer 类中要共享的对象,是在类加载的时候,就集中一次性创建好。而对于字符串String来说,我们没法事先知道要共享哪些字符串常量,所以没办法事先创建好,只能在某个字符串常量第一次被用到的时候创建好并存储到常量池中,当之后再用到的时候,直接引用常量池中已经存在的即可,就不需要再重新创建了。
“共享”、“缓存”、“复用”这些字眼,跟单例、缓存、对象池这些概念有什么区别呢?
在单例模式中,一个类只能创建一个对象;
而在享元模式中,一个类可以创建多个对象,每个对象被多处代码引用共享。享元模式有点类似单例的变体:多例
。
区别两种设计模式,不能只看代码实现,还要看设计意图,和要解决的问题。尽管从代码实现来看,享元模式和多例有很多相似之处,但从设计意图看,它们是完全不同的。享元模式是为了对象复用,节省内存,而应用多例模式是为了限制对象的个数。
享元模式中,通过享元工厂类来“缓存”已经创建好的对象。这里的“缓存”实际上是“存储”的意思,跟我们平时所说的“数据库缓存
”、“CPU 缓存
”、“MemCache 缓存
”是两回事。
而我们平时所讲的缓存,主要是为了提高查询效率,而非复用。
对象池、连接池(比如数据库连接池)、线程池等也是为了复用,那它们跟享元模式有什么区别呢?
虽然对象池、连接池、线程池、享元模式都是为了复用,但是对象池、连接池、线程池等池化技术中的**“复用”**和享元模式中的“复用”
实际上是不同的概念。
池化技术中的“复用”可以理解为“重复使用”,主要目的是节省时间(比如从数据库池中取一个连接,不需要重新创建)。在任意时刻,每一个对象、连接、线程并不会被多处使用,而是被一个使用者独占,当使用完后放回到池中,再由其他使用者重复利用。
而享元模式中的“复用”可以理解为“共享使用”,在整个生命周期中,都是被所有使用者共享的,主要目的是节省空间。