java單例——Java 內存模型之從 JMM 角度分析 DCL


摘要: 原創出處 http://cmsblogs.com/?p=2161 「小明哥」歡迎轉載,保留摘要,謝謝!

作為「小明哥」的忠實讀者,「老艿艿」略作修改,記錄在理解過程中,參考的資料。



DCL ,即 Double Check Lock ,中文稱為“雙重檢查鎖定”。

其實 DCL 很多人在單例模式中用過,LZ 面試人的時候也要他們寫過,但是有很多人都會寫錯。他們為什么會寫錯呢?其錯誤根源在哪里?有什么解決方案?下面就隨 LZ 一起來分析。

1. 問題分析

我們先看單例模式里面的懶漢式:

public class Singleton {

private static Singleton singleton;

private Singleton(){}

public static Singleton getInstance(){
if (singleton == null) {
singleton = new Singleton();
}

return singleton;
}

}

我們都知道這種寫法是錯誤的,因為它無法保證線程的安全性。優化如下:

public class Singleton {

private static Singleton singleton;

private Singleton(){}

public static synchronized Singleton getInstance() {
if (singleton == null) {
singleton = new Singleton();
}

return singleton;
}

}

優化非常簡單,就是在 #getInstance() 方法上面做了同步,但是 synchronized 就會導致這個方法比較低效,導致程序性能下降,那么怎么解決呢?聰明的人們想到了雙重檢查 DCL:

public class Singleton {

private static Singleton singleton;

private Singleton() {}

public static Singleton getInstance(){
if(singleton == null){ // 1
synchronized (Singleton.class){ // 2
if(singleton == null){ // 3
singleton = new Singleton(); // 4
}
}
}
return singleton;
}
}

就如上面所示,這個代碼看起來很完美,理由如下:

  • 如果檢查第一個 singleton 不為 null ,則不需要執行下面的加鎖動作,極大提高了程序的性能。
  • 如果第一個 singleton 為 null ,即使有多個線程同一時間判斷,但是由於 synchronized 的存在,只會有一個線程能夠創建對象。
  • 當第一個獲取鎖的線程創建完成后 singleton 對象后,其他的在第二次判斷 singleton 一定不會為 null ,則直接返回已經創建好的 singleton 對象。

通過上面的分析,DCL 看起確實是非常完美,但是可以明確地告訴你,這個錯誤的。上面的邏輯確實是沒有問題,分析也對,但是就是有問題,那么問題出在哪里呢?在回答這個問題之前,我們先來復習一下創建對象過程,實例化一個對象要分為三個步驟:

memory = allocate();   //1:分配內存空間
ctorInstance(memory); //2:初始化對象
instance = memory; //3:將內存空間的地址賦值給對應的引用

但是由於重排序的原因,步驟 2、3 可能會發生重排序,其過程如下:

memory = allocate();   // 1:分配內存空間
instance = memory; // 3:將內存空間的地址賦值給對應的引用
// 😈 注意,此時對象還沒有被初始化!
ctorInstance(memory); // 2:初始化對象

如果 2、3 發生了重排序,就會導致第二個判斷會出錯,singleton != null,但是它其實僅僅只是一個地址而已,此時對象還沒有被初始化,所以 return 的 singleton 對象是一個沒有被初始化的對象,如下:

DCL00001_2

按照上面圖例所示,線程 B 訪問的是一個沒有被初始化的 singleton 對象。

通過上面的闡述,我們可以判斷 DCL 的錯誤根源在於步驟 4:

singleton = new Singleton();

知道問題根源所在,那么怎么解決呢?有兩個解決辦法:

  1. 不允許初始化階段步驟 2、3 發生重排序。
  2. 允許初始化階段步驟 2、3 發生重排序,但是不允許其他線程“看到”這個重排序。

2. 解決方案

解決方案依據上面兩個解決辦法即可。

2.1 基於 volatile 解決方案

對於上面的DCL其實只需要做一點點修改即可:將變量singleton生命為volatile即可:

public class Singleton {

// 通過volatile關鍵字來確保安全
private volatile static Singleton singleton;

private Singleton(){}

public static Singleton getInstance(){
if(singleton == null){
synchronized (Singleton.class){
if(singleton == null){
singleton = new Singleton();
}
}
}
return singleton;
}
}

當 singleton 聲明為 volatile后,步驟 2、3 就不會被重排序了,也就可以解決上面那問題了。

2.2 基於類初始化的解決方案

該解決方案的根本就在於:利用 ClassLoder 的機制,保證初始化 instance 時只有一個線程。JVM 在類初始化階段會獲取一個鎖,這個鎖可以同步多個線程對同一個類的初始化。

public class Singleton {

private static class SingletonHolder{
public static Singleton singleton = new Singleton();
}

public static Singleton getInstance(){
return SingletonHolder.singleton;
}
}

Java 語言規定,對於每一個類或者接口 C ,都有一個唯一的初始化鎖 LC 與之相對應。從C 到 LC 的映射,由 JVM 的具體實現去自由實現。JVM 在類初始化階段期間會獲取這個初始化鎖,並且每一個線程至少獲取一次鎖來確保這個類已經被初始化過了。

老艿艿:因為基於類初始化的解決方案,涉及到類加載機制,本文就不拓展開來,感興趣的胖友,可以看看 《雙重檢查鎖定與延遲初始化》 的 「基於類初始化的解決方案」 小節。

3. 總結

延遲初始化降低了初始化類或創建實例的開銷,但增加了訪問被延遲初始化的字段的開銷。在大多數時候,正常的初始化要優於延遲初始化。

  • 如果確實需要對實例字段使用線程安全的延遲初始化,請使用上面介紹的基於 volatile 的延遲初始化的方案。
  • 如果確實需要對靜態字段使用線程安全的延遲初始化,請使用上面介紹的基於類初始化的方案。

參考資料

  1. 方騰飛:《Java並發編程的藝術》
  2. 程曉明:《雙重檢查鎖定與延遲初始化》


免責聲明!

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



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