設計模式學習筆記(十二)享元模式及其應用

歸斯君發表於2022-04-02

享元(Flyweight)模式:顧名思義就是被共享的單元。意圖是複用物件,節省記憶體,提升系統的訪問效率。比如在紅白機冒險島遊戲中的背景花、草、樹木等物件,實際上是可以多次被不同場景所複用共享,也是為什麼以前的遊戲佔用那麼小的記憶體,卻讓我們感覺地圖很大的原因。

冒險島中的背景

一、享元模式介紹

1.1 享元模式的定義

享元模式的定義是:運用共享技術來有效地支援大量細粒度物件的複用。

這裡就提到了兩個要求:細粒度和共享物件。而正是因為要求細粒度,那麼勢必會造成物件數量過多而且物件性質相近。所以我們可以將物件分為:內部狀態和外部狀態,內部狀態指物件共享出來的資訊,儲存在享元資訊內部,不會隨著環境改變;外部狀態指物件得以依賴的標記,會隨著環境改變,不可以共享。根據是否共享,可以分成兩種模式:

  • 單純享元模式:該模式中所有具體享元類都是可以共享,不存在非共享具體享元類
  • 複合享元模式:將單純享元物件使用組合模式加以組合,可以形成複合享元物件

實際上享元模式的本質就是快取共享物件,降低記憶體消耗

1.2 享元模式的結構

我們可以根據享元模式的定義畫出大概的結構圖,如下所示:

image-20220402201112689

  • FlyweightFactory:享元工廠,負責建立和管理享元角色
  • Flyweight:抽象享元,是具體享元類的基類,提供具體享元需要的公共介面
  • SharedFlyweight、UnSharedFlyweight:具體享元角色和具體非享元類
  • Client:客戶端,呼叫具體享元和非享元類

1.3 享元模式的實現

根據上面的類圖可以實現如下程式碼:

/**
 * @description: 抽象享元類
 * @author: wjw
 * @date: 2022/4/2
 */
public interface Flyweight {
    /**
     * 抽象享元方法
     * @param state 程式碼外部狀態值
     */
    public void operation(int state);
}
/**
 * @description: 具體享元類
 * @author: wjw
 * @date: 2022/4/2
 */
public class SharedFlyweight implements Flyweight{

    private String key;

    public SharedFlyweight(String key) {
        System.out.println("具體的享元類:" + key + "已被建立");
    }

    @Override
    public void operation(int state) {
        System.out.println("具體的享元類被呼叫:" + state);
    }
}
/**
 * @description: 非共享的具體類,並不強制共享
 * @author: wjw
 * @date: 2022/4/2
 */
public class UnSharedFlyweight implements Flyweight{

    public UnSharedFlyweight() {
        System.out.println("非享元類已建立");
    }

    @Override
    public void operation(int state) {
        System.out.println("我是非享元類" + state);
    }
}
/**
 * @description: 享元工廠類,負責建立和管理享元類
 * @author: wjw
 * @date: 2022/4/2
 */
public class FlyweightFactory {

    private HashMap<String, Flyweight> flyweights = new HashMap<>();

    public FlyweightFactory() {
        flyweights.put("flyweight1", new SharedFlyweight("flyweight1"));
    }

    public Flyweight getFlyweight(String key) {

        return flyweights.get(key);
    }
}
/**
 * @description: 客戶端類
 * @author: wjw
 * @date: 2022/4/2
 */
public class Client {
    public static void main(String[] args) {
        FlyweightFactory flyweightFactory = new FlyweightFactory();
        Flyweight flyweight1 = flyweightFactory.getFlyweight("flyweight1");
        flyweight1.operation(1);

        UnSharedFlyweight unSharedFlyweight = new UnSharedFlyweight();
        unSharedFlyweight.operation(2);
    }
}

測試結果:

具體的享元類:flyweight1已被建立
具體的享元類被呼叫:1
非享元類已建立
我是非享元類2

二、享元模式應用場景

2.1 在文字編輯器中的應用

如果按照每一個字元設定成一個物件,那麼對於幾十萬的文字,儲存幾十萬的物件顯然是不可取,記憶體的利用率也不夠高,這個時候可以將字元設定成一個共享物件,它同時可以在多個場景中使用。不同的場景用字型font、字元大小size和字元顏色colorRGB來進行區分。具體實現如下:

/**
 * @description: 字的格式,享元類
 * @author: wjw
 * @date: 2022/4/2
 */
public class CharacterStyle {
    private String font;
    private int size;
    private int colorRGB;

    public CharacterStyle(String font, int size, int colorRGB) {
        this.font = font;
        this.size = size;
        this.colorRGB = colorRGB;
    }

    @Override
    public boolean equals(Object obj) {
        CharacterStyle otherCharacterStyle = (CharacterStyle) obj;
        return font.equals(otherCharacterStyle.font)
                && size == otherCharacterStyle.size
                && colorRGB == otherCharacterStyle.colorRGB;
    }
}
/**
 * @description: 字風格工廠類,建立具體的字
 * @author: wjw
 * @date: 2022/4/2
 */
public class CharacterStyleFactory {
    private static final List<CharacterStyle> styles = new ArrayList<>();

    public static CharacterStyle getStyle(String font, int size, int colorRGB) {
        CharacterStyle characterStyle = new CharacterStyle(font, size, colorRGB);
        for (CharacterStyle style : styles) {
            if (style.equals(characterStyle)) {
                return style;
            }
        }
        styles.add(characterStyle);
        return characterStyle;
    }
}
/**
 * @description: 字類
 * @author: wjw
 * @date: 2022/4/2
 */
public class Character {
    private char c;
    private CharacterStyle style;

    public Character(char c, CharacterStyle style) {
        this.c = c;
        this.style = style;
    }

    @Override
    public String toString() {
        return style.toString() + c;
    }
}
/**
 * @description: 編輯輸入字
 * @author: wjw
 * @date: 2022/4/2
 */
public class Editor {
    private List<Character> chars = new ArrayList<>();

    public void appendCharacter(char c, String font, int size, int colorRGB) {
        Character character = new Character(c, CharacterStyleFactory.getStyle(font, size, colorRGB));
        System.out.println(character);
        chars.add(character);
    }
}
/**
 * @description: 客戶端測試類
 * @author: wjw
 * @date: 2022/4/2
 */
public class EditorTest {
    public static void main(String[] args) {
        Editor editor = new Editor();
        System.out.println("相同的字--------------------------------------");
        editor.appendCharacter('t', "宋體", 12, 7777);
        editor.appendCharacter('t', "宋體", 12, 7777);
        System.out.println("不相同的字------------------------------------");
        editor.appendCharacter('t', "宋體", 12, 7777);
        editor.appendCharacter('x', "宋體", 12, 7777);
    }
}

測試結果如下:

相同的字--------------------------------------
cn.ethan.design.flyweight.CharacterStyle@610455d6t
cn.ethan.design.flyweight.CharacterStyle@610455d6t
不相同的字------------------------------------
cn.ethan.design.flyweight.CharacterStyle@610455d6t
cn.ethan.design.flyweight.CharacterStyle@610455d6x

從結果可以看出,同一種風格的字用的是同一個享元物件。

2.2 在String 常量池中的應用

從上一應用我們發現,很像Java String常量池的應用:對於建立過的String,直接指向呼叫即可,不需要重新建立。比如說這段程式碼:

String str1 = “abc”;
String str2 = “abc”;
String str3 = new String(“abc”);
String str4 = new String(“abc”);

在Java 執行時區域中:

image-20220402222303309

2.3 在Java 包裝類中的應用

在Java中有Short、Long、Byte、Integer等包裝類。這些類中都用到了享元模式,以Integer 為例進行講解。

在介紹前先看看這段程式碼:

Integer i1 = 100;
Integer i2 = 100;
Integer i3 = 200;
Integer i4 = 200;
System.out.println(i1 == i2);
System.out.println(i3 == i4);

首先說明“==”是判斷兩個物件儲存的地址是否相同

按照常理,最後輸出應該都是true,然而最後的輸出是:

true
false

這是因為Integer包裝型別的自動裝箱和拆箱、Integer中的享元模式的結果導致的。我們一步步來看:

2.3.1 包裝型別的自動裝箱(Autoboxing)和自動拆箱(Unboxing)

  1. 自動裝箱

    就是自動將基本資料型別裝換成包裝型別。實際上Integer i1 = 100底層是Integer i1 = Integer.valueOf(100)。看看這段原始碼:

    /**
    * Returns an {@code Integer} instance representing the specified
    * {@code int} value.  If a new {@code Integer} instance is not
    * required, this method should generally be used in preference to
    * the constructor {@link #Integer(int)}, as this method is likely
    * to yield significantly better space and time performance by
    * caching frequently requested values.
    *
    * This method will always cache values in the range -128 to 127,
    * inclusive, and may cache other values outside of this range.
    *
    * @param  i an {@code int} value.
    * @return an {@code Integer} instance representing {@code i}.
    * @since  1.5
    */
    public static Integer valueOf(int i) {
        if (i >= IntegerCache.low && i <= IntegerCache.high)
            return IntegerCache.cache[i + (-IntegerCache.low)];
        return new Integer(i);
    }
    

    說明在裝箱時,看似相同的值,但是建立了兩個不同的Integer物件,因此兩個100的值自然不相同了。所以上面程式碼建立的物件每個都不相同,所以應該都是false呀,但為什麼i1i2還是相同的呢?

    我們再來看中間的這句話:

    This method will always cache values in the range -128 to 127,

    這個方法總是會快取值在-128到127之間的值,

    說明在[-128, 127]範圍內的值,自動裝箱不會建立物件,是利用享元模式進行共享。而IntegerCache就相當於生成享元物件的工廠類,我們再看其原始碼:

    /**
    * Cache to support the object identity semantics of autoboxing for values between
    * -128 and 127 (inclusive) as required by JLS.
    *
    * The cache is initialized on first usage.  The size of the cache
    * may be controlled by the {@code -XX:AutoBoxCacheMax=<size>} option.
    * During VM initialization, java.lang.Integer.IntegerCache.high property
    * may be set and saved in the private system properties in the
    * sun.misc.VM class.
    */
    
    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() {}
    }
    
  2. 自動拆箱

    是自動將包裝型別轉換成基本資料型別。實際上int j1 = i1底層是int j1 = i1.intValue(),我們看其原始碼:

    /**
    * Returns the value of this {@code Integer} as an
    * {@code int}.
    */
    public int intValue() {
        return value;
    }
    

    實際上也就是直接返回該值。

回到上面的四行程式碼:

  • 前兩行是因為它們的值在[-127, 128]之間,而且由於享元模式,i1i2共用一個物件,所以結果為true
  • 後兩行則是因為它們值在範圍之外,所以重新建立不同的物件,因此結果為false

其實在使用包裝類判斷值時,儘量不要使用“==”來判斷,IDEA中也給我們提了醒:

image-20220402232722051

所以說在判斷包裝類時,應該儘量使用"equals"來進行判斷,先判斷兩者是否為同一型別,然後再判斷其值

public boolean equals(Object obj) {
    if (obj instanceof Integer) {
        return value == ((Integer)obj).intValue();
    }
    return false;
}

所以對於上面的四行程式碼,最後的結果就都會是true了。

三、享元模式和單例模式、快取的區別

3.1 和單例模式的區別

單例模式中,一個類只能建立一個物件,而享元模式中一個類可以建立多個類。享元模式則有點單例的變體多例。但是從設計上講,享元模式是為了物件複用,節省記憶體,而多例模式是為了限制物件的個數,設計意圖不相同。

3.2 和快取的區別

在享元模式中,我們是通過工廠類來“快取”已經建立好的物件,重點在物件的複用。

在快取中,比如CPU的多級快取,是為了提高資料的交換速率,提高訪問效率,重點不在物件的複用

參考資料

《重學Java設計模式》

《設計模式之美》專欄

http://c.biancheng.net/view/1371.html

相關文章