併發編程—Volatile關鍵字

鎖提供了兩種主要特性:互斥(mutual exclusion) 和可見性(visibility)。互斥即一次只容許一個線程持有某個特定的鎖,所以能夠保證一次就只有一個線程在訪問共享數據。可見性要複雜一些,它必須確保釋放鎖以前對共享數據作出的更改對於隨後得到該鎖的另外一個線程是可見的。java

volatile 變量能夠被看做是一種 「輕量級的 synchronized」,與 synchronized 塊相比,volatile 變量所需的編碼較少,而且運行時開銷也較少,可是它所能實現的功能也僅是 synchronized 的一部分。編程

volatile變量

一個共享變量被volatile修飾以後,則具備了兩層語義:緩存

  1. 保證了該變量在多個線程的可見性。
  2. 禁止了指令重排序

保證內存可見性

前面講過Java內存模型,能夠知道:對一個共享變量進行操做時,各個線程會將共享變量從主內存中拷貝到工做內存,而後CPU會基於工做內存中的數據進行處理。線程在工做內存進行操做完成以後什麼時候會將結果寫回主內存中?這個時機對普通變量是沒有規定的。因此才致使了內存可見性問題。安全

volatile是如何解決可見性問題的?
若是代碼中的共享變量被volatile修飾,在生成彙編代碼時會在volatile修飾的共享變量進行寫操做的時候會多出Lock前綴的指令。在多核處理器的狀況下,這個Lock指令主要有3個功能:併發

  1. volatile的變量被修改後會當即寫入到主存中
  2. 這個寫回主存的操做會告知其它線程中該變量對應的緩存行失效,因此其它線程若是要操做這個變量,會從新去主存中讀取最新的值。
  3. 禁止特定類型的重排序。

因此,被volatile修飾的變量可以保證每一個線程可以獲取該變量的最新值,從而避免出現數據髒讀的現象app

禁止指令重排序

對於volatile的共享變量,編譯器在生成字節碼時,會在指令序列中插入內存屏障(Lock指令)來禁止特定類型的重排序。這是在happens-before的原則下作進一步的約束性能

對於編譯器來講,發現一個最優佈置來最小化插入屏障的總數幾乎是不可能的,爲此,JMM採起了保守策略:編碼

  • 在每一個volatile寫操做的前面插入一個StoreStore屏障;
  • 在每一個volatile寫操做的後面插入一個StoreLoad屏障;
  • 在每一個volatile讀操做的後面插入一個LoadLoad屏障;
  • 在每一個volatile讀操做的後面插入一個LoadStore屏障。

須要注意的是:volatile寫是在前面和後面分別插入內存屏障,而volatile讀操做是在後面插入兩個內存屏障。atom

  • StoreStore屏障:禁止上面的普通寫和下面的volatile寫重排序;
  • StoreLoad屏障:防止上面的volatile寫與下面可能有的volatile讀/寫重排序
  • LoadLoad屏障:禁止下面全部的普通讀操做和上面的volatile讀重排序
  • LoadStore屏障:禁止下面全部的普通寫操做和上面的volatile讀重排序

以下兩張圖來自《Java併發編程的藝術》一書:spa

  • volatile變量的寫操做
    volatile寫
  • volatile變量的讀操做
    volatile讀

根據上面的說明也能得出:雖然volatile關鍵字能禁止指令重排序,可是volatile也只能在必定程度上保證有序性。在volatile以前和以後的指令集不會亂序越過volatile變量執行,但volatile以前和以後的指令集在沒有關聯性的前提下,仍然會執行指令重排。

使用 volatile 變量的條件

volatile並不能代替synchronized,要使 volatile 變量提供理想的線程安全,必須同時知足下面兩個條件:

  1. 對變量的寫操做不依賴於當前值
    例如i++的操做就沒法經過volatile保證結果準確性的,由於i++包含了讀取-修改-寫入三個步驟,並非一個原子操做,因此 volatile 變量不能用做線程的安全計數器。
    例以下面的這段代碼,能夠說明volatile變量的操做不具備原子性

    package com.lzumetal.multithread.volatiletest;
    
    public class Counter {
    
      private volatile static int count = 0;
    
      private static void inc() {
          //延遲1毫秒,使得結果明顯
          sleep(1);
          count++;
      }
    
      public static void main(String[] args) {
    
          //同時啓動1000個線程,去進行i++計算,看看實際結果
          for (int i = 0; i < 1000; i++) {
              new Thread(Counter::inc).start();
          }
    
          sleep(1000);   
          System.out.println("運行結果:Counter.count=" + Counter.count); //結果極可能<1000
      }
    
      private static void sleep(long millis) {
          try {
              Thread.sleep(millis);
          } catch (InterruptedException e) {
              e.printStackTrace();
          }
      }
    
    }

    運行計數器的結果很大可能性是<1000的。對於計數器的這種功能,通常是須要使用JUC中atomic包下的類,利用CAS的機制去作。

  2. 該變量沒有包含在具備其餘變量的不變式中
    這句話有點拗口,看代碼比較直觀。

    public class NumberRange {
          private volatile int lower = 0;
           private volatile int upper = 10;
    
          public int getLower() { return lower; }
          public int getUpper() { return upper; }
    
          public void setLower(int value) { 
              if (value > upper) 
                  throw new IllegalArgumentException(...);
              lower = value;
          }
    
          public void setUpper(int value) { 
              if (value < lower) 
                  throw new IllegalArgumentException(...);
              upper = value;
          }
      }

上述代碼中,上下界初始化分別爲0和10,假設線程A和B在某一時刻同時執行了setLower(8)setUpper(5),且都經過了不變式的檢查,設置了一個無效範圍(8, 5),因此在這種場景下,須要使setLower()setUpper()操做原子化 —— 而將字段定義爲 volatile 類型是沒法實現這一目的的。

使用 volatile 舉例

雖然使用 volatile 變量要比使用相應的鎖簡單得多,並且性能也更好,可是通常不會太多的使用它,主要是它比使用鎖更加容易出錯。
想要安全地使用volatile,必須牢記一條原則:只有在狀態真正獨立於程序內其餘內容時才能使用 volatile

修飾狀態標誌量

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

在這個示例使用 synchronized 塊編寫循環要比使用 volatile 狀態標誌編寫麻煩不少。因爲 volatile 簡化了編碼,而且狀態標誌並不依賴於程序內任何其餘狀態,所以此處很是適合使用 volatile。

double-check 單例模式

public class Singleton {

    private volatile static Singleton instance;

    public static Singleton getInstance() {
        if (instance == null) {                 //1
            syschronized(Singleton.class) {     //2
                if (instance == null) {         //3
                    instance = new Singleton(); //4
                }
            }
        }
        return instance;
    } 
}

爲何要用volatile修飾纔是最安全的呢?可能有人會以爲是這樣:線程1執行完第4步,釋放鎖。線程2得到鎖後執行到第4步,因爲可見性的緣由,發現instance仍是null,從而初始化了兩次。
可是不會存在這種狀況,由於synchronized能保證線程1在釋放鎖以前會講對變量的修改刷新到主存當中,線程2拿到的值是最新的。

實際存在的問題是無序性。
第4步這個new操做是無序的,它可能會被編譯成:
a.先分配內存,讓instance指向這塊內存
b.在內存中建立對象

synchronized雖然是互斥的,但不表明一次就把整個過程執行完,它在中間是可能釋放時間片的,時間片不是鎖。也就是說可能在a執行完後,時間片被釋放,線程2執行到1,此時它讀到的instance是否是null呢?基於可見性,多是null,也可能不是null。 有意思的是,在這個例子中,若是讀到的是null,反而沒問題了,接下來會等待鎖,而後再次判斷時不爲null,最後返回單例。 若是讀到的不是null,按代碼邏輯直接return instance,但這個instance還沒執行構造參數,因此使用的時候就會出現問題。

相關文章
相關標籤/搜索