設計模式 | 享元模式及典型應用

小旋鋒發表於2018-09-25

前言

本文的主要內容:

  • 介紹享元模式
  • 示例-雲盤
  • 總結
  • 原始碼分析享元模式的典型應用
    • String中的享元模式
    • Integer中的享元模式
    • Long中的享元模式
    • Apache Common Pool2中的享元模式

推薦閱讀

設計模式 | 簡單工廠模式及典型應用
設計模式 | 工廠方法模式及典型應用
設計模式 | 抽象工廠模式及典型應用
設計模式 | 建造者模式及典型應用
設計模式 | 原型模式及典型應用
設計模式 | 外觀模式及典型應用
設計模式 | 裝飾者模式及典型應用
設計模式 | 介面卡模式及典型應用

點選[閱讀原文]可訪問我的個人部落格:laijianfeng.org

關注【小旋鋒】微信公眾號


享元模式

享元模式(Flyweight Pattern):運用共享技術有效地支援大量細粒度物件的複用。系統只使用少量的物件,而這些物件都很相似,狀態變化很小,可以實現物件的多次複用。由於享元模式要求能夠共享的物件必須是細粒度物件,因此它又稱為輕量級模式,它是一種物件結構型模式。享元模式結構較為複雜,一般結合工廠模式一起使用。

角色

Flyweight(抽象享元類):通常是一個介面或抽象類,在抽象享元類中宣告瞭具體享元類公共的方法,這些方法可以向外界提供享元物件的內部資料(內部狀態),同時也可以通過這些方法來設定外部資料(外部狀態)。

ConcreteFlyweight(具體享元類):它實現了抽象享元類,其例項稱為享元物件;在具體享元類中為內部狀態提供了儲存空間。通常我們可以結合單例模式來設計具體享元類,為每一個具體享元類提供唯一的享元物件。

UnsharedConcreteFlyweight(非共享具體享元類):並不是所有的抽象享元類的子類都需要被共享,不能被共享的子類可設計為非共享具體享元類;當需要一個非共享具體享元類的物件時可以直接通過例項化建立。

FlyweightFactory(享元工廠類):享元工廠類用於建立並管理享元物件,它針對抽象享元類程式設計,將各種型別的具體享元物件儲存在一個享元池中,享元池一般設計為一個儲存“鍵值對”的集合(也可以是其他型別的集合),可以結合工廠模式進行設計;當使用者請求一個具體享元物件時,享元工廠提供一個儲存在享元池中已建立的例項或者建立一個新的例項(如果不存在的話),返回新建立的例項並將其儲存在享元池中。

單純享元模式:在單純享元模式中,所有的具體享元類都是可以共享的,不存在非共享具體享元類。
複合享元模式:將一些單純享元物件使用組合模式加以組合,還可以形成複合享元物件,這樣的複合享元物件本身不能共享,但是它們可以分解成單純享元物件,而後者則可以共享

在享元模式中引入了享元工廠類,享元工廠類的作用在於提供一個用於儲存享元物件的享元池,當使用者需要物件時,首先從享元池中獲取,如果享元池中不存在,則建立一個新的享元物件返回給使用者,並在享元池中儲存該新增物件。

典型的享元工廠類的程式碼如下:

class FlyweightFactory {
    //定義一個HashMap用於儲存享元物件,實現享元池
    private HashMap flyweights = newHashMap();
    public Flyweight getFlyweight(String key){
        //如果物件存在,則直接從享元池獲取
        if(flyweights.containsKey(key)){
            return(Flyweight)flyweights.get(key);
        }
        //如果物件不存在,先建立一個新的物件新增到享元池中,然後返回
        else {
            Flyweight fw = newConcreteFlyweight();
            flyweights.put(key,fw);
            return fw;
        }
    }
}
複製程式碼

享元類的設計是享元模式的要點之一,在享元類中要將內部狀態和外部狀態分開處理,通常將內部狀態作為享元類的成員變數,而外部狀態通過注入的方式新增到享元類中。

典型的享元類程式碼如下所示:

class Flyweight {
    //內部狀態intrinsicState作為成員變數,同一個享元物件其內部狀態是一致的
    private String intrinsicState;
    public Flyweight(String intrinsicState) {
        this.intrinsicState=intrinsicState;
    }
    //外部狀態extrinsicState在使用時由外部設定,不儲存在享元物件中,即使是同一個物件
    public void operation(String extrinsicState) {
        //......
    }
}
複製程式碼

享元模式一般的類圖如下

享元模式類圖

示例

一般網盤對於相同的檔案只保留一份,譬如有一個場景:當我們上傳一部別人上傳過的電影,會發現很快就上傳完成了,實際上不是真的上傳,而是引用別人曾經上傳過的那部電影,這樣一可以提高我們的使用者體驗,二可以節約儲存空間避免資源浪費

注意:這個場景是小編想的,與一般見到的例子不太一樣,小編其實不確定是不是享元模式,請大家多多指教

首先定義一個工具類 HashUtil,計算內容的hash值(注:計算hash是從 www.cnblogs.com/oxgen/p/396… 處複製的)

public class HashUtil {
    public static String computeHashId(String key) {
        String cacheKey;
        try {
            final MessageDigest mDigest = MessageDigest.getInstance("MD5");
            mDigest.update(key.getBytes());
            cacheKey = bytesToHexString(mDigest.digest());
        } catch (NoSuchAlgorithmException e) {
            cacheKey = String.valueOf(key.hashCode());
        }
        return cacheKey;
    }

    private static String bytesToHexString(byte[] bytes) {
        // http://stackoverflow.com/questions/332079
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < bytes.length; i++) {
            String hex = Integer.toHexString(0xFF & bytes[i]);
            if (hex.length() == 1) {
                sb.append('0');
            }
            sb.append(hex);
        }
        return sb.toString();
    }
}
複製程式碼

資源類 Resource,相當於享元類的內部狀態

public class Resource {
    private String hashId;
    private int byteSize;
    private String content;

    public Resource(String content) {
        this.content = content;
        this.hashId = HashUtil.computeHashId(content);   // 檔案的hash值
        this.byteSize = content.length();
    }
    // ....getter、setter、toString...
}
複製程式碼

使用者的檔案類 File,其中的 resource 為內部狀態,owner和filename為外部狀態

public  class File {
    protected String owner;
    protected String filename;
    protected Resource resource;

    public File(String owner, String filename) {
        this.owner = owner;
        this.filename = filename;
    }

    public String fileMeta() {// 檔案儲存到檔案系統中需要的key
        if (this.owner == null || filename == null || resource == null) {
            return "未知檔案";
        }
        return owner + "-" + filename + resource.getHashId();
    }


    public String display() {
        return fileMeta() + ", 資源內容:" + getResource().toString();
    }
    // ....getter、setter、toString...
}
複製程式碼

網盤類 PanServer,該類使用單例模式(在其他例子中該類還使用工廠方法模式),在upload方法中根據所上傳的檔案的hashId判斷是否已經有相同內容的檔案存在,存在則引用,不存在才上傳該檔案

public class PanServer {
    private static PanServer panServer = new PanServer(); // 單例模式
    private Map<String, Resource> resourceSystem; // 資源系統,相當於享元池
    private Map<String, File> fileSystem;   // 檔案系統

    public PanServer() {
        resourceSystem = new HashMap<String, Resource>();
        fileSystem = new HashMap<String, File>();
    }

    public static PanServer getInstance() {
        return panServer;
    }

    public String upload(String username, LocalFile localFile) {
        long startTime = System.currentTimeMillis();
        File file = new File(username, localFile.getFilename());
        String hashId = HashUtil.computeHashId(localFile.getContent());     // 計算檔案hash值
        System.out.println(username + " 上傳檔案");
        try {
            if (resourceSystem.containsKey(hashId)) {
                System.out.println(String.format("檢測到內容相同的檔案《%s》,為了節約空間,重用檔案", localFile.getFilename()));
                file.setResource(this.resourceSystem.get(hashId));
                Thread.sleep(100);
            } else {
                System.out.println(String.format("檔案《%s》上傳中....", localFile.getFilename()));
                Resource newResource = new Resource(localFile.getContent());
                file.setResource(newResource);
                this.resourceSystem.put(newResource.getHashId(), newResource); // 將資源物件儲存到資源池中
                Thread.sleep(3000);     // 上傳檔案需要耗費一定時間
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
        fileSystem.put(file.fileMeta(), file);
        long endTime = System.currentTimeMillis();
        System.out.println(String.format("檔案上傳完成,共耗費 %s 毫秒\n", endTime - startTime));
        return file.fileMeta();
    }


    public void download(String fileKey) {
        File file = this.fileSystem.get(fileKey);
        if (file == null) {
            System.out.println("檔案不存在");
        } else {
            System.out.println("下載檔案:" + file.display());
        }
        // 轉為 LocalFile 返回
    }
}
複製程式碼

客戶端和本地檔案類

public class LocalFile {
    private String filename;
    private String content;

    public LocalFile(String filename, String content) {
        this.filename = filename;
        this.content = content;
    }
    //...省略...
}

public class Test {
    public static void main(String[] args) {
        PanServer panServer = PanServer.getInstance();

        String fileContent = "這是一個pdf檔案《設計模式:從入門到放棄》";
        LocalFile localFile1 = new LocalFile("小明的設計模式.pdf", fileContent);
        String fikeKey1 = panServer.upload("小明", localFile1);

        LocalFile localFile2 = new LocalFile("大明的設計模式.pdf", fileContent);
        String fikeKey2 = panServer.upload("大明", localFile2);

        panServer.download(fikeKey1);
        panServer.download(fikeKey2);
    }
}
複製程式碼

輸出

小明 上傳檔案
檔案《小明的設計模式.pdf》上傳中....
檔案上傳完成,共耗費 3077 毫秒

大明 上傳檔案
檢測到內容相同的檔案《大明的設計模式.pdf》,為了節約空間,重用檔案
檔案上傳完成,共耗費 100 毫秒

下載檔案:小明-小明的設計模式.pdf-f73ea50f00f87b42d1f2e4eb6b71d383, 資源內容:Resource {hashId='f73ea50f00f87b42d1f2e4eb6b71d383', byteSize=22, content='這是一個pdf檔案《設計模式:從入門到放棄》'}
下載檔案:大明-大明的設計模式.pdf-f73ea50f00f87b42d1f2e4eb6b71d383, 資源內容:Resource {hashId='f73ea50f00f87b42d1f2e4eb6b71d383', byteSize=22, content='這是一個pdf檔案《設計模式:從入門到放棄》'}
複製程式碼

小明和大明各自上傳了一份檔案,檔案的內容(內部狀態)是相同的,但是名稱(外部狀態)不同,由於內部狀態相同沒有必要重複儲存,所以內部狀態之拷貝了一份

享元模式總結

享元模式的主要優點如下:

  • 可以極大減少記憶體中物件的數量,使得相同或相似物件在記憶體中只儲存一份,從而可以節約系統資源,提高系統效能。
  • 享元模式的外部狀態相對獨立,而且不會影響其內部狀態,從而使得享元物件可以在不同的環境中被共享。

享元模式的主要缺點如下:

  • 享元模式使得系統變得複雜,需要分離出內部狀態和外部狀態,這使得程式的邏輯複雜化。
  • 為了使物件可以共享,享元模式需要將享元物件的部分狀態外部化,而讀取外部狀態將使得執行時間變長。

適用場景

  • 一個系統有大量相同或者相似的物件,造成記憶體的大量耗費。
  • 物件的大部分狀態都可以外部化,可以將這些外部狀態傳入物件中。
  • 在使用享元模式時需要維護一個儲存享元物件的享元池,而這需要耗費一定的系統資源,因此,應當在需要多次重複使用享元物件時才值得使用享元模式。

原始碼分析享元模式的典型應用

String中的享元模式

Java中將String類定義為final(不可改變的),JVM中字串一般儲存在字串常量池中,java會確保一個字串在常量池中只有一個拷貝,這個字串常量池在JDK6.0以前是位於常量池中,位於永久代,而在JDK7.0中,JVM將其從永久代拿出來放置於堆中。

我們做一個測試:

public class Main {
    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
        System.out.println(s1==s3);//true
        System.out.println(s1==s4);//false
        System.out.println(s1==s9);//false
        System.out.println(s4==s5);//false
        System.out.println(s1==s6);//true
    }
}
複製程式碼

String類的final修飾的,以字面量的形式建立String變數時,jvm會在編譯期間就把該字面量hello放到字串常量池中,由Java程式啟動的時候就已經載入到記憶體中了。這個字串常量池的特點就是有且只有一份相同的字面量,如果有其它相同的字面量,jvm則返回這個字面量的引用,如果沒有相同的字面量,則在字串常量池建立這個字面量並返回它的引用。

由於s2指向的字面量hello在常量池中已經存在了(s1先於s2),於是jvm就返回這個字面量繫結的引用,所以s1==s2

s3中字面量的拼接其實就是hello,jvm在編譯期間就已經對它進行優化,所以s1和s3也是相等的。

s4中的new String("lo")生成了兩個物件,lonew String("lo")lo存在字串常量池,new String("lo")存在堆中,String s4 = "hel" + new String("lo")實質上是兩個物件的相加,編譯器不會進行優化,相加的結果存在堆中,而s1存在字串常量池中,當然不相等。s1==s9的原理一樣。

s4==s5兩個相加的結果都在堆中,不用說,肯定不相等。

s1==s6中,s5.intern()方法能使一個位於堆中的字串在執行期間動態地加入到字串常量池中(字串常量池的內容是程式啟動的時候就已經載入好了),如果字串常量池中有該物件對應的字面量,則返回該字面量在字串常量池中的引用,否則,建立複製一份該字面量到字串常量池並返回它的引用。因此s1==s6輸出true。

Integer 中的享元模式

使用例子如下:

    public static void main(String[] args) {
        Integer i1 = 12 ;
        Integer i2 = 12 ;
        System.out.println(i1 == i2);

        Integer b1 = 128 ;
        Integer b2 = 128 ;
        System.out.println(b1 == b2);
    }
複製程式碼

輸出是

true
false
複製程式碼

為什麼第一個是true,第二個是false? 反編譯後可以發現 Integer b1 = 128; 實際變成了 Integer b1 = Integer.valueOf(128);,所以我們來看 Integer 中的 valueOf 方法的實現

public final class Integer extends Number implements Comparable<Integer> {
    public static Integer valueOf(int var0) {
        return var0 >= -128 && var0 <= Integer.IntegerCache.high ? Integer.IntegerCache.cache[var0 + 128] : new Integer(var0);
    }
    //...省略...
}
複製程式碼

IntegerCache 快取類

    //是Integer內部的私有靜態類,裡面的cache[]就是jdk事先快取的Integer。
    private static class IntegerCache {
        static final int low = -128;//區間的最低值
        static final int high;//區間的最高值,後面預設賦值為127,也可以使用者手動設定虛擬機器引數
        static final Integer cache[]; //快取陣列

        static {
            // high value may be configured by property
            int h = 127;
            //這裡可以在執行時設定虛擬機器引數來確定h  :-Djava.lang.Integer.IntegerCache.high=250
            String integerCacheHighPropValue =
                sun.misc.VM.getSavedProperty("java.lang.Integer.IntegerCache.high");
            if (integerCacheHighPropValue != null) {//使用者設定了
                int i = parseInt(integerCacheHighPropValue);
                i = Math.max(i, 127);//雖然設定了但是還是不能小於127
                // 也不能超過最大值
                h = Math.min(i, Integer.MAX_VALUE - (-low) -1);
            }
            high = h;

            cache = new Integer[(high - low) + 1];
            int j = low;
            //迴圈將區間的數賦值給cache[]陣列
            for(int k = 0; k < cache.length; k++)
                cache[k] = new Integer(j++);
        }

        private IntegerCache() {}
    }
複製程式碼

可以看到 Integer 預設先建立並快取 -128 ~ 127 之間數的 Integer 物件,當呼叫 valueOf 時如果引數在 -128 ~ 127 之間則計算下標並從快取中返回,否則建立一個新的 Integer 物件

Long中的享元模式

public final class Long extends Number implements Comparable<Long> {
    public static Long valueOf(long var0) {
        return var0 >= -128L && var0 <= 127L ? Long.LongCache.cache[(int)var0 + 128] : new Long(var0);
    }   
    private static class LongCache {
        private LongCache(){}

        static final Long cache[] = new Long[-(-128) + 127 + 1];

        static {
            for(int i = 0; i < cache.length; i++)
                cache[i] = new Long(i - 128);
        }
    }
    //...
}
複製程式碼

同理,Long 中也有快取,不過不能指定快取最大值

Apache Commons Pool2中的享元模式

物件池化的基本思路是:將用過的物件儲存起來,等下一次需要這種物件的時候,再拿出來重複使用,從而在一定程度上減少頻繁建立物件所造成的開銷。用於充當儲存物件的“容器”的物件,被稱為“物件池”(Object Pool,或簡稱Pool)

Apache Commons Pool實現了物件池的功能。定義了物件的生成、銷燬、啟用、鈍化等操作及其狀態轉換,並提供幾個預設的物件池實現。

有幾個重要的物件:

PooledObject(池物件):用於封裝物件(如:執行緒、資料庫連線、TCP連線),將其包裹成可被池管理的物件。
PooledObjectFactory(池物件工廠):定義了操作PooledObject例項生命週期的一些方法,PooledObjectFactory必須實現執行緒安全。
Object Pool (物件池):Object Pool負責管理PooledObject,如:借出物件,返回物件,校驗物件,有多少啟用物件,有多少空閒物件。

 // 物件池
 private final Map<S, PooledObject<S>> allObjects = new ConcurrentHashMap<S, PooledObject<S>>();
複製程式碼

重要方法:

borrowObject:從池中借出一個物件。
returnObject:將一個物件返還給池。

由於篇幅較長,後面會專門出一篇介紹並使用 Apache Commons Pool2 的文章,敬請期待

參考:
劉偉:設計模式Java版
慕課網java設計模式精講 Debug 方式+記憶體分析
Java中String字串常量池
Integer的享元模式解析
7種結構型模式之:享元模式(Flyweight)與資料庫連線池的原理
Apache commons-pool2-2.4.2原始碼學習筆記
Apache Commons Pool2 原始碼分析

相關文章