十二、享元模式


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 类图

alt text
说明: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 总结

享元模式 是一种 结构型 设计模式,其核心思想是通过 共享某些对象复用 它们,和单例模式的单例有相似之处,可以减少 内存 和 时间 的消耗。

然而,在使用这种模式时不能随心所欲,共有三点需要特别考虑:

  1. 先明确对象的 外部状态内部状态,然后将 内部状态 放到享元类中,使用享元工厂生产共享实例;将 外部状态 放到另外的类中,在调用共享实例的方法时将其传入。
  2. 由于享元模式可能应用到多线程的环境中,所以在工厂生成共享实例时需要 保证线程安全
  3. 对于一个共享实例的多个引用,如果使用其中一个引用修改这个共享实例,那么会导致所有引用指向的实例都被修改,这也是共享的坏处之一,应该尽可能保证共享实例不会被修改。
  • 12
    点赞
  • 14
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值