享元模式的原理和实现都很简单,但是应用场景却相对狭窄,它和现在我们所熟知的缓存模式、池化模式有所联系,却又有不同。看完这篇文章后,相信你会找到这个不同之处。

一、模式原理分析

享元模式的原始定义是:摒弃了在每个对象中保存所有数据的方式,通过共享多个对象所共有的相同状态,从而让我们能在有限的内存容量中载入更多对象。

从这个定义中你可以发现,享元模式要解决的核心问题就是节约内存空间,使用的办法是找出相似对象之间的共有特征,然后复用这些特征

我们先来看看 UML 图是如何表示享元模式的,如下图:

享元模式:如何通过共享对象减少内存加载消耗?_享元模式

从这个 UML 图中,我们能看出享元模式包含的关键角色有四个。

  • 享元类(Flyweight):定义了享元对象需要实现的公共操作方法。在该方法中会使用一个状态作为输入参数,也叫外部状态,由客户端保存,在运行时改变。

  • 享元工厂类(Flyweight Factory):管理一个享元对象类的缓存池。它会存储享元对象之间需要传递的共有状态,比如,按照大写英文字母来作为状态标识,这种只在享元对象之间传递的方式就叫内部状态。同时,它还提供了一个通用方法 getFlyweight(),主要通过内部状态标识来获取享元对象。

  • 可共享的具体享元类(ConcreteFlyweight):能够复用享元工厂内部状态并实现享元类公共操作的具体实现类。

  • 非共享的具体享元类(UnsharedConcreteFlyweight):不复用享元工厂内部状态,但实现享元类的具体实现类。

下面我们再来看看 UML 对应的代码实现:

//享元类
public interface Flyweight {
    void operation(int state);
}
//享元工厂类
public class FlyweighFactory {
    // 定义一个池容器
    public Map<String,Flyweight> pool = new HashMap<>();
    public FlyweighFactory() {
        pool.put("A", new ConcreteFlyweight("A"));//将对应的内部状态添加进去
        pool.put("B", new ConcreteFlyweight("B"));
        pool.put("C", new ConcreteFlyweight("C"));
    }
    //根据内部状态来查找值
    public Flyweight getFlyweight(String key) {
        if (pool.containsKey(key)) {
            System.out.println("===享元池中有,直接复用,key:"+key);
            return pool.get(key);
        } else {
            System.out.println("===享元池中没有,重新创建并复用,key:"+key);
            Flyweight flyweightNew = new ConcreteFlyweight(key);
            pool.put(key,flyweightNew);
            return flyweightNew;
        }
    }
}
//共享的具体享元类
public class ConcreteFlyweight implements Flyweight {
    private String uniqueKey;
    public ConcreteFlyweight(String key) {
        this.uniqueKey = key;
    }
    @Override
    public void operation(int state) {
        System.out.printf("=== 享元内部状态:%s,外部状态:%s%n",uniqueKey,state);
    }
}

//非共享的具体享元类
public class UnsharedConcreteFlyweight implements Flyweight {
    private String uniqueKey;
    public UnsharedConcreteFlyweight(String key) {
        this.uniqueKey = key;
    }
    @Override
    public void operation(int state) {
        System.out.println("=== 使用不共享的对象,内部状态:"+uniqueKey+",外部状态:"+state);
    }
}

输出结果:
====享元池中有,直接复用,key:A
=== 享元内部状态:A,外部状态:9
====享元池中有,直接复用,key:B
=== 享元内部状态:B,外部状态:8
====享元池中没有,重新创建并复用,key:D
=== 享元内部状态:D,外部状态:7
====创建不共享的对象,key:uX
=== 使用不共享的对象,内部状态:uX,外部状态:6

  • 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.
  • 42.
  • 43.
  • 44.
  • 45.
  • 46.
  • 47.
  • 48.
  • 49.
  • 50.
  • 51.
  • 52.
  • 53.
  • 54.
  • 55.
  • 56.
  • 57.
  • 58.
  • 59.
  • 60.

这段代码实现非常简单,不过这里你可能会联想到缓存模式,比如,LRU 缓存模式。但这两者是完全不同的设计意图,它们的本质区别在于:享元模式要解决的问题是节约内存的空间大小,而缓存模式本质上是为了节省时间

回到上面的代码分析中,我们能看出享元模式封装的变化有:

  • 对象内部状态的定义规则,比如,是通过字母共享状态,还是通过固定的数字来共享状态;

  • 具体享元对象所实现的公共操作的逻辑。

所以说,享元模式本质上是通过创建更多的可复用对象的共有特征来尽可能地减少创建重复对象的内存消耗

三、使用场景分析

一般来讲,享元模式常用的使用场景有以下几种。

  • 系统中存在大量重复创建的对象。比如,同一个商品的展示图片、详情介绍、文字介绍等,当自营商家系统调用或第三方商家调用时,商品系统可以使用同一个对象来节省内存空间。

  • 可以使用外部特定的状态来控制使用的对象。比如,使用常用的中文汉字作为读取的标识,读取享元池中共享的多个中文汉字对象。

  • 相关性很高并且可以复用的对象。比如,公司的组织结构人员基本信息、网站的分类信息列表等。

在现实中,享元模式最常使用的场景是在编辑器软件中,比如,在一个文档中多次出现相同的图片,则只需要创建一个图片对象,通过在应用程序中设置该图片出现的位置,就可以实现该图片在不同地方多次重复显示的效果。

四、为什么使用享元模式?

分析完享元模式的原理和使用场景后,我们再来说说使用享元模式的原因,主要有以下两个。

第一个,减少内存消耗,节省服务器成本。 比如,当大量商家的商品图片、固定文字(如商品介绍、商品属性)在不同的网页进行展示时,通常不需要重复创建对象,而是可以使用同一个对象,以避免重复存储而浪费内存空间。由于通过享元模式构建的对象是共享的,所以当程序在运行时不仅不用重复创建,还能减少程序与操作系统的 IO 交互次数,大大提升了读写性能。

第二个,聚合同一类的不可变对象,提高对象复用性。 比如,Java 中的 Number 对象族类(Integet、Long、Double 等)都是使用了享元模式例子,通过缓存不同范围数值来重复使用相同的数值。

五、享元模式的优缺点

通过上面的分析,我们可以得出使用享元模式主要有以下优点。

  • 可以极大减少内存中对象的数量,使得相同对象或相似对象在内存中只保存一份。

  • 通过封装内存特有的运行状态,达到共享对象之间高效复用的目的。

同样,享元模式也不是万能的,它也有一些缺点。

  • 以时间换空间,间接增加了系统的实现复杂度。比如,需要分离出内部状态和外部状态,其中,外部状态这个定义比较模糊,也很难统一,而内部状态除了一些常用的常量容易被找到以外,那些更通用的组件对象在不同的业务系统中其实是不容易被找到的,因为不同的人对相似对象的理解并不一致,这就需要对程序员的代码设计实现思维有一定的要求。

  • 运行时间更长,对于一些需要快速响应的系统并不合适。享元模式的目的是节省空间,而没有说需要提供更短的时间,这适合数据类项目的使用,而不适合一些有高并发要求的系统。

享元模式为共享对象定义了一个很好的结构范例。不过,用好享元模式的关键在于找到不可变对象,比如,常用数字、字符等。

之所以做对象共享而不是对象复制的一个很重要的原因,就在于节省对象占用的内存空间大小。缓存模式和享元模式最大的区别就是:享元模式强调的是空间效率,比如,一个很大的数据模型对象如何尽量少占用内存并提供可复用的能力;而缓存模式强调的是时间效率,比如,缓存秒杀的活动数据和库存数据等,数据可能会占用很多内存和磁盘空间,但是得保证在大促活动开始时要能及时响应用户的购买需求。也就是说,两者本质上解决的问题类型是不同的。

虽然享元模式的应用不如缓存模式多,但是对于超大型数据模式来说,它却是非常有效的优化方法之一。特别是对于现在越来越多的数据系统来说,共享变得更加重要,因为复制虽然时间效率更高,但是空间上可能完全不够。

文章(专栏)将持续更新,欢迎关注公众号:服务端技术精选。欢迎点赞、关注、转发