1. 程式人生 > >Java併發程式設計系列之四:鎖與volatile的記憶體語義

Java併發程式設計系列之四:鎖與volatile的記憶體語義

前言

在前面的文章中已經提到過volatile關鍵字的底層實現原理:處理器的LOCK指令會使得其他處理器將快取重新整理到記憶體中(確切說是主存)以及會把其他處理器的快取設定為無效。這裡的記憶體語義則說的是在JMM中的實現,那麼為什麼要理解volatile和鎖在JMM中的記憶體語義呢?主要原因是這部分內容是與程式開發息息相關的,所以在高併發量的系統中,如果對這塊知識的瞭解欠缺的話將無法設計出優雅支援高併發的系統(之前廣被吐槽的12306,現在勉強能夠支援千萬級別的訪問量了)。

volatile的記憶體語義

簡而言之,volatile關鍵字具有以下兩個特性:

  1. 可見性。對一個volatile變數的讀,總是能看到(任意執行緒)對這個變數最後的寫入。
  2. 原子性。對任意**單個**volatile變數的讀/寫具有原子性,但是類似volatile++這樣的操作是不具有原子性的。

之前看過一篇文章,將volatile關鍵字從硬體講到軟體,再從軟體講到JMM,然後從JMM講到volatile關鍵字,整個過程顯得特別複雜,這裡僅僅站在程式設計師的角度,對volatile關鍵字在JMM的語義進行說明。上面這個過程的成立時需要一個稱為happens-before的原則支援的。

happens-before原則可以概括為三點:

  1. 程式順序規則:就是每個操作都按照在程式中順序執行的
  2. 監視器鎖規則:鎖的釋放之後就是鎖的獲取
  3. volatile變數規則:volatile寫操作之後才是任意對這個volatile的讀
  4. 傳遞性規則:這個好理解

這個原則保證了volatile特性的成立。現在問題是,雖然volatile有上面的特性,但是這個與我們程式設計師有什麼關係呢?由於新增volatile關鍵字修飾一個變數的時候都是一個被共享的變數,那麼任意對該共享變數的操作何時對其他執行緒可見(也就是記憶體可見性)是我們比較關心的,首先可以從JMM的角度說明volatile:volatile的寫-讀與鎖的釋放-獲取有相同的效果。比如如下的程式碼:

package com.rhwayfun.primer.thread;

public class VolatileExample {

    int a = 0;                          //普通變數
volatile boolean flag = false; //共享變數 //寫執行緒 public void writer(){ a = 1; //1.普通寫 flag = true; //2.volatile寫 } //讀執行緒 public void reader(){ if(flag){ //3.volatile讀 int i = a; //4.普通讀 } } }

根據happens-before原則,具有以下的happens-before關係:

  1. 操作1 happens-befoe 操作2;操作3 happens-before 操作4
  2. 操作2 happens-before 操作3
  3. 操作1 happens-before 操作4

上面的這些關係有什麼作用呢?其中第一點和第三點比較好理解,關鍵是第二點,為什麼操作2會 happens-before 操作3呢?根據前面重排序的知識,編譯器和處理器會對指令進行重排序(在保證正確性的前提下),但是如果一個變數被宣告為volatile,那麼JMM會插入記憶體屏障指令來防止重排序,插入記憶體屏障指令就保證了指令之前的volatile變數與後面的普通讀/寫發生重排序,所以明白這點就可以理解為什麼操作2 happens-before 操作3了。根據前面處理器對volatile的處理,這裡講JMM的volatile語義做一個小結:

  1. 執行volatile寫的時候,JMM會把該執行緒對應的本地記憶體(並不是實際存在的,也稱為TLB,執行緒本地緩衝區)重新整理到主記憶體中。這個過程可以理解為執行緒1(執行寫方法的執行緒)向接下來要讀取這個變數的執行緒2(執行讀方法的執行緒)傳送了一條訊息
  2. 執行volatile讀的時候,JMM會把該執行緒對應的本地記憶體設定為無效。執行緒接下來將直接從主記憶體中讀取共享變數的值。這個過程可以理解為執行緒2接收到了執行緒1傳送的訊息

上面提到了記憶體屏障指令,volatile的語義在JMM中實際上與處理器的重排序規則有些類似,下面是volatile的重排序規則表:

是否能重排序 第二個操作 第二個操作 第二個操作
第一個操作 普通讀/寫 volatile讀 volatile寫
普通讀/寫 NO
volatile讀 NO NO NO
volatile寫 NO NO

第一列表示的是第一個操作,NO的意思是不允許進行重排序。上面的表格有點醜,Markdown的語法不支援跨列(雞肋啊)。我們可以從上面的表格中得到三個結論:

  1. 當第一個操作為volatile讀的時候,不管第二操作是什麼,都不允許進行重排序
  2. 當第二個操作是volatile寫的時候,不管第一個操作是什麼,都不允許進行重排序
  3. 當第一個操作是volatile寫的時候,如果第二個操作也是volatile的操作,那麼不允許進行從排序

上面的結論怎麼理解呢?根據上篇博文中JMM的記憶體屏障指令,以第一條結論為例,我們可以這麼理解:如果volatile讀和下面的普通寫或讀發生了重排序將會讀到錯誤的值。

下面的問題是在volatile讀或者寫之間該使用什麼記憶體屏障指令呢?具體是這樣的:

  • 在每個volatile寫操作的前面插入一個StoreStore屏障。這個屏障可以禁止上面的普通寫和下面的volatile寫發生重排序
  • 在每個volatile寫操作的後面插入一個StoreLoad屏障。該屏障可以防止上面的volatile寫與下面的volatile讀或寫操作發生重排序
  • 在每個volatile讀操作的後面插入一個LoadLoad屏障。該屏障可以禁止下面所有的普通讀操作與上面的volatile讀發生重排序
  • 在每個volatile讀後面插入一個LoadStore屏障,該屏障可以禁止下面所有的普通寫和上面的volatile讀發生重排序

這裡需要注意的是,處理器會根據具體的情況省略不必要的記憶體屏障。所以比如當有連續多個volatile讀的時候,可能會省略一個LoadStore屏障。

鎖的記憶體語義

鎖是實現同步的重要手段,雖然volatile關鍵字很給力,但是volatile只能保證對單個volatile變數讀或寫的操作具有原子性,諸如複雜操作或者多個volatile變數的操作就不能保證了。而鎖的互斥執行的特性可以確保對整個臨界區的程式碼都具有原子性。

鎖中的happens-before關係與volatile中的happens-before關係大致是一樣的,現在考慮鎖的獲取和鎖的釋放在JMM中的實現,瞭解volatile之後,可以把鎖的獲取與釋放的記憶體語義簡要概括為以下幾句話:

  • 執行緒釋放一個鎖,JMM就會把該執行緒對應的本地記憶體中共享變數重新整理到主記憶體中。這個過程可以理解為該執行緒向接下來需要獲取這個鎖的執行緒傳送一條訊息
  • 另一個執行緒獲得該鎖,JMM就會把該執行緒對應的本地記憶體設定為無效。這個過程可以理解為該執行緒接收到了之前釋放該鎖的執行緒傳送的訊息

鎖的記憶體語義的實現與可重入鎖相關,可以簡要總結鎖的記憶體語義的實現包括以下兩種方式:

  • 利用volatile變數的記憶體語義
  • 利用CAS附帶的volatile語義