volatile域淺析

記憶體模型的相關概念

電腦中執行程式時,每條指令都是在CPU中執行,執行指令的過程必然會涉及到數據的讀取和寫入。而程式運行時的數據是存放在主存(物理記憶體)中,由於CPU的讀寫速度遠遠高於記憶體的速度,如果CPU直接和記憶體交互,會大大降低指令的執行速度,所以CPU裡面就引入了高速快取。

腦補當初學習OS時的圖 CPU->記憶體 CPU->暫存器->記憶體

​ 也就是說程式運行時,會將運算所需要的數據從主存中複製一份到高速快取,CPU進行計算的時候可以直接從高速快取讀取和寫入,當運算結束時,在將高速快取中的數據刷新到主存。

​ 但是如果那樣必須要考慮,在多核CPU下數據的一致性問題怎麼保證?比如i=i+1,當執行緒執行這條時,會先從主存中讀取i的值,然後複製一份到高速快取,然後CPU執行指令對i進行加1操作,然後將數據寫入高速快取,最後將高速快取中i最新的值刷新到主存當中。在單執行緒下這段程式碼運行不會存在問題,但如果在多執行緒下多核CPU中,每個CPU都有自己的高速快取,可能存在下面一種情況:初始時,兩個執行緒分別讀取i的值存入各自所在的CPU的高速快取當中,然後執行緒1進行加1操作,然後把i的最新值1寫入到記憶體。此時執行緒2的高速快取當中i的值還是0,進行加1操作之後,i的值為1,然後執行緒2把i的值寫入記憶體。最終結果i的值是1,而不是2。這就是著名的快取一致性問題。通常稱這種被多個執行緒訪問的變數為共享變數

為了解決快取不一致性問題,通常來說有以下2種解決方法:

  1. 通過在匯流排加LOCK#鎖的方式

  2. 通過快取一致性協議

    在這裡插入圖片描述

原子性可見性有序性

並發編程中,通常會考慮的三個問題原子性問題、可見性問題、有序性問題。

(1)原子性:程式中的單步操作或多步操作要麼全部執行並且執行的過程中不能被打斷,要麼都不執行。

如果程式中不具備原子性會出現哪些問題?

轉賬操作就是一個很好的代表,如果轉賬的過程中被中斷,錢轉出去了,由於中斷,收賬方卻沒有收到。

(2)可見性:記憶體可見性,指的是執行緒之間的可見性,當一個執行緒修改了共享變數時,另一個執行緒可以讀取到這個修改後的值。

//執行緒1執行的程式碼
int i = 0;
i = 10;
 
//執行緒2執行的程式碼
j = i;

倘若執行緒1從主存中讀取了i的值並複製到CPU高速快取,然後對i修改為10,這時CPU高速快取中的i值為10,在沒有將高速快取中的值刷新到主存中時,執行緒2讀取到的值還是0,它看不到i值的變化,這就是可見性問題。

Java提供了Volatile關鍵字來保證可見性,當一個共享變數被volatile修飾時,它會保證修改的值會立即被更新到主存,當有其他執行緒需要讀取時,它會去記憶體中讀取新值。

  而普通的共享變數不能保證可見性,因為普通共享變數被修改之後,什麼時候被寫入主存是不確定的,當其他執行緒去讀取時,此時記憶體中可能還是原來的舊值,因此無法保證可見性。

  另外,通過synchronized和Lock也能夠保證可見性,synchronized和Lock能保證同一時刻只有一個執行緒獲取鎖然後執行同步程式碼,並且在釋放鎖之前會將對變數的修改刷新到主存當中。因此可以保證可見性。

(3)有序性:程式執行的順序按照程式碼的先後順序執行。

實際是這樣嗎?

int i = 0;	//[1]
int a,b;	//[2]

[2]一定會在[1]之後執行嗎?不一定,在JVM中,有可能會發生指令重排序(Instruction Reorder)。如果[1]、[2]中有相互依賴,比如[2]中的數據依賴於[1]的結果,那麼則不會發生指令重排序。

什麼是指令重排序?

一般來說,處理器為了提高程式運行效率,可能會對輸入程式碼進行優化,它不保證程式中各個語句的執行先後順序同程式碼中的順序一致,但是它會保證程式最終執行結果和程式碼順序執行的結果是一致的。

​ 指令重排對於提⾼CPU處理性能⼗分必要。雖然由此帶來了亂序的問題,但是這點犧牲是值得的。

指令重排可以保證串⾏語義⼀致,但是沒有義務保證多執行緒間的語義也⼀致。所以在多執行緒下,指令重排序可能會導致⼀些問題。

Java記憶體模型的抽象結構

JVM可以看做是一個有OS架構的處理機,他也有自己的記憶體和處理器,它的記憶體和之前討論的沒有什麼太大的差異。

Java運行時記憶體的劃分如下:

在這裡插入圖片描述

對於每⼀個執行緒來說,棧都是私有的,而堆是共有的。也就是說在棧中的變數(局部變數、⽅法定義參數、異常處理器參數)不會在執行緒之間共享,也就不會有記憶體可⻅性(下⽂會說到)的問題,也不受記憶體模型的影
響。⽽在堆中的變數是共享的,本⽂稱為共享變數。所以記憶體可見性針對的是共享變數

1、既然堆是共享的,為什麼在堆中會有記憶體不可⻅問題?

Java記憶體模型規定所有的變數都是存在主存當中(類似於前面說的物理記憶體),每個執行緒都有自己的工作記憶體(類似於前面的高速快取)。執行緒對變數的所有操作都必須在工作記憶體中進行,而不能直接對主存進行操作。並且每個執行緒不能訪問其他執行緒的工作記憶體。

執行緒之間的共享變數存在主記憶體中,每個執行緒都有⼀個私有的本地記憶體,存儲了該執行緒以讀、寫共享變數的副本。本地記憶體是Java記憶體模型的⼀個抽象概念,並不真實存在。它涵蓋了快取、寫緩衝區、暫存器等。

Java執行緒之間的通訊由Java記憶體模型(簡稱JMM)控制,從抽象的⻆度來說,JMM定義了執行緒和主記憶體之間的抽象關係。JMM的抽象示意圖如圖所示:

在這裡插入圖片描述

從圖中可以看出:

  1. 所有共享變數都存在主記憶體中。
  2. 每個執行緒都保存了一份該執行緒使用到的共享變數的副本。
  3. 執行緒A與執行緒B之間的通訊必須通過主存。

2、JMM與Java記憶體區域劃分的區別與聯繫

  • 區別

    JMM是抽象的,他是⽤來描述⼀組規則,通過這個規則來控制各個變數的訪問⽅式,圍繞原⼦性、有序性、可⻅性等展開的。⽽Java運⾏時記憶體的劃分是具體的,是JVM運⾏Java程式時,必要的記憶體劃分。

  • 聯繫

    都存在私有數據區域和共享數據區域。⼀般來說,JMM中的主記憶體屬於共享數據區域,他是包含了堆和⽅法區;同樣,JMM中的本地記憶體屬於私有數據區域,包含了程式計數器、本地⽅法棧、虛擬機棧。

原子性、可見性、有序性

Java記憶體模型具備一些先天的「有序性」,即不需要通過任何手段就能夠得到保證的有序性,這個通常也稱為 happens-before 原則。如果兩個操作的執行次序無法從happens-before原則推導出來,那麼它們就不能保證它們的有序性,虛擬機可以隨意地對它們進行重排序。

在Java虛擬機規範中試圖定義一種Java記憶體模型(Java Memory Model,JMM)來屏蔽各個硬體平台和作業系統的記憶體訪問差異,以實現讓Java程式在各種平台下都能達到一致的記憶體訪問效果。那麼Java記憶體模型規定了哪些東西呢,它定義了程式中變數的訪問規則,往大一點說是定義了程式執行的次序。注意,為了獲得較好的執行性能,Java記憶體模型並沒有限制執行引擎使用處理器的暫存器或者高速快取來提升指令執行速度,也沒有限制編譯器對指令進行重排序。也就是說,在java記憶體模型中,也會存在快取一致性問題和指令重排序的問題。

volatile的記憶體語義

在Java中,volatile關鍵字有特殊的記憶體語義。volatile主要有以下兩個功能:

  • 保證變數的記憶體可⻅性
  • 禁⽌volatile變數與普通變數重排序(JSR133提出,Java 5 開始才有這個「增強的volatile記憶體語義「)

記憶體可見性

所謂記憶體可見性,指的是當一個執行緒對volatile修飾的變數進行過寫操作時,JMM會立即把執行緒對應的本地記憶體中的共享變數的值刷新到主記憶體;當一個執行緒對volatile修飾的變數進行讀操作時,JMM會立即把該執行緒對應的本地記憶體置為無效,從記憶體中從新讀取共享變數的值。

禁止重排序

JMM是通過記憶體屏障來限制處理器對指令的重排序的。

什麼是記憶體屏障?硬體層面,記憶體屏障分兩種:讀屏障(Load Barrier)和寫屏障(Store Barrier)。記憶體屏障有兩個作用:

  1. 阻止屏障兩側的指令重排序
  2. 強制把寫緩衝區/⾼速快取中的臟數據等寫回主記憶體,或者讓快取中相應的數據 失效。

通俗說,通過記憶體屏障,可以防止指令重排序時,不會將屏障後面的指令排到之前,也不會將屏障之前的指令排到之後。

Volatile關鍵字的應用場景

單例模式下的Double-Check(雙重鎖檢查)

public class Singleton {
    public static Singleton instance;
    private Singleton() {}

    public static Singleton getInstance() {
        if (instance == null) { 			//[1]
            synchronized (Singleton.class) {
                instance = new Singleton(); //[2]
            }
        }
        return instance;
    }
}

如果這裡的變數沒有使用volatile關鍵字,那麼有可能就會發生錯誤。

[2]實例化對象的過程可以分為分配記憶體、初始化對象、引用賦值。

instance = new Singleton(); // [1]
// 可以分解為以下三個步驟
1 memory=allocate();// 分配記憶體 相當於c的malloc
2 ctorInstanc(memory) //初始化對象
3 s=memory //設置s指向剛分配的地址
// 上述三個步驟可能會被重排序為 1-3-2,也就是:
1 memory=allocate();// 分配記憶體 相當於c的malloc
3 s=memory //設置s指向剛分配的地址
2 ctorInstanc(memory) //初始化對象

如果一旦發生了上述的重排序,當程式執行了1和3,這時執行緒A執行了if判斷,判定instance不為空,然後直接返回了一個未初始化的instance。

參考:Java並發編程:volatile關鍵字解析

Tags: