Java併發編程(七):原子性、可見性、有序性與happens-before

一、三大特性

1.1 原子性

原子是化學反應中不可再分的基本微粒,其在化學反應中不可分割。在計算機中,它表示的是一個操作,可能包含一個或多個步驟,這些步驟要麼全部執行成功要麼全部執行失敗,並且執行的過程中不能被其它操作打斷,這類似於數據庫中事務的原子性概念。

前文提到的:i = i + 1,就是一個非原子操作,它涉及到獲取i,獲取1,相加,賦值等4個操作,所以在多線程情況下可能會出現併發問題。

我們在前文提到的JMM八種操作中,read、load、assign、use、store、write都直接保證了原子性(當然,這裏我們不考慮long和double的非原子協議),所以基本數據類型的讀寫是具備原子性特徵的。但是,很多時候我們的操作並不是簡單的基本數據的讀寫,比如i=i+1,我們要保證它的原子性該怎麼做呢?可以通過八種操作中的lock和unlock來達到目的。但是JVM並沒有把lock和unlock操作直接開放給用戶使用,它提供了更高層次的字節碼指令monitorenter和monitorexit來對應lock和unlock,而這兩個字節碼指令反映到我們的java代碼中,就是我們所熟知的synchronized關鍵字,後面我們會詳細探討。

1.2 可見性

可見性表示的是,如果有線程更新了某一個共享變量的值,則其它線程要能夠立即感知到最新的內容。如果不能保證可見性,則可能出現類似於數據庫中的髒讀情況。

前文介紹JMM的時候也提到了,如果要保證可見性,那麼變量被一個線程修改後,需要將其修改後的最新值同步回主存,然後其它線程要讀取該變量時,需要從主存刷新最新的值到本地內存,就這樣通過主存實現可見性。但是將最新值同步回主存的時機是沒有強制要求的,也不知道其它線程什麼時候可能會去從主存刷新最新值,所以普通變量在多線程操作時是保證不了可見性的。

這時有一個比較好使的關鍵字:volatile。JMM對它定義了一些特殊的訪問規則,它能保證修改後的最新值能立即同步到主存,同時,每次使用都從主存刷新。所以volatile能夠保證多線程場景下的可見性。但是Java裏的運算並不是原子操作(比如前面提到的i++會有4條字節碼指令,具體到機器指令可能會有更多操作),volatile只能保證獲取的值是最新的,但是後續的操作過程中,變量在主存中可能已經被修改了,操作的實際上還是爲“舊”值,所以它並不能保證原子性。原理和適用場景在後面我們會詳細探討。

1.3 有序性

從字面上的意思理解,有序就是要保證代碼按照既定的順序依次執行。但是CPU出於性能優化的目的,在保證不會對程序運行結果產生影響的前提下,代碼的執行順序可能會和我們既定的順序不一致。

所以對於Java程序有一句話(主要內容摘自<<深入理解Java虛擬機>>):如果在本線程觀察,所有的操作都是有序的;如果在一個線程中觀察另一個線程,所有的操作都是無序的。前半句是指“線程內表現爲串行的語義”(Within-Thread As-If-Serial Semantics),也就是普通的變量只能保證在方法執行過程中,所有依賴賦值結果的地方都能獲取到正確的結果,而不能保證變量賦值操作的順序與代碼的執行順序一致;後半句是指“指令重排序”現象和“工作內存與主內存同步延遲”的現象。

指令重排優化具體到計算機工程領域也就是亂序執行(out-of-order execution)。它是一種範式,處理器根據一個由輸入數據可用性所決定的順序執行指令,而不是按照原始數據所決定的順序運行。CPU可以根據各電路單元的空閒狀態和各指令能否提前執行的情況進行分析,然後可能將多條指令不按程序既定的順序分開發送到對應的電路單元處理。通過這種方式,可以避免因獲取下一條程序指令所引起的處理器等待。

比如:

int i=1;

int j=1;

這兩行代碼互相沒有任何依賴關係,誰先執行還是後執行,對程序運行結果都不會有什麼影響。經過指令重排後,可能 int j=1;就比int i=1;先執行了。但是指令重排的後果有時會是嚴重的,比如以下代碼:

boolean initialized = false;
Util util;

//初始化線程
initUtil();
initialized = true;

//其它線程
while(!initialized){
    //wait
}
useUtil();

以上代碼中,由一個初始化線程執行Util的初始化操作,初始化成功之後將initialized字段設置爲true,其它線程通過判斷initialized參數判斷Util是否初始化,來確定是否能夠使用。初始化操作和設置initialized的操作如果發生了重排,就會出現Util還沒有初始化,就將Initialized設置成了true,其它線程“誤認爲”Util已經初始化完成,進而去使用它,則會出現問題。而volatile就可以解決指令重排引發的這個問題,後面我們再詳細探討。

二、happens-before

happens-before也就是先行發生原則。它不用依賴任何關鍵字或工具,是Java設定的一些基本且重要的規則,通過這些規則,我們可以解決併發環境下兩個操作之間是否可能存在衝突的所有問題。下面我們列出這些規則:

2.1 程序次序原則(Program Order Rule)

指在一個線程內,按照程序代碼的控制流順序(考慮到循環等結構),編寫在前面的操作先行發生於後面的操作。需要注意這裏注重的是單線程內。比如:

user.setName("bob");

user.getName();

在一個線程內,上述代碼中的set方法要先行於get方法執行。

2.2 管程鎖定原則(Monitor Lock Rule)

對於同一個鎖,一個unlock操作要先行於下一次的lock操作。

2.3 volatile 原則(Volatile Variable Rule)

對一個volatile修飾變量的寫操作要先行發生於下一次的讀操作。

2.4 線程啓動原則(Thread Start Rule)

Thread對象的start()方法先行發生於此線程的每一個操作。

2.5 線程終止原則(Thread Termination Rule)

線程中的所有操作都先行發生於對該線程的終止檢測(比如Thread.join()方法結束。

2.6 線程中斷原則(Thread Interruption Rule)

對線程interrupt方法的調用先行發生於被中斷線程的代碼檢測到中斷事件的發生。

注:Java的中斷是一種協作機制,中斷操作並不能直接中斷一個線程,僅僅只是標識該線程接收到一箇中斷請求,至於實際什麼時候中斷,要中斷線程自己檢測處理。

2.7 對象終結原則(Finalize Rule)

一個對象的初始化完成先行發生於其finalize()方法的開始。

注:如果對象覆蓋了finalize()方法,那麼在GC的第一次標記後,會放入一個叫做F-Queue的隊列中,由一個Finalizer線程觸發該方法的調用。

2.8 傳遞性(Transitivity)

如果A先行發生於B,B先行發生於C,那麼A先行發生於C。

在Java中,無需任何同步手段就能保證這些規則。如果要判斷對一個共享變量的操作是否爲線程安全,可以直接套用上述系列規則,如果不適用於任何規則,那麼就需要一些同步手段才能保證線程安全性。另外,時間上的先後順序和先行發生原則之間沒有直接的聯繫,我們在判斷併發安全問題的時候不能受到時間順序的干擾(這裏的時間先後順序指的就是行爲發生的順序)。後面具體總結同步手段的時候會詳細介紹。

三、總結

這篇博文只是簡要描述了一些簡單的、概念性的東西,但這些又是非常重要的,它們貫穿整個Java併發編程。在真正開始研究Java併發編程之前,應該要對它們有一個感性的認識。

參考:<<深入理解Java虛擬機>>

注:本文是博主的個人理解,如果有錯誤的地方,希望大家不吝指出,謝謝

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