Singleton 單例模式 [MD]


博文地址

我的GitHub 我的博客 我的微信 我的郵箱
baiqiantao baiqiantao bqt20094 baiqiantao@sina.com

簡介

作用:保證類只有一個實例;提供一個全局訪問點 
JDK中的案例:

java.lang.Runtime#getRuntime()

單例模式的幾種方式

餓漢式:簡單安全,但浪費資源

  • 優點:以空間換時間。因為靜態變量會在類加載時初始化,此時不會涉及多個線程對象訪問該對象的問題,虛擬機保證只會加載一次該類,肯定不會發生並發訪問的問題,因此,可以省略 synchronized 關鍵字。
  • 問題:如果只是加載本類,而不調用 getInstance(),甚至永遠都不調用,則會造成資源浪費
class Single {
    private static Single SINGLETON = new Single();//類加載的時候會連帶着創建實例

    private Single() {
        System.out.println("創建了實例");
    }

    public static Single getInstance() {
        return SINGLETON;
    }
}

懶漢式

簡單懶漢式:高效,但不安全

  • 優點:以時間換空間。延時加載,懶加載,用的時候才加載,不會像餓漢式那樣可能造成資源浪費!
  • 問題:在多線程環境下存在風險
class Single {
    private static Single SINGLETON;

    private Single() {
        System.out.println("創建了實例");
    }

    public static Single getInstance() {
        if (SINGLETON == null) SINGLETON = new Single();
        return SINGLETON;
    }
}

線程安全問題出現場景

如果兩個線程A和B同時調用了該方法,然后以如下方式執行:

  • A進入if判斷,此時 instance 為 null,因此進入if內
  • B進入if判斷,此時A還沒有創建 instance,因此 instance 也為 null,因此B也進入if內
  • A創建了一個instance並返回
  • 雖然此時 instance 不為 null,但因為B已經進入了if判斷,所以B也會創建一個instance並返回
  • 此時問題出現了,我們的單例被創建了兩次!

驗證測試代碼

驗證邏輯很簡單,首先在單例的構造方法中打印一條日志,然后我們創建幾十上百個線程,並發的去調用單例模式的getInstance()方法,通過日志打印來判斷到底執行了幾次構造方法,依次來驗證此種單例模式是否是安全的。

public class Test {
    public static void main(String[] args) {
        Runnable runnable = new Runnable() {

            @Override
            public void run() {
                Single.getInstance();
            }
        };
        for (int i = 0; i < 100; i++) {
            new Thread(runnable).start();
        }
    }
}

經過測試,這種場景下多次創建Single實例並非是小概率事件,反而是大概率事件!

加同步鎖方式:性能差

以上問題最直觀的解決辦法就是給getInstance方法加上一個synchronize鎖,這樣每次只允許一個線程調用getInstance方法:

class Single {
    private static Single SINGLETON;

    private Single() {
        System.out.println("創建了實例");
    }

    public static synchronized Single getInstance() {
        if (SINGLETON == null) SINGLETON = new Single();
        return SINGLETON;
    }
}

這種解決辦法的確可以防止錯誤的出現,但是它卻很影響性能:每次調用getInstance方法的時候都必須獲得Singleton.class鎖!

而實際上,僅僅在創建實例時有線程安全問題,而當單例實例被創建以后,其后的請求沒有必要再使用互斥機制了。

☆ 雙重檢查加鎖模式:高效且安全

基本形式

目前大量人使用的都是這個double-checked locking的解決方案:

class Single {
    private static Single SINGLETON; //沒添加【volatile】關鍵字之前

    private Single() {
        System.out.println("創建了實例");
    }

    public static Single getInstance() {
        if (SINGLETON == null) { //目的是為了提高性能,避免非必要加鎖
            synchronized (Single.class) { //加鎖保證現場安全
                if (SINGLETON == null) SINGLETON = new Single(); //避免重復創建實例
            }
        }
        return SINGLETON;
    }
}

讓我們來看一下這個代碼是如何工作的:

  • 當一個線程調用getInstance()方法后,首先會先檢查SINGLETON是否為null,如果不是則直接返回其內容,這樣避免了進入synchronized塊所需要花費的資源。
  • 其次,即使上面提到的情況發生了,即兩個線程同時進入了第一個if判斷,那么他們也必須按照順序執行synchronized塊中的代碼,第一個進入代碼塊的線程會創建一個新的Single實例,而后續的線程則因為無法通過if判斷,而不會創建多余的實例。

存在的安全隱患:指令重排序

上述描述似乎已經解決了我們面臨的所有問題,但實際上,從JVM的角度講,這些代碼仍然可能發生錯誤。

對於JVM而言,它執行的是一個個Java指令。在Java指令中創建對象和賦值操作是分開進行的,也就是說SINGLETON = new Single();語句是分兩步執行的,但是JVM並不保證這兩個操作的先后順序,也就是說有可能JVM會先為新的Single實例分配空間,然后直接賦值給SINGLETON,然后再去初始化這個Single實例。即先賦值指向了內存地址,再初始化,這樣就使出錯成為了可能。

我們仍然以A、B兩個線程為例:

  • A、B線程同時進入了第一個 if 判斷
  • A首先進入synchronized塊,由於SINGLETON為null,所以它執行SINGLETON = new Single();
  • 由於JVM內部的優化機制,JVM先划出了一些分配給Single實例的空白內存,並賦值給SINGLETON成員(注意此時JVM沒有開始初始化這個實例),然后A離開了synchronized塊。
  • 然后B進入synchronized塊,由於SINGLETON此時不是null,因此它馬上離開了synchronized塊並將結果返回給調用該方法的程序。
  • 此時B線程打算使用Single實例,卻發現它沒有被初始化,於是錯誤發生了。

下面參考另一篇文章的描述,意思是一樣的

問題主要在於SINGLETON = new Single();這段代碼並不是原子操作,原子性:即一個操作或者多個操作,要么全部執行並且執行的過程不會被任何因素打斷,要么就都不執行(事務)。

SINGLETON = new Single();其實做了三件事情:

  • 給 SINGLETON 實例分配內存
  • 初始化 Single() 實例
  • 將SINGLETON對象指向 new Single() 分配的內存空間

問題就出在這兒了,因為JVM中有指令重排序的優化,所以呢正常情況按照1,2,3的順序來,沒毛病,但是也可能按照1,3,2的順序來,這個時候就有問題了,調用的時候判斷 SINGLETON != null 就直接返回 SINGLETON 實例,但是這個時候並沒有進行初始化工作,所以在后續的調用中肯定就會報錯了。

所以這里需要引入了 volatile 修飾符修飾 SINGLETON 對象,因為 volatile 能夠禁止指令重排序的功能,所以能解決我們的這個問題

以上情況只是理論分析,實際我經過大量測試發現,這種情況根本展示不出來。但是面試時這個是非常重要的知識點。

添加 volatile 后的終極形式

JDK1.5之后,官方也發現了這個問題,故而具體化了 volatile,即在JDK1.6及以后,只要定義為private volatile static就可解決 DCL 失效問題。

volatile 確保 INSTANCE 每次均在主內存中讀取,這樣雖然會犧牲一點效率,但也無傷大雅。

class Single {
    private static volatile Single SINGLETON; //添加【volatile】關鍵字

    private Single() {
        System.out.println("創建了實例");
    }

    public static Single getInstance() {
        if (SINGLETON == null) { //目的是為了提高性能,避免非必要獲取鎖
            synchronized (Single.class) { //加鎖
                if (SINGLETON == null) SINGLETON = new Single(); //線程安全
            }
        }
        return SINGLETON;
    }
}

☆ 靜態內部類方式:【推薦】

JVM內部的機制能夠保證當一個類被加載的時候,這個類的加載過程是線程互斥的。這樣當我們第一次調用getInstance的時候,JVM能夠幫我們保證INSTANCE只被創建一次,並且會保證把賦值給INSTANCE的內存初始化完畢,這樣我們就不用擔心上面的問題。

另外,INSTANCE是在第一次加載SingleHolder時被創建的,而SingleHolder則只在調用getInstance方法的時候才會被加載,因此也實現了懶加載。

總結:不會像餓漢式那樣立即加載對象,且加載類時是線程安全的,從而兼具了並發高效調用和延遲加載的優勢!

class Single {

    private Single() {
        System.out.println("創建了實例");
    }

    public static Single getInstance() { //只有調用getInstance時才會加載靜態內部類
        return SingleHolder.SINGLETON;
    }

    private static class SingleHolder {
        private static final Single SINGLETON = new Single();
    }
}

這種方法不僅能確保線程安全,也能保證單例的唯一性,同時也延遲了單例的實例化。

枚舉式:天然的防止反射

線程安全、調用效率高,但不能延時加載,並且可以天然的防止反射和反序列化漏洞!

enum Single {
    SINGLETON;//定義一個枚舉的元素,它就代表了Single的一個實例。元素的名字隨意。
    Single() {
        System.out.println("創建了實例");
    }
}

2016-03-20


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM