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

來源:互聯網
上載者:User

標籤:nal   接下來   使用   意圖   lsp   調用   java初始化   utf8   初始化   

在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)。人們想通過雙重檢查鎖定來降低同步的開銷。下面是使用雙重檢查鎖定來實現延遲初始化的範例程式碼:

public class DoubleCheckedLocking {                      //1    private static Instance instance;                    //2    public static Instance getInstance() {               //3        if (instance == null) {                          //4:第一次檢查            synchronized (DoubleCheckedLocking.class) {  //5:加鎖                if (instance == null)                    //6:第二次檢查                    instance = new Instance();           //7:問題的根源出在這裡            }                                            //8        }                                                //9        return instance;                                 //10    }                                                    //11}                                                        //12

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

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

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

問題的根源

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

memory = allocate();   //1:指派至的記憶體空間ctorInstance(memory);  //2:初始化對象instance = memory;     //3:設定instance指向剛分配的記憶體位址

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

memory = allocate();   //1:指派至的記憶體空間instance = memory;     //3:設定instance指向剛分配的記憶體位址  注意,此時對象還沒有被初始化!ctorInstance(memory);  //2:初始化對象

根據《The Java Language Specification, Java SE 7 Edition》(後文簡稱為Java語言規範),所有線程在執行java程式時必須要遵守intra-thread semantics。intra-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線程初始化!下面是這個情境的具體執行時序:

時間

線程A

線程B

t1

A1:指派至的記憶體空間

 

t2

A3:設定instance指向記憶體空間

 

t3

 

B1:判斷instance是否為空白

t4

 

B2:由於instance不為null,線程B將訪問instance引用的對象

t5

A2:初始化對象

 

t6

A4:訪問instance引用的對象

 

這裡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型),就可以實現安全執行緒的延遲初始化。請看下面的範例程式碼:

public class SafeDoubleCheckedLocking {    private volatile static Instance instance;    public static Instance getInstance() {        if (instance == null) {            synchronized (SafeDoubleCheckedLocking.class) {                if (instance == null)                    instance = new Instance();//instance為volatile,現在沒問題了            }        }        return instance;    }}

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

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

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

基於類初始化的解決方案

JVM在類的初始化階段(即在Class被載入後,且被線程使用之前),會執行類的初始化。在執行類的初始化期間,JVM會去擷取一個鎖。這個鎖可以同步多個線程對同一個類的初始化。基於這個特性,可以實現另一種安全執行緒的延遲初始化方案(這個方案被稱之為Initialization On Demand Holder idiom):

public class InstanceFactory {    private static class InstanceHolder {        public static Instance instance = new Instance();    }    public static Instance getInstance() {        return InstanceHolder.instance ;  //這裡將導致InstanceHolder類被初始化    }}

假設兩個線程並發執行getInstance(),下面是執行的:

這個方案的實質是:允許“問題的根源”的三行虛擬碼中的2和3重排序,但不允許非構造線程(這裡指線程B)“看到”這個重排序。

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

  • T是一個類,而且一個T類型的執行個體被建立;
  • T是一個類,且T中聲明的一個靜態方法被調用;
  • T中聲明的一個靜態欄位被賦值;
  • T中聲明的一個靜態欄位被使用,而且這個欄位不是一個常量欄位;
  • T是一個頂級類(top level class,見java語言規範的§7.6),而且一個Assert 陳述式嵌套在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

t1

A1:嘗試擷取Class對象的初始化鎖。這裡假設線程A擷取到了初始化鎖

B1:嘗試擷取Class對象的初始化鎖,由於線程A擷取到了鎖,線程B將一直等待擷取初始化鎖

t2

A2:線程A看到線程還未被初始化(因為讀取到state == noInitialization),線程設定state = initializing

 

t3

A3:線程A釋放初始化鎖

 

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

下面是這個的說明:

時間

線程A

線程B

t1

A1:執行類的靜態初始化和初始化類中聲明的靜態欄位

B1:擷取到初始化鎖

t2

 

B2:讀取到state == initializing

t3

 

B3:釋放初始化鎖

t4

 

B4:在初始化鎖的condition中等待

第三階段:線程A設定state = initialized,然後喚醒在condition中等待的所有線程:

下面是這個的說明:

時間

線程A

t1

A1:擷取初始化鎖

t2

A2:設定state = initialized

t3

A3:喚醒在condition中等待的所有線程

t4

A4:釋放初始化鎖

t5

A5:線程A的初始化處理過程完成

第四階段:線程B結束類的初始化處理:

下面是這個的說明:

時間

線程B

t1

B1:擷取初始化鎖

t2

B2:讀取到state == initialized

t3

B3:釋放初始化鎖

t4

B4:線程B的類初始化處理過程完成

線程A在第二階段的A1執行類的初始化,並在第三階段的A4釋放初始化鎖;線程B在第四階段的B1擷取同一個初始化鎖,並在第四階段的B4之後才開始訪問這個類。

根據java記憶體模型規範的鎖規則,這裡將存在如下的happens-before關係:

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

第五階段:線程C執行類的初始化的處理:

下面是這個的說明:

時間

線程B

t1

C1:擷取初始化鎖

t2

C2:讀取到state == initialized

t3

C3:釋放初始化鎖

t4

C4:線程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的延遲初始化的方案;如果確實需要對靜態欄位使用安全執行緒的延遲初始化,請使用上面介紹的基於類初始化的方案。

參考文獻
  1. Double-checked locking and the Singleton pattern
  2. The Java Language Specification, Java SE 7 Edition
  3. JSR-133: Java Memory Model and Thread Specification
  4. Java Concurrency in Practice
  5. Effective Java (2nd Edition)
  6. JSR 133 (Java Memory Model) FAQ
  7. The JSR-133 Cookbook for Compiler Writers
  8. Java theory and practice: Fixing the Java Memory Model, Part 2

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

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

聯繫我們

該頁面正文內容均來源於網絡整理,並不代表阿里雲官方的觀點,該頁面所提到的產品和服務也與阿里云無關,如果該頁面內容對您造成了困擾,歡迎寫郵件給我們,收到郵件我們將在5個工作日內處理。

如果您發現本社區中有涉嫌抄襲的內容,歡迎發送郵件至: info-contact@alibabacloud.com 進行舉報並提供相關證據,工作人員會在 5 個工作天內聯絡您,一經查實,本站將立刻刪除涉嫌侵權內容。

A Free Trial That Lets You Build Big!

Start building with 50+ products and up to 12 months usage for Elastic Compute Service

  • Sales Support

    1 on 1 presale consultation

  • After-Sales Support

    24/7 Technical Support 6 Free Tickets per Quarter Faster Response

  • Alibaba Cloud offers highly flexible support services tailored to meet your exact needs.