volatile和synchronized的內存語義

volatile和synchronized在Java併發編程中扮演著重要角色,在平時開發中使用的也比較多。今天一起來了解下它們分別代表的內存語義。

Java內存模型抽象結構

操作系統實現線程之間通信主要有兩種方式:共享內存和消息傳遞。共享內存將線程共享狀態存儲在公共存儲區域,比如內存,各個線程共同讀寫公共存儲區域實現多個線程之間狀態共享。消息傳遞模型則沒有共享存儲區域,線程之間通過顯式的發送消息進行通信。Java實現線程之間通信採用的是共享內存模型。

現代處理器模型

我們知道現代計算機基本都是多核處理器,我們可以把多核處理器想象為多個CPU,每個CPU都有一個高速緩存(寄存器)用來緩存CPU當前處理的數據。為了提高處理速度,處理器不直接和內存進行通信,而是將內存中的數據load進高速緩存進行操作,但操作完不知道何時會寫回內存。

volatile和synchronized的內存語義

Java內存模型抽象結構

Java內存模型(JMM)就是基於現代處理器模型進行設計,從抽象的角度來看JMM定義了線程和主內存之間的抽象關係:線程之間的共享變量存儲在主內存,每個線程都有一個線程本地內存,線程本地內存中存儲了線程讀寫變量的副本。

volatile和synchronized的內存語義

線程A如果要與線程B進行通信需要經過兩個步驟:

  1. 將線程A本地緩存的變量刷新到主內存
  2. 線程B去主內存讀取線程A之前更新過的共享變量

這裡我們所說的線程A與線程B通信的過程在沒有其他同步措施的情況下是在非常理想的,然而實際情況下並非總是如此。

緩存不一致問題

在沒有其他任何併發控制的條件下,線程A和線程B併發執行i++操作。下面這樣的情況是可能會發生:

線程A在線程本地緩存中看到的i=0,執行i=i+1, 此時線程A的本地緩存i=1,但線程A並沒有立即將i=1刷新至主內存。

這時候線程B看到本地緩存中仍然是i=0,線程B同樣執行i=i+1,線程B的本地緩存i=1,同時將i=1刷新到主內存中。

在線程B刷新主內存後,這時線程A也執行了i=1回寫主內存,這時就出現了丟失更新的情況。A、B兩個線程分別執行一次+1後,內存中的結果應該是2。由於線程A和B本地緩存中數據不一致,導致其中一次+1操作丟失。

volatile和synchronized的內存語義

緩存不一致導致一次+1操作丟失

緩存一致性協議

為了解決緩存不一致問題,出現了緩存一致性協議:每個處理器會嗅探總線(內存通過總線與CPU進行數據傳遞)上的數據檢查自己緩存的數據是否過期,當處理器發現自己緩存行對應的內存地址被修改,就會將當前處理器緩存置無效,當處理器對這個數據進行修改時,會重新從內存中將數據load進處理器緩存。簡單來說緩存一致性協議實現以下兩點:

  1. 將發生修改的處理器緩存內容寫回主存
  2. 一個處理器緩存寫回導致使用共享變量的其他處理器緩存失效

併發編程中的三個概念

原子性

原子操作是指不可被中斷的一個或一系列操作,滿足原子性的操作我們稱為原子操作。在多線程環境下如果操作不滿足原子性很可能出現不一致問題。我們仍以i++操作舉例:

簡單的i++就是將執行一個i=i+1的操作,它是否是原子操作呢?

答案是否定的,i++其實包含三個操作:從緩存中讀取i的值、執行i=i+1、將i寫回緩存和內存。

類似於這種讀改寫不滿足原子性的操作,就可能出現共享變量被多個處理器同時操作後共享變量值和期望不一致的情況。就如同在說明緩存不一致問題時i最終結果為2的情況。

Java中如何實現原子操作呢?

Java中可以通過鎖和CAS的方式實現原子操作。

Java中包含兩種加鎖方式分別synchronized和ReentrantLock,這兩種方式都可以對一段代碼加鎖來實現操作的原子性,我們平時用的都比較多,這裡就不做展開了。

CAS實現原子操作時我們平時使用較少的,我們一起來了解下它是如何實現原子操作的。

循環CAS實現原子操作

CAS(Compare And Swap)操作包含三個操作數:內存位置(V)、預期原值(A)和新值(B)。執行CAS操作的時候,將內存位置的值與預期原值比較,如果相匹配,那麼處理器會自動將該位置值更新為新值。否則,處理器不做任何操作。

JVM中的CAS操作利用了處理器提供的原子指令CMP進行實現。循環CAS實現的基本思路是循環進行CAS操作直到成功為止。Java1.5後提供了一些原子操作類如AtomicInteger、AtomicBoolean和AtomicLong等就是利用CAS實現的。例如AtomicInteger的addAndGet(int delta)方法:

/**
* Atomically adds the given value to the current value.
*
* @param delta the value to add
* @return the updated value

*/
public final int addAndGet(int delta) {
return unsafe.getAndAddInt(this, valueOffset, delta) + delta;
}
/**
* Unsafe類是java實現CAS操作的輔助類,所有的CAS操作都最終都依賴Unsafe類中的方法
* var1是原子操作類的實例,var2是舊址在內存中的地址,var4是增加的值
*
* while循環會一直嘗試執行CAS操作,直到成功為止
*/
public final int getAndAddInt(Object var1, long var2, int var4) {
int var5;
do {
var5 = this.getIntVolatile(var1, var2); //var5是舊值
} while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));
return var5;
}
/**
* 最終執行CAS操作的方法是一個Java本地方法
*/
public final native boolean compareAndSwapInt(Object var1, long var2, int var4, int var5);

總的來說當某個線程執行addAndGet時是這樣一個流程:先把舊值取出(預期舊值),新值=舊值+增加值,將內存中舊值與預期舊值比較,如果不一致,循環進行CAS操作,直到成功為止。

可見性

可見性是指當一個線程修改了共享變量後,修改後的值對其他線程立馬可見。我們仍然以緩存不一致小節中i++中的例子來說明。

線程A執行i=1+1後,線程A本地緩存的i=1;可見性確保i=1的狀態值立刻被刷新到主內存,同時線程B會即刻得知共享變量i被修改了,重新從主內存中將最新的i值load進線程B的本地緩存。

我們可以將可見性抽象為:線程A修改後共享變量後向線程B發送了狀態變更的消息,不過這個通信過程必須經過主內存。Java內存模型通過控制主內存與每個線程本地緩存之間的交互來為Java程序提供內存可見性。

指令重排序

在程序執行時,為了提高性能,編譯器和處理器通常會對指令做重排序。指令重排序分為三種:

  1. 編譯器優化重排序。編譯器在不改變單線程程序語義的情況下,可以重新安排語句的執行順序。
  2. 指令級並行重排序。現代處理器採用了指令級並行技術來將多條指令重疊執行。如果不存在數據依賴性,處理器可以改變語句對應機器的執行順序。
  3. 內存系統的重排序。由於處理器使用緩存和讀寫緩衝區,這使得加載和存儲操作看上去可能是亂序執行。

從Java源代碼到最終實際執行序列,會分別經歷下面三種重排序:

volatile和synchronized的內存語義

上述重排序類型中1屬於編譯器重排序,2和3屬於處理器重排序,這些重排序可能會導致多線程程序出現可見性問題。因此JMM的編譯器重排序規則會對特定類型的編譯器重排序禁止(不是所有的編譯器重排序都禁止)。對於處理器重排序,JMM的處理器重排序規則會要求Java編譯器在生成指令序列時,插入特定類型的內存屏障(Memory Barriers)來禁止特定類型的處理器重排序。

volatile實現的內存語義

當一個共享變量聲明為volatile後,volatile變量具備以下特性:

  1. 具備可見性。一個線程對一個volatile變量的寫對其他線程立馬可見。
  2. volatile變量並不保證原子性。對任意單個volatile變量的讀/寫具備原子性,但類似於i++的複合操作不具有原子性。
  3. 禁止一定的指令重排序。

對於volatile具備可見性這一點,有了前面我們關於Java內存模型和可見性含義的鋪墊應該很好理解了。

volatile變量寫的內存語義:當寫一個volatile變量時,JMM會把該線程本地緩存中的共享變量刷新到主內存。

volatile變量讀的內存語義:當讀一個volatile變量時,JMM會把該線程對應的本地緩存置無效,線程接下來將從主內存中讀取共享變量。

理解volatile特性的一個好方法就是把對單個volatile變量的讀寫看成對這些單個變量做了讀/寫同步。

public class VolatileExample {
private volatile int i = 0;

//對單個volatile變量的寫
public void set(){
i = 1;
}
//對單個volatile變量的讀
public int get(){
return i;
}
}

以上代碼其實等價於對普通變量的加鎖讀寫

public class VolatileExample {
private int i = 0;
//普通變量的加鎖寫 等價於 對單個volatile變量的寫
public synchronized void set(){
i = 1;
}
//普通變量的加鎖讀 等價於 對單個volatile變量的讀
public synchronized int get(){
return i;
}
}

正如volatile第二點中所提到的,我們應該清楚volatile並沒有提供原子性,類似於i++這種複合操作在多線程併發操作的環境下是無法保證結果的正確性的。除非只有一個線程執行i++,其餘線程僅僅是讀取i的值,在這種情況下使用volatile來代替synchronized或者ReetrantLock就非常合適。

在指令重排序小節中我們介紹了編譯器和處理器為了優化程序執行速度,在不改變單線程執行語義的前提下會對指令進行重排序。對於volatile變量JMM會根據volatile變量重排序規則對包含volatile變量的代碼塊進行重排序干預,禁止不符合規則的重排序。舉例來說:

當第二條語句是volatile變量寫時,不管第一條語句是什麼,都不能重排序。這個規則確保volatile寫之前的操作不會被編譯器重排序到volatile寫之後。

int a = 5; //語句1
int b = 3; //語句2
volatile boolean flag = true; //語句3

雖然在單線程環境下語句1、語句2、語句3重排序不會影響單線程執行語義,但根據volatile變量的禁止排序規則語句3不能和語句1、語句2進行重排序。

語句1和語句2是普通變量寫,它們兩個之間可以進行重排序。

實際上JMM遵從的volatile變量重排序規則比我們舉的例子要複雜些,有興趣的同學可以研究下。

synchronized實現的內存語義

synchronized是Java併發編程中元老級的角色,是最早用於實現Java多線程同步的策略。Java中的每個對象都可以作為鎖,synchronized實現線程同步主要有三種形式:

  • 對於普通同步方法,鎖是當前實例對象
  • 對於靜態同步方法,鎖是當前類的Class對象
  • 對於同步方法塊,鎖是synchronized括號裡配置的對象

線程在訪問同步代碼塊時首先要獲取鎖,退出同步代碼塊或者拋出異常時必須釋放鎖。那麼synchronized關鍵字實現的鎖到底存在哪裡呢?

Java虛擬機給每個對象和class字節碼都設置了一個監聽器Monitor,用於檢測併發代碼的重入,JVM基於Monitor對象來實現方法同步和代碼塊同步。當一個線程進入同步代碼塊時會執行monitorenter指令,表示monitor鎖被當前線程鎖持有,直到當前線程釋放monitor鎖之前其他線程都不能進入同步代碼塊。與monitorenter相匹配,每個線程在退出同步塊或者拋出異常都會執行monitorexit指令,使當前線程釋放monitor鎖。這就是synchronized鎖實現的原理。

眾所周知鎖能夠實現臨界區互斥執行,但鎖的內存語義常常被忽視。

  1. 當線程釋放鎖時,JMM會把該線程本地緩存中的共享變量刷新到主內存中
  2. 當線程獲取鎖是,JMM會把該線程本地緩存置為無效
public class MonitorExample {
private int i = 0;

public synchronized void set(){
i = 1;
}


public synchronized int get(){
return i;
}
}

當線程A執行完set方法釋放鎖後,會立即將i=1刷新到主內存。同樣線程B獲取鎖執行get方法時會將本地緩存置無效,從主存中刷新最新的i值。

線程A釋放鎖隨後線程B獲取鎖,這個過程實質上是線程A向線程B發送消息。

對比volatile的的內存語義,釋放鎖與volatile寫有相同的內存語義;獲取鎖與volatile讀有相同的內存語義。

本篇文章主要介紹了一下幾點:

  1. Java內存模型的抽象結構
  2. 併發編程中的三個重要概念:原子性、可見性、以及指令重排序
  3. volatile的內存語義
  4. synchronzed的內存語義,其實也是Java中鎖所實現的內存語義


分享到:


相關文章: