参考源
享元模式(Flyweight Pattern)属于结构型模式
概述
享元,英文名称为 Flyweigh,轻量级的意思。它通过与其他类似对象共享数据来减小内存占用,也就是它名字的来由:享-分享。
大家都知道围棋有黑白子,用程序定义一局围棋时,如果给每颗黑子和每颗白子都定义一个对象,那一局围棋会产生大量的对象,这样有必要吗?每颗黑子都是类似的,每颗白子也是类似的,完全可以只定义一颗黑子对象和一颗白子对象,其余的棋子都复用这两个对象,这样不仅节省空间,编写也会简单很多,这就是享元模式的思想。
代码实现
这里以下围棋为例介绍享元模式:
1、定义棋子(抽象享元角色)
/**
* 棋子
*/
public interface Piece {
/**
* 落子
*/
public void fall();
}
2、定义具体棋子(具体享元角色)
/**
* 具体棋子
*/
public class PieceImpl implements Piece{
/**
* 棋子
*/
private String piece;
/**
* 构造棋子
* @param color 棋子颜色
*/
public PieceImpl(String color) {
this.piece = color;
}
@Override
public void fall() {
System.out.println(this.piece);
}
}
3、定义棋子工厂(享元工厂)
/**
* 棋子工厂
*/
public enum PieceFactory {
/**
* 这里将前面介绍的单例模式应用起来<br>
* 单例模式的最佳实现是使用枚举类型。<br>
* 只需要编写一个包含单个元素的枚举类型即可<br>
* 简洁,且无偿提供序列化,并由 JVM 从根本上提供线程安全的保障,绝对防止多次实例化,且能够抵御反射和序列化的攻击。
*/
INSTANCE;
/**
* 棋盒
*/
private Map<String,Piece> pieceBox = new HashMap<>();
/**
* 获取棋子
* @param color 棋子颜色
* @return 棋子
*/
public Piece getPiece(String color) {
// 先从棋盒获取棋子
Piece piece = this.pieceBox.get(color);
// 如果棋盒里没有棋子
if (piece == null) {
// 创建一颗棋子
piece = new PieceImpl(color);
// 放入棋盒
this.pieceBox.put(color, piece);
}
// 得到棋子
return piece;
}
}
4、调用
// 获取棋子1
Piece piece1 = PieceFactory.INSTANCE.getPiece("黑子");
// 获取棋子2
Piece piece2 = PieceFactory.INSTANCE.getPiece("黑子");
// 获取棋子3
Piece piece3 = PieceFactory.INSTANCE.getPiece("白子");
// 落子
piece1.fall();
piece2.fall();
piece3.fall();
// 比较两颗黑子是否为同一对象
System.out.println(piece1 == piece2);
// 比较两颗白子是否为同一对象
System.out.println(piece1 == piece3);
输出结果为:
黑子
黑子
白子
true
false
可以发现,两颗黑子为同一对象,黑子与白子为不同对象,这样不管后面定义多少黑子与白子,都可以公用现存的两个对象,极大程度的节省了内存,这就是享元模式的妙处。
与单例模式区别
可以发现,享元工厂的实现方式与单例模式很相似,都在强调复用对象。但它们还是有本质区别的:
-
享元是对象级别的:在多个使用到这个对象的地方都只需要使用这一个对象即可满足要求。
-
单例是类级别的:这个类必须只能实例化出一个对象。
可以这么说:单例是享元的一种特例。单例可以看做是享元的实现方式中的一种,只不过比享元更加严格的控制了对象的唯一性。
线程安全问题
前面的例子都是使用 HashMap 作为对象池,在多线程场景下是不安全的:
for (int i = 0; i < 10; i++) {
new Thread(() -> {
Piece piece1 = PieceFactory.INSTANCE.getPiece("黑子");
Piece piece2 = PieceFactory.INSTANCE.getPiece("黑子");
System.out.println(piece1 == piece2);
}).start();
}
输出结果为:
false
true
true
true
false
false
true
true
true
true
可以看到,里面部分实例对象是不相等的,没有实现享元。
因此必须给工厂里的 getxxx 方法加锁,直接使用 synchronized 关键字即可:
public synchronized Piece getPiece(String color) {
}
这样输出的都是同一个对象了:
true
true
true
true
true
true
true
true
true
true
优缺点
优点
大幅度地降低内存中对象的数量,对象数量越多,越能体现得明显。
缺点
享元模式使得系统更加复杂。为了使对象可以共享,需要将一些状态外部化,这使得程序的逻辑复杂化。
享元模式将享元对象的状态外部化,而读取外部状态使得运行时间稍微变长。
享元模式需要维护一个记录了系统已有的所有享元的哈希表,也称之为对象池,这也需要耗费一定的资源。
使用场景
结合上述的优缺点,不难推出享元模式的使用场景:足够多的对象可共享时才值得使用享元模式。
String
比如 Java 中的字符串 String,组成字符串的字符 char 常常有大量重复的,如果给每个字符都创建对象的话对内存是灾难级的!String 就是享元模式的典型实现。
代码示例:
String a = "a";
String a1 = "a";
String b = "b";
String ab = "ab";
String ab1 = "a" + "b";
System.out.println(a == a1);
System.out.println(a == b);
System.out.println(ab == ab1);
输出结果为:
true
false
true
可以发现,同样字符的 String 指向同一个对象,不用字符的 String 指向不同的对象,字符拼接后相等字符的 String 也指向相同的对象。
String 在 Java 中太常用了,如果它的分配和其他的对象分配一样,将会耗费高昂的时间与空间代价,JVM 为了提高性能和减少内存开销,在实例化字符串常量的时候进行了一些优化。
具体做法是 String 类维护了一个字符串池,每当代码创建字符串常量时,JVM 会首先检查字符串常量池,如果字符串已经在池中,就返回池中的实例的引用;如果字符串不在池中,就会实例化一个字符串并放到池中,Java 能够进行这样的优化是因为字符串是不可变的,可以不用担心数据冲突。
Integer.valueOf
示例1:
Integer a = 1;
Integer b = 1;
System.out.print(a == b); // true
示例2:
Integer a = 200;
Integer b = 200;
System.out.println(a == b); // false
很奇怪,为什么赋值为 1 的 Integer 对象相等,而赋值为 200 的 Integer 对象不相等。
反编译上述程序,得到如下结果:
public static main([Ljava/lang/String;)V
L0
LINENUMBER 19 L0
SIPUSH 200
INVOKESTATIC java/lang/Integer.valueOf (I)Ljava/lang/Integer; // 使用了自动装箱
ASTORE 1
L1
LINENUMBER 20 L1
SIPUSH 200
INVOKESTATIC java/lang/Integer.valueOf (I)Ljava/lang/Integer; // 使用了自动装箱
ASTORE 2
可以发现每次赋值都使用了 Integer.valueOf
自动装箱,再看该方法源码:
public static Integer valueOf(int i) {
if (i >= IntegerCache.low && i <= IntegerCache.high)
return IntegerCache.cache[i + (-IntegerCache.low)];
return new Integer(i);
}
可以发现,当使用 Integer 的自动装箱时,i 值在 cache 的 low 和 high 之间时,也就是 -128 到 +127 之间,会用缓存保存起来,供客户端多次使用,以节约内存;如果不在这个范围内,则创建一个新的 Integer 对象,这也是对享元模式的应用。