JAVA 併發之路 (三)物件的共享 (1)

望舒喜歡夏天發表於2018-01-17

再次重複:要編寫正確的併發程式,關鍵問題在於:在訪問共享的可變的狀態時,需要進行正確的管理。

如在(二)中所述,同步可以確保以原子的方式執行操作,比如關鍵字synchronized可用於實現原子性或者確定臨界區。實際上,同步還有另一個重要的方面:記憶體可見性。我們不僅僅是希望防止在某個執行緒使用物件狀態的同時,有其他執行緒在修改該狀態。而且希望確保當一個執行緒修改了物件狀態後,其他執行緒能夠看到發生的狀態變化。如果沒有同步,則無法實現。

可見性

在多執行緒環境下,當讀操作和寫操作在不同的執行緒中執行時,通常無法確保讀操作能夠適時地看到其他執行緒寫入的值,有時候甚至是根本不可能的事。為了確保多個執行緒之間對記憶體寫入操作的可見性,必須使用同步機制。

重排序

先看一個現象,重排序:在沒有同步的情況下,編譯器,處理器以及執行時等都可能對操作的執行順序進行一些意想不到的調整。在缺少同步的情況下,Java記憶體模型允許編譯器對操作順序進行重排序,並將數值快取在暫存器中,還允許CPU對操作順序進行重排序,並將數值快取在處理器特定的快取中。允許重排序是因為可以讓JVM充分利用現代多核處理器的強大效能。

正是因為重排序的原因,在缺乏足夠同步的多執行緒程式中,要想對記憶體操作的執行順序進行判斷,幾乎無法得出正確的結論

如下面的程式中,沒有使用同步,有可能讀執行緒永遠都看不到ready的值;也有可能讀執行緒看到了寫入ready的值,但是沒有看到number的值;還有可能得到失效的值等。

public class NoVisibility {
    //主執行緒和讀執行緒共享這兩個變數
    private static boolean ready;
    private static int number;

    private static class ReaderThread extends Thread {
        @Override
        public void run() {
            while (!ready) {
                Thread.yield();
            }
            System.out.println(number);
        }
    }

    //主執行緒
    public static void main(String[] args) {
        //啟動讀執行緒
        new ReaderThread().start();
        //寫入number值
        number = 42;
        //寫入ready值
        ready = true;
    }
}
複製程式碼

幸運的是,有一種簡單的方法能夠避免這些複雜問題:只要有資料在多個執行緒之間共享,就使用正確的同步。

非原子的64位操作

Java記憶體模型要求,變數的讀取操作和寫入操作都必須是原子操作,但是對於非volatile型別的long和double變數(8個位元組),JVM允許將64位的讀操作或寫操作分解為兩個32位的操作。(這是因為在編寫Java虛擬機器規範時,許多主流處理器架構還不能有效地提供64位數值的原子操作)

所以當讀取一個非volatile型別的long或double變數時,如果對該變數的讀操作和寫操作在不同的執行緒中執行時,那麼很可能會讀取到某個值的高32位和另一個值的低32位。

因此即便不考慮失效資料問題,在多執行緒程式中使用共享且可變的long和double等型別變數也是不安全的,除非用關鍵字volatile宣告它們或者用鎖保護起來。

加鎖與可見性

內建鎖可以用於確保某個執行緒以一種可預測的方式來檢視另一個執行緒的執行結果。

當執行緒A執行某個同步程式碼塊時,執行緒B隨後進入由同一個鎖保護的同步程式碼塊,在這種情況下可以保證,在鎖被釋放之前,A看到的變數值在B獲得鎖後同樣可以由B看到。也就是說,當執行緒B執行由鎖保護的同步程式碼塊時,可以看到執行緒A之前在同一個同步程式碼塊中的所有操作結果。

所以說為什麼在訪問某個共享且可變的變數時要求所有執行緒在同一個鎖上同步,就是為了確保某個執行緒寫入該變數的值對於其他執行緒來說都是可見的。否則,如果一個執行緒在未持有正確的鎖的情況下讀取某個變數,可能會讀到一個失效值。加鎖不僅僅侷限於互斥行為,還包括記憶體可見性為了確保所有執行緒都能看到共享變數的最新值,所有執行讀操作或寫操作的執行緒都必須在同一個鎖上同步。

volatile變數

上一節提到volatile型別變數也是一種同步機制,不過稍弱。它主要用來確保將變數的更新操作通知到其他執行緒。當把變數宣告為volatile型別後,編譯器和執行時都會注意到這個變數是共享的,因此不會將該變數上的操作與其他記憶體操作一起重排序。並且volatile變數不會被快取在暫存器或者對其他處理器不可見的地方,所以在讀取volatile型別的變數時,總是返回最新寫入的值

可按如下理解volatile變數:將它的讀操作和寫操作分別看成get方法和set方法。但是在訪問volatile變數時不會執行加鎖的操作,所以不會使執行執行緒阻塞,因此volatile變數時一種比synchronized關鍵字更輕量級的同步機制。

public class SynchronizedInteger() {
    private int value;
    public synchronized int get() {
        return value;
    }
    public synchronized void set(int value) {
        this.value = value
    }
}
複製程式碼

volatile變數對可見性的影響比volatile變數本身更為重要。從記憶體可見性角度來看,寫入volatile變數相當於退出同步程式碼塊,而讀取volatile變數則相當於進入同步程式碼塊。

僅當volatile變數能簡化程式碼的實現以及對同步策略的驗證時,才應該使用它們。如果在驗證正確性時需要對可見性進行復雜的判斷,那麼就不要使用volatile變數。

volatile變數通常用做某個操作完成、發生中斷或者狀態的標誌。使用時要非常小心,比如volatile的語義不足以確保遞增操作的原子性,除非能確保只有一個執行緒對變數執行寫操作。(比起volatile,原子變數提供了“讀-改-寫”的原子操作,常常作為一種“更好的volatile變數”)

所以:加鎖機制既能確保可見性又能確保原子性,而volatile變數只能確保可見性。

volatile變數的正確使用方式包括:確保它們自身狀態的可見性;確保它們所引用物件的狀態的可見性;以及標識一些重要的程式生命週期事件的發生(比如初始化,關閉)。

當且僅當滿足以下條件時,才應該使用volatile變數:

  • 對變數的寫入操作不依賴變數的當前值,或者可以確保只有單個執行緒更新變數的值。
  • 該變數不會與其他狀態變數一起納入不變性條件中。
  • 在訪問變數時不需要加鎖。

相關文章