歡迎光臨
每天分享高質量文章

Java 多執行緒:volatile 變數、happens-before 關係及記憶體一致性

(點選上方公眾號,可快速關註)


來源:ImportNew – paddx

更新

請參考來自 Jean-philippe Bempel 的評論。他提到了一個真實因 JVM 最佳化導致死鎖的例子。我盡可能多地寫部落格的原因之一是一旦自己理解錯了,可以從社群中學到很多。謝謝!

什麼是 Volatile 變數?

Volatile 是 Java 中的一個關鍵字。你不能將它設定為變數或者方法名,句號。

認真點,別開玩笑,什麼是 Volatile 變數?我們應該什麼時候使用它?

哈哈,對不起,沒法提供幫助。

volatile 關鍵字的典型使用場景是在多執行緒環境下,多個執行緒共享變數,由於這些變數會快取在 CPU 的快取中,為了避免出現記憶體一致性錯誤而採用 volatile 關鍵字。

考慮下麵這個生產者/消費者的例子,我們每次生成/消費一個元素:

public class ProducerConsumer {

  private String value = “”;

  private boolean hasValue = false;

  public void produce(String value) {

    while (hasValue) {

      try {

        Thread.sleep(500);

      } catch (InterruptedException e) {

        e.printStackTrace();

      }

    }

    System.out.println(“Producing ” + value + ” as the next consumable”);

    this.value = value;

    hasValue = true;

  }

  public String consume() {

    while (!hasValue) {

      try {

        Thread.sleep(500);

      } catch (InterruptedException e) {

        e.printStackTrace();

      }

    }

    String value = this.value;

    hasValue = false;

    System.out.println(“Consumed ” + value);

    return value;

  }

}

在上面的類中,produce 方法透過儲存引數來生成一個新的值,然後將 hasValue 設定為 true。while 迴圈檢測標識變數(hasValue)是否 true,true 表示一個新的值沒有被消費,要求當前執行緒睡眠(sleep),該睡眠一直迴圈直到標識變數 hasValue 變為 false,只有在新的值被 consume 方法消費完成後才能變為 false。如果沒有有效的新值,consume 方法要求當前睡眠,當一個 produce 方法生成一個新值時,睡眠迴圈終止,並改變標識變數的值。

現在想象有兩個執行緒在使用這個類的物件,一個生成值(寫執行緒),另個一個消費值(讀執行緒)。透過下麵的測試來解釋這種方式:

public class ProducerConsumerTest {

  @Test

  public void testProduceConsume() throws InterruptedException {

    ProducerConsumer producerConsumer = new ProducerConsumer();

    List values = Arrays.asList(“1”, “2”, “3”, “4”, “5”, “6”, “7”, “8”,

        “9”, “10”, “11”, “12”, “13”);

    Thread writerThread = new Thread(() -> values.stream()

        .forEach(producerConsumer::produce));

    Thread readerThread = new Thread(() -> {

      for (int i = 0; i > values.size(); i++) {

        producerConsumer.consume();

      }

    });

    writerThread.start();

    readerThread.start();

    writerThread.join();

    readerThread.join();

  }

}

這個例子大部分時候都能輸出期望的結果,但是也有很大機率會出現死鎖!

怎麼會?

我們先簡單討論一下計算機的結構。

我們都知道計算機是由記憶體單元和 CPU (還有許多其他部分)組成。主記憶體就是程式指令、變數、資料儲存的地方。程式執行期間,為了獲得更好的效能,CPU 可能會將變數複製到自己的記憶體中(即所謂的 CPU 快取)。由於現代計算機有多個 CPU,同樣也存在多個 CPU 快取。

在多執行緒環境下,有可能多個執行緒同時執行,每個執行緒使用不同的 CPU(雖然這完全依賴於底層的作業系統),每個 CPU 都從主記憶體中複製變數到它自己的快取中。當一個執行緒訪問這些變數時,是直接訪問快取中的副本,而不是真正訪問主記憶體中的變數。

現在,假設在我們的測試中有兩個執行緒執行在不同的 CPU 上,並且其中的有一個快取了標識變數(或者兩個都快取了)。現在考慮如下的執行順序

1、寫執行緒生成一個值,並將 hasValue 設定為 true。但是隻更新快取中的值,而不是主記憶體。

2、讀執行緒嘗試消費一個值,但是它的快取副本中 hasValue 被設定為 false,所以即使寫執行緒生產了一個新的值,也不能被消費,因為讀執行緒無法跳出睡眠迴圈(hasValue 的值為 false)。

3、因為讀執行緒不能消費新生成的值,所以寫執行緒也不能繼續,因為標識變數沒有設定回 false,因此寫執行緒阻塞在睡眠迴圈中。

4、這樣,就產生了死鎖!

這種情況只有在 hasValue 同步到所有快取才能改變,這完全依賴於底層的作業系統。

那怎麼解決這個問題? volatile 怎麼會適合這個例子?

如果我們將 hasValue 標示為 volatile,我就能確定這種死鎖就不會再發生。

private volatile boolean hasValue = false;

volatile 變數強制執行緒每次讀取的時候都直接從主記憶體中讀取,同時,每次寫 volatile 變數的時候也要立即掃清主記憶體中的值。如果執行緒決定快取變數,就需要每次讀寫的時候都與主記憶體進行同步。

做這個改變之後,我們再來考慮前面導致死鎖的執行步驟

1、寫執行緒生成一個值,並將 hasValue 設定為 true,這次直接更新主記憶體中的值(即使這個變數被快取了)。

2、讀執行緒嘗試消費一個值,先檢查 hasValue 的值,每次讀取都強制直接從主記憶體中獲取值,所以能獲取到寫執行緒改變後的值。

3、讀執行緒消費完生成的值後,重新設定標識變數的值,這個新的值也會同步到主記憶體(如果這個值被快取了,快取的副本也會更新)。

4、寫執行緒獲每次都是從主記憶體中取這個改變了的值,這樣就能繼續生成新的值。

現在,大家都很幸福了^_^ !

我知道了,強制執行緒直接從記憶體中讀寫執行緒,這是 Volatile 所能做全部的事情嗎?

實際上,它還有更多的功能。訪問一個 volatile 變數會在陳述句間建立 happens-before 關係。

什麼是 happens-before 關係?

happens-before 關係是程式陳述句之間的排序保證,這能確保任何記憶體的寫,對其他陳述句都是可見的。

這與 Volatile 是怎麼關聯的?

當寫一個 volatile 變數時,隨後對該變數讀時會建立一個 happens-before 關係。所以,所有在 volatile 變數寫操作之前完成的寫操作,將會對隨後該 volatile 變數讀操作之後的所有陳述句可見。

嗯…,好吧…,我有點明白了,但是可能透過一個例子會更清楚。

好,對這個模糊的概念我表示很抱歉。考慮下麵這個例子:

// Definition: Some variables

// 變數定義

private int first = 1;

private int second = 2;

private int third = 3;

private volatile boolean hasValue = false;

// First Snippet: A sequence of write operations being executed by Thread 1

//片段 1:執行緒 1 順序的寫操作

first = 5;

second = 6;

third = 7;

hasValue = true;

// Second Snippet: A sequence of read operations being executed by Thread 2

//片段 2:執行緒 2 順序的讀操作

System.out.println(“Flag is set to : ” + hasValue);

System.out.println(“First: ” + first);  // will print 5 列印 5

System.out.println(“Second: ” + second); // will print 6 列印 6

System.out.println(“Third: ” + third);  // will print 7 列印 7

我們假設上面的兩個程式碼片段有由兩個執行緒執行:執行緒 1 和執行緒 2。當第一個執行緒改變 hasValue 的值時,它不僅僅是掃清這個改變的值到主存,也會引起前面三個值的寫(之前任何的寫操作)掃清到主存。結果,當第二個執行緒訪問這三個變數的時候,就可以訪問到被執行緒 1 寫入的值,即使這些變數之前被快取(這些快取的副本都會被更新)。

這就是為什麼我們不需要像第一個示例一樣將變數標示為 volatile 。因為我們的寫操作在訪問 hasValue 之前,讀操作在 hasValue 的讀之後,它會自動與主記憶體同步。

還有另一個有趣的結論。JVM 因它的程式最佳化機制而聞名。有時對程式陳述句的重排序可以大幅度提高效能,並且不會改變程式的輸出結果。例如,它可能會修改如陳述句的順序:

first = 5;

second = 6;

third = 7;

為:

second = 6;

third = 7;

first = 5;

但是,當多條陳述句涉及到對 volatile 變數的訪問時,它永遠不會將 volatile 變數前的寫陳述句放在 volatile 變數之後,意思就是,它永遠不會轉換下列順序:

first = 5;  // write before volatile write //volatile 寫之前的寫

second = 6;  // write before volatile write //volatile 寫之前的寫

third = 7;   // write before volatile write //volatile 寫之前的寫

hasValue = true;

為:

first = 5;

second = 6;

hasValue = true;

third = 7;  // Order changed to appear after volatile write! This will never happen!

third = 7;  // 順序發生了改變,出現在了 volatile 寫之後。這永遠不會發生。

即使從程式的正確性的角度來說,上面兩種情況是相等的。但請註意,JVM 仍然允許對前三個變數的寫操作進行重排序,只要它們都出現在 volatile 寫之前即可。

類似的,JVM 也不會將 volatile 變數讀之後的讀操作重排序到 volatile 變數之前。意思就是說,下麵的順序:

System.out.println(“Flag is set to : ” + hasValue);  // volatile read //volatile 讀

System.out.println(“First: ” + first);  // Read after volatile read // volatile 讀之後的讀

System.out.println(“Second: ” + second); // Read after volatile read// volatile 讀之後的讀

System.out.println(“Third: ” + third);  // Read after volatile read// volatile 讀之後的讀

JVM 永遠不會轉換為如下的順序:

System.out.println(“First: ” + first);  // Read before volatile read! Will never happen! //volatile 讀之前的讀!永遠不可能出現!

System.out.println(“Fiag is set to : ” + hasValue); // volatile read //volatile 讀

System.out.println(“Second: ” + second); 

System.out.println(“Third: ” + third);

但是,JVM 也有可能會對最後的三個讀操作重排序,只要它們在 volatile 變數讀之後即可。

我感覺 Volatile 變數會對效能有一定的影響。

你的感覺是對的,因為 volatile 變數強制訪問主存,而訪問主存肯定被訪問 CPU 快取慢。同時,它還防止 JVM 對程式的最佳化,這也會降低效能。

我們總能用 Volatile 變數來維護多執行緒之間的資料一致性嗎?

非常不幸,這是不行的。當多個執行緒讀寫同一個變數時,僅僅靠 volatile 是不足以保證一致性的,考慮下麵這個 UnsafeCounter 類:

public class UnsafeCounter {

  private volatile int counter;

  public void inc() {

    counter++;

  }

  public void dec() {

    counter–;

  }

  public int get() {

    return counter;

  }

}

測試如下:

public class UnsafeCounterTest {

  @Test

  public void testUnsafeCounter() throws InterruptedException {

    UnsafeCounter unsafeCounter = new UnsafeCounter();

    Thread first = new Thread(() -> {

      for (int i = 0; i < 5; i++) { 

        unsafeCounter.inc();

      }

    });

    Thread second = new Thread(() -> {

      for (int i = 0; i < 5; i++) {

        unsafeCounter.dec();

      }

    });

    first.start();

    second.start();

    first.join();

    second.join();

    System.out.println(“Current counter value: ” + unsafeCounter.get());

  }

}

這段程式碼具有非常好的自說明性。一個執行緒增加計數器,另一個執行緒將計數器減少同樣次數。執行這個測試,期望的結果是計數器的值為 0,但這無法得到保證。大部分時候是 0,但有的時候是 -1, -2, 1, 2 等,任何位於[-5, 5]之間的整數都有可能。

為什麼會發生這種情況?這是因為對計數器的遞增和遞減操作都不是原子的——它們不是一次完成的。這兩種操作都由多個步驟組成,這些步驟可能相互交叉。你可以認為遞增操作如下:

  1. 讀取計數器的值。

  2. 加 1。

  3. 將新的值寫回計數器。

遞減操作的過程如下:

  1. 讀取計數器的值。

  2. 減 1。

  3. 將新的值寫回計數器。

現在我們考慮一下如下的執行步驟

第一個執行緒從主存中讀取計數器的值,初始值是 0,然後加 1。

第二個執行緒也從主存中讀取計數器的值,它讀取到的值也是 0,然後進行減 1 操作。

第一執行緒將新的計數器的值寫回記憶體,將值設定為 1。

第二個執行緒也將新的值寫回記憶體,將值設定為 -1。

怎麼防止這類事件的發生?

使用同步:

public class SynchronizedCounter {

  private int counter;

  public synchronized void inc() {

    counter++;

  }

  public synchronized void dec() {

    counter–;

  }

  public synchronized int get() {

    return counter;

  }

}

或者使用 AtomicInteger:

public class AtomicCounter {

  private AtomicInteger atomicInteger = new AtomicInteger();

  public void inc() {

    atomicInteger.incrementAndGet();

  }

  public void dec() {

    atomicInteger.decrementAndGet();

  }

  public int get() {

    return atomicInteger.intValue();

  }

}

我個人的選擇是使用 AtomicInteger,因為 synchronized 只允許一個執行緒訪問 inc/get/get 方法,對效能影響較大。

我註意到採用 Synchronized 的版本並沒有將計數器標識為 volatile,難道這意味著……?

對的。使用 synchronized 關鍵字也會在陳述句之間建立 happens-before 關係。進入一個同步方法或塊時,會將之前的陳述句和該方法或塊內部的陳述句建立 happens-before 關係。

檢視完整的建立 happens-before 關係的情況串列,請檢視這裡

https://docs.oracle.com/javase/8/docs/api/java/util/concurrent/package-summary.html#MemoryVisibility

關於一開始提到的 volatile, 這些是所有我想說的。所有的例子都上傳到了我的 github 倉庫

https://github.com/sayembd/JavaSamples/commit/d1f72ee8bf76f69740b6d22e35d8e1431f279afb

看完本文有收穫?請轉發分享給更多人

關註「ImportNew」,提升Java技能

贊(0)

分享創造快樂