【浅谈设计模式】(12):享元模式--内存节省大法

642 阅读9分钟

前言

好久没写关于设计模式的文章了,距离上次发文还是去年那片疫情的数据统计,而今一年后疫情也放开了,统计也慢慢成为不那么重要了,感叹万分,时间在变,学习还得继续。✈

今天来学习结构型模式的最后一种--享元模式。

一、入门

运用共享技术来有效地支持大量细粒度对象的复用。它通过共享已经存在的对象来大幅度减少需要创建的对象数量、避免大量相似对象的开销,从而提高系统资源的利用率。

1.1 概述

享元模式(英语:Flyweight Pattern)是一种软件设计模式。它使用共享物件,用来尽可能减少内存使用量以及分享资讯给尽可能多的相似物件;它适合用于只是因重复而导致使用无法令人接受的大量内存的大量物件。通常物件中的部分状态是可以分享。常见做法是把它们放在外部数据结构,当需要使用时再将它们传递给享元。

要理解享元设计模式,就需要关注一个点==> 内存

合适的场景下使用享元模式,能够帮我们在内存方面节省大量空间。

那它是怎么来实现节省内存的?

类比 线程池、数据库的连接池的使用,它们通过共享线程连接的方式来让客户端连接和应用程序运行的更加方便,避免了连接线程的创建和销毁的开销。

而享元模式的原理也是如此,它运用共享技术,支持大量细粒度对象的复用。通过共享已经存在的对象来大幅度减少需要创建的对象数量,避免大量相似对象的创建和销毁的开销,提高系统资源的利用率。

那其本质?

  • 缓存共享对象,降低内存消耗!

那么这个对象具备什么特点呢?如果所有对象都一样,那直接从单例池中获取即可,又快对吧。

但如果这些对象非常相似,无法从单例池中获取,这个时候就可以用享元模式,它能够共享多个相同对象或相同属性,从而达到节省内存的目的。

重点:它将对象分为内部状态和外部状态。

  • 内部状态 :即不会随着环境的改变而改变的可共享部分(稳定的属性)。
  • 外部状态:指随环境改变而改变的不可以共享的部分(不稳定因素)。

享元模式的实现要领就是区分应用中的这两种状态,共享不变的内容,并将外部状态外部化。

由此得到两个结论:

  • 享元对象的外部状态必须由客户端保存,并在享元对象被创建之后,在需要使用的时候再传入到享元对象内部。
  • 外部状态不可以影响享元对象的内部状态,它们是相互独立的

1.2 结构

享元模式的主要有以下角色:

  • 抽象享元角色(Flyweight):通常是一个接口或抽象类,在抽象享元类中声明了具体享元类公共的方法,这些方法可以向外界提供享元对象的内部数据(内部状态),同时也可以通过这些方法来设置外部数据(外部状态)。
  • 具体享元(Concrete Flyweight)角色 :它实现了抽象享元类,称为享元对象;在具体享元类中为内部状态提供了存储空间。通常我们可以结合单例模式来设计具体享元类,为每一个具体享元类提供唯一的享元对象。
  • 享元工厂(Flyweight Factory)角色 :负责创建和管理享元角色。当客户对象请求一个享元对象时,享元工厂检査系统中是否存在符合要求的享元对象,如果存在则提供给客户;如果不存在的话,则创建一个新的享元对象。

1.3 使用注意

1.3.1 使用时机

1、系统需要大量相似对象

2、创建这些对象需要花费大量资源

3、状态可分为内在状态和外在状态,可以根据内在状态分为各种组。

4、需要缓冲池的场景

1.3.2 优缺点

优点

  1. 减少对象的创建、降低内存中对象的数量,降低系统的内存,提高效率。
  2. 减少内存之外的其他资源占用。

缺点

  1. 关注内外部状态,关注线程安全问题。
  2. 使系统程序的逻辑复杂化

二、案例讲解

小时候玩过一款游戏,叫帝国时代。

游戏的兵种大概分为 农民、刀斧手、弓箭手、骑兵等。

而游戏进行时,就从三个农民开始发展,直到各种兵种的出现,并强大自己的帝国。

若游戏发展到后期,可以预想会创建出多少个对象,这时候占用的内存就非常庞大,而这里我们就可以考虑对象的复用,使用共享模式,将人作为抽象类,然后农民、骑兵等作为具体类,通过共享模式来创建。

  • 内部状态假设有攻击力、移动速度;
  • 外部状态有英雄名称,移动的位置(x , y) 坐标

2.1 抽象享元角色

抽象享元角色通常由抽象类或接口实现,定义出内部的行为和属性

public abstract class People {
    // 攻击力
    private int aggressivity;
    // 移动速度
    private int speed;

    private String name;
    
    public People(int aggressivity,int speed,String name){
        this.aggressivity = aggressivity;
        this.speed =speed;
        this.name = name;
    }
    // 移动角色位置到 x,y 坐标
    public abstract void movePeople(int x,int y);

    // getter && setter
}

2.2 具体享元角色

具体享元角色,它实现了抽象角色的定义,该角色定义了两个内部状态 攻击力和速度,名称也是内部状态,一旦一个英雄角色被定义出来,同种英雄单位就可以复用了。

而各个英雄单位的差别就是位置的不同,当移动英雄后,英雄的名称、攻击力、速度都是固定的,但其在地图上的坐标是变化的,所以英雄单位的外部状态就是坐标位置。

举个例子:地图上有超大位置可以存放英雄单位,每盘游戏可以产生几百个英雄对象,但我们为了节省内存,即可将每个英雄单位分别创建一个实例,然后各个位置上的同种单位都引用一个实例即可,达到节省内存的目的。

  • 农民具体角色
public class Farmer extends People{
    // 攻击力
    private static int aggressivity =100;
    // 速度
    private static int speed =50;
    public Farmer(String name) {
        super(aggressivity, speed, name);
    }
    @Override
    public void movePeople(int x, int y) {
        System.out.println("当前角色:"+super.getName()+"攻击力为:"+super.getAggressivity()+";移动速度为:"+super.getSpeed()+";棋子移动位置"+x+"==》"+y);
    }
}
  • 骑兵具体角色
public class Cavalry extends People{
    private static int aggressivity =500;
    private static int speed =100;
    public Cavalry(String name) {
        super(aggressivity, speed, name);
    }
    @Override
    public void movePeople(int x, int y) {
        System.out.println("当前角色:"+super.getName()+"攻击力为:"+super.getAggressivity()+";移动速度为:"+super.getSpeed()+";棋子移动位置"+x+"==》"+y);
    }
}

2.3 享元工厂角色

这里提供的是一个享元工厂,通过 map 结构存放已经获取到的内存对象放入内存中,下次可以直接从map 中获取,这种结构在开发中是比较常见的,若是分布式环境可以放入 redis 中。

享元工厂==> 即管理一个对象类的缓冲池

以下工厂根据需要的条件,选择对应的享元角色的创建。

public class EmpireFactory {

    private static Map<String,People> pool = new ConcurrentHashMap<>();

    public static People getPeople(String name){
        if(pool.containsKey(name)){
            System.out.println("==复用英雄对象,名称为:"+name);
            return pool.get(name);
        }else{
            People people = null;
            switch (name){
               case "农民":
                   people = new Farmer(name);
                   break;
               case "骑兵":
                   people = new Cavalry(name);
                   break;
               default:
                   break;
           }
           pool.put(name,people);
           System.out.println("创建英雄对象,名称为:"+name);
           return people;
        }
    }
}

2.4 客户端测试

public class EmpireClient {
    public static void main(String[] args) {
        People farmer1 = EmpireFactory.getPeople("农民");
        People farmer2 = EmpireFactory.getPeople("农民");
        People cavalry = EmpireFactory.getPeople("骑兵");
        People cavalry2 = EmpireFactory.getPeople("骑兵");
        farmer1.movePeople(1,10);
        farmer2.movePeople(200,20);
        System.out.println(farmer1==farmer2);
        cavalry.movePeople(100,50);
        cavalry2.movePeople(200,10);
        System.out.println("骑兵1对象:"+cavalry);
        System.out.println("骑兵2对象:"+cavalry2);
    }
}

输出结果:

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

三、源码中的应用

享元模式在底层应用的较多,例如 JDK 源码中就有多处使用。

3.1 Integer 应用

public class Demo {
    public static void main(String[] args) {
        Integer i1 = 127;
        Integer i2 = 127;

        System.out.println("i1和i2对象是否是同一个对象?" + (i1 == i2));// true

        Integer i3 = 128;
        Integer i4 = 128;

        System.out.println("i3和i4对象是否是同一个对象?" + (i3 == i4));// false
    }
}

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 默认先创建并缓存 -128 ~ 127 之间数的 Integer 对象,当调用 valueOf 时如果参数在 -128 ~ 127 之间则计算下标并从缓存中返回,否则创建一个新的 Integer 对象。

那JDK为何要这样做呢?因为在-128到127之间的数据在int范围内是使用最频繁的,为了节省频繁创建对象带来的内存消耗,这里就用到了享元模式,来提高性能。

3.2 String 引用

Java 中将 String 类定义为 final(不可改变) 的。

JVM 中字符串一般保存在字符串常量池中,Java 会确保一个字符串在常量池中只有一份,其他引用即可。

String 的相关问题:

public class StringDemo {
    public static void main(String[] args) {
        String s1 = "hello";
        String s2 = "hello";
        String s3 = "he" + "llo";
        String s4 = "hel" + new String("lo");
        String s5 = new String("hello");
        String s6 = s5.intern();
        String s7 = "h";
        String s8 = "ello";
        String s9 = s7 + s8;
        System.out.println(s1 == s2);//true (1)
        System.out.println(s1 == s3);//true (2)
        System.out.println(s1 == s4);//false (3)
        System.out.println(s1 == s5);//false (4)
        System.out.println(s1 == s6);//true (5)
        System.out.println(s4 == s5);//false (6)
        System.out.println(s1 == s9);//false (7)
    }
}

解读:

  • ① s1 和 s2 都为 "hello" 这个字面量,只能拷贝一份;
  • ② s1 和 s3 比较,编译器会优化,先把字符串拼接,再去常量池中查找字符串是否存在,让 s3 指向 s1指向的字符串;
  • ③ s1 和 s4:s4 创建了两个对象,“hel” 存放常量池,"lo"存放堆,相加的结果存在堆中,所以不相等;
  • ④ s1 和 s5:s5 的对象在堆中,不相等
  • ⑤ s1 和 s6 :intern 方法在运行期间能动态的将堆中的字符串加入到字符串常量池中,而字符串常量池只有一份,所以相等
  • ⑥ s4 和 s5:都在堆中,肯定不相等
  • ⑦ s1 和 s9:s9 是两个对象相加,结果存在堆中,所以不相等。

四、小结

享元模式精髓也就是达到对象的共享,当系统中有大量对象,这些对象消耗大量内存,并且对象的状态大部分可以外部化时,我们就可以考虑选用享元模式,享元模式经典的应用场景是需要缓冲池的场景,比如String常量池、数据库连接池。以上就是本次浅谈设计模式关于享元模式的内容,下面将开始11中行为模式的学习和分享,下期再会。