1. 程式人生 > >Java單例模式學習記錄

Java單例模式學習記錄

開發十年,就只剩下這套架構體系了! >>>   

在專案開發中經常能遇見的設計模式就是單例模式了,而實現的方式最常見的有兩種:餓漢和飽漢(懶漢)。由於日常接觸較多而研究的不夠深入,導致面試的時候被詢問到後有點沒底,這裡記錄一下學習的過程。


餓漢實現

餓漢的名字由來就是因為很餓很著急,所以在類載入時即建立例項物件,實現如下:

public class Singleton {
	
	private static final Singleton singleton = new Singleton();
	
	private Singleton(){
		
	}
	
	public static Singleton getInstance(){
		return singleton;
	}


餓漢模式本身就是執行緒安全的,為什麼是執行緒安全的呢?原因是這樣的,JVM虛擬機器在執行類載入的初始化階段,能保證一個類的<clinit>方法在多執行緒環境下能夠被正確的加鎖,同步,如果多執行緒初始化一個類,那麼只有一個執行緒會去執行這個類的<clinit>方法,其他需要阻塞,更何況我們還加入了final關鍵字,如果某個成員是final的,JVM規範做出如下明確的保證:一旦物件引用對其他執行緒可見,則其final成員也必須正確的賦值了。

因此居於上述兩點能夠保證餓漢單例正確的在多執行緒環境下執行。


飽漢實現

飽漢的實現跟餓漢不同,飽漢只在呼叫獲取例項的時候才會進行new物件的過程,簡單的實現如下:


public class Singleton {

    private static Singleton singleton;

    private Singleton() {

    }

    public static Singleton getInstance() {
        if (singleton == null) {
            singleton = new Singleton();
        }
        return singleton;
    }
}

在單執行緒的環境中,使用該模式是完全沒有問題的,不會涉及到臨界問題,而在多執行緒模式下,那麼就不能保證了。假設有兩個執行緒A和B,A執行緒判斷singleton==null了,這時候進行singleton = new Singleton()操作,在該步還沒有完成時,執行緒B進入了方法體中,判斷singleton==null,由於A還沒有例項化完成Singleton,導致singleton==null成立,B執行緒也執行了singleton = new Singleton()的操作,那麼就不能保證在只有單次賦值的情況了,也就不能保證每個執行緒中的Singleton物件是一樣的。

那麼改進方式也很簡單,既然有臨界問題,那麼我們就加個鎖來保證執行緒的安全性問題:


public class Singleton {

    private static Singleton singleton;

    private Singleton() {

    }

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


這個方式就能保證單例模式的正常使用了,但是由於我們每次呼叫getInstance()的時候都要進行加鎖/解鎖的操作,在多執行緒中,在CPU排程切換不同執行緒時候會發生上下文切換,上下文切換時候,JVM需要去儲存當前執行緒對應的暫存器使用狀態,以及程式碼執行的位置等等,那麼肯定是會有一定的開銷的。而且當執行緒由於等待某個鎖而被阻塞的時候,JVM通常將該執行緒掛起,掛起執行緒和恢復執行緒都是需要轉到核心態中進行,頻繁的進行使用者態到核心態的切換對於作業系統的併發效能來說會造成不小的壓力。因此上面的寫法實際上相對來說較為低效,那麼,這個時候我們進行優化變成如下程式碼:

public class Singleton {

    private static Singleton singleton;

    private Singleton() {

    }

    public static Singleton getInstance() {
        if (singleton == null) {//1
            synchronized (Singleton.class) {
                if (singleton == null) {//2
                    singleton = new Singleton();
                }
            }
        }
        return singleton;
    }
}

在呼叫synchronized前提前判斷一步是否singleton == null,如果不等於null,那麼說明已經賦值成功,如果等於null,那麼在執行加鎖操作就可以了。所以加兩次判空的主要原因就是因為避免重複加/解鎖的操作,浪費系統資源。

那麼上面的實現還會不會有問題呢?首先分析一下singleton = new Singleton()這句話底層執行的過程:

  1. 在堆中分配Singleton物件記憶體

  2. 填充Singleton物件的必要資訊+具體資料初始化+末位填充

  3. 把singleton引用指向這個物件的堆內地址

本身singleton = new Singleton()不是一個原子操作,例項化過程會經過上面的三個步驟,而且JVM在遵守as-if-serial語義的情況下,允許進行指令重排序的過程,也就是可以執行1-3-2的操作的。

那麼在一些極端的情況就可能會出現問題:

  • 執行緒A和執行緒B同時訪問getInstance()方法,首先A先訪問步驟1,由於第一次訪問,所以肯定會走到singleton = new Singleton()中,這時候JVM進行了重排序優化1-3-2的過程。
  • 執行緒B線上程A例項化single的時候恰巧走到了步驟1當中,同時執行緒A中在執行3,即把singleton引用指向這個物件的堆內地址,由於這時候在鎖外訪問的步驟1,不遵循happen-before原則,執行緒B看到singleton引用不為空了,那麼就直接返回singleton引用了,那麼程式碼就出不符預期的問題。

解決方式也簡單,使用volatile,通過volatile的語義禁止指令重排序功能,那麼就解決了上面的問題了,正確程式碼如下:

public class Singleton {

    private static volatile Singleton singleton;

    private Singleton() {

    }

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