并發(fā)本來就是個有意思的問題,尤其是現(xiàn)在又流行這么一句話:“高帥富加機器,窮矮搓搞優(yōu)化”。從這句話可以看到,無論是高帥富還是窮矮搓都需要深入理解并發(fā)編程,高帥富加多了機器,需要協(xié)調(diào)多臺機器或者多個CPU對共享資源的訪問,因此需要了解并發(fā),窮矮搓搞優(yōu)化需要編寫各種多線程的代碼來壓榨 CPU的計算資源,讓它在同一時刻做更多的事情,這個更需要了解并發(fā)。
在我前一篇關(guān)于并發(fā)的文章http://my.oschina.NET/chihz/blog/54731中提到過管程,管程的特色是在編程語言中 對并發(fā)的細(xì)節(jié)進行封裝,使程序員可以直接在語言中就得到并發(fā)的支持,而不必自己去處理一些像是控制信號量之類容易出錯且繁瑣的細(xì)節(jié)問題。一些語言是通過在 編譯時解開語法糖的方式去實現(xiàn)管程,但java在編譯后生成的字節(jié)碼層面上對并發(fā)仍然是一層封裝,比如syncrhonized塊在編譯之后只是對應(yīng)了兩 條指令:monitorenter和monitorexit。更多的并發(fā)細(xì)節(jié)是在JVM運行時去處理的,而不是編譯。這篇文章主要是針對JVM處理并發(fā)的 一些細(xì)節(jié)的探討。
JAVA內(nèi)存模型
對于我們平時開發(fā)的業(yè)務(wù)應(yīng)用來說,內(nèi)存應(yīng)該是訪問速度最快的存儲設(shè)備,對于頻繁訪問的數(shù)據(jù),我們總是習(xí)慣把它們放到內(nèi)存緩存中,有句話不是說么,緩 存就像是清涼油,哪里有問題就抹一抹。但是CPU的運算速度比起內(nèi)存的訪問速度還要快幾個量級,為了平衡這個差距,于是就專門為CPU引入了高速緩存,頻 繁使用的數(shù)據(jù)放到高速緩存當(dāng)中,CPU在使用這些數(shù)據(jù)進行運算的時候就不必再去訪問內(nèi)存。但是在多CPU時代卻有一個問題,每個CPU都擁有自己的高速緩 存,內(nèi)存又是所有CPU共享的公共資源,于是內(nèi)存此時就成了一個臨界區(qū),如果控制不好各個CPU對內(nèi)存的并發(fā)訪問,那么就會產(chǎn)生錯誤,出現(xiàn)數(shù)據(jù)不一致的情 況。為了避免這種情況,需要采取緩存一致性協(xié)議來保證,這類協(xié)議有很多,各個硬件平臺和操作系統(tǒng)的實現(xiàn)不盡相同。
JVM需要實現(xiàn)跨平臺的支持,它需要有一套自己的同步協(xié)議來屏蔽掉各種底層硬件和操作系統(tǒng)的不同,因此就引入了Java內(nèi)存模型。對于Java來說 開發(fā)者并不需要關(guān)心任何硬件細(xì)節(jié),因此沒有多核CPU和高速緩存的概念,多核CPU和高速緩存在JVM中對應(yīng)的是Java語言內(nèi)置的線程和每個線程所擁有 的獨立內(nèi)存空間,Java內(nèi)存模型所規(guī)范的也就是數(shù)據(jù)在線程自己的獨立內(nèi)存空間和JVM共享內(nèi)存之間同步的問題。下面這兩張圖說明了硬件平臺和JVM內(nèi)存 模型的相似和差異之處。
Java內(nèi)存模型規(guī)定,對于多個線程共享的變量,存儲在主內(nèi)存當(dāng)中,每個線程都有自己獨立的工作內(nèi)存,線程只能訪問自己的工作內(nèi)存,不可以訪問其它 線程的工作內(nèi)存。工作內(nèi)存中保存了主內(nèi)存共享變量的副本,線程要操作這些共享變量,只能通過操作工作內(nèi)存中的副本來實現(xiàn),操作完畢之后再同步回到主內(nèi)存當(dāng) 中。如何保證多個線程操作主內(nèi)存的數(shù)據(jù)完整性是一個難題,Java內(nèi)存模型也規(guī)定了工作內(nèi)存與主內(nèi)存之間交互的協(xié)議,首先是定義了8種原子操作:
(1) lock:將主內(nèi)存中的變量鎖定,為一個線程所獨占
(2) unclock:將lock加的鎖定解除,此時其它的線程可以有機會訪問此變量
(3) read:將主內(nèi)存中的變量值讀到工作內(nèi)存當(dāng)中
(4) load:將read讀取的值保存到工作內(nèi)存中的變量副本中。
(5) use:將值傳遞給線程的代碼執(zhí)行引擎
(6) assign:將執(zhí)行引擎處理返回的值重新賦值給變量副本
(7) store:將變量副本的值存儲到主內(nèi)存中。
(8) write:將store存儲的值寫入到主內(nèi)存的共享變量當(dāng)中。
我們可以看到,要保證數(shù)據(jù)的同步,lock和unlock定義了一個線程訪問一次共享內(nèi)存的界限,有l(wèi)ock操作也必須有unlock操作,另外一 些操作也必須要成對出現(xiàn)才可以,像是read和load、store和write需要成對出現(xiàn),如果單一指令出現(xiàn),那么就會造成數(shù)據(jù)不一致的問題。 Java內(nèi)存模型也針對這些操作指定了必須滿足的規(guī)則:
(1) read和load、store和write必須要成對出現(xiàn),不允許單一的操作,否則會造成從主內(nèi)存讀取的值,工作內(nèi)存不接受或者工作內(nèi)存發(fā)起的寫入操作而主內(nèi)存無法接受的現(xiàn)象。
(2) 在線程中使用了assign操作改變了變量副本,那么就必須把這個副本通過store-write同步回主內(nèi)存中。如果線程中沒有發(fā)生assign操作,那么也不允許使用store-write同步到主內(nèi)存。
(3) 在對一個變量實行use和store操作之前,必須實行過load和assign操作。
(4) 變量在同一時刻只允許一個線程對其進行l(wèi)ock,有多少次lock操作,就必須有多少次unlock操作。在lock操作之后會清空此變量在工作內(nèi)存中原 先的副本,需要再次從主內(nèi)存read-load新的值。在執(zhí)行unlock操作前,需要把改變的副本同步回主存。
內(nèi)存可見性
通過上面Java內(nèi)存模型的概述,我們會注意到這么一個問題,每個線程在獲取鎖之后會在自己的工作內(nèi)存來操作共享變量,在工作內(nèi)存中的副本回寫到主 內(nèi)存,并且其它線程從主內(nèi)存將變量同步回自己的工作內(nèi)存之前,共享變量的改變對其它線程是不可見的。那么很多時候我們需要一個線程對共享變量的改動,其它 線程也需要立即得知這個改動該怎么辦呢?比如以下的情景,有一個全局的狀態(tài)變量open:
boolean open= true;這個變量用來描述對一個資源的打開關(guān)閉狀態(tài),true表示打開,false表示關(guān)閉,假設(shè)有一個線程A,在執(zhí)行一些操作后將open修改為false:
//線程A
resource.close(); open = false;線程B隨時關(guān)注open的狀態(tài),當(dāng)open為true的時候通過訪問資源來進行一些操作:
//線程B
while(open) { doSomethingWithResource(resource); }當(dāng)A把資源關(guān)閉的時候,open變量對線程B不可見,如果此時open變量的改動尚未同步到線程B的工作內(nèi)存中,那么線程B就會用一個已經(jīng)關(guān)閉了的資源去做一些操作,因此產(chǎn)生錯誤。
所以對于上面的情景,要求一個線程對open的改變,其他的線程能夠立即可見,Java為此提供了volatile關(guān)鍵字,在聲明open變量的時 候加入volatile關(guān)鍵字就可以保證open的內(nèi)存可見性,即open的改變對所有的線程都是立即可見的。volatile保證可見性的原理是在每次 訪問變量時都會進行一次刷新,因此每次訪問都是主內(nèi)存中最新的版本。
指令重排序
很多介紹JVM并發(fā)的書或文章都會談到JVM為了優(yōu)化性能,采用了指令重排序,但是對于什么是指令重排序,為什么重排序會優(yōu)化性能卻很少有提及,其實道理很簡單,假設(shè)有這么兩個共享變量a和b:
PRivate int a; private int b;在線程A中有兩條語句對這兩個共享變量進行賦值操作:
a = 1; b = 2;假設(shè)當(dāng)線程A對a進行復(fù)制操作的時候發(fā)現(xiàn)這個變量在主內(nèi)存已經(jīng)被其它的線程加了訪問鎖,那么此時線程A怎么辦?等待釋放鎖?不,等待太浪費時間了,它會去嘗試進行b的賦值操作,b這時候沒被人占用,因此就會先為b賦值,再去為a賦值,那么執(zhí)行的順序就變成了:
b = 2; a = 1;對于在同一個線程內(nèi),這樣的改變是不會對邏輯產(chǎn)生影響的,但是在多線程的情況下指令重排序會帶來問題,看下面這個情景:
在線程A中:
context = loadContext(); inited = true;在線程B中:
while(!inited ){ sleep } doSomethingwithconfig(context);假設(shè)A中發(fā)生了重排序:
inited = true; context = loadContext();那么B中很可能就會拿到一個尚未初始化或尚未初始化完成的context,從而引發(fā)程序錯誤。
想到有一條古老的原則很適合用在這個地方,那就是先要保證程序的正確然后再去優(yōu)化性能。此處由于重排序產(chǎn)生的錯誤顯然要比重排序帶來的性能優(yōu)化要重 要的多。要解決重排序問題還是通過volatile關(guān)鍵字,volatile關(guān)鍵字能確保變量在線程中的操作不會被重排序而是按照代碼中規(guī)定的順序進行訪 問。
最后的總結(jié)
這篇文章簡單的介紹了Java內(nèi)存模型、內(nèi)存可見性和指令重排序。不過最后看來其實主要是在解釋volatile這個關(guān)鍵字,個人感覺 volatile關(guān)鍵字是Java當(dāng)中最令人困惑和最難理解的關(guān)鍵字。相對于synchronized塊的代碼鎖,volatile應(yīng)該是提供了一個輕量 級的針對共享變量的鎖,當(dāng)我們在多個線程間使用共享變量進行通信的時候需要考慮將共享變量用volatile來修飾,對于需要使用volatile的各種 情景,看到IBM Developer Works上有一篇文章總結(jié)的很不錯,推薦一下: http://www.ibm.com/developerworks/cn/java/j-jtp06197.html
補充說明:64位long和double
在JVM規(guī)范中Java內(nèi)存模型要求lock、unlock、read、load、assign、use、store、write這8個操作必須是 原子的,但是對于64位的long和double來說,如果沒有被volatile修飾符修飾,那么可以不是原子的,注意是可以,即虛擬機在實現(xiàn)的時候可 以選擇是否是原子操作。目前幾乎所有的商用虛擬機都將此實現(xiàn)為原子操作,因此不必每次用到它們都去加volatile修飾。
新聞熱點
疑難解答