大家好,我是小黑,一個在互聯網苟且偷生的農民工。
上一期給大家分享了關於Java中線程相關的一些基礎知識。在關於線程終止的例子中,第一個方法講到要想終止一個線程,可以使用標志位的方法,我們再來回顧一下代碼。
class MyRunnable implements Runnable {
// volatile關鍵字,保證主線程修改后當前線程能夠看到被改后的值(可見性)
private volatile boolean exit = false;
@Override
public void run() {
while (!exit) { // 循環判斷標識位,是否需要退出
System.out.println("這是我自定義的線程");
}
}
public void setExit(boolean exit) {
this.exit = exit;
}
}
public class ThreadDemo {
public static void main(String[] args) {
MyRunnable runnable = new MyRunnable();
new Thread(runnable).start();
runnable.setExit(true); //修改標志位,退出線程
}
}
在這個代碼中,標志位exit字段在聲明時使用了volatile關機字修飾,目的是為了保證在另外一個線程修改后當前線程能夠感知到變化,那么這個關鍵字到底做了些什么呢?這一期我們來詳細聊一聊。
在開始講volatile關鍵字之前,需要先和大家聊一聊計算機的內存模型這個玩意兒。
計算機的內存模型
所謂內存模型,英文描述是Memory Model,這玩意兒是一個比較底層的東西,它是與計算機硬件有關的一個概念。
我們都知道,計算機在執行程序的時候,最終是一條條的指令在CPU中執行,在執行過程中往往會存在數據的傳遞。而數據是存放在主內存上的,對,就是你那個內存條。
在剛開始CPU的的執行速度還不夠快的時候並沒有什么問題,但隨着CPU技術的不斷發展,CPU計算的速度越來越快,但是呢,從主內存上讀取和寫入數據的速度有點拉胯,跟不上呀,這就導致CPU每次操作主內存都要花費很多的等待時間。
技術總是要往前發展的,不能因為內存讀寫慢CPU就不發展了吧,也不能讓主內存的讀寫速度成為瓶頸。
想必這里大家也應該想到了,就是在CPU和主內存之間加一個高速緩存,將需要的數據在這個高速緩存上復制一份,而這個高速緩存的特點就是讀寫很快,然后定期的將緩存中的數據和主內存同步。
到這里問題就解決了嗎? too young,too simple啊,這種結構在但線程的情況下是沒有問題的,隨着計算機能力不斷提升,開始支持多線程了,並且CPU牛逼到支持多核,到現在的4核8核16核,在這種情況下是會存在一些問題的,我們來分析一下。
單核多線程情況:多個線程同時訪問一個共享數據,CPU將數據從主內存加載到高速緩存中,多個線程會訪問高速緩存中的同一個地址,這樣即使在線程切換時,緩存數據也不會失效,因為在單核CPU同一時間只能有一個線程在執行,所以也不會有數據訪問的沖突。
多核多線程情況:每個CPU內核都會復制一份數據到自己的高速緩存,這樣的話在不同內核上的兩個線程是並行的,這樣就會導致兩個內核各自緩存的數據發生不一致。這個問題就叫做緩存一致性問題。
除了上面說到的緩存一致性問題,計算機為了使CPU的算力能夠被充分利用,會對輸入的指令進行亂序處理,叫做處理器優化。很多的編程語言為了提高執行效率,也會對代碼的執行順序重新排序,比如咱們Java虛擬機的即時編譯器(JIT)也會做,這個動作叫做指令重排。
int a = 1;
int b = 2;
int c = a + b;
int d = a - b;
比如我們寫的這段代碼,第三行和第四行的執行順序就有可能發生改變,這在單線程中並沒有問題,但是在多線程情況下,會產生和我們預期不一樣的結果。
其實上面提出的緩存一致性問題,處理器優化,指令重排就對應我們並發編程中的可見性問題,原子性問題,有序性問題。帶着這些問題,我們再來看看,在Java中是如何來解決的。
因為存在這些問題,那么肯定要有一種機制來解決。這種解決的機制就是內存模型。
內存模型定義了一個規范,用來保證共享內存的可見性,有序性,原子性。內存模型是怎么解決的呢?主要采取兩種方式:限制處理器優化和內存屏障。這里我們先不深究底層原理。
JMM
從前面我們知道內存模型是一個規范,用來解決並發情況下的一些問題。不同的編程語言對於這個規范都有對應的實現。那么JMM(Java Memory Model)就是Java語言對於這一規范的具體實現。
那么JMM具體是如何解決這寫問題的呢?我們先來看下面這張圖。
內存可見性問題
我們一個一個問題來看,首先,如何解決可見性問題?
如上圖所示,在JMM中,一個線程對於一個數據的操作,分成了6個步驟。
分別是:read,load,use,assign,write,store.
如果說這個變量在聲明時,沒有使用volatile關鍵字,那么兩個線程是各自復制一份到工作內存,線程B將flag賦值為true,線程A是不可見的。
那么要想線程A可見,就需要在聲明flag這個變量時,加上volatile關鍵字。那么加了關鍵字之后JMM是怎么做的呢?這里要分讀和寫兩個情況。
- 線程在讀取一個volatile變量時,JMM會把工作內存中的該變量置為無效,重新從主內存中讀取;
- 線程在寫一個volatile變量時,會立刻將工作內存中的值刷新到主內存中。
也就是說,對於volatile關鍵字修飾的變量,在read,load,use操作必須是一起執行的;assign,write,store操作時一起執行。
通過這樣的方式,就能夠解決內存可見性的問題。
指令重排
而指令重排這個問題,對於編譯器來說,只要該對象聲明為volatile的,那么就不會對它進行指令重排的優化。
而volatile禁止指令重排的這種規則是符合一個叫做happens-before的規則。
happens-before除了在volatile變量規則外,還有一些其他規則。
程序次序規則:在一個線程內一段代碼的執行結果是有序的。就是還會指令重排,但是隨便它怎么排,結果是按照我們代碼的順序生成的不會變。
管程鎖定規則:就是無論是在單線程環境還是多線程環境,對於同一個鎖來說,一個線程對這個鎖解鎖之后,另一個線程獲取了這個鎖都能看到前一個線程的操作結果!(管程是一種通用的同步原語,synchronized就是管程的實現)
volatile變量規則:就是如果一個線程先去寫一個volatile變量,然后一個線程去讀這個變量,那么這個寫操作的結果一定對讀的這個線程可見。
線程啟動規則:在主線程A執行過程中,啟動子線程B,那么線程A在啟動子線程B之前對共享變量的修改結果對線程B可見。
線程終止規則:在主線程A執行過程中,子線程B終止,那么線程B在終止之前對共享變量的修改結果在線程A中可見。也稱線程join()規則。
線程中斷規則:對線程interrupt()方法的調用先行發生於被中斷線程代碼檢測到中斷事件的發生,可以通過Thread.interrupted()檢測到是否發生中斷。
傳遞性規則:happens-before原則具有傳遞性,即hb(A, B) , hb(B, C),那么hb(A, C)。
對象終結規則:一個對象的初始化的完成,也就是構造函數執行的結束一定 happens-before它的finalize()方法。
競態條件
到這里,大家是不是感覺問題已經都解決了?emmm,我們來看下面這個場景:
假設上圖中的線程A和線程B執行在兩個CPU核心上,是並行執行的,它們一起讀取到i的值等於0,然后各自加1,然后一起往主內存寫。如果線程A和線程B是有先后順序執行的,i的值最后應該是等於2才對,但是並行情況下是有可能同時操作的,最后寫回到主內存中的值只被增加了一次。
這就好比你的銀行卡收到了兩筆100塊的轉賬,但是賬戶上只多了100塊。
對於這種問題通過volatile是無法解決的,volatile不會保證該變量操作的原子性。那我們應該怎么解決呢,就需要使用synchronized對這個操作加鎖,保證同一時刻只能有一個線程進行操作。
總結
因為CPU和內存之間存在着高速緩存,在多線程並發情況下,可能會存在緩存一致性問題;而CPU對於輸入的指令會做一些處理器優化,一些高級語言的編譯器也會做指令重排。因為這些問題,會導致我們在並發情況下存在內存可見性問題,有序性問題,而JMM就是Java中為了解決這些問題而出現的。通過volatile關鍵字可以保證內存可見性,並且會禁止指令重排。但是volatile只能保證操作的有序性,無法保證操作的原子性,所以,為了安全,我們對於共享變量的並發處理要進行加鎖。
好的,今天的內容就到這里,我們下期再見。
關注公眾號【小黑說Java】獲取更多干貨。