可以不要再使用Double-Checked Locking了

Double-Checked Locking方法被廣泛的使用於實現多線程環境下單例模式的懶加載方式實現,不幸的是,在JAVA中,這種方式有可能不能夠正常工作。在其他語言環境中,如C++,依賴於處理器的內存模型、編譯器的重排序以及編譯器和同步庫之間的工作方式。由於這些問題在C++中並不確定,因此我們不能夠確定具體的行爲。但是在C++中顯示的內存屏障是可以被用來讓其正常工作的,而這些屏障在JAVA中又不好用。

一、Double-Checked Locking入門

首先來看看下面這段代碼我們期望得到的行爲:

1
2
3
4
5
6
7
8
9
10
// Single threaded version
class Foo {
  private Helper helper = null;
  public Helper getHelper() {
    if (helper == null)
        helper = new Helper();
    return helper;
    }
  // other functions and members...
  }

這段代碼如果運行在多線程環境下,將會出現問題。很顯然的一個問題,兩個或者多個Helper對象將會被分配內存,其他問題我們會在後面提到,我們先簡單的給方法加一個synchronized關鍵字。

1
2
3
4
5
6
7
8
9
10
// Correct multithreaded version
class Foo {
  private Helper helper = null;
  public synchronized Helper getHelper() {
    if (helper == null)
        helper = new Helper();
    return helper;
    }
  // other functions and members...
  }

上面的代碼在每次調用getHelper方法的時候都要進行同步,下面的Double-Checked Locking方式避免了當Helper對象被實例化之後再次進行同步:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 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...
  }

不幸的是,這段代碼在存在編譯優化或多處理器共享內存的情況下不能夠正常工作。

二、Double-Checked Locking不能夠正常工作

爲什麼上文說Double-Checked Locking不能夠正常工作有很多的原因,我們將會描述一對很顯而易見的原因。通過理解存在的問題,我們嘗試着去修復Double-Checked Locking存在的問題,然而我們的修復可能並沒有用,我們可以一起看看爲什麼沒有用,理解這些原因,我們去嘗試着尋找更好的方法,可能還是沒有用,因爲還是存在一些微妙的原因。

1)第一個不能正常工作的原因

Double-Checked Locking不能夠正常工作的一個很顯然的原因是對helper屬性的寫指令和初始化Helper對象的指令可能被衝排序,因此當其他線程再次調用getHelper方法的時候,將會得到一個沒有被初始化完成的Helper對象,如果這個線程訪問了這個對象沒有被初始化的屬性,那麼就會出現位置錯誤。

我們來看看對於下面這行代碼,在Symantec JIT編譯器環境下的指令重排序的例子:

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

下面是實際執行的代碼:

1
2
3
4
5
6
7
8
9
10
11
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的賦值操作是在構造Singleton對象之前,這在當前的JAVA內存模型中是完全合法的,在C和C++中也是合法的。

2)一種無用的修復

理解了上面的問題,有些同學給出了下面的這段代碼,試圖避免問題:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// (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();
            } // release inner synchronization lock
        helper = h;
        }
      }   
    return helper;
    }
  // other functions and members...
  }

上面的代碼將對象構造放在一個內部的synchronized塊裏面,直覺的想法是想通過synchronized釋放之後的屏障來避免問題,從而阻止對helper屬性的賦值和對Helper對象的構造的指令重排序。不幸的是,直覺是錯誤的。因爲synchronization的規則能保證所有在monitorexit之前的動作都能夠生效而並不包含在monitorexit之後的動作在monitorexit之前不生效。也就是我們能夠保證在退出內部同步塊之前Helper能夠被實例化,h能夠被複制,但是不能保證helper被賦值一定發生在退出同步塊之後,因此同樣會出現沒有被構造完的Helper實例被其他線程引用並訪問。

3)其他無用的修復

我們可以通過完全雙向的內存屏障來強制行爲生效,這麼做是粗魯的,非高效的,並且幾乎可以保證一旦JAVA內存模型被修訂,原有方式將不能夠正常工作。所以,請不要這麼做。然而,即使通過完全內存屏障,還是不能夠正常工作。問題是在一些系統上,線程對非空的helper屬性字段同樣需要內存屏障。爲什麼呢?因爲處理器擁有自己的緩存,在一些處理器中,除非處理器執行緩存一致性指令,否則將有可能從緩存讀取錯誤內容,儘管其他處理器將內容從緩存刷新到了主存。

4)至於搞這麼複雜麼?

在很多應用中,簡單的將getHelper方法同步開銷其實並不大,除非能夠證明其他優化方案確實能夠爲應用帶來不少的性能提升。

三、使用靜態域

如果我們正要創建的實例是static的,我們有一種很簡單的方法,僅僅將單例靜態屬性字段在一個單獨的類中定義:

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

這麼做既保證的懶加載,又保證單例被引用的時候已經被構造完成。

四、Double-Checked Locking對32位原始類型有效

儘管Double-Checked Locking對對象引用類型無效,對於32位原始類型卻是有效的,值得注意的是對64位的long和double類型並不是有效的,因爲64爲的long和double不能夠保證被原子地讀寫。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 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函數總是有固定的返回值,我們可以不使用同步塊:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 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...
  }

五、利用ThreadLocal修復Double-Checked Locking問題

Alexander Terekhov提出了一個聰明的方法,通過ThreadLocal來實現Double-Checked Locking,每個Thread保持一個local flag來標識當前線程是否已經進入過同步塊:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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版本,在Sun公司的JDK1.2版本中,ThreadLocal是很慢的,在1.3版本之後變得非常快了。

六、在新的JAVA內存模型中

在JDK1.5或者更晚的版本中,擴展了volatile的語義,使得我們可以通過將helper屬性字段設置爲volatile來修復Double-Checked的問題:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 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;
        }
    }

七、使用不變實例

還有一種方法是講單例對象變爲不可變對象,如所有字段都聲明爲final或者類似String類或Integer類這種。

轉載地址:http://www.importnew.com/23980.html

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