單例模式中volatile關鍵字的作用


什么是單例模式

單例模式指的是,保證一個類只有一個實例,並且提供一個可以全局訪問的入口。

為什么需要使用單例模式

那么我們為什么需要單例呢?其中一個理由,那就是為了節省內存、節省計算。因為在很多情況下,我們只需要一個實例就夠了,如果出現更多的實例,反而純屬浪費。

下面我們舉一個例子來說明這個情況,以一個初始化比較耗時的類來說,代碼如下所示:

public class ExpensiveResource {
    public ExpensiveResource() {
        field1 = // 查詢數據庫
        field2 = // 然后對查到的數據做大量計算
        field3 = // 加密、壓縮等耗時操作
    }
}

 

這個類在構造的時候,需要查詢數據庫並對查到的數據做大量計算,所以在第一次構造時,我們花了很多時間來初始化這個對象。但是假設數據庫里的數據是不變的,我們就可以把這個對象保存在內存中,那么以后開發的時候就可以直接用這同一個實例了,不需要再次構建新實例。如果每次都重新生成新的實例,則會造成更多的浪費,實在沒有必要。

 

雙重檢查鎖模式的寫法

單例模式有多種寫法,我們重點介紹一下和 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;
}

 

在這里我將重點講解 getInstance 方法,方法中首先進行了一次 if (singleton == null) 的檢查,然后是 synchronized 同步塊,然后又是一次 if (singleton == null) 的檢查,最后是 singleton = new Singleton() 來生成實例。

 

我們進行了兩次 if (singleton == null) 檢查,這就是“雙重檢查鎖”這個名字的由來。這種寫法是可以保證線程安全的,假設有兩個線程同時到達 synchronized 語句塊,那么實例化代碼只會由其中先搶到鎖的線程執行一次,而后搶到鎖的線程會在第二個 if 判斷中發現 singleton 不為 null,所以跳過創建實例的語句。再后面的其他線程再來調用 getInstance 方法時,只需判斷第一次的 if (singleton == null) ,然后會跳過整個 if 塊,直接 return 實例化后的對象。

這種寫法的優點是不僅線程安全,而且延遲加載、效率也更高。

為什么要 double-check?

我們先來看第二次的 check,這時你需要考慮這樣一種情況,有兩個線程同時調用 getInstance 方法,由於 singleton 是空的 ,因此兩個線程都可以通過第一重的 if 判斷;然后由於鎖機制的存在,會有一個線程先進入同步語句,並進入第二重 if 判斷 ,而另外的一個線程就會在外面等待。

 

不過,當第一個線程執行完 new Singleton() 語句后,就會退出 synchronized 保護的區域,這時如果沒有第二重 if (singleton == null) 判斷的話,那么第二個線程也會創建一個實例,此時就破壞了單例,這肯定是不行的。

 

而對於第一個 check 而言,如果去掉它,那么所有線程都會串行執行,效率低下,所以兩個 check 都是需要保留的。

在雙重檢查鎖模式中為什么需要使用 volatile 關鍵字?

在java內存模型中,volatile 關鍵字作用可以是保證可見性或者禁止指令重排。這里是因為 singleton = new Singleton() ,它並非是一個原子操作,事實上,在 JVM 中上述語句至少做了以下這 3 件事:

  • 第一步是給 singleton 分配內存空間;

  • 第二步開始調用 Singleton 的構造函數等,來初始化 singleton;

  • 第三步,將 singleton 對象指向分配的內存空間(執行完這步 singleton 就不是 null 了)。

這里需要留意一下 1-2-3 的順序,因為存在指令重排序的優化,也就是說第 2 步和第 3 步的順序是不能保證的,最終的執行順序,可能是 1-2-3,也有可能是 1-3-2。

 

如果是 1-3-2,那么在第 3 步執行完以后,singleton 就不是 null 了,可是這時第 2 步並沒有執行,singleton 對象未完成初始化,它的屬性的值可能不是我們所預期的值。假設此時線程 2 進入 getInstance 方法,由於 singleton 已經不是 null 了,所以會通過第一重檢查並直接返回,但其實這時的 singleton 並沒有完成初始化,所以使用這個實例的時候會報錯,詳細流程如下圖所示:

 

線程 1 首先執行新建實例的第一步,也就是分配單例對象的內存空間,由於線程 1 被重排序,所以執行了新建實例的第三步,也就是把 singleton 指向之前分配出來的內存地址,在這第三步執行之后,singleton 對象便不再是 null。

這時線程 2 進入 getInstance 方法,判斷 singleton 對象不是 null,緊接着線程 2 就返回 singleton 對象並使用,由於沒有初始化,所以報錯了。最后,線程 1 “姍姍來遲”,才開始執行新建實例的第二步——初始化對象,可是這時的初始化已經晚了,因為前面已經報錯了。

 

使用了 volatile 之后,相當於是表明了該字段的更新可能是在其他線程中發生的,因此應確保在讀取另一個線程寫入的值時,可以順利執行接下來所需的操作。在 JDK 5 以及后續版本所使用的 JMM 中,在使用了 volatile 后,會一定程度禁止相關語句的重排序,從而避免了上述由於重排序所導致的讀取到不完整對象的問題的發生。


免責聲明!

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



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