兄弟,你的單例模式可能不是單例!!!

面試官:請你寫個單例模式

你:(太簡單了吧,我給他來個「餓漢式」,再來個「懶漢式」)java

(2分鐘後,你的代碼新鮮出爐了)面試

餓漢式單例模式代碼

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

懶漢式單例模式代碼

public class LazySingleton {
    private static LazySingleton instance;

    private LazySingleton() {
    }

    public static LazySingleton getInstance() {
        if (instance == null) { // 1
            instance = new LazySingleton(); // 2
        }
        return instance;
    }
}

(很棒~可是他們真的時單例嗎)編程

代碼分析

第一段代碼

instance 是一個類變量,類變量再類初始化時建立,類初始化時至關於會加個鎖,保證原子性。所以他確實能保證單例,除非時屢次加載這個類。安全

第二段代碼

單線程環境下沒有問題,確實是單例。多線程

多線程下則須要考慮下了.併發

假設線程A走到了2,同時線程B走到了1. 線程A走完了,實例化了LazySingleton,因爲B在A尚未給instance賦值時走到了1,因此判斷爲instance==null, 因此他也會建立一個LazySingleton實例。高併發

所以此段代碼存在線程安全問題,也就是不能保證LazySingleton是單例的。性能

解決方案

方案一:直接給獲取實例的方法加鎖

咱們能夠經過將getInstance變爲同步方法來保證同一時刻只能有一個線程進入到方法。優化

以下:線程

public class LazySingleton {
    private static LazySingleton instance;

    private LazySingleton() {
    }

    public static synchronized LazySingleton getInstance() {
        if (instance == null) {
            instance = new LazySingleton();
        }
        return instance;
    }
}

這種方式簡單粗暴,可是當高併發去獲取單例時,只有一個線程能競爭到鎖,其餘的線程都將阻塞,效率低下

方案二:雙重鎖定

public class DoubleCheckLocking {
    private static DoubleCheckLocking instance;

    public DoubleCheckLocking() {
    }

    public DoubleCheckLocking getInstance() {
        if (instance == null) {  // 1
            synchronized (DoubleCheckLocking.class) {
                if (instance == null) {
                    instance = new DoubleCheckLocking(); // 問題根源
                }
            }
        }
        return instance;
    }
}

這段代碼很巧妙,前一種方法直接將getInstance方法變爲同步方法會帶來效率低下問題,那麼咱們就只在建立對象的時候加鎖,這樣既能保證效率,也能保證單例。

然而,這種方式也有問題,方騰飛老師在《Java併發編程藝術》中無情地嘲諷了這種作法,原文以下:

所以,人們想出了一個「聰明」的技巧:雙重檢查鎖定(Double-Checked Locking)

問題的根源就在於new DoubleCheckLocking()這句話,建立一個對象大致分爲三步, 僞碼錶示以下:

memory=allocate()    1分配對象內存
ctorInstance(memory) 2初始化對象
instance=memory      3引用指向建立的對象

其中2和3是可能重排序的,由於他們不存在數據依賴性。也就是3可能在2以前執行。

假設A線程獲取單例按一、三、2走到了3,那麼此時instance不爲null, 此時B線程走到1處,直接將instance返回了,因此調用方拿到了一個未被初始化的對象。

因此,這個方法嚴格來說是不可取的。

方案二:改良雙重鎖定

方案很簡單,直接在instance變量前加volatile關鍵字,以下

private static volatile DoubleCheckLocking instance;

加上volatile能夠阻止上述二、3兩條指令的重排序。

方案三:基於類初始化

public class MySingleInstance {
    private MySingleInstance() {
    }

    private static class InstanceHolder {
        public static MySingleInstance instance = new MySingleInstance();
    }

    public static MySingleInstance getInstance() {
        return InstanceHolder.instance;
    }
}

JVM在執行類的初始化期間會去獲取一個鎖, 這個鎖能夠同步多個線程對同一個類的初始化。

一些知識點

這裏簡單總結下以上解決方案中涉及的一些知識點, 只是知識點的簡單羅列,後面會繼續寫一些文章來介紹。

線程

線程是輕量級進程,是系統調度的基本單元,線程之間能夠共享內存變量,每一個線程都有本身獨立的計數器、堆棧和局部變量。

syncronized

方案一中咱們經過syncronized將獲取實例的方法同步化了。

三種形式

  1. 普通同步方法,鎖爲當前實例對象
  2. 靜態同步方法,鎖爲當前類的Class對象
  3. 同步代碼塊,鎖爲()裏邊的那個對象

基本原理

在對象頭存儲鎖信息,基於進入和退出Monitor對象來實現同步方法和代碼塊同步。

volatile

方案三中,咱們經過volatile解決了重排序和內存可見性問題。

volatile的特色:

  • 輕量級的synchronized,不會引發線程上下文切換
  • 保證共享變量可見性,即一個線程修改共享變量時,其餘線程能讀到修改後的值
  • 加了volatile後,寫操做會當即從本地內存刷新到主內存,讀操做會直接標記本地內存無效,從主內存中讀取

這裏的本地內存只是一個抽象概念,並不是真實存在

重排序

方案二中,咱們分析是重排序致使這個方案存在問題。

重排序是編譯器或處理器爲了優化程序性能對指令序列進行從新排列的過程。

分類:

  1. 編譯器的指令重排序
  2. 處理器的指令重排序

處理器的指令重排序規則較爲寬鬆,java編譯器爲了防止處理器對某些指令重排序會使用內存屏障。

例如上面的volatile, 編譯器生成字節碼時會經過加入內存屏障來阻止cpu對volatile變量讀寫操做的重排序。

內部類

在方案三中,咱們使用到了內部類。內部類就是類裏邊的類。

外部類沒法訪問內部類成員,只能經過內部類的實例訪問。

內部類能夠直接訪問外部類的信息,靜態內部類不能訪問實例成員。

按照其所處的不一樣位置能夠分爲:

  • 成員內部類
  • 靜態內部類
  • 方法內部類
  • 匿名內部類

總結

本文介紹常見寫單例的方式存在的問題及解決方案,並將解決方案中涉及的重要知識點作了簡單羅列。

相關文章
相關標籤/搜索