多線程知識梳理(8) - volatile 關鍵字

1、基本概念

1.1 內存模型

在程序的執行過程當中,涉及到兩個方面:指令的執行和數據的讀寫。其中指令的執行經過處理器來完成,而數據的讀寫則要依賴於系統內存,可是處理器的執行速度要遠大於內存數據的讀寫,所以在處理器中加入了高速緩存。在程序的執行過程當中,會 先將數據拷貝處處理器的高速緩存中,待運算結束後再回寫到系統內存當中html

在單線程的狀況下不會有什麼問題,可是若是在多線程狀況下就可能會出現異常的狀況,如下面這段代碼爲例,i是放在堆內存的共享變量:java

i = i + 1; //i 的初始值爲0。
複製代碼

假如線程A和線程B都執行這段代碼,那麼就可能出現下面兩種狀況:編程

  • 第一種狀況:線程A先執行+1操做,而後將i的值寫回到系統內存中;線程B從系統內存中拷貝i的值1到高速緩存中,執行完+1操做再回寫到系統內存中,最終的結果是i=2
  • 第二種狀況:線程A和線程B首先都將i的值0拷貝到各自處理器的高速緩存當中,線程A首先執行+1操做,以後i的值爲1,而後寫回到系統內存中;可是對於線程B而言,它並不知道這一過程,在運行該線程的處理器的高速緩存中i的值仍然爲0,所以在它執行+1操做後,再將i的值寫回到系統內存中,最終的結果是i=1

這種不肯定性就稱爲 緩存不一致緩存

1.2 併發編程中的三個概念

在併發編程中,有三個關鍵的概念:可見性、原子性和有序性,只有保證了這三點才能使得程序在多線程狀況下得到預期的運行結果。安全

1.2.1 可見性

可見性:是指線程之間的可見性,一個線程修改的狀態對另外一個線程是可見的。也就是一個線程修改的結果,另外一個線程立刻就能看到。在1.1所舉的例子就存在可見性的問題。多線程

Javavolatilesynchronizedfinal實現可見性。併發

1.2.2 原子性

原子性:即一個操做或者多個操做,要麼所有執行而且執行的過程不會被任何因素打斷,要麼就都不執行。框架

再好比a++,這個操做實際是a=a+1,是可分割的,因此它不是一個原子操做。非原子操做都會存在線程安全問題,須要咱們使用同步技術來讓它變成一個原子操做。一個操做是原子操做,那麼咱們稱它具備原子性。編程語言

Javasynchronized和在lockunlock中操做或者原子操做類來保證原子性。函數

1.2.3 有序性

有序性:即程序執行的順序按照代碼的前後順序執行。如下面的代碼爲例:

int i = 0;              
boolean flag = false;
i = 1; //語句1 
flag = true; //語句2
複製代碼

在上面的代碼中定義了一個整形和Boolean型變量,並經過語句1和語句2對這兩個變量賦值,可是JVM在執行這段代碼的時候並不保證語句1在語句2以前執行,也就是說可能會發生 指令重排序

指令重排序指的是在 保證程序最終執行結果和代碼順序執行的結果一致的前提 下,改變語句執行的順序來優化輸入代碼,提升程序運行效率。

可是這一前提條件在多線程的狀況下就有可能出現問題,如下面的代碼爲例:

//線程1:
context = loadContext(); //語句1
inited = true; //語句2
 
//線程2:
while (!inited) {
    sleep()
}
doSomethingWithConfig(context);
複製代碼

對於線程1來講,語句1和語句2沒有依賴關係,所以有可能會發生指令重排序的狀況。可是對於線程2來講,語句2在語句1以前執行,那麼就會致使進入doSomethingWithConfig函數的時候context沒有初始化。

Java語言提供了volatilesynchronized兩個關鍵字來保證線程之間操做的有序性,volatile是由於其 自己包含禁止指令重排序 的語義,synchronized是由 一個變量在同一個時刻只容許一條線程對其進行 lock 操做 這條規則得到的,此規則決定了持有同一個對象鎖的兩個同步塊只能串行執行。

2、volatile 詳解

2.1 定義

volatile的定義以下:Java編程語言容許線程訪問共享變量,爲了確保共享變量能被準確和一致地更新,線程應該確保 經過排它鎖單獨地得到這個變量。若是一個字段被聲明成volatileJava線程內存模型確保 全部線程看到這個變量的值是一致的

一旦一個共享變量被volatile修飾以後,那麼就具有了兩層語義:

  • 保證了不一樣線程對這個變量進行操做時的可見性,即一個線程修改了某個變量的值,這新值對其餘線程來講是當即可見的。
  • 禁止進行指令重排序。

下面,咱們用兩個小結解釋一下這兩層語義。

2.2 保證可見性

當咱們在X86處理器下經過工具獲取JIT編譯器生成的彙編指令,來查看對volatile進行寫操做時,會發生下面的事情:

//Java 代碼
instance = new Singleton(); //instance 是 volatile 變量

//轉變成彙編代碼
0x01a3de1d: move $0 x 0, 0 x 1104800 (%esi); 
0x01a3de24: lock add1 $ 0 x 0, (%esp);
複製代碼

volatile變量修飾的共享變量 進行寫操做的時候 會多出兩行彙編代碼,Lock前綴的指令在多核處理器下引起了兩件事情:

  • 將當前處理器 內部緩存 的數據寫回到 系統內存
  • 這個寫回內存的操做會使在其餘處理器裏 緩存了該內存地址的數據無效,當這些處理器對這個數據進行修改操做的時候,會從新從系統內存中把數據讀處處理器緩存裏。

2.3 禁止指令重排序

volatile關鍵字禁止指令重排序有兩層意思:

  • 當程序執行到volatile變量的讀操做或者寫操做時,在其前面的操做的更改確定所有已經進行,且結果已經對後面的操做可見;在其後面的操做確定尚未進行;
  • 在進行指令優化時,不能將在對volatile變量訪問的語句放在其後面執行,也不能把volatile變量後面的語句放到其前面執行。

如下面的例子爲例:

//flag 爲 volatile 變量

x = 2; //語句1
y = 0; //語句2
flag = true;  //語句3
x = 4; //語句4
y = -1; //語句5
複製代碼

因爲flagvolatile變量,所以,能夠保證語句1/2在語句3以前執行,語句4/5在其以後執行,可是並不保證語句1/2之間或者語句4/5之間的順序。

對於1.2.3舉的有關Context問題,咱們就能夠經過將inited變量聲明爲volatile,這樣就會保證loadContext()inited賦值語句之間的順序不被改變,避免出現inited=true可是Context沒有初始化的狀況出現。

2.4 性能問題

volatile相對於synchronized的優點主要緣由是兩點:簡易和性能。若是從讀寫兩方便來考慮:

  • volatile讀操做開銷很是低,幾乎和非volatile讀操做同樣
  • volatile寫操做的開銷要比非volatile寫操做多不少,由於要保證可見性須要實現 內存界定,即使如此,volatile的總開銷仍然要比鎖獲取低。volatile操做不會像鎖同樣 形成阻塞

以上兩個條件代表,能夠被寫入volatile變量的這些有效值 獨立於任何程序的狀態,包括變量的當前狀態。大多數的編程情形都會與這兩個條件的其中之一衝突,使得volatile不能像synchronized那樣廣泛適用於實現線程安全。

所以,在可以安全使用volatile的狀況下,volatile能夠提供一些優於鎖的可伸縮特性。若是讀操做的次數要遠遠超過寫操做,與鎖相比,volatile變量一般可以減小同步的性能開銷。

2.5 應用場景

要使volatile變量提供理想的線程安全,必須同時知足如下兩個條件:

  • 對變量的 寫操做不依賴於當前值。例如x++這樣的增量操做,它其實是一個由讀取、修改、寫入操做序列組成的組合操做,必須以原子方式執行,而volatile不能提供必須的原子特性。
  • 該變量 沒有包含在其它變量的不變式中

避免濫用volatile最重要的準則就是:只有在 狀態真正獨立於程序內其它內容時 才能使用volatile,下面,咱們總結一些volatile的應用場景。

2.5.1 狀態標誌

volatile來修飾一個Boolean狀態標誌,用於指示發生了某一次的重要事件,例如完成初始化或者請求停機。

volatile boolean shutdownRequested;
 
...
 
public void shutdown() { shutdownRequested = true; }
 
public void doWork() { 
    while (!shutdownRequested) { 
        // do stuff
    }
}
複製代碼

2.5.2 一次性安全發佈

在解釋 一次性安全發佈 的含義以前,讓咱們先來看一下 單例寫法 當中著名的 雙重檢查鎖定問題

//使用 volatile 修飾。
    private volatile static Singleton sInstance;

    public static Singleton getInstance() {
        if (sInstance == null) { //(0)
            synchronized (Singleton.class) { //(1) 
                if (sInstance == null) {  //(2) 
                    sInstance = new Singleton(); //(3) 
                }
            }
        }
        return sInstance;
    }
複製代碼

假如 沒有使用volatile來修飾sInstance變量,那麼有可能會發生下面的場景:

  • 第一步:Thread1進入getInstance()方法,因爲sInstance爲空,Thread1進入synchronized代碼塊。
  • 第二步:Thread1前進到(3)處,在構造函數執行以前使sInstance對象成爲非空,並設置sInstance指向的內存空間。
  • 第三步:Thread2執行,它在入口(0)處檢查實例是否爲空,因爲sInstance對象不爲空,Thread2sInstance引用返回,此時sInstance對象並無初始化完成
  • 第四步:Thread1經過運行Singleton對象的構造函數並將引用返回給它,來完成對該對象的初始化。

經過volatile就能夠禁止第二步和第四步的重排序,也就是使得 初始化對象在設置 sInstance 指向的內存空間以前完成

2.5.3 volatile bean 模式

volatile bean模式適用於將JavaBeans做爲「榮譽結構」使用的框架。在volatile bean模式中,JavaBean被用做一組具備getter和/或setter方法的獨立屬性的容器。

volatile bean模式的基本原理是:不少框架爲易變數據的持有者提供了容器,可是放入這些容器中的對象必須是線程安全的。

volatile bean模式中,JavaBean的全部數據成員都是volatile類型的,而且 gettersetter方法必須很是普通,除了獲取或設置相應的屬性外,不能包含任何邏輯。此外,對於對象引用的數據成員,引用的對象必須是有效不可變的。

public class Person {
    private volatile String firstName;
    private volatile String lastName;
    private volatile int age;
 
    public String getFirstName() { return firstName; }
    public String getLastName() { return lastName; }
    public int getAge() { return age; }
    public void setFirstName(String firstName) { 
        this.firstName = firstName;
    }
    public void setLastName(String lastName) { 
        this.lastName = lastName;
    }
    public void setAge(int age) { 
        this.age = age;
    }
}
複製代碼

2.5.4 開銷較低的讀/寫鎖策略

若是讀操做遠遠超過寫操做,您能夠結合使用內部鎖和volatile變量來減小公共代碼路徑的開銷。下面的代碼中使用synchronized確保增量操做是原子的,並使用volatile保證當前結果的可見性。若是更新不頻繁的話,該方法可實現更好的性能,由於讀路徑的開銷僅僅涉及volatile讀操做,這一般要優於一個無競爭的鎖獲取的開銷。

public class CheesyCounter {
    private volatile int value;
    public int getValue() { return value; }
    public synchronized int increment() {
        return value++;
    }
}
複製代碼

3、參考文獻

(1) Java 併發編程:volatile 關鍵字解析
(2) Java 中 volatile 關鍵字詳解
(3) 正確使用 volatile 變量
(4) volatile 的使用

相關文章
相關標籤/搜索