深入淺出 Java 併發程式設計 (2)

huangyz0918發表於2018-09-06

【本文轉載自】蔣古申

本文目錄

  • Java 記憶體模型與可見性
  • 指令重排序
  • 使用 volatile 關鍵字保證可見性
  • 使用 synchronized 關鍵字保證可見性
  • synchronized 和 volatile 關鍵字的異同

Java 記憶體模型與可見性

上一篇文章主要介紹了 synchronized 關鍵字的使用,synchronized 關鍵字本質是互斥鎖,保證了程式在不同執行緒之間執行的順序以及同步。對於 Java 程式之中的變數,在不同的執行緒之中,還有一個關鍵的性質需要了解:可見性

那麼什麼是可見性呢?

在理解可見性之前我們需要稍微瞭解一下 Java 的記憶體模型 (JMM),所謂 Java 記憶體模型,實際上指的是 Java 用於管理記憶體的一種規範,它描述了Java程式中各種變數(執行緒共享變數)的訪問規則,以及在 JVM 中將變數儲存到記憶體和從記憶體中讀取變數這樣的底層細節。對於 Java 執行緒來說,Java 記憶體模型主要把記憶體分成了兩類:

  • 主記憶體:主要對應於Java堆中的物件例項資料部分
  • 執行緒工作記憶體 (本地記憶體):對應於虛擬機器棧中的部分割槽域,是JMM的一個抽象概念,並不真實存在

在理解這兩個記憶體的時候,我曾一直想把他們和之前提到過的 堆記憶體 和 棧記憶體 進行比較,但是實際上來說,主記憶體和工作記憶體與堆、棧記憶體並沒有什麼直接的聯絡。關於這幾種記憶體聯絡的爭論,可以參考這個知乎問答:

JVM中記憶體模型裡的『主記憶體』是不是就是指『堆』,而『工作記憶體』是不是就是指『棧』?

言歸正傳,我們可以用一個簡單的抽象示意圖來理解 Java 記憶體模型:

Java 記憶體模型抽象示意圖

從上面的圖可以看到,假設有三個執行緒Thread1Thread2Thread3,它們在執行的過程中都會對變數 a 進行一定程度的操作,這些操作都是基於 JMM 給出的規定:

  • 所有的變數都儲存在主記憶體中
  • 每個執行緒都有自己獨立的工作記憶體,裡面儲存該執行緒使用到的變數的副本(主記憶體中該變數的一份拷貝)
  • 執行緒對共享變數的所有操作都必須在自己的工作記憶體中進行,不能直接從主記憶體中讀寫
  • 不同執行緒之間無法直接訪問其他執行緒工作記憶體中的變數,執行緒間變數值的傳遞需要通過主記憶體來完成。

也就是說,執行緒想要對變數 a 進行操作,首先得從主記憶體之中獲取一個 a 的副本,然後在自己的本地記憶體(工作記憶體)之中對 a 的副本進行修改。當修改操作完成以後,再將本地記憶體中的 “新版a” 更新到主記憶體之中。

說了這麼多,這些東西和可見性有什麼關係呢?我們先看下面的圖:

執行緒之間通訊

在圖中,一開始Thread1Thread2都從主記憶體中獲取了共享變數a的一個副本:a1a2,它們的初始值滿足:a1 = a2 = a = 0,但是隨著執行緒操作的進行,Thread2a2的值改為了1,由於執行緒1和執行緒2之間的不可見性,所以造成了a1a2值不一致,為了解決這個問題,執行緒2需要把自己修改過的a2先同步到主記憶體中(如圖中紅色箭頭所示),然後再經由主記憶體重新整理到Thread1中,這就是 Java 記憶體模型中執行緒同步變數的方法。

所以稍微總結一下,可見性指的是在不同的執行緒之中,一個執行緒對共享變數值的修改,能夠及時地被其他執行緒看到。而執行緒1對共享變數的修改要想被執行緒2及時看到,必須要經過如下2個步驟:

  1. 把工作記憶體1中更新過的共享變數重新整理到主記憶體中
  2. 將主記憶體中最新的共享變數的值更新到工作記憶體2中

指令重排序

在多執行緒環境裡,除了 Java 執行緒本地工作記憶體造成的不可見性,指令重排序也會對執行緒間的語意和執行結果造成一定程度的影響。那麼,什麼是重排序?

以前有一句古話 “所見即所得” ,但是在計算機程式執行的時候卻不是這個樣子的,為了提高程式的效能,編譯器或處理器會對程式執行的順序進行優化,使得程式碼書寫的順序與實際執行的順序未必相同

指令重排序

而計算機程式重排序主要又可以分為以下幾類:

  • 編譯器優化的重排序(編譯器優化)
  • 指令集並行重排序(處理器優化)
  • 記憶體系統的重排序(處理器優化)

雖然程式碼執行不一定按照其書寫順序執行,但是為了保證在單執行緒中程式碼最終輸出結果不會因為指令重排序而改變,編譯器、執行時環境和處理器都會遵循一定的規範,這裡主要是指 as-if-serial語義happens- before的程式順序規則

as-if-serial語義: 不管怎麼重排序(編譯器和處理器為了提高並行度),(單執行緒)程式的執行結果不能被改變。

為了遵守as-if-serial語義,編譯器和處理器不會對存在資料依賴關係的操作做重排序,因為這種重排序會改變執行結果。但是,如果操作之間不存在資料依賴關係,這些操作可能被編譯器和處理器重排序。為了具體說明,我們繼續使用上面的例子:

int A = 1; // 1
int B = 2; // 2
int C = A + B; // 3
複製程式碼

其中第一行和第二行執行的結果之間不存在資料的依賴性,因為第一行第二行的成功執行不需要對方的計算結果,但是第三行C的計算結果卻是依賴於AB的。這個依賴關係可以用下面的示意圖表示:

依賴關係

所以根據依賴關係,as-if-serial語義將會允許上述程式的第一行和第二行進行重排序,而第三行的執行一定會放在前兩行程式之後。as-if-serial 語義把單執行緒程式保護了起來,遵守as-if-serial語義的編譯器、執行時環境和處理器共同為編寫單執行緒程式的程式設計師們建立了一個幻覺:單執行緒程式是按程式的順序來執行的。as-if-serial 語義使單執行緒程式設計師無需擔心重排序會干擾他們,也無需擔心記憶體可見性問題。

然而在多執行緒情況下就不是這麼簡單的了,指令重排序有可能會導致交叉工作的執行緒在執行完相同的程式之後得到不同的結果。為此我們可以看一下下面的這個小程式:

public class Test {
    int count = 0;
    boolean running = false;

    public void write() {
        count = 1;                  // 1
        running = true;             // 2
    }

    public void read() {
        if (running) {                // 3
            int result =  count++;    // 4
        }
    }
}
複製程式碼

這裡我們定義了一個布林值標記 running ,用來表示變數 count 的值是否已經被寫入。我們假設這裡現在有兩個執行緒(分別為Thread1Thread2),Thread1 首先執行 write(),對變數 count 進行寫入,然後Thread2 隨即執行read()方法,那麼,當Thread2執行到第四行的時候,是否能夠看到Thread1對變數count進行的寫入操作呢?

答案是不一定能夠看得見。

我們對write()來分析,語句1語句2實際上並沒有資料依賴關係,根據as-if-serial 語義,這兩行程式碼在實際執行的時候很可能會被重排序過。同樣的,對read()方法來說,if(runnig)int result = count++; 這兩個語句也沒有資料依賴關係,也會被重排序。那麼對於執行緒Thread1Thread2來說,語句1語句2被重排序的時候,程式執行會出現如下的效果:

可能出現的一種執行順序

在這種情況下,count++ 這句話在 Thread2 裡面比在 Thread1count = 1 更早得到了執行,相比於重排序之前,這樣得到的 count 最終的值為1,而不進行重排序的話結果是2,如此一來,重排序在多執行緒環境中破壞了原有的語意。同樣,對於語句3語句4,大家也可以對重排序是否會導致執行緒不安全做出類似的分析(先考慮資料依賴關係和控制流程依賴關係)。

使用 volatile 關鍵字保證可見性

為了解決 Java 記憶體模型之中多執行緒變數可見性的問題,在上一篇文章中,我們可以利用synchronized互斥鎖的特性來保證多執行緒之間的變數可見性。

但是之前也有提到,synchronized關鍵字實際上是一種重量級的鎖,為了在這種情況下優化它,我們可以使用volatile關鍵字。volatile關鍵字可以修飾變數,一個被其修飾的變數將會具有如下特性:

  • 保證了不同執行緒對這個變數進行操作時的可見性(一個執行緒修改了某個變數的值,這新值對其他執行緒來說是立即可見的)

  • 禁止進行指令重排序

當寫一個volatile變數時,JMM會把該執行緒對應的本地記憶體中的共享變數重新整理到主記憶體。另外的,當讀一個volatile變數時,JMM會把該執行緒對應的本地記憶體置為無效,執行緒接下來將從主記憶體中讀取共享變數。這也是為什麼volatile關鍵字能夠保證不同執行緒對同一個變數的可見性。

關於volatile的底層實現,我不打算深究,但是可以簡要的瞭解一下:如果把加入volatile關鍵字的程式碼和未加入volatile關鍵字的程式碼都生成彙編程式碼,會發現加入volatile關鍵字的程式碼會__多出一個lock字首指令__。

那這個lock字首指令是幹嘛用的呢?

  • 重排序時不能把後面的指令重排序到記憶體屏障之前的位置
  • 使得本CPU的 cache 寫入記憶體
  • 寫入動作也會引起別的CPU或者別的核心無效化其cache,相當於讓新寫入的值對別的執行緒可見

說了那麼多,volatile的使用其實很簡單,讓我們一起來看個demo:

public class VolatileUse {

    private volatile boolean running = true; // 對比一下有無 volatile 關鍵字的時候,執行結果的差別。

    void m() {
        System.out.println("m start...");
        while (running) {

        }
        System.out.println("m end...");
    }

    public static void main(String[] args) {
        VolatileUse t = new VolatileUse();
        new Thread(t::m, "t1").start();

        try {
            TimeUnit.SECONDS.sleep(1);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        t.running = false;
    }
}
複製程式碼

在這個小程式中,如果對 running 加上了 volatile關鍵字,那麼最後處於主執行緒的操作t.running = false; 將會被 執行緒t 所看到,從而打破死迴圈,使方法m()正常結束。如果不加關鍵字,那麼程式將一直卡在m()方法的死迴圈中,永遠也不會輸出m end...

那麼volatile關鍵字能不能取代synchronized呢?我們再來看一個demo:

import java.util.ArrayList;
import java.util.List;

/**
 * volatile 關鍵字,使一個變數在多個執行緒間可見。
 * volatile 只有可見性,synchronized 既保證了可見性,又保證了原子性,但是效率遠不如 volatile。
 *
 * @author huangyz0918
 */
public class VolatileUse02 {

    volatile int count = 0;

    void m() {
        for (int i = 0; i < 10000; i++) {
            count++;
        }
    }

    public static void main(String[] args) {
        VolatileUse02 t = new VolatileUse02();
        List<Thread> threads = new ArrayList<>();

        for (int i = 0; i < 10; i++) {
            threads.add(new Thread(t::m, "thread-" + i));
        }

        threads.forEach((o) -> o.start());

        threads.forEach((o) -> {
            try {
                o.join();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });

        System.out.println(t.count);
    }
}
複製程式碼

嘗試執行了一下:

94141
複製程式碼

再執行一次:

97096
複製程式碼

我們可以看到兩次執行的結果不同,並且都沒有達到理論上所需要達到的目標值:100000。這是為什麼呢?(count++語句包含了讀取count的值,自增,重新賦值操作)

可以這樣理解:有兩個執行緒 (執行緒A 和 執行緒B) 都對變數count進行自加操作,如果某一個時刻執行緒 A 讀取了count的值為100,這時候被阻塞了,因為沒有對變數進行修改,觸發不了volatile的規則。

執行緒B 此時也讀讀count的值,主記憶體裡count的值依舊為100,做自增,然後立刻就被寫回主存了,為101。此時又輪到 執行緒A 執行,由於工作記憶體裡儲存的是100,所以繼續做自增,再寫回主存,101又被寫了一遍。所以雖然兩個執行緒執行了兩次自增操作,結果卻只加了一次。

有人說,volatile不是會使快取行無效的嗎?但是這裡從執行緒A開始讀取count的值一直到 執行緒B 也進行操作之前,並沒有修改count的值,所以 當執行緒B 讀取的時候,還是讀的100。

又有人說,執行緒B將101寫回主記憶體,不會把執行緒A的快取設為無效嗎?但是執行緒A的讀取操作已經做過了啊,只有在做讀取操作時,發現自己快取行無效,才會去讀主記憶體的值,所以這裡執行緒A只能繼續做自增了。

總的來說,volatile其實是無法完全替代synchronied關鍵字的,因為在某些複雜的業務邏輯裡面,volatile並不能保證多執行緒之間的完全同步和操作的原子性。

使用 synchronized 關鍵字保證可見性

在看過《深入淺出 Java 併發程式設計 (1)》 之後,想必大家都對synchronized關鍵字同步鎖的性質有所瞭解了,但是關於為什麼synchronized關鍵字能夠保證可見性還需要從synchronized實現的步驟和原理去理解。

在 Java 記憶體模型中,對synchronized關鍵字有兩條規定:

  • 執行緒解鎖前,必須把共享變數的最新值重新整理到主記憶體中。

  • 執行緒加鎖前,將清空工作記憶體中共享變數的值,從而使用共享變數時需要從主記憶體中重新讀取最新的值(注意:加鎖和解鎖需要是同一把鎖)。

這兩條規定保證了執行緒解鎖前對共享變數的修改在下次加鎖時對其他執行緒可見,從而實現了可見性,我們再來看一下synchronized加鎖前後程式碼具體的實現步驟:

  1. 獲得互斥鎖
  2. 清空工作記憶體
  3. 從主記憶體拷貝變數的最新副本到工作記憶體
  4. 執行程式碼
  5. 將更改後的共享變數的值重新整理到主記憶體
  6. 釋放互斥鎖

保證可見性的步驟顯而易見。

synchronized 和 volatile 關鍵字的異同

最後我們再來聊一聊這兩個關鍵字的異同,這在很多網際網路公司面試的過程中都屬於熱門考點。

簡要總結概括如下:

  • volatile 不需要加鎖,比 synchronized 更輕量級,不會阻塞執行緒。
  • 從記憶體可見性角度,volatile讀相當於加鎖,volatile寫相當於解鎖。
  • synchronized 既能保證可見性,又能保證原子性,而volatile只能保證可見性,無法保證原子性。
  • volatile 只能修飾變數,synchronized 還可修飾方法。

關於所謂執行緒阻塞和死鎖以及相關的問題和解決方法,我們將在以後的文章中具體介紹。

相關閱讀:

本教程純屬原創,轉載請宣告 本文提供的連結若是失效請及時聯絡作者更新

相關文章