1. 程式人生 > 其它 >Java併發程式設計的藝術(三)——volatile

Java併發程式設計的藝術(三)——volatile

1. 併發程式設計的兩個關鍵問題

併發是讓多個執行緒同時執行,若執行緒之間是獨立的,那併發實現起來很簡單,各自執行各自的就行;但往往多條執行緒之間需要共享資料,此時在併發程式設計過程中就不可避免要考慮兩個問題:通訊 與 同步。

  • 通訊 通訊是指訊息在兩條執行緒之間傳遞。 既然要傳遞訊息,那接收執行緒 和 傳送執行緒之間必須要有個先後關係,此時就需要用到同步。通訊和同步是相輔相成的。
  • 同步 同步是指,控制多條執行緒之間的執行次序。

2. 通訊的方式

2.1 通訊方式的種類

執行緒之間的通訊一共有兩種方式:共享記憶體 和 訊息傳遞。

  • 共享記憶體 共享記憶體指的是多條執行緒共享同一片記憶體,傳送者將訊息寫入記憶體,接收者從記憶體中讀取訊息,從而實現了訊息的傳遞。 但這種方式有個弊端,即需要程式設計師來控制執行緒的同步,即執行緒的執行次序。

這種方式並沒有真正地實現訊息傳遞,只是從結果上來看就像是將訊息從一條執行緒傳遞到了另一條執行緒。

  • 訊息傳遞 顧名思義,訊息傳遞指的是傳送執行緒直接將訊息傳遞給接收執行緒。 由於執行次序由併發機制完成,因此不需要程式設計師新增額外的同步機制,但需要宣告訊息傳送和接收的程式碼。

綜上所述:對於共享記憶體的通訊方式,需要進行顯示的同步,隱式的通訊; 而對於訊息傳遞的通訊方式,需要隱式的同步,顯示的通訊。

2.2 Java使用的通訊方式

Java使用共享記憶體的方式實現多執行緒之間的訊息傳遞。因此,程式設計師需要寫額外的程式碼用於執行緒之間的同步。

PS:其實共享記憶體的方式從實現過程來看,跟訊息傳遞一點關係都沒有:一條執行緒將訊息存入共享記憶體,另一條執行緒從共享記憶體中讀這條訊息。 但從結果來看,整個過程就好像是一條訊息被從執行緒A傳遞到了執行緒B。 這種方式之所以能實現訊息傳遞,依託於兩點:

  • 必須有一片共享的記憶體
  • 必須要實現多執行緒的同步

3. Java多執行緒的記憶體模型(簡化版)

所有執行緒都共享一片記憶體,用於儲存共享變數; 此外,每條執行緒都有各自的儲存空間,儲存各自的區域性變數、方法引數、異常物件。

4. volatile是什麼?

Java採用共享記憶體的方式實現訊息傳遞,而共享記憶體需要依託於同步。Java提供了synchronized、volatile關鍵字實現同步。此外volatile關鍵字還擁有一些額外的功能。

5. volatile的使用

在成員變數前加上該關鍵字即可。

public volatile boolean flag;

6. volatile的特性

6.1 重排序

重排序是計算機為了提高程式執行效率而對程式碼的執行順序進行調整。你以為程式碼是一行行順序執行的,但實際並非如此,重排序詳解請移步至:Java併發程式設計的藝術(二)——重排序

若兩行指令之間沒有依賴關係,那麼計算機可以對他們的順序進行重排序,但若兩行之間的某個變數被volatile修飾後,重排序規則會發生變化。

在以下情況下,即使兩行程式碼之間沒有依賴關係,也不會發生重排序:

  • volatile讀
    • 若volatile讀操作的前一行為volatile讀/寫,則這兩行不會發生重排序
    • volatile讀操作和它後一行程式碼都不會發生重排序
  • volatile寫
    • volatile寫操作和它前一行程式碼都不會發生重排序;
    • 若volatile寫操作的後一行程式碼為volatile讀/寫,則這兩行不會發生重排序。

6.2 可見性

什麼是記憶體可見性?

“記憶體可見性”指的是一條執行緒修改完一個共享變數後,另一個執行緒若訪問這個變數將會訪問到修改後的值。即:一條執行緒對共享變數的修改,對其他執行緒立即可見。

但如果未對共享變數採用同步機制,那麼共享變數的修改不會對其他執行緒立即可見。

為什麼會出現記憶體不可見的情況?

通過上文可知,在Java中每條執行緒都有各自獨立的儲存空間,此外還有一個所有執行緒共享的記憶體空間。 當開啟執行緒時,系統會將共享記憶體中的所有共享變數拷貝一份到執行緒專屬的儲存空間中。接下來該執行緒在結束前的所有操作都是基於自己的儲存空間進行的。因此,若一條執行緒改變了一個共享變數,僅僅改變的是這條執行緒專屬儲存空間中的變數值;此時若其他執行緒訪問這個變數,訪問的仍然是先前從共享儲存空間讀出來的值。 然而我們希望一條執行緒將某個共享變數修改後,其他執行緒能立即訪問到這個最新的值,而不是失效值。 這時就需要同步機制來解決這個問題。

如何確保共享變數的可見性?

要確保所有共享變數對所有執行緒是可見的,就需要給所有共享變數使用同步。在Java中你可以選擇將共享變數用同步程式碼塊包裹或用volatile修飾共享變數。

為什麼volatile能保證共享變數的記憶體可見性?

volatile修飾了一個成員變數後,這個變數的讀寫就會比普通變數多一些步驟。

  • volatile變數寫 當被volatile修飾的變數進行寫操作時,這個變數將會被直接寫入共享記憶體,而非執行緒的專屬儲存空間。
  • volatile變數讀 當讀取一個被volatile修飾的變數時,會直接從共享記憶體中讀,而非執行緒專屬的儲存空間中讀。

通過對volatile變數讀寫的限制,就能保證執行緒每次讀到的都是最新的值,從而確保了該變數的記憶體可見性。

volatile變數贈送的附加功能

進行volatile寫操作時,不僅會將volatile變數寫入共享記憶體,系統還會將當前執行緒專屬空間中的所有共享變數寫入共享記憶體。 進行volatile讀操作時,系統也會一次性將共享記憶體中所有共享變數讀入執行緒專屬空間。 這就意味著,如果普通變數在volatile寫操作之前被修改,那麼在volatile讀操作之後就能正確讀到他們。 但是,在volatile寫操作之後被修改的普通變數 和 在volatile讀操作之前被訪問的普通變數 都不具有記憶體可見性。

6.3 原子性

什麼是原子性?

原子性指的是一組操作必須一起完成,中途不能被中斷。

volatile能確保long、double讀寫的原子性

在Java中的所有型別中,有long、double型別比較特殊,他們佔據8位元組(64位元),其餘型別都小於64位元。在32位作業系統中,CPU一次只能讀取/寫入32位的資料,因此對於64位的long、double變數的讀寫會進行兩步。在多執行緒中,若一條執行緒只寫入了long型變數的前32位,緊接著另一條執行緒讀取了這個只有“一半”的變數,從而就讀到了一個錯誤的資料。 為了避免這種情況,需要在用volatile修飾long、double型變數。

在記憶體可見性與原子性上,volatile就相當於是同步的setter和getter函式。但並不具有volatile的重排序規則,同步塊只確保同步塊內部的指令不發生重排序,並不確保同步塊以外的指令的重排序。

PS1:Java中的byte竟然是位元組,bit才是位元(位)。 PS2:char和short-2位元組、int和float-4位元組、long和double-8位元組、byte-1位元組

QA:在同步塊中呼叫wait函式是否會破壞原子性?