从零开始学习Java设计模式 | 结构型模式篇:享元模式

Posted 李阿昀

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了从零开始学习Java设计模式 | 结构型模式篇:享元模式相关的知识,希望对你有一定的参考价值。

在本讲,我们来学习一下结构型模式里面的最后一个设计模式,即享元模式。

概述

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

知道享元模式的概念之后,我们来看一下现实生活中哪些地方大量的运用到了这种共享思想。大家不妨开动脑筋想一想,共享单车是不是就是啊!在没有共享单车的时候,如果你想骑自行车的话,那么你是不是就得需要自己去购买一辆自行车啊!可能你骑了两天的自行车,热度过了之后,你就不再想去骑它了,这样自行车就会闲置在家里,这本身就是资源的一种浪费。那现在如何去提高资源的一个利用率呢?

共享单车就应运而生了,生产一批自行车,把它们都投放在市面上,如果有人想骑的话,那么他只需要扫码进行一个租赁,然后去骑行即可,骑行完了之后,再进行一个归还,归还的目的就是供其他人进行使用,这就是共享思想,其目的主要就是为了提高资源的利用率。

结构

享元(Flyweight)模式中存在以下两种状态:

  1. 内部状态(或者内部数据),即不会随着环境的改变而改变的可共享部分。也就是说,不管是我使用还是你使用,内部状态都是一样的。
  2. 外部状态(或者外部数据),指随环境改变而改变的不可以共享的部分。那么这一部分的数据,我们应该如何进行设置呢?很简单,外部数据可以作为方法的形式参数进行一个传递。

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

了解享元模式中存在的两种状态之后,接下来,我们就来看看享元模式里面有哪些角色。

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

  • 抽象享元角色(Flyweight):通常是一个接口或抽象类,在抽象享元类中声明了具体享元类里面公共的方法(现在大家明白,为什么要把该角色定义成接口或者抽象类了吧!其实就是为了进行一个抽取,以便定义成一套规范),这些方法可以向外界提供享元对象的内部数据(内部状态),同时也可以通过这些方法来设置外部数据(外部状态)。

    其实,上面我也说到了,外部数据(外部状态)可以作为方法的形式参数进行一个传递,这是因为每个人在使用的时候,外部状态都有可能是不一样的。

  • 具体享元(Concrete Flyweight)角色:它实现了抽象享元类,称为享元对象;在具体享元类中为内部状态提供了存储空间,这是因为内部状态是在内部进行一个存储的,并且每个人在使用的时候,内部状态都是一样的。通常我们可以结合单例模式来设计具体享元类,为每一个具体享元类提供唯一的享元对象。

  • 非享元(Unsharable Flyweight)角色:并不是所有的抽象享元类的子类都需要被共享,不能被共享的子类可设计为非共享具体享元类;当需要一个非共享具体享元类的对象时,可以直接通过实例化创建,也就是说我们可以直接去创建该类的对象。

    注意,该非享元角色我们可以将其理解成外部状态(外部数据),若有多个外部数据的话,则可将它们封装起来。

  • 享元工厂(Flyweight Factory)角色:负责创建和管理享元角色。当客户对象请求一个享元对象时,享元工厂检査系统中是否存在符合要求的享元对象,若存在则提供给客户;若不存在的话,则直接创建一个新的享元对象。

享元模式案例

接下来,我们就通过一个案例再来理解一下享元模式以及享元模式里面的两个状态(即内部状态和外部状态),这个案例就是俄罗斯方块。

分析

下面的图片是众所周知的俄罗斯方块中的一个个方块,只要大家有玩过俄罗斯方块,你就一定对它特别特别熟悉。

从上图中可以看到,有I图形、J图形、L图形、O图形、Z图形、T图形以及S图形等这些方块,这些方块就组合成了俄罗斯方块这个游戏。大家在玩俄罗斯方块这个游戏的时候,你会发现图形其实就这几个,只不过是每一个图形可能出现多次,而且颜色也有可能是不一样的,如果我们将每一个小方块都看作是一个实例对象的话,那么I图形出现多次,这就意味着要创建多个对象了。

也就是说,对于相同的图形来说的话,就要创建多个对象了,但这势必会占用过多的内存空间,为了解决这个问题,那么我们就要使用享元模式来进行实现了。这样,对于I图形来说的话,我们只需要去创建一个共享的对象即可,至于不同的颜色,我们可以将其看作是外部状态,这样一来,我们也就能通过参数传递的方式来进行一个实现了。

接下来,我们来看一下下面这张类图,理清楚以下类图涉及到了哪些类以及类和类之间的一个关系。

可以看到,有一个AbstractBox类,从名字上就能知道该类是一个抽象类,想必大家都知道了它就是抽象享元角色。在该类中,提供了两个方法,第一个是getShape,用于获取图形,注意了,该方法去获取图形时结果是以字符串的形式返回的,例如对于I图形来说,返回的就是字符串I,此外,大家还要知道该方法是一个抽象的方法,这是因为只有具体的子类才能明确自己到底是什么样的一个图形;第二个方法是display,将具体的颜色传递进该方法后,该方法就会将其输入到控制台中,注意了,在AbstractBox类中,该方法有具体实现。

然后,AbstractBox类下面又有三个子类,它们分别是IBox、LBox以及OBox,这里我只设计出来了这三个子类,当然了,肯定还会有其他子类,只不过这里我没设计出来,有兴趣的同学不妨再设计出更多的子类,例如JBox、ZBox等等。很显然,这些子类是要去重写父类中的抽象方法的,因为不同的子类返回的图形结果字符串表示形式是不一样的。

接着,我们再来看一下BoxFactory类,它是一个工厂类。既然它是一个工厂类,那么我们得明确一点,就是该工厂类只需要有一个,所以我们在设计该工厂类的时候应将其设计为单例的,自然地,我们就要把单例设计模式融入到该案例里面中去了。当然了,对于IBox、LBox以及OBox这些子类,如果是只需要创建一个对象的话,那么大家同样也可以使用单例设计模式,只不过在此案例中,我们就不把它们三个设置为单例的了,而是只须把工厂类设置为单例的就行。

我们来继续看一下BoxFactory类里面的成员,从上可以看到,有一个HashMap<String, AbstractBox>类型的成员变量,它是用来存储那些图形的名称以及图形对象的。也就是说,有了该成员变量之后,那些图形对象就都存储在内存中了,这样,当咱们去获取具体的图形对象时,就不需要自己再去new了,而是直接通过该工厂类去获取,当然,你得根据图形名称去获取,例如传入一个字符串I,就能拿到一个IBox类的对象。

紧接着,我们再来看一下BoxFactory类里面的方法,映入眼帘的第一个方法就是构造方法,当然了,该构造方法私有了,这是因为我们要将该工厂类设计成单例的。将该工厂类设计成单例的之后,我们是不是还得对外提供一个方法供外界获取该工厂类的对象啊!而这个方法就是getInstance。除此之外,该工厂类里面还有一个方法,即getBox,它是根据图形名称去获取图形对象的。

实现

首先,打开咱们的maven工程,并在com.meimeixia.pattern包下新建一个子包,即flyweight,也即享元模式的具体代码我们是放在了该包下。

然后,创建AbstractBox类,记住,它是一个抽象类。根据我以上对类图的分析,相信大家应该不难写出下面这样一个类出来。

package com.meimeixia.pattern.flyweight;

/**
 * 抽象享元角色
 * @author liayun
 * @create 2021-08-02 7:10
 */
public abstract class AbstractBox {

    // 获取图形的方法
    public abstract String getShape();

    /**
     * 显示图形及颜色
     *
     * 传递外部状态(颜色,即color参数),然后再将其打印出来
     */
    public void display(String color) {
        System.out.println("方块形状:" + getShape() + ",颜色:" + color);
    }

}

接着,创建AbstractBox类的子类,这里我们不妨先创建一个叫IBox的子类,如下所示。

package com.meimeixia.pattern.flyweight;

/**
 * I图形类(具体享元角色)
 * @author liayun
 * @create 2021-08-02 7:15
 */
public class IBox extends AbstractBox {
    @Override
    public String getShape() {
        return "I";
    }
}

再创建一个叫LBox的子类。

package com.meimeixia.pattern.flyweight;

/**
 * L图形类(具体享元角色)
 * @author liayun
 * @create 2021-08-02 7:15
 */
public class LBox extends AbstractBox {
    @Override
    public String getShape() {
        return "L";
    }
}

当然了,根据以上类图咱们最后还得创建一个叫OBox的子类。

package com.meimeixia.pattern.flyweight;

/**
 * O图形类(具体享元角色)
 * @author liayun
 * @create 2021-08-02 7:15
 */
public class OBox extends AbstractBox {
    @Override
    public String getShape() {
        return "O";
    }
}

紧接着,创建工厂类,这里我们就将该类起名为BoxFactory了。记住,我们是要将该类设计成单例的哟!

package com.meimeixia.pattern.flyweight;

import java.util.HashMap;

/**
 * 工厂类,记住,我们是要将该类设计成单例的
 * @author liayun
 * @create 2021-08-02 7:27
 */
public class BoxFactory {

    private HashMap<String, AbstractBox> map; // 注意,在这儿我们并没有为map成员变量赋值,而是在下面的构造方法中为其进行了初始化操作

    // 在构造方法中进行初始化操作
    private BoxFactory() {
        map = new HashMap<String, AbstractBox>();
        map.put("I", new IBox());
        map.put("L", new LBox());
        map.put("O", new OBox());
    }

    // 提供一个方法获取该工厂类对象
    public static BoxFactory getInstance() {
        return factory;
    }

    private static BoxFactory factory = new BoxFactory();

    // 根据图形名称获取图形对象
    public AbstractBox getBox(String name) {
        return map.get(name);
    }

}

大家不妨来回顾一下,对于以上工厂类的实现,它到底用的是单例模式里面的哪种方式呢?很显然是饿汉式,因为我们在一开始的时候就对BoxFactory类型的成员变量factory进行了初始化。

最后,创建一个客户端类,以便进行测试。

package com.meimeixia.pattern.flyweight;

/**
 * @author liayun
 * @create 2021-08-02 7:46
 */
public class Client {
    public static void main(String[] args) {
        // 获取I图形对象
        AbstractBox box1 = BoxFactory.getInstance().getBox("I");
        // 为I图形传递(或者设置)颜色
        box1.display("灰色");

        // 获取L图形对象
        AbstractBox box2 = BoxFactory.getInstance().getBox("L");
        // 为L图形传递(或者设置)颜色
        box2.display("绿色");

        // 获取O图形对象
        AbstractBox box3 = BoxFactory.getInstance().getBox("O");
        // 为O图形传递(或者设置)颜色
        box3.display("灰色");

        // 再获取O图形对象
        AbstractBox box4 = BoxFactory.getInstance().getBox("O");
        // 为O图形传递(或者设置)颜色
        box4.display("红色");

        System.out.println("两次获取到的O图形对象是否是同一个对象:" + (box3 == box4));
    }
}

此时,运行以上客户端类的代码,打印结果如下图所示,可以看到是我们所想要的结果,而且对于两次获取的O图形来说,虽然它们颜色不一样,但是它们仍然是同一个对象。这就使用到了享元模式,即对图形对象进行了共享操作。

享元模式的优缺点以及使用场景

接下来,我们来看一下享元模式的优缺点以及使用场景。

优缺点

优点

享元模式的优点,我总结出来了下面两个。

  1. 极大减少内存中相似或相同对象数量,节约系统资源(主要就是指的内存),提供系统性能。

  2. 享元模式中的外部状态相对独立,且不影响内部状态。

    就拿上述示例来说,图形的颜色就是外部状态,颜色的改变并不影响图形(形状)。

缺点

为了使对象可以共享,需要将享元对象的部分状态外部化(例如,在上述示例中,我们就对图形的颜色进行了一个外部化,也即把图形颜色设置为了外部状态),分离内部状态和外部状态,使程序逻辑复杂。

试想一下,如果我们不进行分离的话,那么我们应该如何去做呢?是不是应该这样做啊!对于I图形来说,若它是红色,则我们要创建一个类的对象;若它是绿色,则我们还要创建一个类的对象,这样的话就会导致在内存中将会占用过多的内存资源,但是我们使用享元模式就不会有这个问题出现,虽然这将使程序的整个逻辑变得更加复杂。

使用场景

享元模式的使用场景,我总结出来了下面三个。

  1. 一个系统有大量相同或者相似的对象,造成内存的大量耗费。

  2. 对象的大部分状态都可以外部化,可以将这些外部状态传入对象中。就像在上述示例中,咱们就把图形的颜色作为方法的参数进行了一个传递。

  3. 在使用享元模式时需要维护一个存储享元对象的享元池(在上述示例中,我们是定义了一个HashMap用来存储图形对象,因此我们就可以把它理解成是享元池),而这需要耗费一定的系统资源(主要还是指内存资源),因此,应当在需要多次重复使用享元对象时才值得使用享元模式。

    如果我们在一开始就初始化了一些图形对象,但是这些图形对象在系统运行过程中压根就用不到,那么这就会无端占用一些内存空间了,这也是一种资源的浪费。

享元模式在JDK源码中的应用

接下来,我们就来看一下享元模式在JDK源码里面的具体应用。

这里我就开门见山了,Integer类就使用到了享元模式。在研究Integer类的底层源码之前,咱们先看一下下面的这个例子。

package com.meimeixia.pattern.flyweight.jdk;

/**
 * @author liayun
 * @create 2021-08-02 8:23
 */
public class Demo {
    public static void main(String[] args) {
        Integer i1 = 127;
        Integer i2 = 127;

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

        Integer i3 = 128;
        Integer i4 = 128;

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

对于上述这段代码,我们不妨直接拿过来执行一下,结果如下图所示。

为什么第一个输出语句输出的是true,第二个输出语句输出的是false呢?而且从以上例子中,似乎我们能看出127是一个临界值,到底是不是这样呢?接下来,我们就来研究研究。

要想对以上问题进行研究,我们就得通过反编译软件对字节码文件进行一个反编译,反编译之后的代码如下。

public class Demo {
    public static void main(String[] args) {
        Integer i1 = Integer.valueOf((int)127);
        Integer i2 Integer.valueOf((int)127);
        System.out.println((String)new StringBuilder().append((String)"i1\\u548ci2\\u5bf9\\u8c61\\u662f\\u5426\\u662f\\u540c\\u4e00\\u4e2a\\u5bf9\\u8c61\\uff1f").append((boolean)(i1 == i2)).toString());
        Integer i3 = Integer.valueOf((int)128);
        Integer i4 = Integer.valueOf((int)128);
        System.out.println((String)new StringBuilder().append((String)"i3\\u548ci4\\u5bf9\\u8c61\\u662f\\u5426\\u662f\\u540c\\u4e00\\u4e2a\\u5bf9\\u8c61\\uff1f").append((boolean)(i3 == i4)).toString());
    }
}

上面代码可以看到,直接给Integer类型的变量赋值基本数据类型数据的操作底层使用的是 valueOf方法 ,所以我们只需要去研究该方法即可。

接下来,我们来看一下valueOf方法底层是如何实现的。下面是我摘取Integer类源码里面的部分代码,大家不妨来看一下。

public final class Integer extends Number implements Comparable<Integer> {

    public static Integer valueOf(int i) {
        if (i >= IntegerCache.low && i <= IntegerCache.high)
            return IntegerCache.cache[i + (-IntegerCache.low)];
        return new Integer(i);
    }

    private static class IntegerCache {
        static final int low = -128;
        static final int high;
        static final Integer cache[];

        static {
            // high value may be configured by property
            int h = 127;
            String integerCacheHighPropValue =
                sun.misc.VM.getSavedProperty("java.lang.Integer.IntegerCache.high");
            if (integerCacheHighPropValue != null) {
                try {
                    int i = parseInt(integerCacheHighPropValue);
                    i = Math.max(i, 127);
                    // Maximum array size is Integer.MAX_VALUE
                    h = Math.min(i, Integer.MAX_VALUE - (-low) -1);
                } catch( NumberFormatException nfe) {
                    // If the property cannot be parsed into an int, ignore it.
                }
            }
            high = h;

            cache = new Integer[(high - low) + 1];
            int j = low;
            for(int k = 0; k < cache.length; k++)
                cache[k] = new Integer(j++);

            // range [-128, 127] must be interned (JLS7 5.1.7)
            assert IntegerCache.high >= 127;
        }

        private IntegerCache() {}
    }

}

注意,我们主要关注Integer类里面的valueOf方法,从上可以知道,该方法需要的是一个int类型的数据,并且最终会返回一个Integer对象。下面我们就来分析一下该方法的具体实现。

从上可以看到,valueOf方法里面有一个if判断,判断条件中涉及到了两个东东,它们分别是:

  • IntegerCache.low:IntegerCache类是Integer类里面的静态内部类,从名字上我们就能知道它就是Integer类的缓存,至于low,它是IntegerCache类里面的一个静态变量,值为-128,表示的是Integer缓存里面的最小值。

  • IntegerCache.high:high也是IntegerCache类里面的一个静态变量,它表示的是Integer缓存里面的最大值。

    至于它的值是多少,我们就要进入IntegerCache静态内部类中去查看一下了。可以看到,在IntegerCache静态内部类的静态代码块中,为它赋了一个127的初始值。

现在大伙知道valueOf方法里面的if判断到底做了一个什么判断吧!如果传递进来的int类型的数据大于等于-128,并且小于等于127,那么就会执行if判断里面的代码,否则,直接返回一个新new出来的Integer类型的对象,并且把传递进来的int类型的数据作为参数进行了一个传递。

搞清楚if判断条件之后,接下来,我们再来分析一下if判断里面的代码。

public static Integer valueOf(int i) {
    if (i >= IntegerCache.low && i <= IntegerCache.high)
        return IntegerCache.cache[i + (-IntegerCache.low)];
    return new Integer(i);
}

可以看到,里面调用了IntegerCachen静态内部类里面的cache数组,既然如此,那么[]里面肯定是数组的索引了,也就是通过索引去拿数组里面的元素。

那么问题来了,数组里面的元素到底是什么呢?这得再去看看IntegerCachen静态内部类里面的静态代码块了。大家看不太懂也没关系,这里我会为大家解释一下。

其实,它说的是Integer会默认先创建并缓存-128 ~ 127之间数的Integer对象,当调用valueOf方法时若参数在-128 ~ 127之间则计算下标并从缓存(即数组)中返回,否则创建一个新的Integer对象

这也很好地解释了一开始的例子所打印的结果了。一开始定义的两个Integer类型的变量的值都是127,所以这俩变量指代的都是同一个Integer对象,这样,在打印这两个Integer对象是否是同一个对象时,结果必然就是true了;而对于值是128的两个Integer类型的变量来说,由于128并不在-128 ~ 127范围之间,所以它是没有被缓存的,如此一来,每次都会new一个新的Integer对象了,自然在打印这两个Integer对象是否是同一个对象时,结果就是false了。

以上就是Integer类里面的valueOf方法源码的一个探究,正是该方法用到了享元模式。

以上是关于从零开始学习Java设计模式 | 结构型模式篇:享元模式的主要内容,如果未能解决你的问题,请参考以下文章

从零开始学习Java设计模式 | 结构型模式篇:代理模式

从零开始学习Java设计模式 | 结构型模式篇:代理模式

从零开始学习Java设计模式 | 结构型模式篇:组合模式

从零开始学习Java设计模式 | 结构型模式篇:组合模式

从零开始学习Java设计模式 | 结构型模式篇:装饰者模式

从零开始学习Java设计模式 | 结构型模式篇:装饰者模式