首先說咱們若是要使用 volatile 了,那確定是在多線程併發的環境下。咱們常說的併發場景下有三個重要特性:原子性、可見性、有序性。只有在知足了這三個特性,才能保證併發程序正確執行,不然就會出現各類各樣的問題。java
原子性,上篇文章說到的 CAS 和 Atomic* 類,能夠保證簡單操做的原子性,對於一些負責的操做,可使用synchronized 或各類鎖來實現。編程
可見性,指當多個線程訪問同一個變量時,一個線程修改了這個變量的值,其餘線程可以當即看獲得修改的值。緩存
有序性,程序執行的順序按照代碼的前後順序執行,禁止進行指令重排序。看似理所固然的事情,其實並非這樣,指令重排序是JVM爲了優化指令,提升程序運行效率,在不影響單線程程序執行結果的前提下,儘量地提升並行度。可是在多線程環境下,有些代碼的順序改變,有可能引起邏輯上的不正確。多線程
而 volatile 作實現了兩個特性,可見性和有序性。因此說在多線程環境中,須要保證這兩個特性的功能,可使用 volatile 關鍵字。併發
說到可見性,就要了解一下計算機的處理器和主存了。由於多線程,無論有多少個線程,最後仍是要在計算機處理器中進行的,如今的計算機基本都是多核的,甚至有的機器是多處理器的。咱們看一下多處理器的結構圖:app
這是兩個處理器,四核的 CPU。一個處理器對應一個物理插槽,多處理器間經過QPI總線相連。一個處理器包含多個核,一個處理器間的多核共享L3 Cache。一個核包含寄存器、L1 Cache、L2 Cache。優化
在程序執行的過程當中,必定要涉及到數據的讀和寫。而咱們都知道,雖然內存的訪問速度已經很快了,可是比起CPU執行指令的速度來,仍是差的很遠的,所以,在內核中,增長了L一、L二、L3 三級緩存,這樣一來,當程序運行的時候,先將所須要的數據從主存複製一份到所在覈的緩存中,運算完成後,再寫入主存中。下圖是 CPU 訪問數據的示意圖,由寄存器到高速緩存再到主存甚至硬盤的速度是愈來愈慢的。
spa
瞭解了 CPU 結構以後,咱們來看一下程序執行的具體過程,拿一個簡單的自增操做舉例。線程
i=i+1;
執行這條語句的時候,在某個核上運行的某線程將 i 的值拷貝一個副本到此核所在的緩存中,當運算執行完成後,再回寫到主存中去。若是是多線程環境下,每個線程都會在所運行的核上的高速緩存區有一個對應的工做內存,也就是每個線程都有本身的私有工做緩存區,用來存放運算須要的副本數據。那麼,咱們再來看這個 i+1 的問題,假設 i 的初始值爲0,有兩個線程同時執行這條語句,每一個線程執行都須要三個步驟:code
一、從主存讀取 i 值到線程工做內存,也就是對應的內核高速緩存區;
二、計算 i+1 的值;
三、將結果值寫回主存中;
建設兩個線程各執行 10,000 次後,咱們預期的值應該是 20,000 纔對,惋惜很遺憾,i 的值老是小於 20,000 的 。致使這個問題的其中一個緣由就是緩存一致性問題,對於這個例子來講,一旦某個線程的緩存副本作了修改,其餘線程的緩存副本應該當即失效纔對。
而使用了 volatile 關鍵字後,會有以下效果:
一、每次對變量的修改,都會引發處理器緩存(工做內存)寫回到主存;
二、一個工做內存回寫到主存會致使其餘線程的處理器緩存(工做內存)無效。
由於 volatile 保證內存可見性,實際上是用到了 CPU 保證緩存一致性的 MESI 協議。MESI 協議內容較多,這裏就不作說明,請各位同窗本身去查詢一下吧。總之用了 volatile 關鍵字,當某線程對 volatile 變量的修改會當即回寫到主存中,而且致使其餘線程的緩存行失效,強制其餘線程再使用變量時,須要從主存中讀取。
那麼咱們把上面的 i 變量用 volatile 修飾後,再次執行,每一個線程執行 10,000 次。很遺憾,仍是小於 20,000 的。這是爲何呢?
volatile 利用 CPU 的 MESI 協議確實保證了可見性。可是,注意了,volatile 並無保證操做的原子性,由於這個自增操做是分三步的,假設線程 1 從主存中讀取了 i 值,假設是 10 ,而且此時發生了阻塞,可是尚未對i進行修改,此時線程 2 也從主存中讀取了 i 值,這時這兩個線程讀取的 i 值是同樣的,都是 10 ,而後線程 2 對 i 進行了加 1 操做,並當即寫回主存中。此時,根據 MESI 協議,線程 1 的工做內存對應的緩存行會被置爲無效狀態,沒錯。可是,請注意,線程 1 早已經將 i 值從主存中拷貝過了,如今只要執行加 1 操做和寫回主存的操做了。而這兩個線程都是在 10 的基礎上加 1 ,而後又寫回主存中,因此最後主存的值只是 11 ,而不是預期的 12 。
因此說,使用 volatile 能夠保證內存可見性,但沒法保證原子性,若是還須要原子性,能夠參考,以前的這篇文章。
Java 內存模型具有一些先天的「有序性」,即不須要經過任何手段就可以獲得保證的有序性,這個一般也稱爲 happens-before 原則。若是兩個操做的執行次序沒法從 happens-before 原則推導出來,那麼它們就不能保證它們的有序性,虛擬機能夠隨意地對它們進行重排序。
以下是 happens-before 的8條原則,摘自 《深刻理解Java虛擬機》。
這裏主要說一下 volatile 關鍵字的規則,舉一個著名的單例模式中的雙重檢查的例子:
class Singleton{ private volatile static Singleton instance = null; private Singleton() { } public static Singleton getInstance() { if(instance==null) { // step 1 synchronized (Singleton.class) { if(instance==null) // step 2 instance = new Singleton(); //step 3 } } return instance; } }
若是 instance 不用 volatile 修飾,可能產生什麼結果呢,假設有兩個線程在調用 getInstance() 方法,線程 1 執行步驟 step1 ,發現 instance 爲 null ,而後同步鎖住 Singleton 類,接着再次判斷 instance 是否爲 null ,發現仍然是 null,而後執行 step 3 ,開始實例化 Singleton 。而在實例化的過程當中,線程 2 走到 step 1,有可能發現 instance 不爲空,可是此時 instance 有可能尚未徹底初始化。
什麼意思呢,對象在初始化的時候分三個步驟,用下面的僞代碼表示:
memory = allocate(); //1. 分配對象的內存空間 ctorInstance(memory); //2. 初始化對象 instance = memory; //3. 設置 instance 指向對象的內存空間
由於步驟 2 和步驟 3 須要依賴步驟 1,而步驟 2 和 步驟 3 並無依賴關係,因此這兩條語句有可能會發生指令重排,也就是或有可能步驟 3 在步驟 2 的以前執行。在這種狀況下,步驟 3 執行了,可是步驟 2 尚未執行,也就是說 instance 實例尚未初始化完畢,正好,在此刻,線程 2 判斷 instance 不爲 null,因此就直接返回了 instance 實例,可是,這個時候 instance 實際上是一個不徹底的對象,因此,在使用的時候就會出現問題。
而使用 volatile 關鍵字,也就是使用了 「對一個 volatile修飾的變量的寫,happens-before於任意後續對該變量的讀」 這一原則,對應到上面的初始化過程,步驟2 和 3 都是對 instance 的寫,因此必定發生於後面對 instance 的讀,也就是不會出現返回不徹底初始化的 instance 這種可能。
JVM 底層是經過一個叫作「內存屏障」的東西來完成。內存屏障,也叫作內存柵欄,是一組處理器指令,用於實現對內存操做的順序限制。
經過 volatile 關鍵字,咱們瞭解了一下併發編程中的可見性和有序性,固然只是簡單的瞭解。更深刻的瞭解,還得靠各位同窗本身去鑽研。若是感受仍是有點做用的話,歡迎點個推薦。