Singleton設計模式

悠遊0902發表於2019-04-08

單例項Singleton設計模式可能是被討論和使用的最廣泛的一個設計模式了,這可能也是面試中問得最多的一個設計模式了。這個設計模式主要目的是想在整個系統中只能出現一個類的例項。這樣做當然是有必然的,比如你的軟體的全域性配置資訊,或者是一個Factory,或是一個主控類,等等。你希望這個類在整個系統中只能出現一個例項。當然,作為一個技術負責人的你,你當然有權利通過使用非技術的手段來達到你的目的。比如:你在團隊內部明文規定,“XX類只能有一個全域性例項,如果某人使用兩次以上,那麼該人將被處於2000元的罰款!”(呵呵),你當然有權這麼做。但是如果你的設計的是東西是一個類庫,或是一個需要提供給使用者使用的API,恐怕你的這項規定將會失效。因為,你無權要求別人會那麼做。所以,這就是為什麼,我們希望通過使用技術的手段來達成這樣一個目的的原因。

本文會帶著你深入整個Singleton的世界,當然,我會放棄使用C++語言而改用Java語言,因為使用Java這個語言可能更容易讓我說明一些事情。

Singleton的教學版本

這裡,我將直接給出一個Singleton的簡單實現,因為我相信你已經有這方面的一些基礎了。我們姑且把這個版本叫做1.0版 1 2 3 4 5 6 7 8 9 10 11

// version 1.0 public class Singleton { private static Singleton singleton = null; private Singleton() { } public static Singleton getInstance() { if (singleton== null) { singleton= new Singleton(); } return singleton; } }

在上面的例項中,我想說明下面幾個Singleton的特點:(下面這些東西可能是盡人皆知的,沒有什麼新鮮的)

私有(private)的建構函式,表明這個類是不可能形成例項了。這主要是怕這個類會有多個例項。
即然這個類是不可能形成例項,那麼,我們需要一個靜態的方式讓其形成例項:getInstance()。注意這個方法是在new自己,因為其可以訪問私有的建構函式,所以他是可以保證例項被建立出來的。
在getInstance()中,先做判斷是否已形成例項,如果已形成則直接返回,否則建立例項。
所形成的例項儲存在自己類中的私有成員中。
我們取例項時,只需要使用Singleton.getInstance()就行了。
複製程式碼

當然,如果你覺得知道了上面這些事情後就學成了,那得給你當頭棒喝一下了,事情遠遠沒有那麼簡單。 Singleton的實際版本

上面的這個程式存在比較嚴重的問題,因為是全域性性的例項,所以,在多執行緒情況下,所有的全域性共享的東西都會變得非常的危險,這個也一樣,在多執行緒情況下,如果多個執行緒同時呼叫getInstance()的話,那麼,可能會有多個程式同時通過 (singleton== null)的條件檢查,於是,多個例項就建立出來,並且很可能造成記憶體洩露問題。嗯,熟悉多執行緒的你一定會說——“我們需要執行緒互斥或同步”,沒錯,我們需要這個事情,於是我們的Singleton升級成1.1版,如下所示: 1 2 3 4 5 6 7 8 9 10 11 12 13 14

// version 1.1 public class Singleton { private static Singleton singleton = null; private Singleton() { } public static Singleton getInstance() { if (singleton== null) { synchronized (Singleton.class) { singleton= new Singleton(); } } return singleton; } }

嗯,使用了Java的synchronized方法,看起來不錯哦。應該沒有問題了吧?!錯!這還是有問題!為什麼呢?前面已經說過,如果有多個執行緒同時通過(singleton== null)的條件檢查(因為他們並行執行),雖然我們的synchronized方法會幫助我們同步所有的執行緒,讓我們並行執行緒變成序列的一個一個去new,那不還是一樣的嗎?同樣會出現很多例項。嗯,確實如此!看來,還得把那個判斷(singleton== null)條件也同步起來。於是,我們的Singleton再次升級成1.2版本,如下所示: 1 2 3 4 5 6 7 8 9 10 11 12 13 14

// version 1.2 public class Singleton { private static Singleton singleton = null; private Singleton() { } public static Singleton getInstance() { synchronized (Singleton.class) { if (singleton== null) { singleton= new Singleton(); } } return singleton; } }

不錯不錯,看似很不錯了。在多執行緒下應該沒有什麼問題了,不是嗎?的確是這樣的,1.2版的Singleton在多執行緒下的確沒有問題了,因為我們同步了所有的執行緒。只不過嘛……,什麼?!還不行?!是的,還是有點小問題,我們本來只是想讓new這個操作並行就可以了,現在,只要是進入getInstance()的執行緒都得同步啊,注意,建立物件的動作只有一次,後面的動作全是讀取那個成員變數,這些讀取的動作不需要執行緒同步啊。這樣的作法感覺非常極端啊,為了一個初始化的建立動作,居然讓我們達上了所有的讀操作,嚴重影響後續的效能啊!

還得改!嗯,看來,線上程同步前還得加一個(singleton== null)的條件判斷,如果物件已經建立了,那麼就不需要執行緒的同步了。OK,下面是1.3版的Singleton。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16

// version 1.3 public class Singleton { private static Singleton singleton = null; private Singleton() { } public static Singleton getInstance() { if (singleton== null) { synchronized (Singleton.class) { if (singleton== null) { singleton= new Singleton(); } } } return singleton; } }

感覺程式碼開始變得有點羅嗦和複雜了,不過,這可能是最不錯的一個版本了,這個版本又叫“雙重檢查”Double-Check。下面是說明:

第一個條件是說,如果例項建立了,那就不需要同步了,直接返回就好了。
不然,我們就開始同步執行緒。
第二個條件是說,如果被同步的執行緒中,有一個執行緒建立了物件,那麼別的執行緒就不用再建立了。
複製程式碼

相當不錯啊,幹得非常漂亮!請大家為我們的1.3版起立鼓掌!

但是,如果你認為這個版本大攻告成,你就錯了。

主要在於singleton = new Singleton()這句,這並非是一個原子操作,事實上在 JVM 中這句話大概做了下面 3 件事情。

給 singleton 分配記憶體
呼叫 Singleton 的建構函式來初始化成員變數,形成例項
將singleton物件指向分配的記憶體空間(執行完這步 singleton才是非 null 了)
複製程式碼

但是在 JVM 的即時編譯器中存在指令重排序的優化。也就是說上面的第二步和第三步的順序是不能保證的,最終的執行順序可能是 1-2-3 也可能是 1-3-2。如果是後者,則在 3 執行完畢、2 未執行之前,被執行緒二搶佔了,這時 instance 已經是非 null 了(但卻沒有初始化),所以執行緒二會直接返回 instance,然後使用,然後順理成章地報錯。

對此,我們只需要把singleton宣告成 volatile 就可以了。下面是1.4版:

// version 1.4
public class Singleton
{
    private volatile static Singleton singleton = null;
    private Singleton()  {    }
    public static Singleton getInstance()   {
        if (singleton== null)  {
            synchronized (Singleton.class) {
                if (singleton== null)  {
                    singleton= new Singleton();
                }
            }
        }
        return singleton;
    }
}
複製程式碼

使用 volatile 有兩個功用:

1)這個變數不會在多個執行緒中存在複本,直接從記憶體讀取。

2)這個關鍵字會禁止指令重排序優化。也就是說,在 volatile 變數的賦值操作後面會有一個記憶體屏障(生成的彙編程式碼上),讀操作不會被重排序到記憶體屏障之前。

但是,這個事情僅在Java 1.5版後有用,1.5版之前用這個變數也有問題,因為老版本的Java的記憶體模型是有缺陷的。 Singleton 的簡化版本

上面的玩法實在是太複雜了,一點也不優雅,下面是一種更為優雅的方式:

這種方法非常簡單,因為單例的例項被宣告成 static 和 final 變數了,在第一次載入類到記憶體中時就會初始化,所以建立例項本身是執行緒安全的。

// version 1.5
public class Singleton
{
    private volatile static Singleton singleton = new Singleton();
    private Singleton()  {    }
    public static Singleton getInstance()   {
        return singleton;
    }
}
複製程式碼

但是,這種玩法的最大問題是——當這個類被載入的時候,new Singleton() 這句話就會被執行,就算是getInstance()沒有被呼叫,類也被初始化了。

於是,這個可能會與我們想要的行為不一樣,比如,我的類的建構函式中,有一些事可能需要依賴於別的類乾的一些事(比如某個配置檔案,或是某個被其它類建立的資源),我們希望他能在我第一次getInstance()時才被真正的建立。這樣,我們可以控制真正的類建立的時刻,而不是把類的建立委託給了類裝載器。

好吧,我們還得繞一下:

下面的這個1.6版是老版《Effective Java》中推薦的方式。

// version 1.6
public class Singleton {
    private static class SingletonHolder {
        private static final Singleton INSTANCE = new Singleton();
    }
    private Singleton (){}
    public static final Singleton getInstance() {
        return SingletonHolder.INSTANCE;
    }
}
複製程式碼

上面這種方式,仍然使用JVM本身機制保證了執行緒安全問題;由於 SingletonHolder 是私有的,除了 getInstance() 之外沒有辦法訪問它,因此它只有在getInstance()被呼叫時才會真正建立;同時讀取例項的時候不會進行同步,沒有效能缺陷;也不依賴 JDK 版本。 Singleton 優雅版本

public enum Singleton{
   INSTANCE;
}
複製程式碼

居然用列舉!!看上去好牛逼,通過EasySingleton.INSTANCE來訪問,這比呼叫getInstance()方法簡單多了。

預設列舉例項的建立是執行緒安全的,所以不需要擔心執行緒安全的問題。但是在列舉中的其他任何方法的執行緒安全由程式設計師自己負責。還有防止上面的通過反射機制呼叫私用構造器。

這個版本基本上消除了絕大多數的問題。程式碼也非常簡單,實在無法不用。這也是新版的《Effective Java》中推薦的模式。 Singleton的其它問題

怎麼?還有問題?!當然還有,請記住下面這條規則——“無論你的程式碼寫得有多好,其只能在特定的範圍內工作,超出這個範圍就要出Bug了”,這是“陳式第一定理”,呵呵。你能想一想還有什麼情況會讓這個我們上面的程式碼出問題嗎?

在C++下,我不是很好舉例,但是在Java的環境下,嘿嘿,還是讓我們來看看下面的一些反例和一些別的事情的討論(當然,有些反例可能屬於鑽牛角尖,可能有點學院派,不過也不排除其實際可能性,就算是提個醒吧):

其一、Class Loader。不知道你對Java的Class Loader熟悉嗎?“類裝載器”?!C++可沒有這個東西啊。這是Java動態性的核心。顧名思義,類裝載器是用來把類(class)裝載進JVM的。JVM規範定義了兩種型別的類裝載器:啟動內裝載器(bootstrap)和使用者自定義裝載器(user-defined class loader)。 在一個JVM中可能存在多個ClassLoader,每個ClassLoader擁有自己的NameSpace。一個ClassLoader只能擁有一個class物件型別的例項,但是不同的ClassLoader可能擁有相同的class物件例項,這時可能產生致命的問題。如ClassLoaderA,裝載了類A的型別例項A1,而ClassLoaderB,也裝載了類A的物件例項A2。邏輯上講A1=A2,但是由於A1和A2來自於不同的ClassLoader,它們實際上是完全不同的,如果A中定義了一個靜態變數c,則c在不同的ClassLoader中的值是不同的。

於是,如果我們們的Singleton 1.3版本如果面對著多個Class Loader會怎麼樣?呵呵,多個例項同樣會被多個Class Loader建立出來,當然,這個有點牽強,不過他確實存在。難道我們還要整出個1.4版嗎?可是,我們怎麼可能在我的Singleton類中操作Class Loader啊?是的,你根本不可能。在這種情況下,你能做的只有是——“保證多個Class Loader不會裝載同一個Singleton”。

其二、序例化。如果我們的這個Singleton類是一個關於我們程式配置資訊的類。我們需要它有序列化的功能,那麼,當反序列化的時候,我們將無法控制別人不多次反序列化。不過,我們可以利用一下Serializable介面的readResolve()方法,比如:

public class Singleton implements Serializable
{
    ......
    ......
    protected Object readResolve()
    {
        return getInstance();
    }
}
複製程式碼

其三、多個Java虛擬機器。如果我們的程式執行在多個Java的虛擬機器中。什麼?多個虛擬機器?這是一種什麼樣的情況啊。嗯,這種情況是有點極端,不過還是可能出現,比如EJB或RMI之流的東西。要在這種環境下避免多例項,看來只能通過良好的設計或非技術來解決了。

其四,volatile變數。關於volatile這個關鍵字所宣告的變數可以被看作是一種 “程度較輕的同步synchronized”;與 synchronized 塊相比,volatile 變數所需的編碼較少,並且執行時開銷也較少,但是它所能實現的功能也僅是synchronized的一部分。當然,如前面所述,我們需要的Singleton只是在建立的時候執行緒同步,而後面的讀取則不需要同步。所以,volatile變數並不能幫助我們即能解決問題,又有好的效能。而且,這種變數只能在JDK 1.5+版後才能使用。

其五、關於繼承。是的,繼承於Singleton後的子類也有可能造成多例項的問題。不過,因為我們早把Singleton的建構函式宣告成了私有的,所以也就杜絕了繼承這種事情。

其六,關於程式碼重用。也話我們的系統中有很多個類需要用到這個模式,如果我們在每一個類都中有這樣的程式碼,那麼就顯得有點傻了。那麼,我們是否可以使用一種方法,把這具模式抽象出去?在C++下這是很容易的,因為有模板和友元,還支援棧上分配記憶體,所以比較容易一些(程式如下所示),Java下可能比較複雜一些,聰明的你知道怎麼做嗎?


template class Singleton
{
    public:
        static T& Instance()
        {
            static T theSingleInstance; //假設T有一個protected預設建構函式
            return theSingleInstance;
        }
};
 
class OnlyOne : public Singleton
{
    friend class Singleton;
    int example_data;
 
    public:
        int GetExampleData() const {return example_data;}
    protected:
        OnlyOne(): example_data(42) {}   // 預設建構函式
        OnlyOne(OnlyOne&) {}
};
 
int main( )
{
    cout << OnlyOne::Instance().GetExampleData() << endl;
    return 0;
}
複製程式碼

文章轉載自:coolshell.cn/articles/26…

相關文章