作爲一名程序員,或許誰都能說出一種或幾種設計模式,其中單利模式是最常見、最簡單的一種設計模式,但是我們或許對單例模式的瞭解不一定全面。今天我們就探討下單例模式。
【單例模式的定義】
單例模式確保某個類只有一個實例,而且自行實例化並向整個系統提供這個實例。
【單例模式的三個基本要點】
一是這個類只能有一個實例;二是它必須自行創建這個實例;三是它必須自行向整個系統提供這個實例。
【單例模式分類】
單例模式分爲餓漢式和懶漢式。
// 餓漢模式
public final class Singleton {
private static Singleton instance=new Singleton();// 自行創建實例
private Singleton(){}// 構造函數
public static Singleton getInstance(){// 通過該函數向整個系統提供實例
return instance;
}
}
餓漢式通過static修飾成員變量instance,該變量會在類初始化的過程中被收集進類構造器的clinit方法中,在多線程場景下,JVM會保證只有一個線程執行該clinit方法。等到唯一的一次clinit方法執行完成,其他線程將不會再執行該clinit方法,也就是說,static修飾的成員變量instance,在多線程環境下只實例化一次,從而保證了該對象只有一個實例。
餓漢式單例模式有一個缺點,在類初始化階段就創建好了對象,如果一個框架的類都採用這種方式,那麼在類初始化階段就把所有類都創建了一個對象,而一些類我們是不常用的,那麼這些不常用的類也被實例化了,這樣只是白白的浪費內存。餓漢式雖然高效,但是浪費內存。
// 懶漢模式
public final class Singleton {
private static Singleton instance= null;// 不實例化
private Singleton(){}// 構造函數
public static Singleton getInstance(){// 通過該函數向整個系統提供實例
if(null == instance){// 當 instance 爲 null 時,則實例化對象,否則直接返回對象
instance = new Singleton();// 實例化對象
}
return instance;// 返回已存在的對象
}
}
懶漢式單例模式是爲了避免直接在加載類時就創建對象,採用懶加載的方式,只有當系統使用到類對象時,纔會創建對象。這種方式在單線程是沒有問題,但是在多線程環境下,就會出現實例化多個對象的情況。
當線程A執行到if判斷還沒有創建對象時,A線程cpu時間片用完了,這時B線程也執行到If判斷,發現還沒有對象,就new一個對象出來,這時A線程又有執行權,繼續執行下面代碼,就會也new一個對象。這種情況下,就會創建多個實例。
【懶漢式單例模式逐步升級】
通過上面分析,這種懶漢式在多線程環境下可能會創建多個實例。那我們採用加同步鎖synchronized的方式,保證多線程情況下僅創建一個實例。
// 懶漢模式 + synchronized 同步鎖
public final class Singleton {
private static Singleton instance= null;// 不實例化
private Singleton(){}// 構造函數
public static synchronized Singleton getInstance(){// 加同步鎖,通過該函數向整個系統提供實例
if(null == instance){// 當 instance 爲 null 時,則實例化對象,否則直接返回對象
instance = new Singleton();// 實例化對象
}
return instance;// 返回已存在的對象
}
}
我們知道,同步鎖會增加鎖競爭,帶來系統性能開銷,從而導致系統性能下降,因此這種方式也會降低單例模式的性能。
還有,每次請求獲取類對象時,都會通過 getInstance() 方法獲取,除了第一次爲 null,其它每次請求基本都是不爲 null 的。在沒有加同步鎖之前,是因爲 if 判斷條件爲 null 時,才導致創建了多個實例。基於以上兩點,我們可以考慮將同步鎖放在 if 條件裏面,這樣就可以減少同步鎖資源競爭。所以我們進一步優化代碼如下:
// 懶漢模式 + synchronized 同步鎖
public final class Singleton {
private static Singleton instance= null;// 不實例化
private Singleton(){}// 構造函數
public static Singleton getInstance(){// 加同步鎖,通過該函數向整個系統提供實例
if(null == instance){// 當 instance 爲 null 時,則實例化對象,否則直接返回對象
synchronized (Singleton.class){
instance = new Singleton();// 實例化對象
}
}
return instance;// 返回已存在的對象
}
}
看到這裏,你是不是覺得這樣就可以了呢?答案是依然會創建多個實例。這是因爲當多個線程進入到 if 判斷條件裏,雖然有同步鎖,但是進入到判斷條件裏面的線程依然會依次獲取到鎖創建對象,然後再釋放同步鎖。所以我們還需要在同步鎖裏面再加一個判斷條件,進一步優化代碼如下:
// 懶漢模式 + synchronized 同步鎖 + double-check
public final class Singleton {
private static Singleton instance= null;// 不實例化
private Singleton(){}// 構造函數
public static Singleton getInstance(){// 加同步鎖,通過該函數向整個系統提供實例
if(null == instance){// 第一次判斷,當 instance 爲 null 時,則實例化對象,否則直接返回對象
synchronized (Singleton.class){// 同步鎖
if(null == instance){// 第二次判斷
instance = new Singleton();// 實例化對象
}
}
}
return instance;// 返回已存在的對象
}
}
以上這種方式,通常被稱爲 Double-Check,它可以大大提高支持多線程的懶漢模式的運行性能。那這樣做是不是就能保證萬無一失了呢?還會有什麼問題嗎?
其實這裏又跟 Happens-Before 規則和重排序扯上關係了,這裏我們先來簡單瞭解下 Happens-Before 規則和重排序。
JVM編譯器爲了儘可能地減少寄存器的讀取、存儲次數,會充分複用寄存器的存儲值,比如以下代碼,如果沒有進行重排序優化,正常的執行順序是步驟 1/2/3,而在編譯期間進行了重排序優化之後,執行的步驟有可能就變成了步驟 1/3/2,這樣就能減少一次寄存器的存取次數。
int a = 1;// 步驟 1:加載 a 變量的內存地址到寄存器中,加載 1 到寄存器中,CPU 通過 mov 指令把 1 寫入到寄存器指定的內存中
int b = 2;// 步驟 2 加載 b 變量的內存地址到寄存器中,加載 2 到寄存器中,CPU 通過 mov 指令把 2 寫入到寄存器指定的內存中
a = a + 1;// 步驟 3 重新加載 a 變量的內存地址到寄存器中,加載 1 到寄存器中,CPU 通過 mov 指令把 1 寫入到寄存器指定的內存中
在 JMM 中,重排序是十分重要的一環,特別是在併發編程中。如果 JVM 可以對它們進行任意排序以提高程序性能,也可能會給併發編程帶來一系列的問題。例如,我上面講到的 Double-Check 的單例問題,假設類中有其它的屬性也需要實例化,這個時候,除了要實例化單例類本身,還需要對其它屬性也進行實例化:
// 懶漢模式 + synchronized 同步鎖 + double-check
public final class Singleton {
private static Singleton instance= null;// 不實例化
public List<String> list = null;//list 屬性
private Singleton(){
list = new ArrayList<String>();
}// 構造函數
public static Singleton getInstance(){// 加同步鎖,通過該函數向整個系統提供實例
if(null == instance){// 第一次判斷,當 instance 爲 null 時,則實例化對象,否則直接返回對象
synchronized (Singleton.class){// 同步鎖
if(null == instance){// 第二次判斷
instance = new Singleton();// 實例化對象
}
}
}
return instance;// 返回已存在的對象
}
}
在執行 instance = new Singleton(); 代碼時,正常情況下,實例過程這樣的:
1、給 Singleton 分配內存;
2、調用 Singleton 的構造函數來初始化成員變量;
3、將 Singleton 對象指向分配的內存空間(執行完這步 singleton 就爲非 null 了)。
如果虛擬機發生了重排序優化,這個時候步驟 3 可能發生在步驟 2 之前。如果初始化線程剛好完成步驟 3,而步驟 2 沒有進行時,則剛好有另一個線程到了第一次判斷,這個時候判斷爲非 null,並返回對象使用,這個時候實際沒有完成其它屬性的構造,因此使用這個屬性就很可能會導致異常。在這裏,Synchronized 只能保證可見性、原子性,無法保證執行的順序。
這個時候,就體現出 Happens-Before 規則的重要性了。通過字面意思,你可能會誤以爲是前一個操作發生在後一個操作之前。然而真正的意思是,前一個操作的結果可以被後續的操作獲取。這條規則規範了編譯器對程序的重排序優化。
我們知道 volatile 關鍵字可以保證線程間變量的可見性,簡單地說就是當線程 A 對變量 X 進行修改後,在線程 A 後面執行的其它線程就能看到變量 X 的變動。除此之外,volatile 在 JDK1.5 之後還有一個作用就是阻止局部重排序的發生,也就是說,volatile 變量的操作指令都不會被重排序。所以使用 volatile 修飾 instance 之後,Double-Check 懶漢單例模式就萬無一失了。
第一種真正懶漢式單例模式代碼如下:
/**
* 單例模式--懶漢式:synchronized + double-check + volatile
* @author: lsl
* @date: 2020/1/3
*/
public class Singlnton {
private volatile static Singlnton instance = null;
private List<String> list = null;
private Singlnton(){
list = new ArrayList<>();
}
public static Singlnton getSinglnton(){
if (null == instance){
synchronized(Singlnton.class){
if (null == instance){
instance = new Singlnton();
}
}
}
return instance;
}
}
這種方式實現的懶漢式單例模式雖然保證了只創建一個對象實例,但是由於加了synchronized同步鎖,性能會有一定降低,另外代碼實現也必將複雜。那麼有沒有其他方式實現懶漢式單例模式呢?答案是肯定有的,採用內部類來實現單例。代碼如下:
/**
* 單例模式 - 懶漢式 : 內部類實現
* @author: lsl
* @date: 2020/1/3
*/
public class Singlnton {
private Singlnton(){}
public static class InnerSingleton{
private static Singlnton instance = new Singlnton();
}
public static Singlnton getInstance(){
return InnerSingleton.instance;
}
}
除了內部類的形式實現懶漢式單例模式,還可以通過枚舉實現懶漢式單例模式。代碼如下:
public class SinletonExample5 {
private static SinletonExample5 instance = null;
// 私有構造函數
private SinletonExample5(){
}
public static SinletonExample5 getInstance(){
return Sinleton.SINLETON.getInstance();
}
private enum Sinleton{
SINLETON;
private SinletonExample5 singleton;
// JVM保證這個方法只調用一次
Sinleton(){
singleton = new SinletonExample5();
}
public SinletonExample5 getInstance(){
return singleton;
}
}
}