享元 Flyweight

享元模式是一种结构型设计模式,它摒弃了在每个对象中保存所有数据的方式,通过共享多个对象所共有的相同状态,让你能在有限的内存容量中载入更多对象。

享元模式要解决的问题是节约内存的空间大小。它的本质就是在使用时找到不可变的特征并缓存起来,当类似对象使用时从缓存中读取以达到节省内存空间的目的。


为什么要使用?

享元模式的对象职责:

通过共享多个对象所共有的相同状态,让你能在有限的内存容量中载入更多对象。

使用享元模式可以减少内存消耗,例如,展示商品的一些固定信息时,不需要重复创建对象。

使用享元模式还可以聚合同一类的不可变对象,提高对象的复用性。例如,在 Integer 类中使用到的 IntegerCache 类。


模式结构

享元模式只是一种优化。在应用该模式之前,你要确定程序中存在与大量类似对象同时占用内存相关的内存消耗问题,并且确保该问题无法使用其他更好的方式来解决。

  • 享元(Flyweight)类包含原始对象中部分能在多个对象中共享的状态。同一享元对象可在许多不同情景中使用。享元中存储的状态被称为“内在状态”。传递给享元方法的状态被称为“外在状态”。

  • 情景(Context)类包含原始对象中各不相同的外在状态。情景与享元对象组合在一起就能表示原始对象的全部状态。通常情况下,原始对象的行为会保留在享元类中。因此调用享元方法必须提供部分外在状态作为参数。但你也可将行为移动到情景类中,然后将连入的享元作为单纯的数据对象。

  • 客户端(Client)负责计算或存储享元的外在状态。在客户端看来,享元是一种可在运行时进行配置的模板对象,具体的配置方式为向其方法中传入一些情景数据参数。

  • 享元工厂(Flyweight Factory)会对已有享元的缓存池进行管理。有了工厂后,客户端就无需直接创建享元,它们只需调用工厂并向其传递目标享元的一些内在状态即可。工厂会根据参数在之前已创建的享元中进行查找,如果找到满足条件的享元就将其返回;如果没有找到就根据参数新建享元。

享元模式的类图:

从类图中,我们可以看出,Client 通过 Context 保存对象,将不可变特征(内在状态)通过工厂转换为享元对象 Flyweight ,并注册在工厂内部,需要时通过调用工厂并向其传递目标享元的一些内在状态即可。将可变特征(外在状态)保存在 Context 中,使用时通过享元对象的方法调用。


模式实现

该示例使用享元模式将大字符字母(由 #.\n 组成)变为一个个享元,在大字母字符串类中使用它们。将不同的大字符字母存储起来,在使用时不需要生成重复字母,以达到减少内存占用的目的。

示例程序的类图

代码实现

享元
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
package example;

import java.io.BufferedReader;
import java.io.FileReader;
import java.io.IOException;

/** 享元,共享大体积字符 */
public class BigChar {
/** 大体积字符名 */
private char charName;
/** 大体积字符(由“#”,“.”,“\n”组成) */
private String fontData;

/**
* 根据字符名读取文件,生成大体积字符
* @param charName 字符名
*/
public BigChar(char charName) {
String path = System.getProperty("user.dir") + "\\flyweight\\resource\\";
this.charName = charName;
try {
BufferedReader reader = new BufferedReader(new FileReader(path + "big-" + charName + ".txt"));
String line;
StringBuffer buf = new StringBuffer();
while ((line = reader.readLine()) != null) {
buf.append(line);
buf.append("\n");
}
reader.close();
this.fontData = buf.toString();
} catch (IOException ex) {
ex.printStackTrace();
}

}

/** 打印大体积字符 */
public void print() {
System.out.println(fontData);
}
}
享元工厂
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
package example;

import java.util.HashMap;
import java.util.Map;

/** 享元工厂,生成大体积字符享元 */
public class BigCharFactory {
/** 管理已经生成的BigChar实例 */
private Map pool = new HashMap();
/** 单例工厂 */
private static BigCharFactory singleton = new BigCharFactory();

/** 单例模式 */
private BigCharFactory() { }

/**
* 获取享元工厂实例
* @return 享元工厂实例
*/
public static BigCharFactory getInstance() {
return singleton;
}

/**
* 共享大体积字符BigChar实例
* @param charName BigChar字符名
* @return BigChar实例
*/
public BigChar getBigChar(char charName) {
BigChar bigChar = (BigChar) pool.get("" + charName);
if (bigChar == null) {
bigChar = new BigChar(charName);
pool.put("" + charName, bigChar);
}
return bigChar;
}
}
情景
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
package example;

/** 将字符串中的重复字符变成享元,组合享元字符成字符串 */
public class BigString {
/** 享元数组 */
private BigChar[] bigChars;

/**
* 创建享元工厂,根据传入的字符串来生成大体积字符串。
* 将字符串中的重复字符变成大体积字符享元,组合大体积字符享元成大体积字符串
* @param string
*/
public BigString(String string) {
bigChars = new BigChar[string.length()];
BigCharFactory bigCharFactory = BigCharFactory.getInstance();
for (int i = 0; i < bigChars.length; i++) {
bigChars[i] = bigCharFactory.getBigChar(string.charAt(i));
}
}

/** 调用具体享元的print()方法 */
public void print() {
for (int i = 0; i < bigChars.length; i++) {
bigChars[i].print();
}
}
}

代码测试

1
2
3
4
5
6
7
8
9
import example.BigString;

/** 测试享元模式 */
public class Test {
public static void main(String[] args) {
BigString hellovie = new BigString("hellovie");
hellovie.print();
}
}

输出结果

备注:这里为了展示,手动将字符排成一行。

1
2
3
4
5
6
.##....##..########..##........##.........######...#......#....####....########.
.##....##..##........##........##........##....##..#......#.....##.....##.......
.########..########..##........##........##....##..#......#.....##.....########.
.########..########..##........##........##....##...#....#......##.....########.
.##....##..##........##........##........##....##....#..#.......##.....##.......
.##....##..########..########..########...######......##.......####....########.

打印一个大体积的字符串,每个字符的打印样式都存放在文件里,需要从文件中读取。

如果每打印一个大字符就读取一次文件并写入内存,十分消耗内存。将字符串中不变的 26 个字母抽离成享元共享,就能大大减少内存消耗。

这里的 BigString 类似一个情景(Context)与享元对象 BigChar 组合在一起就能表示一个完整的大体积字符串对象。


常用场景和解决方案

  • 系统中存在大量重复创建的对象。

  • 可以使用外部特定的状态来控制使用的对象。

  • 相关性很高并且可以复用的对象。

  • JDK 1.8Integer 类中的 valueOf(int i) 方法:

    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);
    }

    如果判断当前 i 的值是否在 -128 到 127 之间。如果存在,则直接返回引用;如果不存在,就创建一个新的对象。

  • 仅在程序必须支持大量对象且没有足够的内存容量时使用享元模式。

    • 程序需要生成数量巨大的相似对象;
    • 这将耗尽目标设备的所有内存;
    • 对象中包含可抽取且能在多个对象间共享的重复状态。

模式的优缺点

优点 缺点
如果程序中有很多相似对象,那么你将可以节省大量内存。 你可能需要牺牲执行速度来换取内存,因为他人每次调用享元方法时都需要重新计算部分情景数据。
代码会变得更加复杂。团队中的新成员总是会问:“为什么要像这样拆分一个实体的状态?”。

使用享元模式的优势

  • 可以极大减少内存中对象的数量,使得相同对象或相似对象在内存中只保存一份。
  • 通过封装内存特有的运行状态,达到共享对象之间高效复用的目的。

使用享元模式的劣势

  • 以时间换空间,间接增加了系统的实现复杂度。
  • 运行时间更长,对于一些需要快速响应的系统并不适合。

拓展知识

  • 用好享元模式的关键是找到不可变对象
  • 享元模式强调的是空间效率,缓存模式强调的是时间效率。
  • 对于超大型数据模式,享元模式是非常有效的优化方法之一。
  • 如果你能将对象的所有共享状态简化为一个享元对象,那么享元就和单例模式类似了。但这两个模式有两个根本性的不同。
    • 只会有一个单例实体,但是享元类可以有多个实体,各实体的内在状态也可以不同。
    • 单例对象可以是可变的。享元对象是不可变的。


🔙 设计模式

📌最后:希望本文能够给您提供帮助,文章中有不懂或不正确的地方,请在下方评论区💬留言!

🔗参考文献:

🌐 设计模式 –refactoringguru

▶️ bilibili-趣学设计模式;黄靖锋. –拉勾教育

📖 图解设计模式 /(日)结城浩著;杨文轩译. –北京:人民邮电出版社,2017.1