1. 程式人生 > 實用技巧 >WAN - 資料在廣域網中的傳輸 - Ponit-to-Point 協議

WAN - 資料在廣域網中的傳輸 - Ponit-to-Point 協議

聊聊單例模式,面試加分題

猶記得之前面xx時,面試官一上來就問你知道哪些設計模式,來手寫一個單例模式的場景;尷尬的我,只寫了懶漢式餓漢式,對於單例其他的變種一概不知;這次就來彌補下這方面的知識盲區!

餓漢式

餓漢式,從字面上理解就是很餓,一上來就要吃的,那麼它會把吃的先準備好,以滿足它的需求;那麼對應到程式上的表現就為:在類載入的時候就會首先進行例項的初始化,後面如果應用程式需要這個例項的話,就有現成的了,可以直接使用當前的單例物件!

我們來手寫下餓漢式的程式碼:

public class Singleton{
    // 宣告靜態私有例項 並例項化
    private static Singleton singleton = new Singleton();

    // 提供對外初始化方法 靜態類載入就初始化
    public static Singleton initInstance(){
        return singleton;
    }

    // 宣告私有構造方法  即在外部類無法通過new 初始化例項
    private Singleton(){

    }

    public void doSomeThing(){
        System.out.println("do some thing!");
    }
}
class SingletonDemo{
    public static void main(String[] args) {
        Singleton singleton = Singleton.initInstance();
    }
}

餓漢式的優點:它是執行緒安全的,因為單例物件在類載入的時候就被初始化了,當呼叫單例物件時只需要去把對應的物件賦值給變數即可!

餓漢式的缺點:如果這個類不經常使用,會造成一定的資源浪費!

懶漢式

懶漢式,就是比較懶,每次需要填飽肚子時才會外出覓食;那麼對應到程式層面的理解:當應用程式需要某個物件時,該物件的類就會去建立一個例項,而不是提前準備好的!

我們來手寫下懶漢式的程式碼:

public class Singleton2 {
    // 宣告私有靜態物件
    private static Singleton2 singleton2;

    // 對外提供初始化方法
    public static Singleton2 initInstance(){
        if(singleton2 == null){
            singleton2 = new Singleton2();
        }
        return singleton2;
    }

    // 私有構造器
    private Singleton2(){

    }

    public void doSomeThing(){
        System.out.println("do some thing!");
    }
}
class SingletonDemo2{
    public static void main(String[] args) {
        Singleton2 singleton2 = Singleton2.initInstance();
        singleton2.doSomeThing();
    }
}

同樣我們看下懶漢式的優點:不會造成資源的浪費

懶漢式的缺點:多執行緒情況下,會有執行緒安全的問題;

上面我們可以看到,餓漢式和懶漢式的唯一區別就是:餓漢式在類載入時就完成了物件的初始化,而懶漢式是在需要初始化的時候再去初始化物件;其實在單執行緒情況下,他們都是執行緒安全的;但是我們寫的程式碼,必須考慮多執行緒情況下的併發問題,那麼懶漢式的這種寫法基本不滿足需求,我們需要做些改造,使得它變得執行緒安全,滿足我們的需求!

雙重檢測鎖

我們知道,懶漢式下物件的初始化在併發環境下,可能多個執行緒同時執行到singleton2 == null,從而初始化了多個例項,這就引發了執行緒安全問題!

我們就需要改寫它的初始化方法,我們知道加鎖可以解決一般的執行緒安全問題,synchronized

這個關鍵字可以修飾一個程式碼塊或方法,被其修飾的方法或程式碼塊就被加了鎖;而從某些方面理解,synchronized是個同步鎖,亦是個可重入鎖!哈哈,關於鎖的種類及概念有點多,後面準備寫一篇關於鎖的部落格來總結下;不再發散了,迴歸正題

我們來改造下懶漢式的初始化方法如下:

// 對外提供初始化方法
public synchronized static Singleton2 initInstance(){
    if(singleton2 == null){
        singleton2 = new Singleton2();
    }
    return singleton2;
}

我們看下上面的程式碼,初看沒什麼問題是解決了執行緒安全問題;但是由於整個方法都被synchronized修飾,那麼在多執行緒的情況下就增加了執行緒同步的開銷,降低了程式的執行效率;為了改進這個問題,我們將synchronized放入到方法內,實現程式碼塊的同步;改下如下:

// 對外提供初始化方法
public  static Singleton2 initInstance(){
    if(singleton2 == null){
        synchronized(Singleton2.class){
            singleton2 = new Singleton2();
        }
    }
    return singleton2;
}

呃,這樣就滿足了我們的要求了嗎?聰明如你一定發現了,雖然我們將synchronized移到了方法內部,降低了同步的開銷,但是在併發的情況下假設多個執行緒同時執行到if(singleton2 == null)時,依舊會排隊初始化Singleton2例項,這樣又會造成新的執行緒安全問題;那麼為了解決這個問題,就出現了大名鼎鼎的“雙重檢測鎖”。我們來看下它的實現,將上述程式碼改寫如下:

// 對外提供初始化方法
public  static Singleton2 initInstance(){
    if(singleton2 == null){// 第一次非空判斷
        synchronized(Singleton2.class){
            if(singleton2 == null)// 第二次非空判斷
                singleton2 = new Singleton2();
        }
    }
    return singleton2;
}

哈哈,這個雙重即是判斷兩次的意思,並不是加兩把鎖哈;那麼這樣就能行了嗎?初看沒問題啊,但是我們細想之下這樣寫真的沒問題嗎?你寫的程式碼,執行的時候真的會按你想的過程執行嗎?有沒有考慮過指令重排呢?問題就出現在new Singleton2()這個程式碼上,這行程式碼不是一個原子操作!

我們再來回顧下指令重排的大致執行流程:

1.給物件例項分配記憶體空間

2.呼叫物件構造方法,初始化成員變數

3.將構造的物件指向分配的記憶體空間

問題就出在指令重排後,cpu對指令重排的優化上,也就是說上述的三個過程並不是每次都是1-2-3順序執行的,而是也有可能1-3-2;那麼我們試想下併發情況下可能出現的場景,當執行緒A執行到步驟3時,cpu時間片正好輪詢到執行緒B,那麼執行緒B判斷例項已經指向了對應的記憶體空間,不為null就不會 初始化例項了,就得到了一個未初始化完成的物件,這就導致了問題的誕生!

為了解決這個問題,我們知道還有一個關鍵字volatile可以完美的解決指令重排,使得非原子性的操作對其他物件是可見的!(volatile關鍵字保障了變數的記憶體的可見性和一致性問題,關於記憶體屏障可以看我之前的一篇文章JMM 記憶體模型知識點探究瞭解)。那麼我們將懶漢式改寫如下:

public class Singleton2 {
    // 宣告私有靜態物件
    private volatile static Singleton2 singleton2;

    // 對外提供初始化方法
    public  static Singleton2 initInstance(){
        if(singleton2 == null){
            synchronized(Singleton2.class){
                if(singleton2 == null)
                    singleton2 = new Singleton2();
            }
        }
        return singleton2;
    }

    // 私有構造器
    private Singleton2(){

    }

    public void doSomeThing(){
        System.out.println("do some thing!");
    }
}
class SingletonDemo2{
    public static void main(String[] args) {
        Singleton2 singleton2 = Singleton2.initInstance();
        singleton2.doSomeThing();
    }
}

其實除了上面的單例實現外,還有兩種常見的單例實現

靜態內部類

程式碼如下:

public class InnerClassSingleton {
    // 私有靜態內部類
    private static class InnerInstance{
        private static final InnerClassSingleton singleton = new InnerClassSingleton();
    }
    // 對外提供的初始化方法
    public static InnerClassSingleton initInstance(){
        return InnerInstance.singleton;
    }
    // 私有構造器
    private InnerClassSingleton(){

    }

    public void doSomeThing(){
        System.out.println("do some thing!");
    }
}
class InnerClassSingletonDemo{
    public static void main(String[] args) {
        InnerClassSingleton innerClassSingleton = InnerClassSingleton.initInstance();
        innerClassSingleton.doSomeThing();
    }
}

其實,靜態內部類的方式和餓漢式本質是一樣的,都是根據類載入機制來初始化例項,從而保證單例和執行緒安全的;不同的是靜態內部類的方式是按需構建例項,不會如餓漢式一樣造成資源浪費的問題;所以這個是餓漢式一個比較好的變種!

列舉類

列舉是比較推薦的一種單例模式,它是執行緒安全的,且通過反射、序列化以及反序列化都無法破壞它的單例屬性(其他的單例採用私有構造器的實現其實並不安全),至於為什麼呢?這個可以參考部落格:[為什麼要用列舉實現單例模式(避免反射、序列化問題)]

程式碼如下:

public class EnumSingleton {
    // 宣告私有的列舉型別
    private enum Enum{
        INSTANCE;
        // 宣告單例物件
        private final EnumSingleton instance;
        // 例項化
        Enum(){
            instance = new EnumSingleton();
        }
        private EnumSingleton getInstance(){
            return instance;
        }
    }
    // 對外提供的初始化方法
    public static EnumSingleton initInstance(){
        return Enum.INSTANCE.getInstance();
    }

    // 私有構造器
    private EnumSingleton(){

    }

    public void doSomeThing(){
        System.out.println("do some thing!");
    }
}
class EnumSingletonDemo{
    public static void main(String[] args) {
        EnumSingleton enumSingleton = EnumSingleton.initInstance();
        enumSingleton.doSomeThing();
    }
}

好,至此我們總結了單例的幾種實現方式;比較推薦的是後面兩種方式,一般懶漢式我們就採用雙重檢測鎖的方式;你可以發散思考下單例的應用場景,例如Spring中的Bean的初始化就是單例模式的典型應用,或者在訊息中心中使用比較頻繁的短連結!