秒懂Java併發之volatile關鍵字引發的思考

版權申明】非商業目的註明出處可自由轉載
博文地址:https://blog.csdn.net/ShuSheng0007/article/details/85165773
出自:shusheng007

概述

寫這個關鍵字的文章已經很多了,我是在重溫《深入理解Java虛擬機》時覺的應該記錄一下。首先我們應該明確在Java中 volatile 關鍵字涉及到的領域,換句話說,大家在談論什麼問題的時候會涉及到這個關鍵字。相信首次接觸這個關鍵字的同學內心是崩潰的,首先可以肯定的是大部分中國人不認識這個單詞,那麼連望文生義的機會都沒有了,我就是其中一員。其實當我們在談論Java併發編程的時候,就會想到這哥們兒,在此希望可以以通俗的語言描述一下 volatile 身邊的故事。

併發(Concurrency )

計算機發展到今天的地步,併發早已司空見慣,但是很多程序員對其的理解是極其淺薄和有限的,我曾經不止一次的問過面試者,我們爲什麼需要併發編程,多線程可以帶給我們的好處,很少有令人滿意的答覆。
我們需要併發的場景非常多:
第一種情況:盡力壓榨CPU的運算能力
第二種情況:對於GUI程序併發可以提高UI的響應度,提升用戶體驗
第三種情況:服務端同時對多個客戶端提供服務
等等
併發系統其實非常複雜,只是離普通程序員比較遠,目前關於併發系統的理論研究主要有兩大定律比較受學術界認可:阿姆達爾定律 與古斯塔夫森定律。

硬件物理架構

要講volatile 關鍵字就不得不提Java內存模型,由於Java的內存模型與計算機的物理架構有很好的類比性,所以我們有必要先簡單介紹一下計算機的硬件架構。爲了可以充分利用CPU的性能了,需要讓計算機併發執行多個任務,但是這些任務不可能只通過CPU就能完成,期間必然伴隨着讀寫主內存的步驟。然而CPU的運算速度與內存的讀寫速度是差着幾個數量級的,所以對於CPU來說,讀寫內存簡直是慢的不能忍,所以我們就在CPU與主內存之間加入了高速緩存,現代的PC機一般都是三級緩存。
在這裏插入圖片描述
圖片來源

引入高速緩存是緩解了CPU與內存速度差問題,但是也引入了緩存一致性問題(Cache Coherence)。因爲每個CPU都有自己的高速緩存,而他們又共用同一主內存,所以當各個CPU的高速緩存數據不一致的時候,同步回主內存時使用誰的值呢?所以這是需要一套每個CPU在訪問緩存時候都遵循的一套協議的。

以上的緩存問題對應到Java虛擬機上就是Java內存模型要解決的問題。

此外,爲了執行效率,CPU還會對代碼亂序執行(Out-Of-Order Execution),這對應到Java虛擬機上爲指令重排(Instruction Reorder)

Java內存模型

Java內存模型(Java Memory Model)是Java虛擬機所定義的一套抽象規範,用來屏蔽不同硬件和操作系統的內存訪問差異,讓java程序在各種平臺下都能達到一致的內存訪問效果,其主要目的是定義程序中各個變量的訪問規則,即在虛擬機中將變量存儲到內存和從內存中取出變量這樣的底層細節。

在java虛擬機中,每個線程都一份自己的工作內存,存儲着被該線程使用到的變量的主內存副本份拷貝。所有的線程只能操作自己工作內存中的數據,然後同步到主內存中,不能直接讀寫主內存,。不同線程之間也不能直接訪問對方的工作內存,線程之間變量的數據共享必須通過主內存,線程、主內存與工作內存三者交互關係見下圖。
在這裏插入圖片描述

正是存在這樣的內存模型,所以多線程併發就會存在原子性,可見性,有序性的問題。

Volatile

關鍵字Volatile是Java虛擬機提供的最輕量級的同步機制。Volatile 修飾的變量在併發編程中有兩個作用

  1. 可見性
    使用volatile修飾的變量V,其值被線程A改變後,線程B立刻可以讀取到最新值 ,普通變量是無法保證這一點的。那麼volatile 是如何保證這一點的呢?
    因爲線程A每次修改在工作內存中的變量V的值後都必須立刻將其同步到主內存中,而線程B每次從自己的工作內存中讀取變量V的值的時候必須先從主內存刷新最新的值。
  2. 禁止指令重排優化
    Java虛擬機爲了提升執行效率,會執行編譯期指令重排優化。編譯器只會保證在該方法的執行過程中所有依賴賦值結果的地方都能獲得正確的結果,而不能保證變量的賦值操作順序與程序代碼中的執行順序一致。這在單線程中是毫無問題的,但是多線程中就會發生混亂。如果從線程A內看自己的內部執行順序永遠是順序的,但是從線程A看線程B的執行順序就永遠是亂序的。

使用場景

利用可見性特性的例子:

  • 運算結果並不依賴變量的當前值,或者可以保證只有單一線程修改變量的值,可以有多個線程讀取變量的值。
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);
    }
  • 變量不需要與其他的狀態變量共同參與不變約束

例如下面的場景,可以多個線程訪問調用shutdown(),均可以保證doWork立即停止工作。如果變量shutdownRequested不使用volatile 關鍵字修飾,則在併發訪問shutdown()時不能保證doWork立即停止工作。

volatile boolean shutdownRequested;
//volatile boolean shutdownRequested2;
...
public void shutdown() { 
      shutdownRequested = true;
   }

public void doWork() { 
   while (!shutdownRequested) { 
       // do stuff
   }
}

利用阻止指令重排的例子

一個比較突出的例子就是在JDK1.5版本之後使用volatile 實現Java版的雙鎖檢查單例,由於指令重排優化的存在,在JDK 1.5之前在Java中是無法實現線程安全的雙鎖檢查單例模式的。

class Singleton{
    private volatile static Singleton instance = null;     
    private Singleton() {         
    }     
    public static Singleton getInstance() {
        if(instance==null) {
            synchronized (Singleton.class) {
                if(instance==null)
                    instance = new Singleton();
            }
        }
        return instance;
    }
 }

其實,在JDK1.5之後,即使可以使用雙鎖檢查來實現單例我們也不應該優先使用,我們應該優先使用枚舉來實現單例模式,見如下代碼。

public enum Singleton{
    INSTANCE;
}

在此我想對爲什麼要使用雙鎖檢查做一個說明,因爲我以前完全不明白這樣做的目的,稍微有點跑題,不感興趣的就可以略過這一段了。

非線程安全單例創建模式

1.代碼清單1

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

爲什麼說它是非線程安全的呢?假設現在有兩個線程1,2按照下面的步驟來構建此類的實例。

1線程 1 調用 getInstance() 方法,此時 instance 在 //1 處爲 null。
2線程 1 進入 if 代碼塊,但在執行 //2 處的代碼行時被線程2搶佔 。
3線程 2 調用 getInstance() 方法,此時instance 在 //1 處仍然爲 null。
4線程 2 進入 if 代碼塊並創建一個新的 Singleton 對象並在 //2 處將變量 instance 分配給這個新對象。
5線程 2 在 //3 處返回 Singleton 對象引用。
6線程 1 在它停止的地方再次啓動,並執行 //2 代碼行,這導致創建另一個 Singleton 對象。
7線程 1 在 //3 處返回另一個Singleton 對象引用。
這樣兩個線程各創建了一個實例對象,破壞了單例模式

2.代碼清單2

public static synchronized Singleton getInstance(){
  if (instance == null)                   //1
      instance = new Singleton();  //2
  return instance;                        //3
}

爲了解決併發問題,我們需要對getInstance()方法進行同步,這樣是安全的,但是有一個弊端。我們每次調用這個方法都會有同步開銷,而事實上我們只有在第一次調用這個方法時候才需要同步,以後就都不需要同步了,需要同步的代碼僅僅是執行實例化的那句instance = new Singleton();,因而代碼可優化爲下面的清單3。

3.代碼清單3

public static Singleton getInstance(){
  if (instance == null)  {
    synchronized(Singleton.class) {
      instance = new Singleton();
    }
  }
  return instance;
}

但是清單3就會遇到和清單1一樣的問題了,多線程併發時可能產生多個實例,於是雙鎖檢查就出現了

4.代碼清單4

public static Singleton getInstance(){
  if (instance == null)  {
    synchronized(Singleton.class) {
    	  if (instance == null) {
    	         instance = new Singleton();
    	  }  
      }
  }
  return instance;
}

雙鎖檢查在理論上是完美的,但是就是因爲指令重排優化會導致其失敗,至於爲什麼失敗需要分析彙編代碼,有興趣的同學可以查找相關資料自己實踐。

總結

教授Volatile關鍵字的文章已經很多了,我不想再繼續重複下去。其實理解volatile 應該首先立即Java的內存模型,一旦理解了Java的內存模型,volatile就是小菜一碟了。正所謂厚積才能薄發,我們應該注重原理性的知識積澱。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章