Java 多線程 :Volatile

在多線程併發編程中,鎖的運用很常見。synchronized 的幾種運用方式,相信大部分 Java 程序員已經很熟悉。而 volatile 做爲輕量級的 synchronized,不會像鎖同樣形成阻塞,所以,在可以安全使用 volatile 的狀況下,volatile 能夠提供一些優於鎖的可伸縮特性。若是讀操做的次數要遠遠超過寫操做,與鎖相比,volatile 變量一般可以減小同步的性能開銷。html

在現代計算機系統中,因爲計算機的存儲設備與處理器的運算速度有幾個數量級的差距,因此現代計算機系統都不得不加入一層讀寫速度儘量接近處理器運算速度的高速緩存來做爲內存與處理器之間的緩衝:將運算須要使用到的數據複製到緩存中,讓運算能快速進行,當運算結束後再從緩存同步回內存之中,這樣處理器就無須等待緩存的內存讀寫了。java

下面是計算機系統中處理器、高速緩存、主內存間的交互關係:程序員

計算機系統內存模型

基於高速緩存的存儲交互很好的解決了處理器與內存的速度矛盾,可是也爲計算機系統帶來更高的複雜度,由於它引入了一個新的問題:緩存一致性。編程

下面是Java中線程、主內存、工做內存交互關係:緩存

Java 內存模型

Volatile 的官方定義

Java 語言規範第三版中對 volatile 的定義以下: java編程語言容許線程訪問共享變量,爲了確保共享變量能被準確和一致的更新,線程應該確保經過排他鎖單獨得到這個變量。Java語言提供了 volatile,在某些狀況下比鎖更加方便。若是一個字段被聲明成volatile,java線程內存模型確保全部線程看到這個變量的值是一致的。安全

內存不可見的含義

在 JVM 中,對於多線程應用,若是多個線程同時使用某個沒有 volatile 修飾的變量時,每一個線程會從主內存拷貝目標變量到當前線程的工做內存中,而後在各自的工做內存進行具體的操做。多線程

可見性的定義:可見性是指當一個線程修改了共享變量的值,其餘線程可以當即獲得這個修改。架構

在上面的情景中,不一樣線程的對主內存變量副本的操做不可以即時的反饋到主內存區,其餘線程的工做內存更是沒法感知,內存不可見。併發

如何保證內存可見

volatile 如何實現內存可見的呢? 在x86處理器下經過工具獲取JIT編譯器生成的彙編指令:app

語言 代碼片斷
Java instance = new Singleton();
//instance 是 volatile 修飾變量
彙編 0x01a3de1d: movb $0x0,0x1104800(%esi);
0x01a3de24: lock addl $0x0,(%esp);

有 volatile 變量修飾的共享變量進行寫操做的時候會多第二行彙編代碼,經過查IA-32架構軟件開發者手冊可知,lock前綴的指令在多核處理器下會引起了兩件事情。

  • 將當前處理器緩存行的數據回寫到系統內存。
  • 這個回寫內存的操做會引發在其餘CPU裏緩存了該內存地址的數據無效。

也就是說,處理器爲了提升處理速度,不直接和內存通信,而是先將內存數據拷貝到緩存後再操做(同上圖)。若是變量聲明瞭 volatile,那麼處理器讀取操做會直接和內存進行通信,將變量所在緩存行的數據直接寫入系統內存或者直接讀取系統內存數據。可是若是其餘處理器緩存的數據仍然是舊的數據,那麼再執行計算操做就是無心義的。因此這裏就存在緩存一致性協議,每一個處理器經過嗅探在總線上傳播的數據來檢測自身緩存是否過時,若是檢測到本身緩存行對應的數據被修改,那麼會將當前處理器緩存行設置爲無效狀態。當處理器須要該數據進行操做時,會強制從系統內存從新加載到當前處理器緩存中。

緩存一致性機制會阻止同時修改被兩個以上處理器緩存的內存區域數據。

具體的專有名詞及細節能夠看文末的 reference(本節內容摘錄自文末的參考文章).

保證對 64 位變量讀寫的原子性

JVM 能夠保證對 32位 數據讀寫的原子性,可是對於 long 和 double 這樣 64位 的數據的讀寫,會將其分爲 高32位 和 低32位 分兩次讀寫。因此對於 long 或 double 的讀寫並非原子性的,這樣在併發程序中共享 long 或 double 變量就可能會出現問題,因而 JVM 提供了 volatile 關鍵字來解決這個問題:

使用 volatile 修飾的 long 或 double 變量,JVM 能夠保證對其讀寫的原子性。

可是,此處的 「寫」 僅指對 64位 的變量進行直接賦值。

指令從新排序對 volatile 的影響

若是一個操做不是原子操做,那麼 JVM 即可能會對該操做涉及的指令進行 重排序優化。重排序即在不改變程序語義的前提下,經過調整指令的執行順序,儘量達到提升運行效率的目的。

int a = 1;
int b = 2;

a++;
b++;
複製代碼

可能會被從新排序爲:

int a = 1;
a++;

int b = 2;
b++;
複製代碼

這樣看是沒什麼影響的。

但當一個變量是 volatile 修飾時,指令重排序就可能會出現問題。

public class Counter {
    private int numA;
    private int numB
    private volatile int numC;

    public void update(int numA, int numB, int numC){
        this.numA  = numA;
        this.numB = numB;
        this.numC   = numC;
    }
}
複製代碼

當 update 方法調用時,numA,numB,numC 的新值都會直接寫入系統內存。可是若是從新排序成這樣:

public void update(int numA, int numB, int numC){
    this.numC  = numC;
    this.numA   = numA;
    this.numB = numB;
}
複製代碼

修改 numC 變量時,A和B的值仍會寫入主內存,但這一次是在A和B的新值寫入以前發生的。所以,其餘線程沒法正確地看到A和B的新值。從新排序的指令的語義已經改變。

爲了解決指令從新排序這個難題,Java volatile 關鍵字除了提供可見性保證以外,還提供「happens-before」保證:

  • 若是讀取/寫入其餘變量的操做最初就發生在寫入 volatile 修飾變量以前,那麼指令從新排序時,不容許這個操做被排到被 volatile 修飾的變量寫入以後;注意,對於其餘變量的操做最初發生在寫入 volatile 修飾變量以後的,那麼從新排序是仍然有可能排到 volatile 修飾變量寫入以前。

  • 若是讀取/寫入其餘變量的操做最初就發生在寫入 volatile 修飾變量以後,那麼指令從新排序時,不容許這個操做被排到被 volatile 修飾的變量寫入以前;注意,對於其餘變量的操做最初發生在寫入 volatile 修飾變量以前的,那麼從新排序是仍然有可能排到 volatile 修飾變量寫入以後。

上述的「happens-before」保證正在被實施。

必須保證操做原子性

對 volatile 修飾的變量操做時,即便每次都是從系統內存讀取,都是直接寫入系統內存,仍然會存在問題。

當多個線程同時寫入一個 volatile 變量時,例如 i++ 操做。對於 i++ 這個語句,事實上涉及了 讀取-修改-寫入 三個操做:

  • 讀取變量到棧中某個位置
  • 對棧中該位置的值進行自增
  • 將自增後的值寫回到變量對應的存儲位置

volatile 變量只能保證可見性,在不符合如下兩條規則的運算場景中,仍須要經過加鎖(使用 synchronized 或 java.util.concurrent 中的原子類)來保證原子性。

  • 運算結果並不依賴變量的當前值,或者可以確保只有單一的線程修改變量的值。
  • 變量不須要與其餘的狀態變量共同參與不變約束。

合適的使用場景

讀取和寫入一個 volatile 變量會直接和系統內存通訊,對比與處理器緩存通訊的消耗要大得多。訪問 volatile 變量還防止指令從新排序,這是一種正常的性能加強技術。因此只有在真正須要變量強制可見性時才應該使用。

具體的幾種場景能夠參考正確使用 Volatile 變量

參考資料:

  1. www.infoq.com/cn/articles…
  2. www.ibm.com/developerwo…
  3. tutorials.jenkov.com/java-concur…
  4. 深刻理解Java虛擬機 - JVM高級特性與最佳實踐
相關文章
相關標籤/搜索