設計模式——單例模式(懶漢式與餓漢式)詳解

一、什麼是單例?
    單例模式(Singleon),是一種常用的軟件設計模式。在應用這個模式時,單例對象的類必須保證只有一個實例存在。


二、單例的種類有哪些?之間有什麼區別?

  • 懶漢式:指全局的單例實例在第一次被使用時構建。
  • 餓漢式:全局的單例實例在類裝載(ClassLoader)時構建。(餓漢式單例性能優於懶漢式單例)

1、懶漢式與餓漢式區別:
    1.1、懶漢式默認不會實例化,外部什麼時候調用什麼時候new。餓漢式在類加載的時候就實例化,並且創建單例對象。
     1.2、懶漢式是延時加載,在需要的時候才創建對象,而餓漢式是在虛擬機啓動的時候就會創建。
     1.3、懶漢式在多線程中是線程不安全的,而餓漢式是不存在多線程安全問題的。

2、懶漢模式:

2.1、創建一個最簡單的懶漢式單例【方法1】

//最簡單的一種懶漢式單例模式
public class SingleTest {
    //定義一個私有類變量來存放單例,私有的目的是指外部無法直接獲取這個變量,而要使用提供的公共方法來獲取。
    private static SingleTest instance; 
    public SingleTest () {}  
    //定義一個公共的公開的方法來返回該類的實例。
    public static SingleTest getInstance() {
        //第一次訪問去創建實例
        if (instance == null) {
            instance = new SingleTest ();
        }
        //否則直接返回實例
        return instance;
    }
}

注意:
上面是最簡單的一種懶漢式單例,但是這樣寫會有一些問題需要改進:

    1、構造器爲public,這樣外部可以調用,要改爲私有的private,防止外部調用。
    2、這種方式在多線程下是不安全的。 額。。 好吧!那麼我們接着改進完善,讓它越來越完美。

2.2、創建一個線程安全的懶漢式單例【方法2】

 // 把構造函數設置爲私有,並使用synchronized修飾詞來修飾方法,保證線程同步從而達到線程安全。
public class SingleTest {
    private static SingleTest instance;
    //定義私有構造器,表示只在類內部使用,只能在內部創建。
    private SingleTest () {}
    //使用synchronized修飾達到線程同步從而保證線程安全。
    public static synchronized SingleTest getInstance() {
        if (instance == null) {
            instance = new SingleTest ();
        }
        return instance;
    }
}

注意:
以上方法修改之後解決了第一次創建遺留的兩個問題:
    1、修改構造函數爲private,避免外部調用。
    2、使用synchronized修飾方法(也就是獲取對象的鎖),此時就避免多線程同時訪問同一個對象,保證線程同步,從而達到線程安全。

額。。看上去已經好了,但是還有問題
舉個例子吧:
    現在有A、B、C三個線程來訪問此對象創建單例。由於線程執行順序是由CPU心情決定的,所以不能保證誰先先訪問到。
假設:
    1、A線程先創建單例,getInstance()使用synchronized同步鎖,這個時候A線程就獲得了getInstance()的鎖。(synchronized同步鎖不瞭解的同學可以先學習下多線程中的線程同步)。

    2、此時B、C線程也被CPU調度來創建單例。但是這個時候A線程已經獲取了getInstance()的鎖,那麼B、C將無法調用getInstance()方法。B、C線程就會一直等待,直到A線程執行完畢纔可以訪問。

    3、這樣的話就造成了線程阻塞,影響性能。

2.3、讓我們改變同步鎖的位置試一試。。【方法3】

// 改變了synchronizaed同步鎖的位置,雙重檢查判斷
public class SingleTest {
    private static SingleTest instance;
    private SingleTest () {}
    public static SingleTest getInstance() {
        //先進行實例判斷,只有當不存在的時候纔去創建實例。
        //這樣就解決了99%的已經獲取實例但是還要去獲取同步鎖的問題。
        if (instance == null) {
            //用synchronized 同步代碼塊
            synchronized (SingleTest.class) {
                if (instance == null) {
                    instance = new SingleTest();
                }
            }
        }
        //如果已經獲取實例,那麼直接返回就可以,不必要去獲取同步鎖,也就不會影響其他線程,造成線程阻塞問題。
        return instance;
    }
}

    感覺是不是已經不錯了,也解決了上面寫法的效率問題,爲什麼說解決了效率問題呢?不是解決是提升效率哈哈。。。那麼我們來分析一下吧:

    1、首先我們單例的定義和含義:“單例對象的類必須保證只有一個實例存在”。那麼我們用單例其實就是爲了限制一個類不能多個實例,也就是說只能被創建一次,明白這點之後我們來看【方法2】的代碼。。。

    2、【方法2】A、B、C三個線程來創建實例,只有第一個訪問的線程纔會走:if(instance)下面的代碼 instance = new SingleTest();來創建實例。否則都會直接返回這個實例 return instance;。100次調用,1次new,99次直接return。創建實例的概率爲1%,獲取實例的概率爲99%。
    3、但是我們如果要像【方法2】中用synchronized同步鎖來修飾getInstance()方法,那麼不管是需要創建實例還是獲取實例都會只能被一個線程調用,那麼性能肯定會浪費很多,其實我們只關心1%創建實例。99%都是浪費性能。

    4、先在判斷if(instance)==null,如果是,那代表第一次來獲取實例,接着我們把同步鎖加在創建實例的代碼塊上,這樣就減少了獲取線程的性能消耗,只有在需要創建的時候纔會加同步鎖,纔可能會造成線程阻塞,只有1%的情況會影響性能,而不是【方法二】100%會影響。所以性能會得到提升。否則直接返回實例 return instance。

    OK,好像這樣寫既提升了性能,還保證了線程安全,已經很完善了,真的是這樣嗎?真的是安全了嗎?跟着我繼續看,看一看還能不能繼續改進,哈哈!

2.4、讓我們繼續改進吧!【方法4】

//使用volatile修飾變量,具有原子性、可見性和防止指令重排的特性。
public class SingleTest {
    private static volatile SingleTest instance;
    private SingleTest() {}
    public static SingleTest getInstance() {
        if (instance == null) {
            synchronized (SingleTest.class) {
                if (instance == null) {
                    instance = new SingleTest();
                }
            }
        }
        return instance;
    }
}

【方法4】中我們只在變量上加了一個volatile修飾詞,那麼爲什麼要這麼做呢?這樣做有什麼好處?我們接着分析:
  1、我們瞭解下原子操作、指令重排這兩個知識點。

  2、什麼是原子操作呢?
     簡單來說,原子操作(atomic)就是不可分割的操作,在計算機中,就是指不會因爲線程調度被打斷的操作。
小例子:

a=1;//賦值,把值1賦給a,這是原子操作。

假如m原先的值爲1,那麼對於這個操作,要麼執行成功m變成了6,要麼是沒執行m還是1,而不會出現諸如m=3這種中間態——即使是在併發的線程中。

int a=1;//先聲明一個變量,再把值賦給這個變量,這不是原子操作。

因爲這個操作對於計算機來說是兩步:
    1、聲明變量 int a;
    2、進行賦值 a=1;
這樣的話單線程情況下是沒有問題的,那麼在多線程下就會出現問題,因爲多線程的執行順序是不確定的。接下來大家需要了解一個新的知識點:指令重排。

  3、什麼是指令重排呢?
簡單來說,就是計算機爲了提高執行效率,會做的一些優化,在不影響最終結果的情況下,可能會對一些語句的執行順序進行調整。

  4、上述案例

第一步:int a;
第二步:a=1;

計算機在運行的時候不一定會按照正常的順序1——>2步執行,它可能會是2——>1,由於計算機的指令重排的特性,無論他們的執行順序是什麼都不會對運行結果造成影響。

  5、接下來我們再看我們【方法4】創建單例方法。
主要在於singleton = new Singleton()這句,這並非是一個原子操作,事實上在 JVM 中這句話大概做了下面 3 件事情
    1、內存區域分配內存給singleton
    2、 調用 Singleton 的構造函數來初始化成員變量,形成實例
    3、 將singleton對象指向分配的內存空間(執行完這步 singleton纔是非 null 了)
  分析完之後,是不是稍微明白了點,爲什麼說方法【4】及時加了同步鎖也不是線程安全的。
    但是在 JVM 的即時編譯器中存在指令重排序的優化。也就是說上面的第1步和第3步的順序是不能保證的,最終的執行順序可能是 1-2-3 也可能是 1-3-2。如果是後者,則在 3 執行完畢、2 未執行之前,被線程B搶佔了,這時 instance 已經是非 null 了(但卻沒有初始化),所以線程B會直接返回 instance,然後使用,然後順理成章地報錯。
    爲了保證原子性和禁止指令重排,所以我們valutile修飾詞。從而解決小概率線程不安全事件。

以上的話基本就是懶漢式單例模式的幾種寫法以及寫法的深入瞭解。大家多多思考。


3、餓漢模式

3.1、我們來創建一個餓漢式的單例模式【方法1】

//餓漢式單例實現

public class SingleTest {

    private static final SingleTest INSTANCE = new SingleTest();

    private SingleTest() {}

    public static SingleTest getInstance() {

        return INSTANCE;

    }

}

  餓漢式的寫法這樣就可以了,那麼我們來說下餓漢式的缺點吧。
    1、所以它的缺點也就只是餓漢式單例本身的缺點所在了——由於INSTANCE的初始化是在類加載時進行的,而類的加載是由ClassLoader來做的,所以開發者本來對於它初始化的時機就很難去準確把握。
    2、過早的就會被實例化,可能會造成資源浪費。
    3、如果初始化本身依賴於一些其他數據,那麼也就很難保證其他數據會在它初始化之前準備好。

3.2讓我們繼續改進一下【方法2】

public class Singleton {
    //創建一個內部靜態類
    private static class SingletonHolder {
        private static final Singleton INSTANCE = new Singleton();
    }
    private Singleton (){}
    public static final Singleton getInstance() {
        return SingletonHolder.INSTANCE;
    }
}

  通過內部靜態類來實現對初始化的控制。
    1、對於內部類SingletonHolder,它是一個餓漢式的單例實現,在SingletonHolder初始化的時候會由ClassLoader來保證同步,使INSTANCE是一個真·單例。
    2、由於SingletonHolder是一個內部類,只在外部類的Singleton的getInstance()中被使用,所以它被加載的時機也就是在getInstance()方法第一次被調用的時候。

總結:它利用了ClassLoader來保證了同步,同時又能讓開發者控制類加載的時機。從內部看是一個餓漢式的單例,但是從外部看來,又的確是懶漢式的實現。


三、單例有哪些優點和缺點呢?


優點:
    1、在內存裏只有一個實例,減少了內存的開銷,避免頻繁的創建和銷燬實例。
    2、避免對資源的多重佔用(比如寫文件操作),提升了性能。
    3、提供了對唯一實例的受控訪問。

缺點:
    1、不適用於變化的對象,如果同一類型的對象總是要在不同的用例場景發生變化,單例就會引起數據的錯誤,不能保存彼此的狀態。
    2、由於單利模式中沒有抽象層,因此單例類的擴展有很大的困難。
    3、從設計原則方面說,單例類的職責過重,在一定程度上違背了“單一職責原則”。
    4、濫用單例將帶來一些負面問題,如爲了節省資源將數據庫連接池對象設計爲的單例類,可能會導致共享連接池對象的程序過多而出現連接池溢出;如果實例化的對象長時間不被利用,系統會認爲是垃圾而被回收,這將導致對象狀態的丟失。


四、單例模式的使用場景:
1、WEB 中的計數器,不用每次刷新都在數據庫里加一次,用單例先緩存起來。
2、創建的一個對象需要消耗的資源過多,比如 I/O 與數據庫的連接等。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章