1. 程式人生 > >設計模式(4)—— 建立型 ——單例(Singleton)

設計模式(4)—— 建立型 ——單例(Singleton)

導航

首先通過懶漢式的單例模式簡單程式碼實現作為開頭,發現有執行緒安全問題,並且在此懶漢模式程式碼上進行改進,衍生出同步懶漢設計模式雙重檢查懶漢設計模式。另外還有靜態內部類方式實現單例,它是一種基於類初始化的延遲載入解決方案。

與懶漢式相對應的是餓漢式單例模式,其在類載入時就進行初始化例項,所以並不存在懶漢式單例模式存在的執行緒同步安全問題

除以上探討的單例模式實現外,還列舉了三種實現的單例模式的方法:列舉類容器ThreadLocal

單例模式介紹——摘要

定義: 保證一個類僅有一個例項,並提供一個全域性訪問點
型別:建立型
適用場景:想確保任何情況下只有一個例項
優點:

  1. 在記憶體中只有一個例項,減少記憶體開銷
  2. 避免資源的多重佔用
  3. 設定全域性訪問點,嚴格控制訪問

缺點:沒有介面,擴充套件困難

重點關注幾點:

  • 私有構造器
  • 執行緒安全
  • 延遲載入

後期更新:

  • 序列化,反射相關安全性

懶漢式

上程式碼:

/**
 * 懶漢。顧名思義,等我們用到的時候再例項化
 */
public class LazySingleton {

    private static LazySingleton instance = null;      //初始化,為null
//私有構造器:外部不允許直接通過new運算獲取物件例項 private LazySingleton(){ } //靜態方法,外面方面直接通過靜態方法來獲取例項,不需先例項化類 public static LazySingleton getInstance(){ if(singleton == null){ singleton = new LazySingleton(); } return singleton; } }

上面程式碼很容易理解,外部只能通過唯一的訪問點LazySingleton.getInstance()

來獲取物件例項。
但是對於單執行緒來說,這樣寫沒有問題。到了多執行緒我們再分析getInstance程式碼塊,很容易發現問題。我們假設現在有兩個執行緒同時來獲取例項

/**
 * 測試用例
 * 同時開兩個執行緒,對最簡單的懶漢單例模式進行測試
 */
public class LazyTest {

    //主執行緒
    public static void main(String[] args) {

        //第一個執行緒
        new Thread( () ->{
            LazySingleton singleton = LazySingleton.getInstance();
            System.out.println( "Current Singleton :" + singleton );
        } ).start();

        //第二個執行緒
        new Thread( () -> {
            LazySingleton singleton = LazySingleton.getInstance();
            System.out.println( "Current Singleton :" + singleton );
        } ).start();

    }

}

現在分析會出現什麼情況,最簡單的當然是第一個執行緒開始執行,知道它執行結束,再輪到第二個執行緒開始執行。然而我們檢視兩個執行緒的分別呼叫getInstance的執行圖:

LazySingleton執行用例圖
執行緒1執行①程式碼之後,正要執行singleton = new LazySingleton() 的時候,由於CPU執行緒排程,使執行緒2開始執行。執行緒2一路執行下去。並且返回Singleton例項物件。而後,執行緒1繼續執行,直到結束。
很清晰的知道,LazySingleton已經被例項化兩次,違反了Singleton的原則。

有一系列的對最基本的懶漢式改進的方法。

懶漢式——同步(synchronized)

最容易改進的方法,只需新增synchronized關鍵字即可。

// 新增關鍵字同步synchronized,加鎖
public synchronized static LazySingleton getInstance(){
        if(singleton == null){
            singleton = new LazySingleton();
        }
        return singleton;
}

當新增此關鍵字的時候,我們再看到上面兩個執行緒的getInstance 執行圖 。 當①執行之後,開始試圖進入執行執行緒2的程式碼。但是此時該程式碼是加鎖的,執行緒2會被 阻塞 ,待執行緒1執行結束之後,執行緒2方可執行。如此就 避免了if判斷的執行緒錯誤 。最後只能獲得一個唯一的例項。

但是:

  • 同步鎖的加鎖解鎖較為消耗資源。
  • synchronized 關鍵字修飾static函式的時候,其實相當於synchronized整個類,範圍較大,不利於控制。

懶漢式——雙重檢查(Double Check)

上程式碼:

public class LazyDoubleCheckSingleton {

    private volatile static LazyDoubleCheckSingleton instance = null;      //初始化,為null

    //私有構造器:外部不允許直接通過new運算獲取物件例項
    private LazyDoubleCheckSingleton(){

    }

    public static LazyDoubleCheckSingleton getInstance(){
        if(instance == null){
            synchronized (LazyDoubleCheckSingleton.class){
                if(instance == null) {
                
                	/****2, 3可以重排序。       
                    *****使用volatile關鍵字禁止重排序
                    *****/
                    
                    // 1. 為物件分配記憶體
                    // 2. 初始化物件
                    // 3. 設定instance指向剛分配的記憶體地址
                    instance = new LazyDoubleCheckSingleton();
                }
            }
        }
        return instance;
    }
}

synchronized關鍵字

從上面的程式碼看到,所謂double check就是進行雙重判斷。相比於直接在static方法上用synchronized,在區域性程式碼塊用synchronized修飾,配合double check使用在效能上更勝一籌。然而如此一來卻有一個坑。

volatile關鍵字修飾就是為了填補這個坑的。

volatile關鍵字

注意到用於修飾instance 的volatile關鍵字。這裡集中看到這行程式碼:
instance = new LazyDoubleCheckSingleton();
這行程式碼並不是原子操作。所謂原子操作,可以簡單理解為此操作一步執行,不可拆分。 具體到這個例子中,
instance = new LazyDoubleCheckSingleton();的執行步驟如下:

  1. 為物件分配記憶體
  2. 初始化物件
  3. 設定instance指向剛分配的記憶體地址
  4. 外部可以開始對instance進行訪問以及其它操作

而java編譯器在編譯時有個指令重排序的概念。在這裡的意思就是2,3執行步驟可能會交換(但並不影響4,也就是說並不影響最終的結果),這樣做的好處是根據具體情況來調整執行順序,提高執行效率。

那麼這就會導致隱藏的程式bug。現在假定執行緒1的執行順序如下

  1. 為物件分配記憶體
  2. 設定instance指向剛分配的記憶體地址
  3. 初始化物件
  4. 外部可以開始對instance進行訪問以及其它操作

現在假定當上面的步驟2執行完畢。CPU執行緒排程,輪到執行緒2開始執行
getInstance()程式碼。執行第一步:if(instance == null){/*...*/},很顯然,instace已經指向了分配的記憶體地址,所以instance != null。所以執行緒2的執行結果是直接返回instace物件。執行緒2中的應用層程式碼可以直接獲取到instace例項並且開始使用,但是值得注意的是我們的instance並沒有執行過初始化物件這一步驟,而我們知道,一個物件沒有完成物件初始化就開始使用,在某些情況下是非常嚴重的錯誤,程式bug。

而我們的程式碼private volatile static LazyDoubleCheckSingleton instance = null;中的volatile就是為了禁止指令重排序的。

當然我們 還有一種解決方案: 就是上面的2,3指令執行步驟在別的執行緒看來是不可見的,也就是說,別的執行緒是把2,3這兩個步驟看成一個整體,外部不能介入其中執行的。

這種解決方案就是下面要介紹的基於***靜態內部類***的解決方案。

靜態內部類方式(基於類初始化的延遲載入解決方案)

直接上程式碼,程式碼很容易。重要的是理解內在JVM機制。

public class OuterSingleton {

    private OuterSingleton(){
    }

    public static OuterSingleton getInstance(){
        return InnerClass.staticInnerClassSingleton;
    }

    private static class InnerClass{
        private static OuterSingleton staticInnerClassSingleton = new OuterSingleton();
    }
}

我們要獲取的單例,為OuterSingleton類的單例。而靜態內部類作為建立這個實現單例的內在機制。

上面程式碼很容易看懂,值得注意的是兩個private:一個是構造器的private(之前的幾個單例模式也說過,外部只能通過getInstance獲取單例物件,防止new一個物件);一個是靜態內部類的private,靜態內部類只是實現單例的內在機制,不應暴露給外部。

為什麼靜態內部類能夠代替volatile關鍵字,解決指令重排序的問題?

JVM在類的初始化階段( Class被載入之後~~執行緒使用之前 )執行類的初始化(也就是我們前面所說的 1,2,3 或者 1,3,2階段)。類的初始化期間,類會去獲取一個鎖,此鎖用於同步多個執行緒對於一個類的初始化

靜態內部類方式的單例模式

其中類被初始化會發生在以下幾種情況:什麼時候類會被初始化

餓漢式

從名字可以看出,餓漢式與懶漢式相對應。

餓漢式單例模式實現較為簡單,在類載入時就完成了初始化操作,如此避免了執行緒同步問題。當然缺點也是因為在類載入時就完成了初始化,沒有了懶漢式的延遲載入效果。

public class HungerySingleton {

   public static final HungerySingleton instance;

   static{
        instance = new HungerySingleton();
   }

   private HungerySingleton(){

   }

   public static HungerySingleton getInstance(){
       return instance;
   }
}

當然,也能使用如下方法:

public class HungerySingleton {

   public static final HungerySingleton instance = new HungerySingleton();

   private HungerySingleton(){

   }

   public static HungerySingleton getInstance(){
       return instance;
   }
}

列舉類(Enum)

public enum EnumSingleton {
    INSTANCE{
        protected  void methodTest(){
            System.out.println("Method test.");
        }
    };
    
    protected abstract void methodTest();
    
    private Object data;

    public Object getData() {
        return data;
    }

    public void setData(Object data) {
        this.data = data;
    }
    
    public static EnumInstance getInstance(){
        return INSTANCE;
    }
}

容器式

  • 通過容器儲存 <key,Object>,也就是一個key對應於一個類的單個例項。
  • 優點:通過key-value容器儲存,當單例過多時,方便統一管理,節省資源。
  • 缺點:執行緒不安全(下面的程式碼實現)
import org.apache.commons.lang3.StringUtils;

import java.util.HashMap;
import java.util.Map;

public class ContainerSingleton {

    private ContainerSingleton(){
    }
    
    //or `new HashMap<String, Object>();`
    private static Map<String,Object> singletonMap = new HashMap<>();

    public static void putInstance(String key,Object instance){
        if(StringUtils.isNotBlank(key) && instance != null){
            if(!singletonMap.containsKey(key)){
                singletonMap.put(key,instance);
            }
        }
    }

    public static Object getInstance(String key){
        return singletonMap.get(key);
    }
}

ThreadLocal方式

public class ThreadLocalSingleton {

    private static final ThreadLocal<ThreadLocalSingleton> instance =
            new ThreadLocal<ThreadLocalSingleton>() {
                @Override
                protected ThreadLocalSingleton initialValue() {
                    return new ThreadLocalSingleton();
                }
            };


    private ThreadLocalSingleton(){

    }

    public static ThreadLocalSingleton getInstance(){
        return instance.get();
    }
}

這裡所說的單例是相對於執行緒來說的。也就是說在 同一個執行緒內,都共享一個單例的記憶體空間;而對不同執行緒來說,獲取到的是各自執行緒的單例。 如圖:
LocalThread單例模式圖