多線程程式設計bug源頭
cpu,記憶體,I/O裝置都在不斷的疊代,不斷朝着更快的方向努力,但是,在這個快速發展的過程中,有一個核心沖突一直存在,就是這三者的速度差異。cpu > 記憶體 > i/0。根據木桶理論,程式整體的性能取決于最慢的操作-i/o裝置的讀寫,也就是說單方面提高cpu性能是無效的。 為了平衡這三者的速度差異,計算機體系機構,作業系統,編譯程式都做出了貢獻,主要展現在:
- cpu增加了緩存,以均衡與記憶體的速度差異;
- 作業系統增加了程序,線程,以分時複用cpu,進而均衡cpu與i/o裝置的速度差異;
- 編譯程式優化指令執行次序,使得緩存能夠得到更加合理地利用。
CPU緩存-可見性問題
在單核時代,所有的線程都在一顆cpu上運作,cpu緩存與記憶體的資料一緻性容易解決,因為所有線程都操作同一顆cpu的緩存,一個線程對緩存的寫,對另外一個線程來說一定是可見的。 一個線程對共享變量的修改,另外一個線程能夠立刻看到,我們稱之為可見性。
在多核時代,每顆cpu都有自己的緩存,這時cpu緩存與記憶體的資料一緻性就沒那麼容易解決了。當多個線程在不同的cpu上運作時,這些線程操作的是不同的cpu緩存。假設線程a在cpu-1上運作,線程b在CPU-2上運作,此時線程a對變量v的操作對線程b來說是不可見的。
public class Test{
private long count = 0;
private void add(){
int i = 0;
while(i++ <= 10000){
count += 1;
}
}
public static long calc(){
final Test test = new Test();
Thread t1 = new Thread(()->{
test.add();
});
Thread t2 = new Thread(()->{
test.add();
});
t1.start();
t2.start();
t1.join();
t2.join();
retun count;
}
}
複制代碼
上面的程式,如果在單核時代,那麼結果毋庸置疑是20000,但在多核時代,最後結果是10000-20000之間的随機數。 我們假設t1和t2線程同時開始執行,那麼第一次都會将count=0讀到各自的cpu緩存裡,執行完count += 1之後,各自的cpu緩存裡的值都是1,而不是我們期望的2.之後由于各自的cpu緩存裡都有count值,是以導緻最終count的計算結果小于20000.這就是緩存的可見性問題。
線程切換-原子性問題
java并發程式都是基于多線程的,這樣也會涉及到線程切換。執行count += 1的操作,至少需要三條cpu指令:
- 首先,需要把變量count 從記憶體中加載到cpu的寄存器。
- 在寄存器中執行 +1 操作。
- 将結果寫入記憶體,緩存機制導緻可能寫入的是cpu的緩存還不是記憶體。
對于上面的三個指令來說,如果線程a剛剛執行完指令1,就和線程b發生了線程切換,導緻count的值還是為0,而不是+1之後的值,就會導緻其結果不是我們希望的2.
我們把一個或者多個操作在cpu執行的過程中不被中斷的特性稱為原子性。
編譯執行-有序性問題
有序性指的是程式按照代碼的先後順序執行。 但是編譯器為了優化性能,有時候會改變程式中語句的先後順序。例如程式:“a = 6; b = 7”,編譯優化後的順序可能為:”b=7;a=6;“。 java中最經典的案例就是單例模式,利用雙重檢查建立單例對象,保證線程安全。
public class Singleton{
static Singleton bean;
static Singleton getInstance(){
if(bean == null){
synchronized(Singleton.class){
if(bean == null){
bean = new Singleton();
}
}
}
return bean;
}
}
複制代碼
假設有兩個線程a b同時調用getInstance()方法,于是同時對Singleton.class加鎖,此時jvm保證隻有一個線程能夠加鎖成功,假設是a,那麼b就會處于等待狀态,當a執行完,釋放鎖之後,B被喚醒,繼續執行,發現已經有bean對象,是以直接return了。我們以為jvm建立對象的順序是這樣的:
- 配置設定一塊記憶體m;
- 在記憶體m上初始化singleton對象;
- 然後m的位址指派給bean變量;
但是實際上優化後的執行路徑是這樣的:
- 配置設定一塊記憶體m;
- 将m的位址指派給bean變量;
- 在記憶體M上初始化singleton對象;
當線程a先執行完getinstance()方法,當執行完指令2的時候,恰好發生了線程切換,切換到了線程b,如果此時線程B,也執行了getinstance方法,那麼線程B在執行第一個判斷的時候,就會發現bean!=null,直接return,如果這個時候我們通路bean對象的時候,就會報空指針異常。
實際上,在代碼的編譯執行過程中,有三種情況可能會導緻指令重排
- 編譯優化導緻的重排序
- CPU指令并行執行導緻的重排序
- 硬體記憶體模型導緻的重排序
Java記憶體模型
從上面的分析,CPU緩存導緻了可見性問題,線程切換導緻了原子性問題,編譯執行導緻了有序性問題。那如何解決這三個問題呢?很直接的做法就是禁止使用CPU緩存,禁止線程切換,禁止指令重排。但是CPU緩存,線程切換,指令重排都是為了提高代碼運作的效率,但為了保證多線程程式設計不會出現問題,過度禁止使用這些技術,也會影響代碼執行效率,是以java記憶體模型就應運而生,對應的規範就是JSR-133。之是以叫java記憶體模型,是因為要解決的問題,都是跟記憶體有關。
Java記憶體模型解決多線程的3個問題,主要依靠3個關鍵詞和1個規則,3個關鍵詞分别是:volatile、synchronized、final,1個規則是:happens-before規則。
volatile
volatile關鍵字可以解決可見性、有序性和部分原子性問題。
volatile如何解決可見性問題
對于用volatile修飾的變量,在編譯成機器指令時,會在寫操作後面,加上一條特殊的指令:“lock addl #0x0, (%rsp)”,這條指令會将CPU對此變量的修改,立即寫入記憶體,并通知其他CPU更新緩存資料。
volatile如何解決有序性問題
禁止指令重排序又分為完全禁止指令重排序和部分禁止指令重排序。完全禁止指令重排是指volatile修飾的變量的讀寫指令不可以跟其前面的讀寫指令重排,也不可以跟後面的讀寫指令重排。
指令重排是為了優化代碼的執行效率,過于嚴格的限制指令重排,顯然會降低代碼的執行效率。是以,Java記憶體模型将volatile的語義定義為:部分禁止指令重排序。
對volatile修飾的變量執行寫操作,Java記憶體模型隻禁止位于其前面的讀寫操作與其進行重排序,位于其後面的讀寫操作可以與其進行指令重排序。
對volatile修飾的變量執行讀操作,Java記憶體模型隻禁止位于其後面的讀寫操作與其進行重排序,位于其前面的讀寫操作可以與其進行指令重排序。
為了能實作上述細化之後的指令重排禁止規則,Java記憶體模型定義了4個細粒度的記憶體屏障(Memory Barrier),也叫做記憶體栅欄(Memory Fence),它們分别是:StoreStore、StoreLoad、LoadLoad、LoadStore。
- 在每個 volatile 寫操作的前面插入一個 StoreStore 屏障。
- 在每個 volatile 寫操作的後面插入一個 StoreLoad 屏障。
- 在每個 volatile 讀操作的後面插入一個 LoadLoad 屏障。
- 在每個 volatile 讀操作的後面插入一個 LoadStore 屏障。
# other ops
[StoreStore]
x = 1 # volatile修飾x變量,volatile寫操作
[StoreLoad]
y = x # volatile讀操作
[LoadLoad]
[LoadStore]
# other ops
複制代碼
volatile如何解決部分原子性問題
兩類原子性問題,一類是64位long和double類型資料的讀寫的原子性問題,另一類是自增語句(例如count++)的原子性問題。volatile可以解決第一類原子性問題,但是無法解決第二類原子性問題。
synchronized
synchronized也可以解決可見性、有序性、原子性問題。隻不過,它的解決方式比較簡單粗暴,讓原本并發執行的代碼串行執行,并且,每次加鎖和釋放鎖,都會同步CPU緩存和記憶體中的資料。
final
final修飾變量時,初衷是告訴編譯器:這個變量生而不變,可以可勁兒優化,這就導緻在1.5版本之前優化的很努力,以至于都優化錯了,雙重檢索方法建立單例,構造函數的錯誤重排導緻線程可能看到final變量的值會變化。但是1.5以後已經對final修飾的變量的重排進行了限制。
happens-before
概念:前面一個操作的結果對後續操作是可見的。也就是說,happens-before限制了編譯器的優化行為,雖允許編譯器優化,但是要求編譯器優化後一定要遵守happens-before原則。
happens-before一共有六項規則:
程式的順序性規則
前面的操作happens-before于後續的任意操作。
例如上面的代碼 x=42happens-before于v=true;,比較符合單線程的思維,程式前面對某個變量的修改一定是對後續操作可見的。
volatile變量規則
對一個volatile變量的寫操作,happens-befores與後續對這個變量的讀操作。 這怎麼看都有禁用緩存的意思,貌似和1.5版本之前的語義沒有變化,這個時候我們需要關聯下一個規則來看這條規則。
傳遞性
a happens-before于 b,b happens-before于c,那麼a happens-before與c。
- x = 42 happens-before于 寫變量v = true; —-規則1
- 寫變量v = true happens-before于 讀變量v==true。—-規則2
- 是以x = 42 happens-before于 讀變量v == true;—-規則3
如果線程b讀到了v= true,那麼線程a設定的x=42對線程b是可見的。也就是說線程b能看到x=42。
管程中鎖的原則
對一個鎖的解鎖happens-before于對後續這個鎖的加鎖操作。 synchronized是java裡對管程的實作。
管程中的鎖在java裡是隐式實作的,例如下面的代碼,在進入同步塊之前,會自動加鎖,而在代碼塊執行完會自動釋放鎖。加鎖和解鎖的操作都是編譯器幫我們實作的。
synchronzied(this){// 此處自動加鎖
// x 是共享變量,初始值是10;
if(this.x < 12){
this.x = 12;
}
}// 此處自動解鎖
複制代碼
根據管程中鎖的原則,線程a執行完代碼塊後x的值變成12,線程B進入代碼塊時,能夠看到線程a對x的寫的操作,也就是線程b能看到x=12。
start()
主線程a啟動子線程b後,子線程B能夠看到主線程a在啟動子線程b前的操作。也就是線上程a中啟動了線程b,那麼該start()操作happens-before于線程b中任意操作。
int x = 0;
Thread B = new Thread(()->{
// 這裡能看到變量x的值,x = 12;
});
x = 12;
B.start();
複制代碼
join
主線程a等待子線程b完成(主線程a調用子線程b的join方法實作),當b完成後(主線程a中join方法傳回),主線程a能夠看到子線程b的任意操作。這裡都是對共享變量的操作。
如果線上程a中調用了線程b的join()并成功傳回,那麼線程b中任意操作happens-before于該join操作的傳回。
int x = 0;
Thread b = new Thread(()->{
x = 11;
});
x = 12;
b.start();
b.join();
// x = 11;
複制代碼
線程中斷規則
線程a調用了線程b的interrupt()方法,happens-before于線程b的代碼檢測到中斷事件的發生。
對象終結規則
一個對象初始化完成,happens-before于它的finalize()方法的調用
CPU緩存一緻性協定與可見性
CPU緩存一緻性協定
目的是為了保證不同CPU之間的緩存資料一緻,比較經典的一緻性協定就是MESI協定。
緩存行有4種不同的狀态:
- 已修改Modified (M)
- 緩存行是髒的(dirty),與主存的值不同。如果别的CPU核心要讀主存這塊資料,該緩存行必須回寫到主存,狀态變為共享(S).
- 獨占Exclusive (E)
- 緩存行隻在目前緩存中,但是幹淨的(clean)--緩存資料同于主存資料。當别的緩存讀取它時,狀态變為共享;目前寫資料時,變為已修改狀态。
- 共享Shared (S)
- 緩存行也存在于其它緩存中且是幹淨的。緩存行可以在任意時刻抛棄。
- 無效Invalid (I)
- 緩存行是無效的
我們通過一個例子來深入了解一下MESI緩存行4種不同狀态的轉移方式,例如我們有3個CPU,分别為CPU0,CPU1,CPU2,初始變量V=1。
- 第一步,CPU0讀取V,CPU0 Cache裡的V=1,狀态為E。其餘CPU Cache沒有資料
- 第二步,CPU0更新V=2,CPU0 Cache裡的V=2,狀态為M,Memory裡V = 1。其餘CPU Cache沒有資料
- 第三步,CPU1讀取V,先通過總線廣播一條讀請求到其他CPU,CPU0接收到通知後,狀态為M,需要将更新後的資料更新到住記憶體,再将狀态變為S,才能響應CPU1的讀取請求,并将CPU1 Cache裡的緩存行的變為S。
- 第四步,CPU2讀取V,同第三步
- 第五步,CPU2更新V,因為CPU2緩存行裡的狀态為S,如果需要修改V,則需要先廣播其他緩存行狀态為S的CPU Cache,将其他CPU上對應的緩存行的狀态改為I,并回複invalidate ack消息給CPU2,CPU2收到invalidate ack消息後,更新資料V=3,同步更新到主記憶體上,CPU2上的緩存行狀态則變為E。
- 第六步,CPU0讀取,發現其CPU緩存行的狀态為I,是以CPU0先廣播讀請求,CPU1不做處理,CPU2上将緩存行的狀态改為S,CPU0從記憶體中讀取,并更新緩存行狀态為S。
Store Buffer
從上述第五步中我們可以了解到,當多個CPU共享同一個資料,其中一個CPU更新資料,需要先廣播invalidate消息,其他CPU收到invalidate消息後将緩存行狀态改為I,然後傳回invalidate ack消息給這個CPU,然後這個CPU收到invalidate ack消息後,才能更新資料并同步更新到記憶體上。這個是非常耗時的一個操作,需要等CPU寫操作完成後才能執行其他指令,會影響CPU的執行效率。是以計算機科學家,在CPU和CPU緩存之間增加了Store Buffer,用于完成異步寫操作。
CPU會将寫操作的資訊存儲到Store Buffer後,CPU就可以執行其他操作指令,Store Buffer負責完成廣播invalidate消息,接收invalidate ack消息,寫入緩存和記憶體等。
讀取消息的時候也是先從Store Buffer裡擷取,如果Store Buffer裡沒有再從緩存和主存裡擷取。
Invalidate Queue
Store Buffer發送給其他CPU invalidate消息之後,需要等待其他CPU設定緩存失效并傳回invalidate ack消息,才能執行寫入緩存和記憶體的操作。但是其他CPU可能忙于執行其他指令,是以導緻store buffer寫入緩存和記憶體操作不及時,有大量的寫操作資訊存儲在Store Buffer裡。
你也許會想到可以擴大Store Buffer的存儲空間,來讓Store Buffer存儲更多的寫操作資訊。
計算機科學家則是在Cpu Cache和總線之間設計了一個Invalidate Queue,用于存儲invalidate消息和傳回invalidate ack消息,并異步執行緩存行狀态設定為I的操作。
CPU緩存一緻性協定與可見性
如果沒有Store Buffer和Invalidate Queue,那麼,緩存一緻性協定是可以保證各個CPU緩存之間的資料一緻性,也就不會存在可見性問題。但是,當引入Store Buffer和Invalidate Queue來異步執行寫操作之後,即便使用緩存一緻性協定,但各個CPU緩存之間仍然會存在短暫的資料不一緻的情況,也就是會存在短暫的可見性問題。
可見性案例:
- CPU0和CPU1均讀取了記憶體中的資料a=1到各自的緩存中,對應的緩存行狀态均标記為S(共享)。CPU0執行寫入操作a=2,為了提高寫入的速度,CPU0将寫入操作a=2存儲到Store Buffer中後就立刻傳回。假設此時Store Buffer還沒有完成寫入緩存和記憶體操作。
- CPU0讀取資料,是直接從Store Buffer裡擷取到a=2。CPU1讀取資料,發現Store Buffer裡沒有資料,就從緩存裡讀取到a = 1。此時出現緩存資料不一緻的情況。
- 假設Cpu0的Store Buffer會發送消息給Cpu1的Invalidate Queue。在Invalidate Queue還沒有将失效資訊更新到Cpu1的緩存前,Cpu1還是讀取不到最新值a=2。
将寫操作寫入Store Buffer到Invalidate Queue根據失效資訊将Cpu緩存行的狀态設定為I的這段時間内,多個Cpu之間的緩存資料會存在短暫不一緻的情況