減小內存的占用問題——享元模式和單例模式的對比分析


前言

接口的常用用法都有什么?策略模式復習總結 的話題提起了:如何解決策略類膨脹的問題,說到

“有時候可以通過把依賴於環境Context類的狀態保存到客戶端里面,而將策略類設計成可共享的,這樣策略類實例可以被不同客戶端使用。”

換言之,可以使用享元模式來減少對象的數量,享元模式它的英文名字叫 Flyweigh 模式,翻譯為羽量級(搏擊比賽的術語,也就是輕量級的體現)模式,它是構造型模式之一,它通過與其他類似對象共享數據來減小內存占用,也正應了它的名字:享-分享。

那么享元模式到底是什么樣子的呢?下面看個例子,有一個文檔,里面寫了很多英文,大家知道英文字母有26個,大小寫一起一共是52個:

保存這個文件的時候,所有單詞都占據了一份內存,每個字母都是一個對象,如果文檔里的字母有重復的,怎么辦?

難道每次都要創建新的字母對象去保存么?答案是否定的,其實每個字母只需要創建一次,然后把他們保存起來,當再次使用的時候直接在已經創建好的字母里取就ok了,這就是享元模式的一個思想的體現。

說到這兒,其實想起了Java的String類,這個類就是應用了享元模式

享元模式的樣子

先看享元模式的具體實現例子:

抽象享元角色(接口或者抽象類):所有具體享元類的父類,規定一些需要實現的公共接口

具體享元角色:抽象享元角色的具體實現類,並實現了抽象享元角色規定的方法

享元工廠角色:負責創建和管理享元角色。它必須保證享元對象可以被系統適當地共享。當一個客戶端對象調用一個享元對象的時候,享元工廠角色會檢查系統中是否已經有一個符合要求的享元對象。如果已經有了,享元工廠角色就應當提供這個已有的享元對象,如果系統中沒有一個適當的享元對象的話,享元工廠角色就應當創建一個合適的享元對象。

生成字符的例子

代碼如下:

public interface ICharacter {
    /**
     * 享元模式的抽象享元角色,所有具體享元類的父類,規定一些需要實現的公共接口。其實沒有這個接口也可以的。
     * 顯式我自己的字母
     */
    void displayCharacter();
}

/**
 * 具體的享元模式角色
 */
public class ChracterBuilder implements ICharacter {
    private char aChar;

    public ChracterBuilder(char c) {
        this.aChar = c;
    }

    @Override
    public void displayCharacter() {
        System.out.println(aChar);
    }
}

// 享元工廠類
public class FlyWeightFactory {
    /**
     * 享元工廠里維護一個內存的“共享池”,來避免大量相同內容對象的開銷。這種開銷最常見、最直觀的就是內存的損耗。
     * 我們這里使用數組也行,或者 HashMap(concurrenthashmap等)
     */
    private Map<Character, ICharacter> characterPool;

    public FlyWeightFactory() {
        this.characterPool = new HashMap<>();
    }

    public ICharacter getICharater(Character character) {
        // 創建(獲得)一個對象的時候,先去 pool 里判斷,是否已經存在
        ICharacter iCharacter = this.characterPool.get(character);
        if (iCharacter == null) {
            // 如果共享池里沒有,才 new 一個新的,並同時加到 pool 里,緩存起來
            iCharacter = new ChracterBuilder(character);
            this.characterPool.put(character, iCharacter);
        }

        // 否則,直接從pool里取出,不再新建
        return iCharacter;
    }
}

客戶端調用,下面的客戶端代碼其實不是享元模式的真正體現,只是普通的調用,為了對比說明問題:

public class MainFlyWeight {
    public static void main(String[] args) {
        // 不用享元模式,我們每次創建相同內容的字母的時候,都要new一個新的對象
        ICharacter iCharacter = new ChracterBuilder('a');
        ICharacter iCharacter1 = new ChracterBuilder('b');
        ICharacter iCharacter2 = new ChracterBuilder('b');
        ICharacter iCharacter3 = new ChracterBuilder('b');
        ICharacter iCharacter4 = new ChracterBuilder('b');

        iCharacter.displayCharacter();
        iCharacter1.displayCharacter();
        iCharacter2.displayCharacter();
        iCharacter3.displayCharacter();
        iCharacter4.displayCharacter();

        if (iCharacter2 == iCharacter1) {
            System.out.print("true");
        } else {
            // 執行后,發現打印了 false,說明是兩個不同的對象
            System.out.print("false");
        }
}

下面使用享元模式,必須指出的是,使用享元模式,那么客戶端絕對不可以直接將具體享元類實例化,而必須通過工廠得到享元對象

public class MainFlyWeight {
    public static void main(String[] args) {
        // 使用享元模式,必須指出的是,客戶端不可以直接將具體享元類實例化,而必須通過一個工廠
        FlyWeightFactory flyWeightFactory = new FlyWeightFactory();
        ICharacter iCharacter = flyWeightFactory.getICharater('a');
        ICharacter iCharacter1 = flyWeightFactory.getICharater('b');
        ICharacter iCharacter2 = flyWeightFactory.getICharater('b');

        iCharacter.displayCharacter();
        iCharacter1.displayCharacter();
        iCharacter2.displayCharacter();

        if (iCharacter1 == iCharacter2) {
            // 確實打印了 =============,說明對象共享了
            System.out.print("============");
        }
    }
}

打印的都一樣,但是對象使用的內存卻不一樣了,減少了內存的占用。

類圖如下:

一般而言,享元工廠對象在整個系統中只有一個,因此也可以使用單例模式,由工廠方法產生所需要的享元對象且設計模式不用拘泥於具體代碼, 代碼實現可能有n多種方式,n多語言……

教師管理的例子

再看一例子,需求很簡單。有一個老師類,繼承 Person 類,老師類里保存一個數字編號,客戶端可以通過它來找到對應的老師。

為了節省篇幅,簡單的堆砌一個 name 信息即可。

其中享元工廠類設計為單例的。

public class Person {
    private String name;/**
     * person是享元抽象角色
     */
    public Person(String name) {this.name = name;
    }

    public Person() {
    }public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }
}

// 具體享元角色
public class Teacher extends Person {
    private int number;

    public Teacher(int number, String name) {
        super(name);
        this.number = number;
    }

    public Teacher() {
        super();
    }

    public int getNumber() {
        return number;
    }

    public void setNumber(int number) {
        this.number = number;
    }
}

// 享元工廠類,設計為單例的
public class TeacherFactory {
    private Map<Integer, Teacher> integerTeacherMapPool;

    private TeacherFactory() {
        this.integerTeacherMapPool = new HashMap<>();
    }

    public static TeacherFactory getInstance() {
        return Holder.instance;
    }

    public Teacher getTeacher(int num) {
        Teacher teacher = integerTeacherMapPool.get(num);
        if (teacher == null) {
            // TODO 模擬用,不要把 teacher 寫死,每次使用 set
            teacher = new Teacher();
            teacher.setNumber(num);
            integerTeacherMapPool.put(num, teacher);
        }

        return teacher;
    }

    private static class Holder {
        private static final TeacherFactory instance = new TeacherFactory();
    }
}
 
/////// 客戶端,查詢老師
public class MainClass {
    public static void main(String[] args) {
        // 先創建工廠
        TeacherFactory teacherFactory = TeacherFactory.getInstance();
        // 通過工廠得到具體老師對象
        Teacher teacher = teacherFactory.getTeacher(1000);
        Teacher teacher1 = teacherFactory.getTeacher(1001);
        Teacher teacher2 = teacherFactory.getTeacher(1000);

        System.out.println(teacher.getNumber());
        System.out.println(teacher1.getNumber());
        System.out.println(teacher2.getNumber());

        // 判斷是否是相等對象
        if (teacher == teacher2) {
            // 確實打印了,ok
            System.out.print("____________-");
        }
    }
}

享元模式的使用場景

1、應用程序的底層性能優化時常用的一種策略

比如,一個系統中存在着大量的細粒度對象,且這些細粒度對象耗費了大量的內存。這里也要明白,享元模式比起工廠,單例,策略,裝飾,觀察者等模式,其實不算是常用的設計模式,它主要用在底層的設計上比較多,比如 JDK 的 String 類,Integer 的 valueOf(int)方法等。

2、管理大量相似對象的一種策略,可以理解為緩存的實現方案

其實感覺1和2是一個意思。。。

3、大量的細粒度對象的狀態中的大部分都可以外部化

理解對象的內部狀態和外部狀態兩個概念

現在多了幾個新的概念(外部化,內部,外部狀態……):

內部狀態:存儲在享元對象內部的對象(可以理解為其內部的一些穩定的屬性),這些屬性對象是不會隨環境的改變而變化的。正因為這個原因,一個享元對象才可以被共享

外部狀態:對象的一些屬性會隨環境的改變而改變,屬於不穩定的屬性,故這些屬性對象不可以被共享

由此得到一個結論:享元對象的外部狀態必須由客戶端保存,並在享元對象被創建之后,在需要使用的時候再傳入到享元對象內部。外部狀態不可以影響享元對象的內部狀態,它們是相互獨立的

滿足以上的這些條件的系統才可以使用享元模式。

回憶之前的教師管理例子:具體享元角色類Teacher類的int類型的number屬性,其實就是一個內部狀態,它的值會在享元對象被創建時賦予,也就是所謂的內部狀態對象讓享元對象自己去保存,且可以被客戶端共享,所有的內部狀態在對象創建之后,就不再改變。

具有外部狀態的享元模式實現——模擬數據庫連接池小例子

這個教師管理的例子,其享元對象沒有外部狀態,下面看一個具有外部狀態+內部狀態的享元模式例子——常見的一些數據庫連接池,其實就是利用享元模式對數據庫連接進行封裝和共享。

如果一個享元對象有外部狀態,所有的外部狀態都必須存儲在客戶端,在使用享元對象時,再由客戶端傳入享元對象。

public interface BaseDao {
    /**
     * 連接數據源,享元模式的抽象享元角色
     * @param session String 數據源連接的session,該參數就是外部狀態
     */
    void connect(String session);
}

例子里只有一個外部狀態——connect()方法的參數 session,實際上這個 session 是很復雜的,我們這里簡單的用 string 代替。

外部狀態:對象的一些屬性會隨環境的改變而改變,屬於不穩定的屬性,故這些屬性對象不可以被共享

享元對象的外部狀態必須由客戶端保存,並在享元對象被創建之后,在需要使用的時候再傳入到享元對象內部。

外部狀態不可以影響享元對象的內部狀態,它們是相互獨立的

public class DaoA implements BaseDao {
    /**
     * 內部狀態,存儲每個數據庫連接的一些信息,這里也用字符串簡化了
     */
    private String strConn = null;

    /**
     * 內部狀態在創建享元對象的時候作為參數傳入構造器
     */
    public DaoA(String s) {
        this.strConn = s;
    }

    /**
     * 外部狀態 session 作為參數傳入抽象方法,可以改變方法的行為,但是對於內部狀態不做改變,兩者獨立
     * 外部狀態(對象)存儲在客戶端,當客戶端使用享元對象的時候才被傳入享元對象,而不是開始就有。*/
    @Override
    public void connect(String session) {
        System.out.print("內部狀態 是" + this.strConn);
        System.out.print("外部狀態 是" + session);
    }
}

享元工廠

public enum Factory {
    /**
     * 單例模式的最佳實現是使用枚舉類型。只需要編寫一個包含單個元素的枚舉類型即可
     * 簡潔,且無償提供序列化,並由JVM從根本上提供線程安全的保障,絕對防止多次實例化,且能夠抵御反射和序列化的攻擊。
     */
    instance;

    /**
     * 可以有自己的操作
     */
    private Map<String, BaseDao> stringBaseDaoMapPool = new HashMap<>();

    public BaseDao factory(String s) {
        BaseDao baseDao = this.stringBaseDaoMapPool.get(s);
        if (baseDao == null) {
            baseDao = new DaoA(s);
            this.stringBaseDaoMapPool.put(s, baseDao);
        }

        return baseDao;
    }
}

下面是客戶端調用,雖然客戶端申請了三個享元對象,但是實際創建的享元對象只有兩個,這就是共享的含義

public class Client {
    public static void main(String[] args) {
        BaseDao baseDao = Factory.instance.factory("A連接數據源");
        BaseDao baseDao1 = Factory.instance.factory("B連接數據源");
        BaseDao baseDao2 = Factory.instance.factory("A連接數據源");
        baseDao.connect("session1");
        baseDao1.connect("session2");
        baseDao2.connect("session1");

        if (baseDao == baseDao2) {
            // 確實打印了
            System.out.print("===========");
        }
    }
}

享元模式的優點和缺陷

享元模式的優點在於,它能大幅度地降低內存中對象的數量。但是,它做到這一點所付出的代價也是很高的:

1、享元模式使得系統更加復雜。

為了使對象可以共享,需要將一些狀態外部化,這使得程序的邏輯復雜化

2、享元模式將享元對象的狀態外部化,而讀取外部狀態使得運行時間稍微變長

3、享元模式需要維護一個記錄了系統已有的所有享元的哈希表,也稱之為對象池,這也需要耗費一定的資源。應當在有足夠多的享元實例可供共享時才值得使用享元模式。

單例模式和享元模式的比較

享元模式到這里總結的差不多了,前面的享元模式的例子,工廠Factory類使用了單例模式實現,那么這里還要順便總結一個老生常談,但是又不見得真的談對了的設計模式——單例模式。

具體細節:最簡單的設計模式——單例模式的演進和推薦寫法(Java 版)

下面是簡單分析:

享元是對象級別的:在多個使用到這個對象的地方都只需要使用這一個對象即可滿足要求。

單例是類級別的:這個類必須只能實例化出一個對象。

可以這么說:單例是享元的一種特例。設計模式不用拘泥於具體代碼, 代碼實現可能有 n 多種方式,而單例可以看做是享元的實現方式中的一種,只不過他比享元更加嚴格的控制了對象的唯一性。

享元模式和線程安全

前面的例子都是使用的 hashmap,作為對象池,如果在多線程的場景下,是否安全呢?

答案是否定的,必須給工廠里的 getxxx 方法加鎖,比如直接使用 synchronized 關鍵字即可。看下面例子:

public class TeacherFactory {
    private Map<Integer, Teacher> integerTeacherMapPool;

    private TeacherFactory() {
        this.integerTeacherMapPool = new HashMap<>();
    }

    public static TeacherFactory getInstance() {
        return Holder.instance;
    }

    public synchronized Teacher getTeacher(int num) {
        Teacher teacher = integerTeacherMapPool.get(num);
        if (teacher == null) {
            // TODO 模擬用,不要把 teacher 寫死,每次使用 set
            teacher = new Teacher();
            teacher.setNumber(num);
            integerTeacherMapPool.put(num, teacher);
        }

        return teacher;
    }

    private static class Holder {
        private static final TeacherFactory instance = new TeacherFactory();
    }
}

如果不加鎖,可能有如下場景:

線程1 線程2
執行 getTeacher(100) 方法  
判斷拿出的對象是否為 null  
為 null,new Teacher  
  執行 getTeacher(100) 方法
  判斷拿出的對象是否為 null
  為 null,new Teacher
  put 到對象池
put 到對象池  

 

 

  

 

 

 

 

 

 

當然,也可以直接使用 concurrentHashMap

在JDK中有哪些使用享元模式的例子?舉例說明。

說兩個,一個是String類,第二個是java.lang.Integer 的 valueOf(int)方法。

String 類

針對String,也是老生常談了,它是final的,字符串常量通常是在編譯的時候就確定好的,定義在類的方法區里。如下:

String s1 = "hello";
String s2 = "he" + "llo";

if (s1 == s2) {
    System.out.print("====");// 打印了,說明 s1,s2 引用了同一個對象 hello
}

使用相同的字符序列,而不是使用 new 關鍵字創建的兩個字符串,會創建指向Java字符串常量池中的同一個字符串的指針。字符串常量池是 Java 節約資源的一種方式,其實就是使用了享元模式的思想。

字符串的分配,和其他的對象分配一樣,耗費高昂的時間與空間代價,JVM 為了提高性能和減少內存開銷,在實例化字符串常量的時候進行了一些優化。

字符串類維護了一個字符串池,每當代碼創建字符串常量時,JVM 會首先檢查字符串常量池,如果字符串已經在池中,就返回池中的實例的引用;如果字符串不在池中,就會實例化一個字符串並放到池中,Java能夠進行這樣的優化是因為字符串是不可變的,可以不用擔心數據沖突

java.lang.Integer 的 valueOf(int)方法源碼分析(8.0版本)

先看一個例子A

Integer a = 1;
Integer b = 1;
System.out.print(a == b); // true

再看一例子B

// 再看一例子;
Integer a = new Integer(1);
Integer b = new Integer(1);
System.out.print(a == b); // false

如上比較的結果很容易理解,再看一個“奇怪的”例子C

Integer a = 200;
Integer b = 200;
System.out.println(a == b); // false

比較的結果怎么還是 false 呢? 例子 A 里明明是 true,為什么到了例子 C 里就是 false 了?

反編譯上述程序,看看發生了什么

public static main([Ljava/lang/String;)V
   L0
    LINENUMBER 19 L0
    SIPUSH 200
    INVOKESTATIC java/lang/Integer.valueOf (I)Ljava/lang/Integer; // 發現每次都是使用了自動裝箱
    ASTORE 1
   L1
    LINENUMBER 20 L1
    SIPUSH 200
    INVOKESTATIC java/lang/Integer.valueOf (I)Ljava/lang/Integer;
    ASTORE 2

發現每次賦值的時候,都會自動調用其自動裝箱方法,如下

Integer c = Integer.valueOf(200);

再看該方法源碼

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

發現,當使用Integer的自動裝箱時,i 值在 cache 的 low 和 high 之間時,會用緩存保存起來,供客戶端多次使用,以節約內存。如果不在這個范圍內,則創建一個新的 Integer 對象,這就是享元模式的設計思想。

看看范圍:-128~+127

歡迎關注

dashuai的博客是終身學習踐行者,大廠程序員,且專注於工作經驗、學習筆記的分享和日常吐槽,包括但不限於互聯網行業,附帶分享一些PDF電子書,資料,幫忙內推,歡迎拍磚!

 

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() {}
    }
View Code


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM