1. 程式人生 > >同步原語 volatile 簡介與單例模式

同步原語 volatile 簡介與單例模式

Volatile 簡介

volatile 的作用

在多執行緒併發程式設計中 synchronizedvolatile 都扮演著重要的角色,volatile 是輕量級的 synchronized,它在多處理器開發中保證了共享變數的“可見性”。共享變數是指所有的例項域靜態域陣列元素,它們都儲存在堆記憶體中,堆記憶體線上程之間共享。可見性的意思是當一個執行緒修改一個共享變數時,另外一個執行緒能讀到這個修改的值。如果 volatile 變數修飾符使用恰當的話,它比 synchronized 的使用和執行成本更低,因為它不會引起執行緒上下文的切換和排程。

通過 volatile 保證執行緒間共享變數的可見性

執行緒間的同步

同步是指程式中用於控制不同執行緒間操作發生的相對順序的機制。只有正確同步的程式,JVM 才可以保證執行緒之間的可見性。

執行緒間的通訊

要理解共享變數可見性的問題,首先要理解執行緒之間如何進行通訊。在指令式程式設計中,執行緒之間的通訊機制有兩種,共享記憶體和訊息傳遞。在共享記憶體的併發模型中,執行緒之間共享程式的公共狀態,通過讀-寫記憶體中的公共狀態進行隱式通訊。在訊息傳遞的併發模型中,執行緒之間沒有公共狀態,執行緒之間通過傳送訊息來顯示進行通訊。Java 的併發採用的是共享記憶體模型。Java 執行緒間的通訊的抽象示意如下圖所示:
JVM抽象結構示意圖

執行緒間通訊示例-不能保證可見性的示例

public class VolatileTest {
    int a = 0;
    boolean flag = false;

    public void writer() {
        a = 2;                      //1
        flag = true;                //2 
    }

    public void reader() {
        if (flag) {                //3
            int i = a * 3;         //4
        }
    }
}

假設有兩個執行緒 A 和 B。A 首先執行 writer 方法,然後 B 執行緒接著執行 reader 方法。執行緒 B 一定能看到 執行緒 A 在 2 中對 flag 的寫入嗎?如果執行緒 B 讀到了執行緒A在 2 中對 flag 的寫入,那它執行操作 4 時,一定能看到執行緒A 在 1 對共享變數 a 的寫入呢?
第一個問題答案是:不一定能看到。
為什麼呢? 因為Java執行緒是不直接讀寫主記憶體中的共享變數的,而是把它所需要的共享變數先從主記憶體拷貝到本地記憶體,執行緒對變數的所有操作都是在本地記憶體進行,而執行緒的本地記憶體何時同步回主記憶體是不可預期的。
第二個問題答案也是:不一定能看到。
為什麼呢? 因為可能會因為重排序而得到非預期的結果,具體分析如下。

原始碼到執行序列示意圖

原始碼到執行序列示意圖
上述的 1 屬於編譯器重排序, 2 和 3 屬於處理器重排序。

重排序可能的影響

當 1 和 2 發生了重排序時,可能會產生什麼效果呢?如下圖所示:
可能的重排序
在這裡,多執行緒程式的語義被重排序破壞了。執行緒之間共享變數有可見性的問題
造成這個問題的原因是:程式沒有進行正確同步。對於執行緒A來說,這樣重排序並不會影響它的執行結果,仍然遵循 as-if-serial 語義,是允許的。

如何修改程式,讓它可以正確同步呢?

有兩種方法可以實現正確同步:

  • 1、保證本地記憶體重新整理到主記憶體;允許 1 和 2 重排序,但是不允許其他執行緒“看到”這個重排序
  • 2、保證本地記憶體重新整理到主記憶體;不允許 1 和 2 重排序

採用第 1 種方法,程式碼修改如下:

public class VolatileTest {
    int a = 0;
    boolean flag = false;

    public synchronized void writer() {
        a = 2;                                       //1
        flag = true;                                 //2
    }

    public synchronized void reader() {
        if (flag) {                                  //3
            int i = a * 3;                           //4
        }
    }
}

這種方法是通過加鎖的方式實現的,在臨界區內,允許 1 和 2 進行重排序,這裡執行緒B是“看不到”臨界區的重排序的。

採用第 2 種方法,程式碼修改如下:

public class VolatileTest {
    int a = 0;
    volatile boolean flag = false;

    public  void writer() {
        a = 2;                                   //1
        flag = true;                            //2
    }

    public  void reader() {
        if (flag) {                             //3
            int i = a * 3;                      //4
        }
    }
}

執行緒讀取被 volatile 修飾的共享變數時,總會從主記憶體中讀取最新值;執行緒完成對被volatile修飾的共享變數的寫入時,總會立即把本地記憶體中的修改重新整理到主記憶體。利用volatile 的這個特性,“保證本地記憶體重新整理到主記憶體”的問題解決了。而從JSR-133 開始(即從 JDK 5 開始),JVM 增強了 volatile 的記憶體語義:嚴格限制編譯器和處理器對 volatile 變數與普通變數的重排序(在 JDK 5 之前,只是不允許 volatile 變數之前重排序)。這樣做的目的是為了提供一種比鎖更輕量級的執行緒之間通訊的機制:使 volatile 的寫-讀有鎖的釋放-獲取的記憶體語義。利用 volatile 的這個特性,“不允許 1 和 2 重排序”的問題解決了。

volatile 的實現原理

如何保證本地記憶體重新整理到主記憶體

要分析這個特性的實現原理,可以從程式的執行過程入手。以彙編程式碼為切入點,結合相關處理器說明手冊(我的電腦用的是 Intel 酷睿 i7 CPU,所以本部落格參照的是 英特爾® 64 位和 IA-32 架構開發人員手冊)解讀彙編程式,進行對比分析,最後得出結論。

程式執行過程

高階語言程式的執行,一般如下面示意圖所示:
高階語言程式的執行
Java 語言程式的執行過程:
Java 語言程式的執行過程
因此,要以彙編程式碼為切入點分析 volatile 的實現原理,需要做兩件事情:
-1、讓測試程式碼成為“熱點程式碼”,以觸發 Jit 編譯
-2、觸發 Jit 編譯時,輸入反彙編程式碼
下面以 Intel 處理器為例,分析 volatile 的實現原理

Intel 處理器上如何實現 volatile?

下面詳細介紹如何獲取測試程式碼執行時對應的反彙編程式碼。
在真正動手操作之前,先介紹跟整個流程密切相關的一些基本知識。

Hotspot 的編譯器(Jit)簡介

Hotspot 虛擬機器中內建了兩個即時編譯器,分別稱為 Client Compiler 和 Server Compiler。目前主流的 Hotspot 虛擬機器,預設採用直譯器和其中一個編譯器直接配合的方式工作。程式採用哪個編譯器,取決於虛擬機器的執行模式。Hotspot 虛擬器會根據自身的版本和宿主機器的硬體效能自動選擇執行模式,使用者也可以使用“-client” 或 “-server”引數去控制虛擬機器執行在 Client 模式或 Server 模式。

編譯條件

只有“熱點程式碼”,虛擬機器才對它進行編譯,以提高執行效率。
熱點程式碼有兩類
-1、被多次呼叫的方法
-2、被多次執行的迴圈體
Hotspot 中採用的是基於計數器的熱點探測方法,因此它為每個方法準備了兩類計數器:方法呼叫計數器和回邊計數器。這兩個計數器都有一個確定的閾值,當計數器超過閾值溢位了,就會觸發 Jit 編譯。

測試編譯程式碼

public class JitTest {

    volatile int a = 0;

    private void write() {
        a = 2;
    }

    public static void main(String[] args) {
        JitTest jitTest = new JitTest();
        // client 模式下,方法呼叫計數器預設閾值為1500,回邊計數器預設閾值為 13995;
        // server 模式下,方法呼叫計數器預設閾值為10000,回邊計數器預設閾值為 10700;
        //當前JVM執行在 server 模式,通過重複執行 write 方法 10701 次來確保一定觸發 jit 編譯
        for (int i = 1; i <= 10701;i++){
            jitTest.write();
        }
        System.out.println("finished!");
    }
}

JVM配置

-server
-XX:+UnlockDiagnosticVMOptions
-XX:+PrintAssembly

這些配置使 JVM 執行在 Server 模式,並輸出彙編程式碼。
另外,要輸出彙編程式碼,還需要在目錄 $JAVA_HOME\jre\bin\server 下新增 hsdis-amd64.dll(Windows 64位系統)。下載 hsdis-amd64.dll

彙編程式碼分析

執行上面的測試程式碼,獲得如下的彙編程式碼(僅擷取需要分析的部分)

  0x0000000002e9ce38: je      2e9ce57h          ;*aload_0
                                                ; - com.andy.JitTest::[email protected] (line 12)

  0x0000000002e9ce3e: mov     esi,2h
  0x0000000002e9ce43: mov     dword ptr [rdx+0ch],esi
  0x0000000002e9ce46: lock add dword ptr [rsp],0h  ;*putfield a
                                                ; - com.andy.JitTest::[email protected] (line 12)

  0x0000000002e9ce4b: add     rsp,30h
  0x0000000002e9ce4f: pop     rbp
  0x0000000002e9ce50: test    dword ptr [2a50100h],eax
                                                ;   {poll_return}
  0x0000000002e9ce56: ret
  0x0000000002e9ce57: mov     qword ptr [rsp+8h],rsi
  0x0000000002e9ce5c: mov     qword ptr [rsp],0ffffffffffffffffh
  0x0000000002e9ce64: call    2e80f60h          ; OopMap{rdx=Oop off=137}
                                                ;*synchronization entry
                                                ; - com.andy.JitTest::[email protected] (line 12)
                                                ;   {runtime_call}
  0x0000000002e9ce69: jmp     2e9ce3eh

把測試程式碼中,修飾變數 a 的 volatile 去掉,然後再次執行程式,獲得以下彙編程式碼(僅擷取需要分析的部分)

0x0000000002f27938: je      2f27951h          ;*aload_0
                                                ; - com.andy.JitTest::write@0 (line 12)

  0x0000000002f2793e: mov     dword ptr [rdx+0ch],2h  ;*putfield a
                                                ; - com.andy.JitTest::write@2 (line 12)

  0x0000000002f27945: add     rsp,30h
  0x0000000002f27949: pop     rbp
  0x0000000002f2794a: test    dword ptr [7a0100h],eax  ;   {poll_return}
  0x0000000002f27950: ret
  0x0000000002f27951: mov     qword ptr [rsp+8h],rsi
  0x0000000002f27956: mov     qword ptr [rsp],0ffffffffffffffffh
  0x0000000002f2795e: call    2f10860h          ; OopMap{rdx=Oop off=131}
                                                ;*synchronization entry
                                                ; - com.andy.JitTest::write@-1 (line 12)
                                                ;   {runtime_call}
  0x0000000002f27963: jmp     2f2793eh

比較兩份彙編程式碼,會發現,當變數a有 volatile 修飾時, write 方法對應的彙編程式碼多了一個 lock 字首:

  0x0000000002e9ce46: lock add dword ptr [rsp],0h  ;*putfield a
                                                ; - com.andy.JitTest::write@2 (line 12)

這個 lock 字首,有什麼用呢?檢視英特爾® 64 位和 IA-32 架構開發人員手冊,可以看到這樣的描述:
這裡寫圖片描述
這裡寫圖片描述
lock 字首會使處理器在宣告一個 LOCK# 訊號,這個訊號會引起匯流排鎖或者快取鎖(支援快取鎖的處理器)。也就是說,當進行一個 volatile 寫的時候,處理器宣告一個匯流排鎖或者快取鎖,然後把快取中的共享變數寫到主記憶體中。這不僅解決了“立刻把本地記憶體重新整理到主記憶體”的問題,還保證了這個操作的原子性。當多個處理器都快取了同一個共享變數的值時,有快取一致性協議保證各個處理器都獲得正確的值。即當一個處理器修改了某個共享變數,其他處理器可以“知道”,並從主記憶體中讀取最新的值。

volatile 如何禁止重排序?

這一部分工作由JVM來做。 JVM 在恰當的位置插入記憶體屏障,來禁止編譯器和處理器進行重排序。
volatile 寫 插入記憶體屏障後生成的指令序列示意圖(保守策略下):
這裡寫圖片描述
volatile 讀 插入記憶體屏障後生成的指令序列示意圖(保守策略下):
這裡寫圖片描述

單例模式

主要分析 volatile 在單例模式中的作用

初級版本

public class SingletonTest {
    private static Instance instance;

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

因為每次訪問都要加鎖,在高併發情況下效率非常低。

優化版本版本

錯誤的版本

public class SingletonTest {
    private static Instance instance;

    public  static Instance getInstance(){
        if (instance == null){                      //第一次檢查
            synchronized (SingletonTest.class){    //加鎖
                if (instance == null) {              //第二次檢查
                    instance = new Instance();       //問題出在這裡
                }
            }
        }
        return instance;
    }

}

問題的根源
instance = new Instance() 可以分解為如下的 3 行虛擬碼:

memory = allocate();  //1、分配物件的記憶體空間
ctorInstance(memory); //2、初始化物件
instance = memory;    //3、設定 instance 指向剛剛分配的記憶體地址

上面的虛擬碼中, 2 和 3 可能會被重排序。當重排序發生時:

memory = allocate();  //1、分配物件的記憶體空間
instance = memory;    //3、設定 instance 指向剛剛分配的記憶體地址
                      //注意:此時 instance 還未被初始化
ctorInstance(memory); //2、初始化物件

這時候,多執行緒環境下,就可能會發生“獲得一個未被初始化或者正在被初始化的物件”的錯誤。
有兩種方法可以解決這個問題:

  • 1、允許 2 和 3 重排序,但是不允許其他執行緒“看到”這個重排序
  • 2、不允許 2 和 3 重排序
    採用第一種方法,可以藉助類初始化鎖實現:
public class SingletonTest {

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

    public static Instance getInstance(){
        return InstanceHolder.instance;
    }

}

第一次訪問 InstanceHolder 類時初始化,即第一次呼叫 getInstance 時初始化。通過類初始化鎖保證其他執行緒“看不到”重排序的過程
採用第二種方法,可以利用 volatile 的特性實現:

public class SingletonTest {
    private volatile static Instance instance;

    public  static Instance getInstance(){
        if (instance == null){                      //第一次檢查
            synchronized (SingletonTest.class){    //加鎖
                if (instance == null) {              //第二次檢查
                    instance = new Instance();       //instance 為 volatile,沒有問題了
                }
            }
        }
        return instance;
    }


}

單例模式的經典應用

單例模式的應用場景很多。下面以 Spring 框架為例進行分析

Spring 框架中的單例模式分析

程式碼:

/**
     * Return the (raw) singleton object registered under the given name.
     * <p>Checks already instantiated singletons and also allows for an early
     * reference to a currently created singleton (resolving a circular reference).
     * @param beanName the name of the bean to look for
     * @param allowEarlyReference whether early references should be created or not
     * @return the registered singleton object, or {@code null} if none found
     */
    @Nullable
    protected Object getSingleton(String beanName, boolean allowEarlyReference) {
        Object singletonObject = this.singletonObjects.get(beanName);
        if (singletonObject == null && isSingletonCurrentlyInCreation(beanName)) {
            synchronized (this.singletonObjects) {
                singletonObject = this.earlySingletonObjects.get(beanName);
                if (singletonObject == null && allowEarlyReference) {
                    ObjectFactory<?> singletonFactory = this.singletonFactories.get(beanName);
                    if (singletonFactory != null) {
                        singletonObject = singletonFactory.getObject();
                        this.earlySingletonObjects.put(beanName, singletonObject);
                        this.singletonFactories.remove(beanName);
                    }
                }
            }
        }
        return singletonObject;
    }

從整體上看,用了“雙重檢測鎖定”,但是這裡說的“雙重檢測鎖定”跟以上例子不一樣,為什麼這麼做跟Spring的設計有關,這裡不研究Spring的設計邏輯。
從區域性上看:

synchronized (this.singletonObjects) {
                singletonObject = this.earlySingletonObjects.get(beanName);
                if (singletonObject == null && allowEarlyReference) {
                    ObjectFactory<?> singletonFactory = this.singletonFactories.get(beanName);
                    if (singletonFactory != null) {
                        singletonObject = singletonFactory.getObject();
                        this.earlySingletonObjects.put(beanName, singletonObject);
                        this.singletonFactories.remove(beanName);
                    }
                }
            }

earlySingletonObjects 中變數的讀寫都在一個鎖裡面,即使裡面發生了重排序,其他執行緒也“看不到”,所以是執行緒安全的。

參考文件:

相關推薦

同步 volatile 簡介模式

Volatile 簡介 volatile 的作用 在多執行緒併發程式設計中 synchronized 和 volatile 都扮演著重要的角色,volatile 是輕量級的 synchronized,它在多處理器開發中保證了共享變數的“可見性”。共享變數

Java中基於靜態變數模式對快取的簡單實現

●What & Why 快取是什麼?他有什麼好處?相信不用說大家都知道。 目前筆者在做一個Java開發的Web專案,專案啟動的時候需要將大量不變的平臺數據放入快取中,方便快速讀取。一開始筆者很疑惑,Java是不能直接操作記憶體的,但是我們快取卻是要把資料放入記憶體

設計模式的分類 模式

設計模式GOF23(Group of four 四人幫) 一、設計模式是面向物件思想中重要的一點。 二、模式分為分為三種: 1.建立型模式:幫助我們建立物件 (1)單例模式 (2)工廠模式 (3)抽象工廠模式 (4)建造者模式 (5)原型模式 2.結構型模式:

關鍵字static模式的一點理解

static是java語言中的一個關鍵字,表示一個靜態修飾符,修飾符比較容易理解,靜態修飾符又有什麼特點呢,首先程式中的任何變數或者程式碼都是在編譯時,由系統自動分配記憶體來儲存的,而靜態的特點就是指,在編譯後所分配的記憶體會一直存在,直到程式退出是才會釋放這個

對工廠模式模式的理解

正式學java也那麼久了,今天就來梳理一下java的工廠模式(據說這是java23例模式中最簡單的一種模式) 所謂的工廠模式大概就是通過一個介面實現對子類的呼叫,當初我接觸工廠模式的時候,還沒意識到原來這就是工廠模式, 當時老師是據了一個抽獎系統的例子,大概是這樣,先寫一個

Python基礎——類new方法模式

介紹:     new方法是類中魔術方法之一,他的作用是給類例項化開闢一個記憶體地址,並返回一個例項化,再由__init__對這個例項進行初始化,故它的執行肯定就是在初始化方法__init__之前了。new方法的第一個引數cls是類本身的含義(即你要例項化的類),與self

享元模式模式區別

單例模式是類級別的,一個類只能有一個物件例項; 享元模式是物件級別的,可以有多個物件例項,多個變數引用同一個物件例項; 享元模式主要是為了節約記憶體空間,提高系統性能,而單例模式主要為了可以共享資料;

php面向物件(工廠模式模式

今天剛學習了php的設計模式,一個是工廠模式而另一個是單例模式,工廠模式設計出來就是為了一種方便建立物件而做出來的。還有一個是單例模式,單例模式的設計有些比較難以理解,我們必須一步一步的分析:單例類的情況必須去建立類的例項,而且必須只有一個,首先沒有物件例項的情況就是將它的

[Unity]建構函式模式

從BUG說起 在實現一個小功能時,遇到了一個bug,程式碼如下: public class EnemySpawner : MonoBehaviour { #region singleton private EnemySpawner()

Java模式

最近在閱讀《Effective Java 》這本書,第3個條款專門提到了單例屬性,並給出了使用單例的最佳實踐建議。讓我對這個單例模式(原本我以為是設計模式中最簡單的一種)有了更深的認識。 單例模式 單例模式(Singleton Pattern)是

[設計模式] 多模式模式區別

多例模式與單例模式都禁止外界直接將之例項化,同時通過靜態工廠方法向外界提供迴圈使用的自身的例項。它們的不同在於單例模式僅有一個例項,而多例模式則可以有多個例項。 多例模式往往具有一個聚集屬性,通過向這個聚集屬性登記已經建立過的例項達到迴圈使用例項的目的。一般而言,一個典型的

3. 【建立銷燬物件】用同步、靜態內部類和列舉型別強化模式

本文是《Effective Java》讀書筆記第3條。 單例模式,顧名思義,就是當你需要並且僅需要某個類只有一個例項的時候所採用的設計模式。 /** * 餓漢式單例模式 */ public class Singleton { private

《大話設計模式》讀書筆記:模式Java同步鎖synchronized

單例模式,保證一個類僅有一個例項,並提供一個訪問它的全域性訪問點。在單例模式下,類本身負責儲存它的唯一例項,這個類必須保證沒有其他例項可以被建立,並且它可以提供一個訪問該例項的方法。單例模式的類中,構造方法(函式/體)被設為private,從而堵死了外部例項化該類的可能。同

模式線程安全問題淺析

ati 多線程 con data 非常完美 賦值 return span author 近期看到到Struts1與Struts2的比較。說Struts1的控制器是單例的,線程不安全的;Struts2的多例的,不存在線程不安全的問題。之後又想到了之前自

模式靜態成員

很好 nullptr () 單例 配置 ora pri 文件 初始 高博的《視覺SLAM十四講》中,project裏的Config類,很好的示範了單例模式的實現與static靜態成員的使用 每天早上起床拜一拜高博~ Config類是為了設置配置文件,並且從配置文件中讀取預設

關於模式的想法-volatile

get 變量 共享 zed span () == ati urn 今天看著一個多線程並發用到的關鍵字:volatile,看了不少資料發現這個是一個共享的直接寫入內存使用的關鍵字修飾變量,用來修飾類變量或者類靜態變量,所以有了一個關於單利模式的想法,我們都知道的單例模式的一個

Hibernate簡介

jdb roo package evel 簡單 per resource str mode 一、Hibernate簡介   1、什麽是Hibernate? Hibernate是數據持久層的一個輕量級框架。數據持久層的框架有很多比如:iBATIS,myBa

三種模式Object祖先類

三種單例模式 object 單例有三種模式,懶漢式,餓漢式,和優化後的懶漢式 餓漢式單例模式: 餓漢式就像饑餓的人一樣先把事情都提前準備好,因為它是先在靜態屬性裏先提前構建好對象,然後再用靜態方法將對象返回出去,所以會提前占用資源,但是速度比較快。例如:懶漢式單例模式: 懶漢式就像懶人一樣要等到事

模式(Singleton)的同步鎖synchronized

靜態方法 兩種 餓漢 初始化 同步方法 上線 懶漢式 urn 同步鎖 單例模式,有“懶漢式”和“餓漢式”兩種。 懶漢式 單例類的實例在第一次被引用時候才被初始化。 public class Singleton { private static Singleto

模式序列化反序列化

int nts 如果 mex res tac tor cep ios package com.wz.thread.resolve;import java.io.ObjectStreamException;import java.io.Serializable;/** * 序