提到併發,通常首先想到是鎖,其實對共享資源的互斥操作是一方面,在java中還有一方面是內存的可見性和順序化,瞭解JMM的同學可能會更清楚些,內存可見性和順序性同樣非常重要,在這裏簡單提一下JMM模型,首先介紹一下SMP(對稱多處理結構)如下圖:
在計算機中緩存到處可見,我們知道cpu的運算速度非常快,而從內存、甚至磁盤的讀取速度則相對慢了幾個數量級,所以緩存起到的是一個緩衝的作用,提高cpu相對的運算效率。SMP中每個cpu都有自己的緩存並且對其他cpu不可見,同時多個cpu共同享有一個主內存,主內存還每個cpu的緩存通訊通過總線IO來實現,因此當cpu緩存中對於主存數據的副本改變時,要同步的通過IO總線來刷新主存的數據,保證其他cpu看見得數據是合法的。在JMM中每個線程都有自己的工作內存,對其他線程不可見,同時有一個主內存,共所有的線程共享,java中有個volatile關鍵字,是一種輕量級的同步,主要是用來實現內存的可見性。有volatile關鍵字修飾的變量,當在線程的工作內存發生變化的時候,會同時寫回到主內存,其他線程讀取的時候,也會強制從主內存重讀,這就保證其他線程讀到的數據是正確的。
下面看一張別人畫的圖:
上面就是提到的JMM模型,實際上每個線程都有自己的工作內存且只對自己可見,而這裏的共享內存,一般指的也是java中的堆。
上面提到過,現代的處理器由於處理速度非常快,因此通常都會有一個寫內存,先把值保存到自己的緩存中,找個合適的實際在刷新到共享內存,因此這裏對內存的操作可能存在可見性的問題,舉個例子:
Processor A | Processor B |
---|---|
a = 1; //A1 x = b; //A2 |
b = 2; //B1 y = a; //B2 |
初始狀態:a = b = 0 處理器允許執行後得到結果:x = y = 0 |
a = 1;
b = 2;
x = b;
y = a;
其中a和b全爲共享變量,可以理解是成員變量。
由於是多線程併發指向,完全可能出現上面表中的操作順序。理論上即使是多線程也會得到x = 2;y = 1的結果(這裏並沒有數據爭用),但是有可能會發生下面的情況:
處理器A(也可理解爲線程A)的操作順序是A1,A2,A3,處理器B的操作順序是B1,B2,B3。
1.處理器A先把a=1寫到自己的緩衝區,注意此時共享內存的a仍爲0,於此同時處理B把b=2寫到自己的緩衝區,但此時共享內存的b還是0。
2.處理器A從共享內存讀取b的值,並賦值給x,於此同時處理器B從共享內存讀取a的值,賦值給y。此時x = y = 0;
3.處理器A和B分別把自己緩衝區的值刷新到共享內存。
從代碼層面看處理器A質性的是A1->A2,但是從內存可見性看,執行完刷新共享內存a的寫入纔算完成。因此這裏的實際質性順序是A2->A1,因此這裏的指令被重排序了。因爲大多數啊處理器都應用到了寫緩衝區,所以重排序的特性很常見。
JMM針對這種重排序的特性會生成內存屏障指令來阻止某種程度的重排序,從而保證內存的可見性,cpu爲了提高執行速度,會對我們的代碼(編譯後生成的指令)進行重排序,因此代碼的執行順序並不重要,只要我們的最終結果正確就行,因此有時候爲了程序的正確性,jvm不得不作出某些動作來保證結果的可見性,這其中包括下面幾種:
屏障類型 | 指令示例 | 說明 |
LoadLoad Barriers | Load1; LoadLoad; Load2 | 確保Load1數據的裝載,之前於Load2及所有後續裝載指令的裝載。 |
StoreStore Barriers | Store1; StoreStore; Store2 | 確保Store1數據對其他處理器可見(刷新到內存),之前於Store2及所有後續存儲指令的存儲。 |
LoadStore Barriers | Load1; LoadStore; Store2 | 確保Load1數據裝載,之前於Store2及所有後續的存儲指令刷新到內存。 |
StoreLoad Barriers | Store1; StoreLoad; Load2 | 確保Store1數據對其他處理器變得可見(指刷新到內存),之前於Load2及所有後續裝載指令的裝載。StoreLoad Barriers會使該屏障之前的所有內存訪問指令(存儲和裝載指令)完成之後,才執行該屏障之後的內存訪問指令。 |
實際上java中的voilate原語就是阻止對指令的重排序,volatile變量在寫操作之後會插入一個store屏障,在讀操作之前會插入一個load屏障。(也就是說對於volatile變量,如果有線程修改了它的值,該值會馬上對其他線程可見,並且一個線程讀取該值的時候,其他線程緩存中的值會被同步刷新到最新值)。一個類的final字段會在初始化後插入一個store屏障,來確保final字段在構造函數初始化完成並可被使用時可見。因此volatile使用需要謹慎,用的不好會造成性能的浪費(頻繁的通過總線刷新各個處理器的值,可能造成數據風暴)。
爲了簡化這種可見性,java中有個happens-before規則,它描述了同一個線程或者不同線程的某個操作的結果,對另外一個操作可見性的原則。happens-before實際上就是定義在各種action上的一種偏序關係。所謂的action包括變量的讀寫,監視器的加鎖和解鎖,線程的啓動和拼接等等。
happens-before規則如下:
程序順序規則:一個線程中的每個操作,happens- before 於該線程中的任意後續操作。
監視器鎖規則:對一個監視器鎖的解鎖,happens- before 於隨後對這個監視器鎖的加鎖。
volatile變量規則:對一個volatile域的寫,happens- before 於任意後續對這個volatile域的讀。
傳遞性:如果A happens- before B,且B happens- before C,那麼A happens- before C。
Thread.start()的調用會happens-before於啓動線程裏面的動作。
Thread中的所有動作都happens-before於其他線程從Thread.join中成功返回。
解釋一下第一條,在單線程中,一個線程的操作對該操作後續的所有操作都可見(注意happens-before描述的是可見性規則,並不是順序),該規則也保證了單線程中程序的正確執行。再來看下第二條,並不是講解鎖操作後再加鎖,而是講一個線程釋放某個鎖的時候,在釋放鎖或它之前的操作都對另外一個鎖定該鎖的線程(也可以是一個線程)可見。
上面只是列了幾個規則,實際可能不止這些,如果不滿足上面的規則,則需要考慮使用同步等方法,來強制滿足。
另外上面的幾個規則是基於java的內存模型給出的,在java語言層面也給出了很多happens-before原則,比如ReentrantLock的unlock與lock操作,又如AbstractQueuedSynchronizer的release與acquire,setState與getState等等。
happens-before簡化了併發編程的難度,瞭解它的含義多少對我們有些好處。附上一張java的內存模型圖: