Java裡面為什麼搞了雙重檢查鎖,寫完這篇文章終於真相大白了

来源:https://www.cnblogs.com/jiagooushi/archive/2023/02/02/17086105.html
-Advertisement-
Play Games

雙重檢查鎖定與延遲初始化 在 java 程式中,有時候可能需要推遲一些高開銷的對象初始化操作,並且只有在使用這些對象時才進行初始化。此時程式員可能會採用延遲初始化。但要正確實現線程安全的延遲初始化需要一些技巧,否則很容易出現問題。比如,下麵是非線程安全的延遲初始化對象的示例代碼: COPYpubli ...


img

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

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

COPYpublic 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() 做同步處理來實現線程安全的延遲初始化。示例代碼如下:

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

COPYpublic 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();)創建一個對象。這一行代碼可以分解為如下的三行偽代碼:

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

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

COPYmemory = 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 在構造對象後,立即訪問這個對象):

img

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

下麵,再讓我們看看多線程併發執行的時候的情況。請看下麵的示意圖:

img

由於單線程內要遵守 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 型),就可以實現線程安全的延遲初始化。請看下麵的示例代碼:

COPYpublic 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 之間的重排序,在多線程環境中將會被禁止。上面示例代碼將按如下的時序執行:

img

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

基於類初始化的解決方案

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

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

COPYpublic class InstanceFactory {
    private static class InstanceHolder {
        public static Instance instance = new Instance();
    }

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

假設兩個線程併發執行 getInstance(),下麵是執行的示意圖:

img

這個方案的實質是:允許“問題的根源”的三行偽代碼中的 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 對象。下麵是對應的示意圖:

img

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

時間 線程 A 線程 B
t1 A1: 嘗試獲取 Class 對象的初始化鎖。這裡假設線程 A 獲取到了初始化鎖 B1: 嘗試獲取 Class 對象的初始化鎖,由於線程 A 獲取到了鎖,線程 B 將一直等待獲取初始化鎖
t2 A2:線程 A 看到線程還未被初始化(因為讀取到 state == noInitialization),線程設置 state = initializing
t3 A3:線程 A 釋放初始化鎖
第二階段

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

img

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

時間 線程 A 線程 B
t1 A1: 執行類的靜態初始化和初始化類中聲明的靜態欄位 B1:獲取到初始化鎖
t2 B2:讀取到 state == initializing
t3 B3:釋放初始化鎖
t4 B4:在初始化鎖的 condition 中等待
第三階段

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

img

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

時間 線程 A
t1 A1:獲取初始化鎖
t2 A2:設置 state = initialized
t3 A3:喚醒在 condition 中等待的所有線程
t4 A4:釋放初始化鎖
t5 A5:線程 A 的初始化處理過程完成
第四階段

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

img

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

時間 線程 B
t1 B1:獲取初始化鎖
t2 B2:讀取到 state == initialized
t3 B3:釋放初始化鎖
t4 B4:線程 B 的類初始化處理過程完成

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

img

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

第五階段

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

img

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

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

本文由傳智教育博學谷教研團隊發佈。

如果本文對您有幫助,歡迎關註點贊;如果您有任何建議也可留言評論私信,您的支持是我堅持創作的動力。

轉載請註明出處!


您的分享是我們最大的動力!

-Advertisement-
Play Games
更多相關文章
  • 1 簡介 之前在文章《dapr入門與本地托管模式嘗試》中介紹了dapr和本地托管,本文我們來介紹如果在代碼中使用dapr的服務調用功能,並把它整合到Spring Boot中。 Dapr服務調用的邏輯如下: 本次實驗會創建兩個服務: pkslow-data,提供數據服務,用於返回數據; pkslow- ...
  • /*C Primer Plus (7.11) 3*/ 1 #include<stdio.h> 2 int main() 3 { 4 double weight,height; 5 printf("Please enter your weight and height.\n"); 6 printf(" ...
  • 這篇文章主要討論如何在不知道介面的情況下進行RPC調用,以及如何在一個線上系統中支持多種不同的RPC協議。 ...
  • Java版本 JavaSE 標準版,用於桌面程式、控制台等,這是最核心的部分,需要首先學習 JavaME 嵌入式開發,用於家電等(很少用) JavaEE 企業級,用於web、伺服器 一些概念 JDK Java Development Kit,Java開發工具。包含JRE、JVM,且包含一些開發工具( ...
  • 題目描述 給定一個 n 個元素有序的(升序)整型數組 nums 和一個目標值 target ,寫一個函數搜索 nums 中的 target,如果目標值存在返回下標,否則返回 -1。 來源:力扣(LeetCode) 鏈接:https://leetcode.cn/problems/binary-sear ...
  • 第一種:for in girl_dict= {"China": "小美", "Japan": "圖多天光", "Korea": "斯密達美"} for everyKey in girl_dict: print ("key:" + everyKey + "value:" + girl_dict[eve ...
  • Word文檔屬性包括常規、摘要、統計、內容、自定義。其中摘要包括標題、主題、作者、經理、單位、類別、關鍵詞、備註等項目。屬性相當於文檔的名片,可以添加你想要的註釋、說明等。還可以標註版權。 今天就為大家介紹一下,如何通過Java代碼向Word文檔添加文檔屬性。詳情請閱讀以下內容。 將內置文檔屬性添加 ...
  • 顧名思義單調棧就是具有單調性的棧 ==常見模型:找出每個數左邊離它最近的比它大/小的數== 【演算法】 int stk[N],tt = 0; // 棧中存數據 for (int i = 1; i <= n; i ++){ int x; cin >> x; while (tt && stk[tt] >= ...
一周排行
    -Advertisement-
    Play Games
  • 概述:在C#中,++i和i++都是自增運算符,其中++i先增加值再返回,而i++先返回值再增加。應用場景根據需求選擇,首碼適合先增後用,尾碼適合先用後增。詳細示例提供清晰的代碼演示這兩者的操作時機和實際應用。 在C#中,++i 和 i++ 都是自增運算符,但它們在操作上有細微的差異,主要體現在操作的 ...
  • 上次發佈了:Taurus.MVC 性能壓力測試(ap 壓測 和 linux 下wrk 壓測):.NET Core 版本,今天計劃準備壓測一下 .NET 版本,來測試並記錄一下 Taurus.MVC 框架在 .NET 版本的性能,以便後續持續優化改進。 為了方便對比,本文章的電腦環境和測試思路,儘量和... ...
  • .NET WebAPI作為一種構建RESTful服務的強大工具,為開發者提供了便捷的方式來定義、處理HTTP請求並返迴響應。在設計API介面時,正確地接收和解析客戶端發送的數據至關重要。.NET WebAPI提供了一系列特性,如[FromRoute]、[FromQuery]和[FromBody],用 ...
  • 原因:我之所以想做這個項目,是因為在之前查找關於C#/WPF相關資料時,我發現講解圖像濾鏡的資源非常稀缺。此外,我註意到許多現有的開源庫主要基於CPU進行圖像渲染。這種方式在處理大量圖像時,會導致CPU的渲染負擔過重。因此,我將在下文中介紹如何通過GPU渲染來有效實現圖像的各種濾鏡效果。 生成的效果 ...
  • 引言 上一章我們介紹了在xUnit單元測試中用xUnit.DependencyInject來使用依賴註入,上一章我們的Sample.Repository倉儲層有一個批量註入的介面沒有做單元測試,今天用這個示例來演示一下如何用Bogus創建模擬數據 ,和 EFCore 的種子數據生成 Bogus 的優 ...
  • 一、前言 在自己的項目中,涉及到實時心率曲線的繪製,項目上的曲線繪製,一般很難找到能直接用的第三方庫,而且有些還是定製化的功能,所以還是自己繪製比較方便。很多人一聽到自己畫就害怕,感覺很難,今天就分享一個完整的實時心率數據繪製心率曲線圖的例子;之前的博客也分享給DrawingVisual繪製曲線的方 ...
  • 如果你在自定義的 Main 方法中直接使用 App 類並啟動應用程式,但發現 App.xaml 中定義的資源沒有被正確載入,那麼問題可能在於如何正確配置 App.xaml 與你的 App 類的交互。 確保 App.xaml 文件中的 x:Class 屬性正確指向你的 App 類。這樣,當你創建 Ap ...
  • 一:背景 1. 講故事 上個月有個朋友在微信上找到我,說他們的軟體在客戶那邊隔幾天就要崩潰一次,一直都沒有找到原因,讓我幫忙看下怎麼回事,確實工控類的軟體環境複雜難搞,朋友手上有一個崩潰的dump,剛好丟給我來分析一下。 二:WinDbg分析 1. 程式為什麼會崩潰 windbg 有一個厲害之處在於 ...
  • 前言 .NET生態中有許多依賴註入容器。在大多數情況下,微軟提供的內置容器在易用性和性能方面都非常優秀。外加ASP.NET Core預設使用內置容器,使用很方便。 但是筆者在使用中一直有一個頭疼的問題:服務工廠無法提供請求的服務類型相關的信息。這在一般情況下並沒有影響,但是內置容器支持註冊開放泛型服 ...
  • 一、前言 在項目開發過程中,DataGrid是經常使用到的一個數據展示控制項,而通常表格的最後一列是作為操作列存在,比如會有編輯、刪除等功能按鈕。但WPF的原始DataGrid中,預設只支持固定左側列,這跟大家習慣性操作列放最後不符,今天就來介紹一種簡單的方式實現固定右側列。(這裡的實現方式參考的大佬 ...