這篇文章要梳理的概念會比較多,我在第一次接觸這些概念時理解了很久,反反覆覆,用了很長時間才弄明白個大概。我想在這篇文章把概念說清楚,每個概念本身都有很多外延的內容,外延的內容我會不斷學習後逐漸到文中。
JMM(Java Memory Model:java內存模型)
這是一個java技術規範,java的強大之一是它的多線程支持。java多線程執行期間是如何使用內存的呢?JMM就是這樣一個規範,它描述了多線程執行時的內存使用方式。
硬體層面,CPU的指令執行速度遠快於內存存取,為了緩解這種速度差,在CPU和內存之間,往往會有很多級速度比內存快的寄存器,存儲一些CPU頻繁訪問的變量,這比直接去頻繁訪問內存要快很多。
Java在多線程方面,也會充分利用以上物理特性,為每個線程構建私有的工作內存。JMM規範要求,線程對變量的讀寫,需要從主存拷貝變量副本到工作內存中,以提高執行性能,再在合適的時機同步回主存,以使其他線程可見。
盜用一張網絡圖片
上面提到,線程會在工作內存操作線程副本,合適時機同步回內存。這裡就會有一個同步問題,這是外延內容,以後補充。
這裡我們只需要了解:線程有自己的工作內存,對變量的讀寫是需要從主存拷貝進工作內存的。
如果是一個共享變量,多線程都在並發訪問,這時候會有一個問題:就是線程T1時序上先改了共享變量a,把值變成了a=1,原始值比如a=100
線程T2時序上在此後讀取a,T2讀取到的a值不一定等於1。注意這裡是不一定,不是必然。就是說T1雖然改了共享變量的值,其它線程比如T2不一定能看到這個值。因為T2讀取也是工作在自己的工作內存上,T1雖然做了修改,而這個也是發生在T1的工作內存上,只有T1把變量a的值同步回主存,T2再從主存拷貝a的值到自己的工作內存,才能保證T2能看到T1的修改。
這就是JMM規範下的一個變量在多線程情況下的可見性問題,下面要講解的Volatile關鍵字是一個解決可見性問題的一個關鍵字。
Volatile
volatile 是java語言當中的一個關鍵字。它用於聲明變量,聲明變量後,起到的作用是:保障變量在多線程環境下的可見性。
什麼意思呢?保障變量在多線程環境下的可見性。
在講述JMM時,我們最後說了一個問題,JMM規範下,一個線程修改了某個共享變量的值,另外一個線程即使在它之後執行,也不一定能看到這個變量被修改過後的值。我用以下代碼說明:
共享變量 boolean ready = false; //初始值false線程T1先執行以下代碼while(!ready){// wait}// ready, do something線程T2後執行以下代碼ready = true;//do other thing
看完以上代碼,我們可能以為線程T1啟動後,一直檢測ready的值,如果是false,就一直while循環,直到線程T2啟動,把ready的值改為true,線程T1才能退出循環。
其實真正執行以上代碼,你會發現,T1始終不能發現ready的值變更,會一直在while中死循環。這就是可見性問題,T1沒有發現這個變量變更了。
即使把以上共享變量換成數組,如
共享變量 boolean [] ready = new boolean[3]; //初始值false線程T1先執行以下代碼while(!ready[1]){// wait}// ready, do something線程T2後執行以下代碼ready[1] = true;//do other thing
它的效果也是一樣的。這時候,你給ready變量,加上volatile後,再試試
volatile boolean ready = false; 或
volatile boolean [] ready = new boolean[3];
你會發現,T2一執行,T1馬上跳出了循環。這就是volatile的作用,它保障了被修飾變量的可見性,T2一旦做過變更,T1就能看到。
那麼究竟volatile底層又是如何保障的呢,這屬於外延內容,我後續補充。
重排序
先說說什麼是重排序,看下面這段代碼。
int a = 1;char b = 『b』;byte c = 2;
這三行代碼很簡單,就是簡單的聲明了3個變量。你可能會認為,這三行代碼的執行順序就如寫代碼的順序一樣,按a b c的順序進行,實際上是不一定的。java語言是允許重排序的,也就是不按照abc的順序執行,可能是cba,bac都有可能。前提是不改變執行結果,數據之間不存在依賴。
如果
int a = 1;int b = a;
像上面這種b對a有數據依賴的,是不會被重排序的,執行順序必然是a在b之前。
重排序發生在以下幾個階段:
編譯器優化的重排序。編譯器在不改變單線程程序語義放入前提下,可以重新安排語句的執行順序。指令級並行的重排序。現代處理器採用了指令級並行技術來將多條指令重疊執行。如果不存在數據依賴性,處理器可以改變語句對應機器指令的執行順序。內存系統的重排序。由於處理器使用緩存和讀寫緩衝區,這使得加載和存儲操作看上去可能是在亂序執行。為什麼會重排序,上面這幾個階段裡大概提到了,提高並行效率,編譯器認為重排序後執行更優,指令級重排序並行效率更好等等。在單個線程的視角看來,重排序不存在任何問題,重排序不改變執行結果,如下例:
int a = 1;int b = 2;int c = a + b;
c因為對a和b有數據依賴,因此c不會被重排序,但是a 、b的執行可能被重排序。但在單個線程下,這種重排序不存在任何問題,不論先初始化a、還是先初始化b,c的值都是3。但是在多線程情況下,重排序就可能帶來問題,如下例:
線程T1執行:
a = 1; //共享變量 int ab = true; //共享變量 boolean b
線程T2執行:
if (b){int c = a;System.out.println(c);}
假如某個並發時刻,T2檢測到b變量已經是true值了,並且變量都對T2可見。c 賦值得到的一定是 1 嗎?
答案是不一定,原因就是重排序問題的存在,在多線程環境下,會造成問題。T1線程如果 a 和 b變量的賦值被重排序了,b先於 a發生,這個重排序對T1線程本身不存在什麼問題,之前我們已經討論過。但是在T2這個線程看來,這個執行就有問題了,因為在T2看來,如果沒有重排序,b值變為true之前,a已經被賦值1了。而重排序使得這個推斷變得不確定,b有可能先執行,a還沒來的及執行,此時線程T2已經看到b變更,然後去獲取a的值,自然不等於1。
happen-before原則
因為有以上重排序問題,會導致並發執行的問題,那麼有沒有方法解決呢?
happen-before原則,就是用來解決這個問題的一個原則說明,它告訴我們的開發者,你放心的寫並發代碼,但是你要遵循我告訴你的原則,你就能避免以上重排序導致的問題。
這個原則是什麼呢?
1、程序次序規則:在一個單獨的線程中,按照程序代碼的執行流順序,(時間上)先執行的操作happen—before(時間上)後執行的操作。2、管理鎖定規則:一個unlock操作happen—before後面(時間上的先後順序,下同)對同一個鎖的lock操作。3、volatile變量規則:對一個volatile變量的寫操作happen—before後面對該變量的讀操作。4、線程啟動規則:Thread對象的start()方法happen—before此線程的每一個動作。5、線程終止規則:線程的所有操作都happen—before對此線程的終止檢測,可以通過Thread.join()方法結束、Thread.isAlive()的返回值等手段檢測到線程已經終止執行。6、線程中斷規則:對線程interrupt()方法的調用happen—before發生於被中斷線程的代碼檢測到中斷時事件的發生。7、對象終結規則:一個對象的初始化完成(構造函數執行結束)happen—before它的finalize()方法的開始。8、傳遞性:如果操作A happen—before操作B,操作B happen—before操作C,那麼可以得出A happen—before操作C。
我們一條條看,
第一條
單線程情況下,happen-before原則告訴你,你放心的認為代碼寫在前面的就是先執行就ok了,不會有任何問題的。(當然實際並非如此,因為有指令重排序嘛,有的雖然寫在前面,但是未必先執行,但是單線程情況下,這並不會給實際造成任何問題,寫在前面的代碼造成的影響一定對後面的代碼可見)
happen-before 有一種記法,hb(a,b) ,表示a比b先行發生。
單線程情況下,寫在前面的代碼都比寫在後面的代碼先行發生
int a = 1;
int b= 2;
hb(a,b)
第二條
看如下代碼
線程T1:
a = 1;lock.unlock();b = ture;
線程T2:
if (b){lock.lock();int c = a;System.out.println(c);lock.unlock();}
此前在講重排序的時候說過這個問題,說c有可能讀取到的a值不一定是1。因為重排序,導致a的賦值語句可能沒執行。但是現在在
b賦值之前加了解鎖操作,線程T2在讀取到b值變更後,做了加鎖操作。這時候就是第二條原則生效的時候,它告訴我們,假如在時間上T1的lock.unlock()先執行了,T2 的lock.lock()後執行,那麼T1 unlock之前的所有變更,a = 1這個變更,T2是一定可見的,即T2 在 lock後,c拿到的值一定是 a 被賦值1的值。
因為 a = 1 和 lock.unlock() 有 hb 關係 hb(a=1 , lock.unlock() )
第二條原則 hb(unlock, lock), 而 hb(lock , c = a ),因此c在被賦值a時,a=1一定會先行發生。
第三條
volatile關鍵字修飾的變量的寫先行發生與對這個變量的讀,如下
線程T1:
a = 1;vv = 33;//volatileb = ture;
線程T2:
if (b){int ff = vv;// vv is volatileint c = a;System.out.println(c);}
與前面的鎖原則一樣,這次是volatile變量 寫happen-before讀。線程T2在讀取a變量前先讀取以下vv這個volatile變量。因為第三條原則的存在,只要T1在時間上執行了vv寫操作,T2在執行vv讀操作後,a=1的賦值一定可以被T2讀到。
第四條、第五條、第六條
線程T1 start方法,先行發生於T1即將做的所有操作。
如,在某個線程中啟動thread1
a = 1;thread1.start();
如上,a =1 先行發生 thread.start(),而第四條規則又說,start方法先行發生該線程即將做的所有操作,那麼a =1 ,也必將先行發生於 thread1 的任何操作。所以thread1啟動後,是一定可以讀取到a的值為1的
五、六條類似,線程終止前的所有操作先行發生於終止方法的返回。這就保障了一個線程結束後,其他線程一定能感知到線程所做的所有變更。
第七條
對象被垃圾回收調用finalize時,對象的構造一定已經先行發生。
第八條
傳遞性
至此,概念基本梳理完了。後續增加的外延有,分析java並發集合在實現時,為了符合happen-before的一些處理。