深入理解java併發程式設計基礎篇(三)-------volatile
一、前言
在上一篇,我們研究了Java記憶體模型,並且知道Java記憶體模型的概念以及作用,圍繞著原子性、可見性、有序性進行了簡單的概述,那麼在這一篇我們首先會介紹volatile關鍵字的基礎認知,然後深入的去解析volatile在這三個特性中究竟有什麼樣的作用?volatile是如何實現的?
二、volatile的用法
volatile通常被比喻成”輕量級的Synchronized“,也是Java併發程式設計中比較重要的一個關鍵字。和Synchronized不同,volatile是一個變數修飾符,只能用來修飾變數。無法修飾方法及程式碼塊等。
volatile的用法比較簡單,只需要在宣告一個可能被多執行緒同時訪問的變數時,使用volatile修飾就可以了。
舉一個單例實現的簡單例子,程式碼如下:
package com.MyMineBug.demoRun.test;
public class Singleton {
private volatile static Singleton singleton;
private Singleton() {
};
public static Singleton getInstance() {
if(singleton == null) {
synchronized (Singleton.class) {
if (singleton == null) {
singleton = new Singleton();
}
}
}
return singleton;
}
}
複製程式碼
這段程式碼是比較典型的使用雙重鎖校驗實現單例的一種形式,其中使用volatile關鍵字修飾可以被多個執行緒同時訪問。
三、volatile的特性
首先看一個程式碼例子:
package com.MyMineBug.demoRun.test;
class VolatileFeaturesExample {
volatile long vl = 0L; // 使用 volatile 宣告 64 位的 long 型變數
public void set(long l) {
vl = l; // 單個 volatile 變數的寫
}
public void getAndIncrement () {
vl++; // 複合(多個)volatile 變數的讀 / 寫
}
public long get() {
return vl; // 單個 volatile 變數的讀
}
}
複製程式碼
假設有多個執行緒分別呼叫上面程式的三個方法,這個程式在語意上和下面程式等價:
package com.MyMineBug.demoRun.test;
class VolatileFeaturesExample {
long vl = 0L; // 64 位的 long 型普通變數
public synchronized void set(long l) { // 對單個的普通 變數的寫用同一個監視器同步
vl = l;
}
public void getAndIncrement () { // 普通方法呼叫
long temp = get(); // 呼叫已同步的讀方法
temp += 1L; // 普通寫操作
set(temp); // 呼叫已同步的寫方法
}
public synchronized long get() {
// 對單個的普通變數的讀用同一個監視器同步
return vl;
}
}
複製程式碼
如上面示例程式所示,對一個 volatile 變數的單個讀 / 寫操作,與對一個普通變數的讀 / 寫操作使用同一個監視器鎖來同步,它們之間的執行效果相同。 通過對比,我們可以知道:
1.可見性。對一個 volatile 變數的讀,總是能看到(任意執行緒)對這個 volatile 變數最後的寫入。
2.原子性:對任意單個 volatile 變數的讀 / 寫具有原子性,但類似於 volatile++ 這種複合操作不具有原子性。
3.1 volatile與有序性
volatile一個強大的功能,那就是他可以禁止指令重排優化。通過禁止指令重排優化,就可以保證程式碼程式會嚴格按照程式碼的先後順序執行。那麼volatile又是如何禁止指令重排的呢?
先看一個概念記憶體屏障(Memory Barrier):是一類同步屏障指令,是CPU或編譯器在對記憶體隨機訪問的操作中的一個同步點,使得此點之前的所有讀寫操作都執行後才可以開始執行此點之後的操作。而volatile就是是通過記憶體屏障來禁止指令重排的。下表描述了和volatile有關的指令重排禁止行為:
從上表我們可以看出:
當第二個操作是volatile寫時,不管第一個操作是什麼,都不能重排序。這個規則確保volatile寫之前的操作不會被編譯器重排序到volatile寫之後。
當第一個操作是volatile讀時,不管第二個操作是什麼,都不能重排序。這個規則確保volatile讀之後的操作不會被編譯器重排序到volatile讀之前。
當第一個操作是volatile寫,第二個操作是volatile讀時,不能重排序。
為了實現 volatile 的記憶體語義,編譯器在生成位元組碼時,會在指令序列中插入記憶體屏障來禁止特定型別的處理器重排序。對於編譯器來說,發現一個最優佈置來最小化插入屏障的總數幾乎不可能,為此,JMM 採取保守策略。下面是基於保守策略的 JMM 記憶體屏障插入策略:
在每個 volatile 寫操作的前面插入一個 StoreStore 屏障。
在每個 volatile 寫操作的後面插入一個 StoreLoad 屏障。
在每個 volatile 讀操作的後面插入一個 LoadLoad 屏障。
在每個 volatile 讀操作的後面插入一個 LoadStore 屏障。
這種保守策略總結如下:
接下來,我們通過具體的程式碼來說明:
package com.MyMineBug.demoRun.test;
class VolatileBarrierExample {
int a;
volatile int v1 = 1;
volatile int v2 = 2;
void readAndWrite() {
int i = v1; // 第一個 volatile 讀
int j = v2; // 第二個 volatile 讀
a = i + j; // 普通寫
v1 = i + 1; // 第一個 volatile 寫
v2 = j * 2; // 第二個 volatile 寫
}
… // 其他方法
}
複製程式碼
針對 readAndWrite() 方法,編譯器在生成位元組碼時可以做如下的優化:
所以,volatile通過在volatile變數的操作前後插入記憶體屏障的方式,來禁止指令重排,進而保證多執行緒情況下對共享變數的有序性。
3.2 volatile與可見性
可見性是指當多個執行緒訪問同一個變數時,一個執行緒修改了這個變數的值,其他執行緒能夠立即看得到修改的值。
在上一篇文章深入理解java併發程式設計基礎篇(二)-------執行緒、程式、Java記憶體模型中,我們知道:Java記憶體模型規定了所有的變數都儲存在主記憶體中,每條執行緒還有自己的工作記憶體,執行緒的工作記憶體中儲存了該執行緒中是用到的變數的主記憶體副本拷貝,執行緒對變數的所有操作都必須在工作記憶體中進行,而不能直接讀寫主記憶體。不同的執行緒之間也無法直接訪問對方工作記憶體中的變數,執行緒間變數的傳遞均需要自己的工作記憶體和主存之間進行資料同步進行。所以,就可能出現執行緒1改了某個變數的值,但是執行緒2不可見的情況。
在Java中,我們知道被volatile修飾的變數在被修改後可以立即同步到主記憶體,被其修飾的變數在每次是用之前都從主記憶體重新整理。因此,可以使用volatile來保證多執行緒操作時變數的可見性。那麼被volatile修飾的變數程式是如何讓具體保證其可見性呢?這就與*記憶體屏障有關。
volatile對於可見性的實現,記憶體屏障也起著至關重要的作用。因為記憶體屏障相當於一個資料同步點,他要保證在這個同步點之後的讀寫操作必須在這個點之前的讀寫操作都執行完之後才可以執行。並且在遇到記憶體屏障的時候,快取資料會和主存進行同步,或者把快取資料寫入主存、或者從主存把資料讀取到快取。
所以,記憶體屏障也是保證可見性的重要手段,作業系統通過記憶體屏障保證快取間的可見性,JVM通過給volatile變數加入記憶體屏障保證執行緒之間的可見性。
3.3 volatile與原子性
原子性是指一個操作是不可中斷的,要麼全部執行完成,要麼就都不執行。
在我們的實際應用場景中,我們應該知道的是:volatile是不能保證原子性的。 那麼為神馬volatile是不能保證原子性?
下一篇介紹synchronized的時候,我們會知道為了保證原子性,需要通過位元組碼指令monitorenter和monitorexit,但是volatile和這兩個指令之間是沒有任何關係的。
根據自己的理解是:執行緒是CPU排程的基本單位。CPU有時間片的概念,會根據不同的排程演演算法進行執行緒排程。當一個執行緒獲得時間片之後開始執行,在時間片耗盡之後,就會失去CPU使用權。所以在多執行緒場景下,由於時間片線上程間輪換,就會發生原子性問題。
下面來看一段volatile與原子性的程式碼:
package com.MyMineBug.demoRun.test;
public class Test {
public volatile int inc = 0;
public void increase() {
inc++;
}
public static void main(String[] args) {
final Test test = new Test();
for(int i=0;i<10;i++){
new Thread(){
public void run() {
for(int j=0;j<1000;j++)
test.increase();
};
}.start();
}
while(Thread.activeCount()>1) //保證前面的執行緒都執行完
Thread.yield();
System.out.println(test.inc);
}
}
複製程式碼
以上程式碼比較簡單,就是建立10個執行緒,然後分別執行1000次 i++ 操作。正常情況下,程式的輸出結果應該是10000,但是,多次執行的結果都小於10000。這其實就是volatile無法滿足原子性的原因。
為什麼會出現這種情況呢,那就是因為雖然volatile可以保證inc在多個執行緒之間的可見性。但是無法inc++ 的原子性。
四、總結
volatile有序性和可見性是通過記憶體屏障實現的。而volatile是無法保證原子性的。 在下一下篇,我們將深入解析關鍵字synchronized。
如果覺得還不錯,請點個贊!!!
Share Technology And Love Life