【Java併發編程】從CPU緩存模型到JMM來理解volatile關鍵字

併發編程三大特性

原子性

一個操做或者屢次操做,要麼全部的操做所有都獲得執行而且不會受到任何因素的干擾而中斷,要麼全部的操做都執行,要麼都不執行html

對於基本數據類型的訪問,讀寫都是原子性的【long和double可能例外】。java

若是須要更大範圍的原子性保證,可使用synchronized關鍵字知足。git

可見性

當一個變量對共享變量進行了修改,另外的線程都能當即看到修改後的最新值面試

volatile保證共享變量可見性,除此以外,synchronizedfinal均可以 實現可見性。編程

synchronized:對一個變量執行unclock以前,必須先把此變量同步回主內存中。緩存

final:被final修飾的字段在構造器中一旦被初始化完成,而且構造器沒有把this的引用傳遞出去,其餘線程中就可以看見final字段的值。多線程

有序性

即程序執行的順序按照代碼的前後順序執行【因爲指令重排序的存在,Java 在編譯器以及運行期間對輸入代碼進行優化,代碼的執行順序未必就是編寫代碼時候的順序】,volatile經過禁止指令重排序保證有序性,除此以外,synchronized關鍵字也能夠保證有序性,由【一個變量在同一時刻只容許一條線程對其進行lock操做】這條規則得到。併發

CPU緩存模型是什麼

高速緩存爲什麼出現?

計算機在執行程序時,每條指令都是在CPU中執行的,而執行指令過程當中,勢必涉及到數據的讀取和寫入。因爲程序運行過程當中的臨時數據是存放在主存(物理內存)當中的,這時就存在一個問題,因爲CPU執行速度很快,而從內存讀取數據和向內存寫入數據的過程跟CPU執行指令的速度比起來要慢的多,所以若是任什麼時候候對數據的操做都要經過和內存的交互來進行,會大大下降指令執行的速度。app

爲了解決CPU處理速度和內存不匹配的問題,CPU Cache出現了。ide

圖源:JavaGuide

緩存一致性問題

當程序在運行過程當中,會將運算須要的數據從主存複製一份到CPU的高速緩存當中,那麼CPU進行計算時就能夠直接從它的高速緩存讀取數據和向其中寫入數據,當運算結束以後,再將高速緩存中的數據刷新到主存當中。

在單線程中運行是沒有任何問題的,可是在多線程環境下問題就會顯現。舉個簡單的例子,以下面這段代碼:

i = i + 1;

按照上面分析,主要分爲以下幾步:

  • 從主存讀取i的值,複製一份到高速緩存中。
  • CPU執行執行執行對i進行加1操做,將數據寫入高速緩存。
  • 運算結束後,將高速緩存中的數據刷新到內存中。

多線程環境下,可能出現什麼現象呢?

  • 初始時,兩個線程分別讀取i的值,存入各自所在的CPU高速緩存中。
  • 線程T1進行加1操做,將i的最新值1寫入內存。
  • 此時線程T2的高速緩存中i的值仍是0,進行加1操做,並將i的最新值1寫入內存。

最終的結果i = 1而不是i = 2,得出結論:若是一個變量在多個CPU中都存在緩存(通常在多線程編程時纔會出現),那麼就可能存在緩存不一致的問題。

如何解決緩存不一致

解決緩存不一致的問題,一般來講有以下兩種解決方案【都是在硬件層面上提供的方式】:

經過在總線加LOCK#鎖的方式

在早期的CPU當中,是經過在總線上加LOCK#鎖的形式來解決緩存不一致的問題。由於CPU和其餘部件進行通訊都是經過總線來進行的,若是對總線加LOCK#鎖的話,也就是說阻塞了其餘CPU對其餘部件訪問(如內存),從而使得只能有一個CPU能使用這個變量的內存。好比上面例子中 若是一個線程在執行 i = i +1,若是在執行這段代碼的過程當中,在總線上發出了LCOK#鎖的信號,那麼只有等待這段代碼徹底執行完畢以後,其餘CPU才能從變量i所在的內存讀取變量,而後進行相應的操做。這樣就解決了緩存不一致的問題。

但,有一個問題,在鎖住總線期間,其餘CPU沒法訪問內存,致使效率低下,因而就出現了下面的緩存一致性協議。

經過緩存一致性協議

較著名的就是Intel的MESI協議,MESI協議保S證了每一個緩存中使用的共享變量的副本是一致的。

當CPU寫數據時,若是發現操做的變量是共享變量,即在其餘CPU中也存在該變量的副本,會發出信號通知其餘CPU將該變量的緩存行置爲無效狀態,所以當其餘CPU須要讀取這個變量時,發現本身緩存中緩存該變量的緩存行是無效的【嗅探機制:每一個處理器經過嗅探在總線上傳播的數據來檢查本身的緩存的值是否過時】,那麼它就會從內存從新讀取

基於MESI一致性協議,每一個處理器須要不斷從主內存嗅探和CAS不斷循環,無效交互會致使總線帶寬達到峯值,出現總線風暴

圖源:JavaFamily 敖丙三太子

JMM內存模型是什麼

JMM【Java Memory Model】:Java內存模型,是java虛擬機規範中所定義的一種內存模型,Java內存模型是標準化的,屏蔽掉了底層不一樣計算機的區別,以實現讓Java程序在各類平臺下都能達到一致的內存訪問效果

它描述了Java程序中各類變量【線程共享變量】的訪問規則,以及在JVM中將變量存儲到內存和從內存中讀取變量這樣的底層細節。

注意,爲了得到較好的執行性能,Java內存模型並無限制執行引擎使用處理器的寄存器或者高速緩存來提高指令執行速度,也沒有限制編譯器對指令進行重排序。也就是說,在java內存模型中,也會存在緩存一致性問題和指令重排序的問題。

JMM的規定

全部的共享變量都存儲於主內存,這裏所說的變量指的是【實例變量和類變量】,不包含局部變量,由於局部變量是線程私有的,所以不存在競爭問題

每一個線程都有本身的工做內存(相似於前面的高速緩存)。線程對變量的全部操做都必須在工做內存中進行,而不能直接對主存進行操做。

每一個線程不能訪問其餘線程的工做內存。

Java對三大特性的保證

原子性

在Java中,對基本數據類型的變量的讀取和賦值操做是原子性操做,即這些操做是不可被中斷的,要麼執行,要麼不執行。

爲了更好地理解上面這句話,能夠看看下面這四個例子:

x = 10;  	//1
y = x;   	//2
x ++;    	//3
x = x + 1;  //4
  1. 只有語句1是原子性操做:直接將數值10賦值給x,也就是說線程執行這個語句的會直接將數值10寫入到工做內存中
  2. 語句2實際包含兩個操做:先去讀取x的值,再將x的值寫入工做內存,雖然兩步分別都是原子操做,可是合起來就不能算做原子操做了。
  3. 語句3和4表示:先讀取x的值,進行加1操做,寫入新的值

須要注意的點:

  • 在32位平臺下,對64位數據的讀取和賦值是須要經過兩個操做來完成的,不能保證其原子性。在目前64位JVM中,已經保證對64位數據的讀取和賦值也是原子性操做了。https://www.zhihu.com/question/38816432
  • Java內存模型只保證了基本讀取和賦值是原子性操做,若是要實現更大範圍操做的原子性,能夠經過synchronized和Lock來實現。

可見性

Java提供了volatile關鍵字來保證可見性。

當一個共享變量被volatile修飾時,它會保證修改的值會當即被更新到主存,當有其餘線程須要讀取時,它會去內存中讀取新值。

另外,經過synchronized和Lock也可以保證可見性,synchronized和Lock能保證同一時刻只有一個線程獲取鎖而後執行同步代碼,而且在釋放鎖以前會將對變量的修改刷新到主存當中。所以能夠保證可見性。

有序性

在Java內存模型中,容許編譯器和處理器對指令進行重排序,可是重排序過程不會影響到單線程程序的執行,卻會影響到多線程併發執行的正確性。

在Java裏面,能夠經過volatile關鍵字來保證有序性,另外也能夠經過synchronized和Lock來保證有序性。

Java內存模型具有一些先天的有序性,前提是兩個操做知足happens-before原則,摘自《深刻理解Java虛擬機》:

  • 程序次序規則:一個線程內,按照代碼順序,書寫在前面的操做先行發生於書寫在後面的操做【讓程序看起來像是按照代碼順序執行,虛擬機只會對不存在數據依賴性的指令進行重排序,只能保證單線程中執行結果的正確性,多線程結果正確性卻沒法保證】
  • 鎖定規則:一個unLock操做先行發生於後面對同一個鎖額lock操做
  • volatile變量規則:對一個變量的寫操做先行發生於後面對這個變量的讀操做
  • 傳遞規則:若是操做A先行發生於操做B,而操做B又先行發生於操做C,則能夠得出操做A先行發生於操做C
  • 線程啓動規則:Thread對象的start()方法先行發生於此線程的每一個一個動做
  • 線程中斷規則:對線程interrupt()方法的調用先行發生於被中斷線程的代碼檢測到中斷事件的發生
  • 線程終結規則:線程中全部的操做都先行發生於線程的終止檢測,咱們能夠經過Thread.join()方法結束、Thread.isAlive()的返回值手段檢測到線程已經終止執行
  • 對象終結規則:一個對象的初始化完成先行發生於他的finalize()方法的開始

若是兩個操做的執行次序沒法從happens-before原則推導出來,那麼它們就不能保證它們的有序性,虛擬機能夠隨意地對它們進行重排序。

volatile解決的問題

  • 保證了不一樣線程對共享變量【類的成員變量,類的靜態成員變量】進行操做是時的可見性,一個線程修改了某個變量的值,新值對其餘線程來講是當即可見的

  • 禁止指令重排序。

舉個簡單的例子,看下面這段代碼:

//線程1
boolean volatile stop = false;
while(!stop){
    doSomething();
}
//線程2
stop = true;
  1. 線程1和2各自都擁有本身的工做內存,線程1和線程2首先都會將stop變量的值拷貝一份放到本身的工做內存中,
  2. 共享變量stop經過volatile修飾,線程2將stop的值改成true將會當即寫入主內存。
  3. 線程2寫入主內存以後,致使線程1工做內存中緩存變量stop的緩存行無效。
  4. 線程1的工做內存中緩存變量stop的緩存行無效,致使線程1會再次從主存中讀取stop值。

volatile保證原子性嗎?怎麼解決?

volatile沒法保證原子性,如對一個volatile修飾的變量進行自增操做i ++,沒法保證多線程下結果的正確性。

解決方法:

  • 使用synchronized關鍵字或者Lock加鎖,保證某個代碼塊 在同一時刻只能被一個線程執行。
  • 使用JUC包下的原子類,如AtomicInteger等。【Atomic利用CAS來實現原子操做】。

volatile的實現原理

下面這段話摘自《深刻理解Java虛擬機》:

觀察加入volatile關鍵字和沒有加入volatile關鍵字時所生成的彙編代碼發現,加入volatile關鍵字時,會多出一個lock前綴指令。

lock前綴指令實際上至關於一個內存屏障(也成內存柵欄),內存屏障會提供3個功能:

  • 它確保指令重排序時不會把其後面的指令排到內存屏障以前的位置,也不會把前面的指令排到內存屏障的後面;即在執行到內存屏障這句指令時,在它前面的操做已經所有完成;
  • 它會強制將對緩存的修改操做當即寫入主存;
  • 若是是寫操做,它會致使其餘CPU中對應的緩存行無效。

volatile和synchronized的區別

volatile變量讀操做的性能消耗與普通變量幾乎沒有什麼差異,可是寫操做則會慢一些,由於它須要在本地代碼中插入許多內存屏障指令來保證處理器不發生亂序執行。不過即使如此,大多數場景下volatile的總開銷仍然要比鎖來的低

  • volatile只能用於變量,而synchronized能夠修飾方法以及代碼塊。
  • volatile能保證可見性,可是不能保證原子性。synchronized二者都能保證。若是隻是對一個共享變量進行多個線程的賦值,而沒有其餘的操做,推薦使用volatile,它更加輕量級。
  • volatile 關鍵字主要用於解決變量在多個線程之間的可見性,而 synchronized 關鍵字解決的是多個線程之間訪問資源的同步性。

volatile的使用條件

使用volatile必須具有兩個條件【保證原子】:

  • 對變量的寫操做不依賴於當前值。
  • 該變量沒有包含在具備其餘變量的不變式中。

volatile與雙重檢查鎖實現單例

用雙重檢查鎖的方式實現單例模式:

public class Singleton {
	//注意使用volatile防止指令重排序
    private volatile static Singleton instance;
	//私有化構造方法,單例模式基本操做
    private Singleton() {
    }
	//靜態獲取單例的方法
    public  static Singleton getInstance() {
       //先判斷對象是否已經實例過,沒有實例化過才進入加鎖代碼
        if (instance == null) {
            //類對象加鎖
            synchronized (Singleton.class) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

使用volatile的緣由:防止指令重排序。

instance= new Singleton();這一步,是一個實例化的過程,底層其實分爲三部執行:

  1. 爲instance分配內存空間:memory = allocate();
  2. 實例化instance。ctorInstance(memory);
  3. 將instance指向分配的內存地址。instance = memory;

因爲JVM具備指令重排序的特性,指令的執行順序可能會變成1,3,2。在多線程環境下,可能某個線程可能會獲得未初始化的實例。

舉個例子:加入線程A執行了1和2以後,線程B調用getInstance的時候,會發現instance不爲null,會直接返回這個沒有執行過指令3的實例。

參考

相關文章
相關標籤/搜索