日報表格只有一份—單例模式

anly_jun發表於2019-03-01

前情提要

上集講到, 小光建立了開分店的標準(工廠), 以後開分店都按照這套標準執行(從CompanyFactory的實現中生產開分店的必須東西), 開分店變得更加容易了.

小光也是馬上將自己的這套”開分公司的工廠”投入使用了, 開出了花山軟體新城分店.

隨著分店越來越多, 小光也請了分別請了店長來”代理”小光之前的職責. 當然, 小光可不能完全放任不管啊, 他想著我至少得知道下每天各個店的基本情況吧.

所有示例原始碼已經上傳到Github, 戳這裡

日報制度

小光想到了當時做程式猿時, 敏捷開發每天站立會議的三個問題:

  1. 昨天完成了什麼
  2. 今天要做什麼
  3. 有什麼困難, 阻力

心想, 我也可以根據這個弄個日報制度啊, 讓各個店長按照這個格式彙報下當天的戰績:

日報表格只有一份—單例模式

表格:

public class Form {

    private ArrayList<String> mFormData = new ArrayList<>();

    public void write(String data) {
        mFormData.add(data);
    }

    @Override
    public String toString() {
        return "表格:" + this.hashCode() + ", 資料:" + mFormData;
    }
}複製程式碼

每天讓各個店子在打烊之前在系統中拿當日的表格(如果還沒有, 就建立一個)來填寫資料, 然後提交.

出了問題

想法挺好, 但是剛剛用上, 就出了問題:

光谷店店長表妹登入系統, 發現2016-12-16這個資料夾中還沒有表格檔案, 於是本地建立了一個, 用來填寫資料, 準備稍後提交. 然而此時, 花山店的店長小章也登入了系統, 也發現還沒有表格檔案, 也建立了一個…

讓我們來看下操作:

表妹:

public class Cousins {

    public Form submitReport() {
        Form form = new Form();
        form.write("光谷店資料");
        return form;
    }
}複製程式碼

小章:

public class XiaoZhang {

    public Form submitReport() {
        Form form = new Form();
        form.write("花山店資料");
        return form;
    }
}複製程式碼

兩人的使用流程:

public class Demo {

    public static void main(String[] args) {

        Cousins cousins = new Cousins();
        Form form = cousins.submitReport();
        System.out.println(form);

        XiaoZhang xiaoZhang = new XiaoZhang();
        Form form2 = xiaoZhang.submitReport();
        System.out.println(form2);
    }
}複製程式碼

來看下結果:

表格:1639705018, 資料:[光谷店資料]
表格:1627674070, 資料:[花山店資料]複製程式碼

最終這個資料夾中有了兩個(不同的)表格, 小光看起來很是不方便…

表格應該只能有一份

表格只能有一份, 小光心想. 那麼怎麼保證呢, 很簡單, 我提前給建立好, 大家通過統一的介面來取這個檔案, 而不能自己建立. 這樣就不會有問題了:

public class HungryForm extends Form {

    // 提前建立好
    private static HungryForm sInstance = new HungryForm();

    // 私有化的構造, 避免別人直接建立表格
    private HungryForm() {}

    // 店長們通過這個介面來取表格
    public static HungryForm getInstance() {
        return sInstance;
    }
}複製程式碼

店長們這樣提交報告:

public class Cousins {

    public Form submitReport() {
        // 直接新建一個表格
        // Form form = new Form();

        // 從固定的介面取表格
        Form form = HungryForm.getInstance();
        form.write("光谷店資料");
        return form;
    }
}

public class XiaoZhang {

    public Form submitReport() {
        // 直接新建一個表格
        // Form form = new Form();

        // 從固定的介面取表格
        Form form = HungryForm.getInstance();
        form.write("花山店資料");
        return form;
    }
}複製程式碼

提交方式不變:

public class Demo {

    public static void main(String[] args) {

        Cousins cousins = new Cousins();
        Form form = cousins.submitReport();
        System.out.println(form);

        XiaoZhang xiaoZhang = new XiaoZhang();
        Form form2 = xiaoZhang.submitReport();
        System.out.println(form2);
    }
}複製程式碼

來看下現在的結果:

表格:1639705018, 資料:[光谷店資料]
表格:1639705018, 資料:[光谷店資料, 花山店資料]複製程式碼

可以看到兩人用的是同一份表格(hashCode一樣的), 生成的資料也沒有問題了.

故事之後

看到這, 同學們應該都看出來了, 小光這就是使用了大名鼎鼎的單例模式.
照例, 看下類圖, 這個應該是最簡單的類圖了:

日報表格只有一份—單例模式

單例模式
保證一個類(HungryForm)僅有一個例項(sInstance), 並提供一個訪問該例項的全域性訪問點(getInstance).
這就意味著單例通常有如下兩個特點:

  1. 建構函式是私有的(避免別的地方建立它)
  2. 有一個static的方法來對外提供一個該單例的例項.

擴充套件閱讀一

同學們可能注意到了, 我們在這個單例模式中使用了Hungry這個詞, 沒錯, 我們這裡實現單例的方式使用的就是餓漢式.

1, 餓漢式單例

餓漢式單例
顧名思義, 就是很餓, 不管三七二十一先建立了一個例項放著, 而不管最終用不用.

然而, 這個單例可能最終並不需要, 如果提前就建立好, 就會浪費記憶體空間了.
例如, 我們這個故事中, 年底假期中, 所有店子都歇業十天, 這十天就沒有任何店長會去訪問這個表格, 然而小光還是都每天都建立了, 這就造成了空間浪費(假設這個表格資料(物件例項)很大…)

2, 懶漢式單例

那麼怎麼辦呢?
我們可以使用懶漢式單例:

public class LazyForm extends Form {

    private static LazyForm sInstance;

    // 私有化的構造, 避免別人直接建立表格
    private LazyForm() {}

    // 店長們通過這個介面來取表格
    public static LazyForm getInstance() {

        // 在有店長訪問該檔案時才建立, 通過判斷當前檔案是否存在(sInstance == null)來避免重複建立
        if (sInstance == null) {
            sInstance = new LazyForm();
        }
        return sInstance;
    }
}複製程式碼

懶漢式單例
“懶”, 也就是現在懶得建立, 等有使用者要用的時候才建立.

3, 執行緒安全的懶漢式單例

但是這樣建立也會有問題啊, 因為他是通過sInstance == null判斷當前是否已經存在表格檔案的, 假設有兩個店長同時呼叫getInstance來取檔案, 同時走到sInstance == null判斷這一步, 就會出問題了 — 有可能建立了兩個檔案(例項), 就達不到單例的目的了.

所以說這種懶漢式是執行緒不安全的, 在多執行緒環境下, 並不能做到單例.

那麼, 該如何做, 既能懶載入, 又執行緒安全呢?
我們都知道Java中多執行緒環境往往會用到synchronized關鍵字, 通過他來做執行緒併發性控制.

synchronized方法控制對類成員變數的訪問, 每個類例項對應一把鎖, synchronized修飾的方法必須獲得呼叫該方法的類例項的鎖方能執行, 否則所屬執行緒阻塞. 方法一旦執行, 就獨佔該鎖. 直到從該方法返回時才將鎖釋放. 此後被阻塞的執行緒方能獲得該鎖, 重新進入可執行狀態.

讓我們來看下執行緒安全的懶漢式單例:

public class SynchronizedLazyForm extends Form {

    private static SynchronizedLazyForm sInstance;

    // 私有化的構造, 避免別人直接建立表格
    private SynchronizedLazyForm() {}

    // 店長們通過這個介面來取表格
    // 注意, 這是一個synchronized方法
    // 參考https://docs.oracle.com/javase/tutorial/essential/concurrency/syncmeth.html
    public static synchronized SynchronizedLazyForm getInstance() {

        // 在有店長訪問該檔案時才建立, 通過判斷當前檔案是否存在(sInstance == null)來避免重複建立
        if (sInstance == null) {
            sInstance = new SynchronizedLazyForm();
        }
        return sInstance;
    }
}複製程式碼

執行緒安全的懶漢式單例
利用synchronized關鍵字來修飾對外提供該類唯一例項的介面(getInstance)來確保在一個執行緒呼叫該介面時能阻塞(block)另一個執行緒的呼叫, 從而達到多執行緒安全, 避免重複建立單例.

然而, synchronized有很大的效能開銷. 而且在這裡我們是修飾了getInstance方法, 意味著, 如果getInstance被很多執行緒頻繁呼叫時, 每次都會做同步檢查, 會導致程式效能下降.

實際上我們要的是單例, 當單例已經存在的時候, 我們是不需要用同步方法來控制的. 一如我們第一種單例的實現—餓漢模式單例, 我們一開始就建立好了單例, 就無需擔心執行緒同步問題.

但是餓漢模式是提前建立, 那麼我們怎麼能做到延遲建立, 且執行緒安全, 且效能有所提升呢?

4, 雙重檢查鎖定DCL(Double-Checked Locking)單例

如上所言, 我們想要的是單例, 故而單例已經存在的情況下我們無需做同步檢查, 如下實現:

public class DCLForm extends Form {

    // 注意, 這裡我們引入了volatile關鍵字
    private volatile static DCLForm sInstance;

    // 私有化的構造, 避免別人直接建立表格
    private DCLForm() {}

    // 店長們通過這個介面來取表格
    public static DCLForm getInstance() {

        // 第一次檢查
        if (sInstance == null) {
            // 第一次呼叫getInstance時, sInstance為空, 進入此分支
            // 使用synchronized block來確保多執行緒的安全
            synchronized (DCLForm.class) {
                // 第二次檢查
                if (sInstance == null) {
                    sInstance = new DCLForm();
                }
            }
        }
        return sInstance;
    }
}複製程式碼
  1. 捨棄了同步方法
  2. 在getInstance時, 先檢查單例是否已經存在, 如果存在了, 我們無需同步操作了, 任何執行緒過來直接取單例就行, 大大提升了效能.
  3. 若單例不存在(第一次呼叫時), 使用synchronized同步程式碼塊, 來確保進入的只有一個執行緒, 在此再做一次單例存在與否的檢查, 進而建立出單例.

這樣就保證了:

  1. 在單例還沒有建立時, 多個執行緒同時呼叫getInsance時, 保證只有一個執行緒能夠執行sInstance = new DCLForm()建立單例.
  2. 在單例已經存在時, getInsance沒有加鎖, 直接訪問, 訪問建立好的單例, 從而達到效能提升.

注意
這裡我們對sInstance使用的volatile關鍵字
具體原因和原理, 請參考這篇文章, 講的很詳細.

然而, 使用volatile關鍵字的雙重檢查方案需要JDK5及以上(因為從JDK5開始使用新的JSR-133記憶體模型規範,這個規範增強了volatile的語義).

那麼我們還有什麼更通用的方式能保證多執行緒單例建立, 以及懶載入方式呢?

5, 靜態內部類單例

public class StaticInnerClassForm extends Form {

    // 私有化的構造, 避免別人直接建立表格
    private StaticInnerClassForm() {}

    // 店長們通過這個介面來取表格
    public static StaticInnerClassForm getInstance() {
       return FormHolder.INSTANCE;
    }

    // 在靜態內部類中例項化該單例
    private static class FormHolder {
       private static final StaticInnerClassForm INSTANCE = new StaticInnerClassForm();
    }
}複製程式碼

這種方式, 通過JVM的類載入方式(虛擬機器會保證一個類的初始化在多執行緒環境中被正確的加鎖、同步), 來保證了多執行緒併發訪問的正確性.

另外, 由於靜態內部類的載入特性 — 在使用時才載入, 這種方式也達成了懶載入的目的.

顯然, 這種方式是一種比較完美的單例模式. 當然, 它也有其弊端, 依賴特定程式語言, 適用於JAVA平臺.


還有很多單例的實現模式, 例如利用JDK 5起的Enum 列舉單例模式, 使用容器類管理的單例模式等, 在此就不一一說了, 網上都比較氾濫了…

從使用上, 如果是單執行緒環境的, 個人推薦使用第二種懶漢式單例, 簡單便捷. 如果考慮多執行緒同步的話, 推薦使用第五種靜態內部類單例, 確保同步且懶載入完美結合.


好了, 小光建立並改善了一套完整的日報系統. 這樣, 他每天就可以看到各個分店的戰況了, 也能根據各個店的問題, 來及時協調資源解決, 保證各個分店的良好運轉了.

相關文章