關於雙查鎖失效問題

關於雙查鎖失效問題

一句話總結下整篇文章內容,在某些jdk實現中,對於對象中的單例屬性,如果這個單例屬性構造函數非默認空函數的話,雙查鎖可能會出現線程問題。 

然而我在i7-4790 openjdk1.8環境下結合文章中的代碼和我自己構造的代碼做了很多很多的測試,並沒有復現出來文中的問題。誰復現出來了麻煩跟我說下^_^

原文 The "Double-Checked Locking is Broken" Declaration

雙查鎖(Double-Checked Locking,dcl)被認爲是一種在多線程環境下高效的懶加載機制的實現而被廣泛使用。然而,在java的實現中,在沒有額外的同步限制下,它並不是一種平臺無關的可靠方式。在類似C++的其他語言實現中,dcl機制依賴處理器的內存模型、編譯器語序重排機制和編譯器與同步庫的交互。在像C++這樣的語言中,這些機制都是沒有明確規定的,很難說在那種情況下dcl會起作用。在c++中,可以通過顯示的使用內存屏障來使之生效,但是在java中不存在這種屏障機制。

首先看下下面的代碼

//單線程環境實現
class Foo { 
  private Helper helper = null;
  public Helper getHelper() {
    if (helper == null) 
        helper = new Helper();
    return helper;
    }
  //其他屬性和方法.
  }

如果上面的代碼在多線程環境中使用的話,會出現很多問題。最明顯的,Helper對象對被實例化2次或者更多次。一個最簡單的修復方法是用synchronize關鍵字來修飾下getHelper()這個方法:

// 正確的多線程環境實現
class Foo { 
  private Helper helper = null;
  public synchronized Helper getHelper() {
    if (helper == null) 
        helper = new Helper();
    return helper;
    }
 //其他屬性和方法.
  }

上面的代碼在每次調用getHelper()這個方法時都會進入同步狀態(個人註釋:獲得同步鎖操作比較壓效率)。而dcl機制是企圖這樣在分配了helper對象後避免再次進入同步狀態:

// Broken multithreaded version
// "Double-Checked Locking" idiom
class Foo { 
  private Helper helper = null;
  public Helper getHelper() {
    if (helper == null) 
      synchronized(this) {
        if (helper == null) 
          helper = new Helper();
      }    
    return helper;
    }
  // other functions and members...
  }

然而,這段代碼無論是在優化型編譯器還是共享內存處理器的存在下都是無法工作的(注:意思是無法嚴格保證線程安全)

它是有問題的

上面的代碼不起作用的原因有很多。我們先說下一些顯而易見的原因,在理解了這些以後,你可能會嘗試修復dcl問題。你的修復是註定沒法工作的,原因你的修復會帶來更多的問題。

很多 聰明人 花費了大量的時間關注這個問題,但是,不存在任何在同步區外獲得helper對象的方法能讓它正確工作。

第一個原因

最明顯的一個原因就是,初始化Helper對象和給helper引用賦值是可以亂序執行的。因此一個調用getHelper方法的線程可以獲得一個指向helper對象的非空引用,但是看到的卻是helper對象的初始值,而非構造函數中設置的值。如果編譯器內連了構造函數的調用,在編譯器確認了構造函數內部不會拋出異常或實現了同步操作的情況下,給對象設置初始化值的操作和給helper屬性賦值的操作順序更是可以自由的重排。

個人註釋:假設存在兩個線程A、B同時調用getHelper方法,錯誤是指下面的線程B的情況
 -->A:讀到helper爲空
 -->A:進入同步塊
 -->A:讀到helper爲空
 -->A:申請一個Helper對象空間h(此時h內屬性爲默認值)
       將h的地址賦值給引用helper
 -->B:讀到helper非空
 <--B:返回helper,使用Helper未經構造函數處理屬性的默認值進行操作
 -->A:調用Helper構造函數,設置helper對象屬性正確值
 <--A:返回helper,使用Helper經過構造函數處理屬性的正確值進行操作

即使編譯器沒有重排這些寫入操作,在一個多核處理器上處理器或內存系統也可能把這些操作重排,從而被在其它核上運行的線程感知到。

Doug Lea 寫過一篇相關的文章more detailed description of compiler-based reorderings.

關於問題的一個測試

Paul Jakubik發現了一個dcl問題的例子.A slightly cleaned up version of that code is available here

當代碼運行在賽門鐵克(Symantec)的jit上時,它就出問題了。賽門鐵克(Symantec)會把語句

singletons[i].reference = new Singleton();

編譯成下面的形式(賽門的jit是一種基於 handle-based 的對象分配系統)

0206106A   mov         eax,0F97E78h
0206106F   call        01F6B210                  ; allocate space for
                                                ; Singleton, return result in eax
02061074   mov         dword ptr [ebp],eax       ; EBP is &singletons[i].reference 
                                                ; store the unconstructed object here.
02061077   mov         ecx,dword ptr [eax]       ; dereference the handle to
                                                ; get the raw pointer
02061079   mov         dword ptr [ecx],100h      ; Next 4 lines are
0206107F   mov         dword ptr [ecx+4],200h    ; Singleton's inlined constructor
02061086   mov         dword ptr [ecx+8],400h
0206108D   mov         dword ptr [ecx+0Ch],0F84030h

如你所見,給 singletons[i].reference賦值操作是在單例構造函數調用前執行的。這樣的操作在現有java內存模型中是完全合法的,同樣在C和C++中也不存在問題(因爲他們都沒有內存模型一說^_^).

一種無效的修復

在給出了上面的解釋後,一些人提出了下面的代碼:

// (Still) Broken multithreaded version
// "Double-Checked Locking" idiom
class Foo { 
  private Helper helper = null;
  public Helper getHelper() {
    if (helper == null) {
      Helper h;
      synchronized(this) {
        h = helper;
        if (h == null) 
            synchronized (this) {
              h = new Helper();
            } // 這裏釋放內層的同步鎖(試圖讓Helper初始化操作鎖定在內存同步塊,阻止在完成初始化前給helper引用賦值。然而並不一定會)
        helper = h;
        } 
      }    
    return helper;
    }
  // other functions and members...
  }

上面的代碼把Helper對象的構造函數放在了同步塊裏面。這種直觀的想法是,在同步原語(synchronization)被釋放時,應該會有一個內存屏障阻止Helper對象初始化操作和helper引用的賦值操作重排。不幸的是,這種假設是完全錯誤的。同步機制並不是這樣工作的。monitorexit(釋放同步原語synchronization)的規則是,任何在monitorexit之前的動作必須在monitor釋放前完成。但是,沒有任何規則規定說在monitorexit之後的動作不能在monitor釋放前執行。因此,在上面的例子中,編譯器把賦值操作helper = h;挪到內層同步塊中去是完全合理合法的。很多處理器提供這種單向內存屏障指令。更改語義來要求把釋放鎖操作變成一個完整的內存屏障的話會帶來性能損失。

更多無效的修復

你還有可以做到強制寫操作做到雙向內存屏障的。然而這些做法是笨重且低效的,尤其是當java內存模型變動時幾乎可以確定不再有效。千萬別這麼幹。如果對這種技術感興趣的話,我把對它的描述放在了這裏.千萬別這麼幹。

然而,即使完全的內存屏障在初始化helper對象的那個線程中被實現了,它仍然無效。

問題在於在一些系統中,讀取非空helper的另外的線程也需要實現內存屏障。爲什麼呢?因爲處理器也有他們自己的本地緩存的內存(指的是cpu用於提高速度的L1、L2、L3緩存等)。在一些處理器中,除非處理器實現了 cache coherence(個人註釋:誰知道咋翻,緩存相干性?)指令(一種內存屏障),讀取操作可以在過時的本地緩存拷貝上完成,即使其他核使用內存屏障把他們的寫如強制刷新到全局內存中了。

我創建了一個額外的web頁來討論在 Alpha 處理器上這種情況是怎樣發神的。

這破問題值得大動干戈嗎?

對於大多數應用來說,簡單的把getHelper()方法同步起來的代價並不大,只有在你明確知道了同步會給你的應用帶來大量開銷時,你才應該考慮這種優化問題。 通常來說,使用一些高級技巧,比如使用內置的歸併排序(mergesort)而不是交換排序(參見SPECJVM DB 基準測試)帶來的影響更大。

正確的靜態單例

如果你創建的是一個靜態單例(從始至終只有一個Hepler對象被創建),而不是每個對象一個獨立屬性(每個Foo對象一個單獨的Helper實例),有一個簡單且優雅的方案。

只需要把這個單例在一個額外的類中定義成一個靜態屬性。java語義上保證了字段在被引用前不會被初始化(懶加載),並且任何訪問這個字段的線程看到的都是初始化後的結果。

class HelperSingleton {  static Helper singleton = new Helper();}

dcl對32位的原始數據類型其實是有效的

儘管dcl不能用於對象的引用,但它對32位的原始類型(int4、float等)是沒問題的。值得注意的是它對long、double是沒用的,因爲非同步的讀寫64位原始類型不能保證原子性(意思是給前32位寫值指令和給後32位寫值指令之間可以插入其他指令)。

// Correct Double-Checked Locking for 32-bit primitives
class Foo { 
  private int cachedHashCode = 0;
  public int hashCode() {
    int h = cachedHashCode;
    if (h == 0) 
    synchronized(this) {
      if (cachedHashCode != 0) return cachedHashCode;
      h = computeHashCode();
      cachedHashCode = h;
      }
    return h;
    }
  // other functions and members...
  }

事實上,如果computeHashCode函數總是返回相同的值且沒有邊際效應(即冪等idempotent),你甚至可以不用同步。

  • 個人註釋:我覺得這裏說的是廢話,如果構造函數也是冪等的,用對象引用的像直接判斷返回也是可以的
// Lazy initialization 32-bit primitives
// Thread-safe if computeHashCode is idempotent
class Foo { 
  private int cachedHashCode = 0;
  public int hashCode() {
    int h = cachedHashCode;
    if (h == 0) {
      h = computeHashCode();
      cachedHashCode = h;
      }
    return h;
    }
  // other functions and members...
  }

用顯式內存屏障解決

如果有顯式內存屏障指令的話,dcl模式也是可以工作的。比如在c++,中你可以使用Doug Schmidt等人的書中的代碼:

// C++ implementation with explicit memory barriers
// Should work on any platform, including DEC Alphas
// From "Patterns for Concurrent and Distributed Objects",
// by Doug Schmidt
template <class TYPE, class LOCK> TYPE *
Singleton<TYPE, LOCK>::instance (void) {
    // First check
    TYPE* tmp = instance_;
    // Insert the CPU-specific memory barrier instruction
    // to synchronize the cache lines on multi-processor.
    asm ("memoryBarrier");
    if (tmp == 0) {
        // Ensure serialization (guard
        // constructor acquires lock_).
        Guard<LOCK> guard (lock_);
        // Double check.
        tmp = instance_;
        if (tmp == 0) {
                tmp = new TYPE;
                // Insert the CPU-specific memory barrier instruction
                // to synchronize the cache lines on multi-processor.
                asm ("memoryBarrier");
                instance_ = tmp;
        }
    return tmp;
    }

用ThreadLocal解決dcl

Alexander Terekhov ([email protected])有個聰明的主意,使用threadlocal來實現dcl機制。每個線程使用ThreadLocal標記來判斷是否線程已經把必要的同步工作搞定了。

  • 個人註釋:每個線程一定會進入一次同步塊,但也只會進入一次,效率高低看ThreadLocal的訪問速度
    
class Foo {
	 /** If perThreadInstance.get() returns a non-null value, this thread
		has done synchronization needed to see initialization
		of helper */
         private final ThreadLocal perThreadInstance = new ThreadLocal();
         private Helper helper = null;
         public Helper getHelper() {
             if (perThreadInstance.get() == null) createHelper();
             return helper;
         }
         private final void createHelper() {
             synchronized(this) {
                 if (helper == null)
                     helper = new Helper();
             }
	     // Any non-null value would do as the argument here
             perThreadInstance.set(perThreadInstance);
         }
	}


這種實現的表現略微以來你使用的jdk版本。在sun1.2的實現中,ThreadLocal慢的一比。但在1.3中有了顯著提高,1.4中被認爲會更更快。Doug Lea分析了一些懶加載技術的性能表現

在新java內存模型下

在jdk5中,實現了新的java內存模型和線程規範

使用volatile來搞dcl問題

jdk5及更高版本擴展了volatile關鍵字的語義,系統不允許將對volatile變量的寫入操作與之前的讀寫操作重排,同時對volatile變量的讀取操作也不會與之後的讀寫操作重排。參見 this entry in Jeremy Manson's blog

  • 個人註釋:這段話的意思是,對於volatile修飾的變量的寫操作來講,它之前的任何讀寫操作不會跑到它之後進行;:對於volatile修飾的變量的讀取操作來講,它之後的任何讀寫操作不會跑到它之後進行

在把helper屬性聲明爲volatile後,dcl就可以就可以正常幹活了。這在1.4及以前的版本中是無效的

// Works with acquire/release semantics for volatile
// Broken under current semantics for volatile
  class Foo {
        private volatile Helper helper = null;
        public Helper getHelper() {
            if (helper == null) {
                synchronized(this) {
                    if (helper == null)
                        helper = new Helper();
                }
            }
            return helper;
        }
    }


dcl不可變對象

假如Helper是個不可變對象,不如Helper裏的所有屬性都被聲明爲final,則dcl會正常起作用,而不需要使用volatile修飾。這是因爲不可變對象的引用的表現很大程度上和int/float相同,讀寫不可變對象的引用是原子的。

dcl相關說明


發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章