速讀原著-雙重檢查鎖定與延遲初始化

  • 2020 年 2 月 14 日
  • 筆記

雙重檢查鎖定與延遲初始化

在Java 程式中,有時候可能需要推遲一些高開銷的對象初始化操作,並且只有在使用這些對象時才進行初始化。此時程式設計師可能會採用延遲初始化。但要正確實現執行緒安全的延遲初始化需要一些技巧,否則很容易出現問題。比如,下面是非執行緒安全的延遲初始化對象的示例程式碼:

public class UnsafeLazyInitialization {          private static Instance instance;            public static Instance getInstance() {              if (instance == null) {                  //1:A 執行緒執行                  instance = new Instance();                  // 2:B 執行緒執行                  return instance;              }          }        }

UnsafeLazyInitialization 中,假設 A 執行緒執行程式碼 1 的同時,B 執行緒執行程式碼 2。此時,執行緒A 可能會看到 instance 引用的對象還沒有完成初始化(出現這種情況的原因見後文的「問題的根源」)。

對於UnsafeLazyInitialization,我們可以對getInstance()做同步處理來實現執行緒安全的延遲初始化。示例程式碼如下:

    public class SafeLazyInitialization {          private static Instance instance;              public synchronized static Instance getInstance() {              if (instance == null)                  instance = new Instance();              return instance;          }      }

由於對 getInstance()做了同步處理,synchronized 將導致性能開銷。如果 getInstance()被多個執行緒頻繁的調用,將會導致程式執行性能的下降。反之,如果 getInstance()不會被多個執行緒頻繁的調用,那麼這個延遲初始化方案將能提供令人滿意的性能。

在早期的 JVM 中,synchronized(甚至是無競爭的 synchronized)存在這巨大的性能開銷。因此, 人們想出了一個「聰明」的技巧:雙重檢查鎖定(double-checked locking)。人們想通過雙重檢查鎖定來降低同步的開銷。下面是使用雙重檢查鎖定來實現延遲初始化的示例程式碼:

如上面程式碼所示,如果第一次檢查instance 不為null,那麼就不需要執行下面的加鎖和初始化操 作。因此可以大幅降低 synchronized 帶來的性能開銷。上面程式碼表面上看起來,似乎兩全其美:

  • 在多個執行緒試圖在同一時間創建對象時,會通過加鎖來保證只有一個執行緒能創建對象。
  • 在對象創建好之後,執行getInstance()將不需要獲取鎖,直接返回已創建好的對象。

雙重檢查鎖定看起來似乎很完美,但這是一個錯誤的優化!在執行緒執行到第 4 行程式碼讀取到 instance 不為 null 時,instance 引用的對象有可能還沒有完成初始化。

問題的根源

前面的雙重檢查鎖定示例程式碼的第 7 行(instance = new Singleton();)創建一個對象。這一行程式碼可以分解為如下的三行偽程式碼:

上面三行偽程式碼中的 2 和 3 之間,可能會被重排序(在一些JIT 編譯器上,這種重排序是真實發生的,詳情見參考文獻 1 的「Out-of-order writes」部分)。2 和 3 之間重排序之後的執行時序如下:

根據 The Java Language Specification, Java SE 7 Edition(後文簡稱為Java 語言規範),所有執行緒在執行Java 程式時必須要遵守 intra-thread semanticsintra-thread semantics 保證重排序不會改變單執行緒內的程式執行結果。換句話來說,intra-thread semantics 允許那些在單執行緒內,不會改變單執行緒程式執行結果的重排序。上面三行偽程式碼的 2 和 3 之間雖然被重排序了,但這個重排序並不會違反 intra-thread semantics。這個重排序在沒有改變單執行緒程式的執行結果的前提下,可以提高程式的執行性能。

為了更好的理解 intra-thread semantics,請看下面的示意圖(假設一個執行緒 A 在構造對象後,立即訪問這個對象):

如上圖所示,只要保證 2 排在 4 的前面,即使 2 和 3 之間重排序了,也不會違反 intra-thread semantics。

下面,再讓我們看看多執行緒並發執行的時候的情況。請看下面的示意圖:

由於單執行緒內要遵守 intra-thread semantics,從而能保證 A 執行緒的程式執行結果不會被改變。但是當執行緒A 和B 按上圖的時序執行時,B 執行緒將看到一個還沒有被初始化的對象。

註:本文統一用紅色的虛箭線標識錯誤的讀操作,用綠色的虛箭線標識正確的讀操作

回到本文的主題,DoubleCheckedLocking 示例程式碼的第 7 行(instance = new Singleton();)如果發生重排序,另一個並發執行的執行緒B 就有可能在第 4 行判斷instance 不為null。執行緒B 接下來將訪問 instance 所引用的對象,但此時這個對象可能還沒有被 A 執行緒初始化!下面是這個場景的具體執行時序:

這裡 A2 和 A3 雖然重排序了,但 Java 記憶體模型的 intra-thread semantics 將確保 A2 一定會排在A4 前面執行。因此執行緒 A 的 intra-thread semantics 沒有改變。但 A2 和 A3 的重排序,將導致執行緒 B 在 B1 處判斷出 instance 不為空,執行緒 B 接下來將訪問 instance 引用的對象。此時,執行緒 B 將會訪問到一個還未初始化的對象。

在知曉了問題發生的根源之後,我們可以想出兩個辦法來實現執行緒安全的延遲初始化:

1.不允許 2 和 3 重排序; 2.允許 2 和 3 重排序,但不允許其他執行緒「看到」這個重排序。後文介紹的兩個解決方案,分別對應於上面這兩點。

基於volatile 的雙重檢查鎖定的解決方案

對於前面的基於雙重檢查鎖定來實現延遲初始化的方案(指 DoubleCheckedLocking 示例程式碼), 我們只需要做一點小的修改(把 instance 聲明為 volatile 型),就可以實現執行緒安全的延遲初始化。請看下面的示例程式碼:

注意,這個解決方案需要 JDK5 或更高版本(因為從 JDK5 開始使用新的 JSR-133 記憶體模型規範,這個規範增強了volatile 的語義)。

當聲明對象的引用為 volatile 後,「問題的根源」的三行偽程式碼中的 2 和 3 之間的重排序,在多執行緒環境中將會被禁止。上面示例程式碼將按如下的時序執行:

這個方案本質上是通過禁止上圖中的 2 和 3 之間的重排序,來保證執行緒安全的延遲初始化。

基於類初始化的解決方案

JVM 在類的初始化階段(即在 Class 被載入後,且被執行緒使用之前),會執行類的初始化。在執行類的初始化期間,JVM 會去獲取一個鎖。這個鎖可以同步多個執行緒對同一個類的初始化。

基於這個特性,可以實現另一種執行緒安全的延遲初始化方案(這個方案被稱之為 Initialization On Demand Holder idiom):

假設兩個執行緒並發執行 getInstance(),下面是執行的示意圖:

這個方案的實質是:允許「問題的根源」的三行偽程式碼中的 2 和 3 重排序,但不允許非構造執行緒 (這裡指執行緒B)「看到」這個重排序。

初始化一個類,包括執行這個類的靜態初始化和初始化在這個類中聲明的靜態欄位。根據 java 語言規範,在首次發生下列任意一種情況時,一個類或介面類型T 將被立即初始化:

  • T 是一個類,而且一個T 類型的實例被創建;
  • T 是一個類,且T 中聲明的一個靜態方法被調用;
  • T 中聲明的一個靜態欄位被賦值;
  • T 中聲明的一個靜態欄位被使用,而且這個欄位不是一個常量欄位;
  • T 是一個頂級類(top level class,見Java 語言規範的§7.6 ),而且一個斷言語句嵌套在T 內部被執行。

在 InstanceFactory 示例程式碼中,首次執行 getInstance()的執行緒將導致 InstanceHolder 類被初始化 (符合情況 4)。

由於 Java 語言是多執行緒的,多個執行緒可能在同一時間嘗試去初始化同一個類或介面(比如這裡多個執行緒可能在同一時刻調用 getInstance()來初始化 InstanceHolder 類)。因此在 Java 中初始化一個類或者介面時,需要做細緻的同步處理。

Java 語言規範規定,對於每一個類或介面 C,都有一個唯一的初始化鎖 LC 與之對應。從C 到LC 的映射,由JVM 的具體實現去自由實現。JVM 在類初始化期間會獲取這個初始化鎖,並且每個執行緒至少獲取一次鎖來確保這個類已經被初始化過了(事實上,Java 語言規範允許JVM 的具體實現在這裡做一些優化,見後文的說明)。

對於類或介面的初始化,Java 語言規範制定了精巧而複雜的類初始化處理過程。Java 初始化一個類或介面的處理過程如下(這裡對類初始化處理過程的說明,省略了與本文無關的部分;同時為了更好的說明類初始化過程中的同步處理機制,筆者人為的把類初始化的處理過程分為了五個階段):

第一階段:通過在 Class 對象上同步(即獲取 Class 對象的初始化鎖),來控制類或介面的初始化。這個獲取鎖的執行緒會一直等待,直到當前執行緒能夠獲取到這個初始化鎖。 假設 Class 對象當前還沒有被初始化(初始化狀態 state 此時被標記為 state = noInitialization), 且有兩個執行緒A 和B 試圖同時初始化這個Class 對象。下面是對應的示意圖:

下面是這個示意圖的說明:

第二階段:執行緒A 執行類的初始化,同時執行緒B 在初始化鎖對應的condition 上等待:

下面是這個示意圖的說明:

第三階段:執行緒A 設置 state = initialized,然後喚醒在 condition 中等待的所有執行緒:

下面是這個示意圖的說明:

第四階段:執行緒B 結束類的初始化處理:

下面是這個示意圖的說明:

執行緒A 在第二階段的 A1 執行類的初始化,並在第三階段的A4 釋放初始化鎖;執行緒B 在第四階段的 B1 獲取同一個初始化鎖,並在第四階段的 B4 之後才開始訪問這個類。根據 java 記憶體模型規範的鎖規則,這裡將存在如下的 happens-before 關係:

這個 happens-before 關係將保證:執行緒 A 執行類的初始化時的寫入操作(執行類的靜態初始化和初始化類中聲明的靜態欄位),執行緒B 一定能看到。 第五階段:執行緒C 執行類的初始化的處理:

下面是這個示意圖的說明:

在第三階段之後,類已經完成了初始化。因此執行緒 C 在第五階段的類初始化處理過程相對簡單一些(前面的執行緒A 和B 的類初始化處理過程都經歷了兩次鎖獲取-鎖釋放,而執行緒C 的類初始化處理只需要經歷一次鎖獲取-鎖釋放)。

執行緒 A 在第二階段的 A1 執行類的初始化,並在第三階段的 A4 釋放鎖;執行緒 C 在第五階段的C1 獲取同一個鎖,並在在第五階段的 C4 之後才開始訪問這個類。根據 Java 記憶體模型規範的鎖規則,這裡將存在如下的happens-before 關係:

這個 happens-before 關係將保證:執行緒A 執行類的初始化時的寫入操作,執行緒C 一定能看到。

注 1:這裡的 condition 和 state 標記是本文虛構出來的。Java 語言規範並沒有硬性規定一定要使用 condition 和 state 標記。JVM 的具體實現只要實現類似功能即可。

注 2:Java 語言規範允許 Java 的具體實現,優化類的初始化處理過程(對這裡的第五階段做優化),具體細節參見 Java 語言規範的 12.4.2 章。

通過對比基於 volatile 的雙重檢查鎖定的方案和基於類初始化的方案,我們會發現基於類初始化的方案的實現程式碼更簡潔。但基於 volatile 的雙重檢查鎖定的方案有一個額外的優勢:除了可以對靜態欄位實現延遲初始化外,還可以對實例欄位實現延遲初始化。

總結

延遲初始化降低了初始化類或創建實例的開銷,但增加了訪問被延遲初始化的欄位的開銷。在大多數時候,正常的初始化要優於延遲初始化。如果確實需要對實例欄位使用執行緒安全的延遲初始化,請使用上面介紹的基於 volatile 的延遲初始化的方案;如果確實需要對靜態欄位使用執行緒安全的延遲初始化,請使用上面介紹的基於類初始化的方案。