文章目录
- 1 基本介绍
- 2 案例
- 2.1 Digit 接口
- 2.2 Color 枚举
- 2.3 BigDigit 类
- 2.4 DigitFactory 类
- 2.5 Client 类
- 2.6 Client 类的测试结果
- 2.7 总结
- 3 各角色之间的关系
- 3.1 角色
- 3.1.1 Flyweight ( 抽象享元 )
- 3.1.2 ConcreteFlyweight ( 具体享元 )
- 3.1.3 UnsharedFlyweight ( 非享元 )
- 3.1.4 FlyweightFactory ( 享元工厂 )
- 3.1.5 Client ( 客户端 )
- 3.2 类图
- 4 注意事项
- 5 在源码中的使用
- 6 优缺点
- 7 适用场景
- 8 总结
1 基本介绍
享元模式(Flyweight Pattern)是一种 结构型 设计模式,其核心思想是通过 共享某些对象 来 复用 它们,和单例模式的单例有相似之处——共享实例对象。
此处的翻译并非直译,中文和英文对应的含义如下:
- 对于中文 享元模式,“享元”实际上是“共享实例对象”的意思。
- 对于英文 Flyweight,Flyweight 的中文翻译是 蝇量级,这个概念原本是拳击比赛的一种重量级,用在此处表示这种模式可以使程序变得更“轻”,即 占用的内存更少。
2 案例
本案例实现了 打印不同颜色的大数字(虽然只有 3 种颜色、3 种大数字)。
2.1 Digit 接口
public interface Digit { // 数字接口
void print(Color color); // 按照指定颜色打印对象中所存储的数
}
2.2 Color 枚举
public enum Color { // 颜色的枚举
GREEN("\u001B[32m"), // 绿色
BLUE("\u001B[34m"), // 蓝色
PURPLE("\u001B[35m"); // 紫色
Color(String colorStr) {
this.colorStr = colorStr;
}
private String colorStr; // 保存颜色的字符串
public String getColorStr() {
return colorStr;
}
}
2.3 BigDigit 类
import java.io.PrintStream;
public class BigDigit implements Digit { // 大数字
private String pattern; // 大数字的图像
public BigDigit(int num) {
switch (num) {
case 1:
pattern = DIGIT_1_PATTERN;
break;
case 2:
pattern = DIGIT_2_PATTERN;
break;
case 3:
pattern = DIGIT_3_PATTERN;
break;
default:
pattern = "尚未实现,敬请期待!";
}
}
// 按照指定的颜色打印大数字的图像
@Override
public void print(Color color) {
PrintStream defaultPrintStream = System.out; // 保存默认的打印流
System.setOut(new ColoredDigitPrintStream(color)); // 将打印流更换为指定颜色的打印流
System.out.println(pattern);
System.setOut(defaultPrintStream); // 还原默认的打印流
}
private static final String DIGIT_1_PATTERN = """
................
......##........
..######........
......##........
......##........
......##........
......##........
..##########....
................"""; // 大数字 1 的图像
private static final String DIGIT_2_PATTERN = """
................
....######......
..##......##....
..........##....
......####......
....##..........
..##............
..##########....
................"""; // 大数字 2 的图像
private static final String DIGIT_3_PATTERN = """
................
....######......
..##......##....
..........##....
......####......
..........##....
..##......##....
....######......
................"""; // 大数字 3 的图像
private static class ColoredDigitPrintStream extends PrintStream { // 带有颜色的数字打印流
private Color color; // 打印流的颜色
public ColoredDigitPrintStream(Color color) {
super(System.out);
this.color = color;
}
@Override
public void println(String x) {
super.println(this.color.getColorStr() + x + DEFAULT_COLOR);
}
private static final String DEFAULT_COLOR = "\u001B[0m"; // 默认的字符颜色
}
}
2.4 DigitFactory 类
public class DigitFactory { // 数字的工厂
// 数字池,存储 int 数 与 数字 的映射
private static Map<Integer, Digit> digitPool = new HashMap<>();
/**
* 获取指定 int 数对应的数字
* 注意本方法是 synchronized 的,防止多个线程同时创建多个共享实例,类似于单例模式
*
* @param num 指定的 int 数
* @return 返回指定 int 数对应的数字
*/
public synchronized static Digit getInstance(int num) {
if (!digitPool.containsKey(num)) { // 如果 digitPool 中不存在 num 对应的数字
digitPool.put(num, new BigDigit(num)); // 则创建新的大数字
}
return digitPool.get(num); // 返回 digitPool 中 num 对应的数字
}
}
2.5 Client 类
public class Client { // 客户端,测试大数字的输出
public static void main(String[] args) {
// 可以通过 digit1 打印不同颜色的 1
Digit digit1 = DigitFactory.getInstance(1);
digit1.print(Color.BLUE);
digit1.print(Color.PURPLE);
Digit digit2 = DigitFactory.getInstance(2);
digit2.print(Color.GREEN);
Digit digit3 = DigitFactory.getInstance(3);
digit3.print(Color.PURPLE);
Digit digit3_1 = DigitFactory.getInstance(3);
System.out.println(digit3 == digit3_1); // true,因为 digit3 和 digit3_1 是同一个对象
}
}
2.6 Client 类的测试结果
注意:文章中无法显示大数字的颜色,可以自己在 IDEA 上运行一下,即可看到不同的颜色。
................
......##........
..######........
......##........
......##........
......##........
......##........
..##########....
................
................
......##........
..######........
......##........
......##........
......##........
......##........
..##########....
................
................
....######......
..##......##....
..........##....
......####......
....##..........
..##............
..##########....
................
................
....######......
..##......##....
..........##....
......####......
..........##....
..##......##....
....######......
................
true
2.7 总结
通过使用享元模式,减少了创建大数字对象的机会,从而降低了程序的内存占用;通过 Map
管理生成的实例,避免了多次 new
新对象耗费的时间。可谓是时间和空间上的双赢。
此外,认真观察大数字的两个“属性”:图像 和 颜色,可以发现:
- 图像是固定的,不论任何情况都不会改变,所以图像是 应当共享的信息,将其放到
BigDigit
中。 - 颜色不是固定的,随着
Client
需要的颜色而变化,所以颜色是 不应当共享的信息,将其放到其他类Color
中。
3 各角色之间的关系
3.1 角色
3.1.1 Flyweight ( 抽象享元 )
该角色负责 将 UnsharedFlyweight 角色作为参数,定义 ConcreteFlyweight 角色需要实现的方法。如果系统的功能很简单,则不需要该角色。本案例中,Digit
接口扮演该角色。
3.1.2 ConcreteFlyweight ( 具体享元 )
该角色负责 实现 Flyweight 角色定义的方法,定义可以共享的部分。本案例中,BigDigit
类扮演该角色。
3.1.3 UnsharedFlyweight ( 非享元 )
该角色负责 定义无法共享的部分,作为参数传入 Flyweight 角色定义的方法中。本案例中,Color
枚举扮演该角色。
3.1.4 FlyweightFactory ( 享元工厂 )
该角色负责 生成可共享的 Flyweight 角色,内部有一个 池 的字段来保存已生成的 Flyweight 实例,当传入相同的参数时,返回这些已有的实例。要注意 getInstance()
方法需要被 synchronized
关键字修饰,以保证线程安全。本案例中,DigitFactory
类扮演该角色。
3.1.5 Client ( 客户端 )
该角色负责 使用 FlyweightFactory 角色来生成 Flyweight 角色。本案例中,Client
类扮演该角色。
3.2 类图
说明:FlyweightFactory 角色的 pool
字段 和 getInstance()
方法都是 静态 的,使用了 简单(静态)工厂模式。
4 注意事项
- 划分外部状态和内部状态:正确划分 内部状态 和 外部状态 是享元模式应用的关键。
- 内部状态:指对象中 可以共享的信息,这部分信息 存储在享元对象内部,不会随环境的改变而改变。
- 外部状态:指对象中 不可共享的信息,这部分信息 在享元对象外部用新的对象来存储,随环境改变而改变,在方法调用时 以参数形式传入。
- 生成共享实例的工厂:享元模式中的类通常需要一个 工厂 来 控制对象的 创建 和 管理。当客户对象请求一个享元对象时,享元工厂会 先检查系统中是否存在符合要求的享元对象,如果存在则提供给客户;如果不存在,则创建一个新的享元对象。
- 线程安全问题:由于享元模式中的对象可能被多个线程下的客户端 共享,因此必须确保这些对象在并发环境下是线程安全的。这可能需要使用 同步机制 来 保护共享对象的内部状态。
- 性能考虑:虽然享元模式可以显著减少对象的创建和内存使用,但 读取外部状态 可能 会使运行时间稍微变长。在性能敏感的应用中,需要仔细评估这一点。
5 在源码中的使用
在 JDK 中的 Integer
类就使用了享元模式的思想,(如果不设置 VM 参数,则)为常用的 int
数(范围为 [-128, 127]
)提前初始化其对应的 Integer
对象。
在调用 Integer.valueOf()
时,如果传入的参数在 [-128, 127]
之内,则返回提前初始化的 Integer
对象;否则就为传入的参数初始化一个新的 Integer
对象。
Integer
类的部分代码如下所示:
// java.lang.Integer 类
public final class Integer extends ... implements ... {
public static Integer valueOf(int i) { // 相当于 享元工厂 的 getInstance()
// 如果 i 在范围 [low, high] 内,则直接返回提前初始化好的 Integer 对象
if (i >= IntegerCache.low && i <= IntegerCache.high)
return IntegerCache.cache[i + (-IntegerCache.low)];
return new Integer(i);
}
private static class IntegerCache { // Integer 的缓存
static final int low = -128; // 最小的 int 数
static final int high; // 最大的 int 数,可能由 VM 参数指定
static final Integer[] cache; // 保持不变的常量池
static Integer[] archivedCache; // archive 中的缓存
static {
// high 的值可能被 VM 参数所指定
int h = 127;
String integerCacheHighPropValue =
VM.getSavedProperty("java.lang.Integer.IntegerCache.high");
if (integerCacheHighPropValue != null) {
try {
h = Math.max(parseInt(integerCacheHighPropValue), 127);
// 缓存数组的最大长度是 Integer.MAX_VALUE
h = Math.min(h, Integer.MAX_VALUE - (-low) -1);
} catch( NumberFormatException nfe) {
// 如果这个属性不能被解析成一个 int 数,则忽略这个异常
}
}
high = h;
// 如果可能的话,从 archive 中加载 IntegerCache.archivedCache
CDS.initializeFromArchive(IntegerCache.class); // 此方法是 native 方法
int size = (high - low) + 1;
// 如果 archive 缓存 存在 且 足够大,则使用它;否则构建新的缓存
if (archivedCache == null || size > archivedCache.length) {
// 将范围 [low, high] 内的 Integer 对象提前放入常量池
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;
// 确保范围 [-128, 127] 内的 Integer 对象提前将其放入常量池
assert IntegerCache.high >= 127;
}
private IntegerCache() {}
}
}
说明:
Integer
类中 写一个静态内部类来创建共享的对象实例,这样可以避免给valueOf()
方法添加synchronized
修饰,因为 类的加载是线程安全 的,所以不可能生成两个相同值的Integer
共享实例。这种思想可以类比到 单例模式 的 静态内部类实现 中。- 在
Integer
类中:valueOf()
方法相当于 FlyweightFactory 角色的getInstance()
方法。IntegerCache.cache
相当于 FlyweightFactory 角色的pool
字段,用于存储已创建的共享实例。
Integer
类并没有完全使用享元模式,它只是提前初始化了部分高频使用的Integer
对象,将其共享,从而减少valueOf()
方法创建过多的对象,引发 内存溢出 问题。此外,这么做还可以减少构建共享对象所花费的时间,使Integer
类更加高效。
6 优缺点
优点:
- 减少内存消耗:享元模式通过 共享对象的内部状态 来 减少内存占用,从而避免为每个相似的对象都创建新的实例。
- 提高性能:由于 减少了对象的创建和销毁次数,享元模式可以 提高系统的性能。特别是在 频繁创建和销毁对象 的场景中,享元模式能够显著减少这些操作带来的开销。
- 降低系统复杂性:享元模式将对象的状态分为 内部状态 和 外部状态,使得系统更容易理解和维护。内部状态 由 享元对象 管理,外部状态 由 客户端 管理,这种分离 降低了系统的复杂性,并有效地 降低了对象间的耦合度。
缺点:
- 增加编程复杂性:实现享元模式需要将对象的状态分为 内部状态 和 外部状态,这要求开发者对系统的 状态管理 有深入的理解。同时,区分内部状态和外部状态可能会使代码逻辑变得复杂,增加编程的难度。
- 可能引入线程安全问题:如果多个线程同时访问和修改共享对象,可能会导致线程安全问题。因此,在使用享元模式时,需要特别注意线程安全的处理。
- 可能增加运行时开销:由于享元对象的 外部状态 是通过参数传递给享元对象的,这可能会增加运行时的开销。特别是在外部状态较多或频繁变化的情况下,这种开销可能会更加明显。
7 适用场景
- 相似对象的大量使用:当系统中存在大量相似对象,即这些对象只有少部分数据不同时,使用享元模式可以显著减少对象的数量,从而降低内存消耗。
- 对象具有许多共同属性:当对象具有许多 共同属性,而这些属性可以 被所有实例共享 时,使用享元模式可以避免在每个实例中重复存储这些属性,从而节省内存。
- 频繁创建和销毁对象:如果系统中频繁地创建和销毁大量对象,这会导致大量的内存分配和回收操作,从而降低系统的性能。使用享元模式可以减少对象的创建和销毁次数,从而提高性能。
- 需要快速响应时间的系统:在一些需要 快速响应时间 的系统中,如实时游戏、实时交易系统等,减少对象的创建和销毁时间 是非常重要的。享元模式可以通过减少对象的数量来降低这些操作的开销,从而提高系统的响应时间。
8 总结
享元模式 是一种 结构型 设计模式,其核心思想是通过 共享某些对象 来 复用 它们,和单例模式的单例有相似之处,可以减少 内存 和 时间 的消耗。
然而,在使用这种模式时不能随心所欲,共有三点需要特别考虑:
- 先明确对象的 外部状态 和 内部状态,然后将 内部状态 放到享元类中,使用享元工厂生产共享实例;将 外部状态 放到另外的类中,在调用共享实例的方法时将其传入。
- 由于享元模式可能应用到多线程的环境中,所以在工厂生成共享实例时需要 保证线程安全。
- 对于一个共享实例的多个引用,如果使用其中一个引用修改这个共享实例,那么会导致所有引用指向的实例都被修改,这也是共享的坏处之一,应该尽可能保证共享实例不会被修改。