通過前面一章我們了解了synchronized是一個重量級的鎖驱闷,雖然JVM對它做了很多優(yōu)化,而下面介紹的volatile則是輕量級的synchronized篮灼。如果一個變量使用volatile锄俄,則它比使用synchronized的成本更加低浇坐,因為它不會引起線程上下文的切換和調(diào)度。Java語言規(guī)范對volatile的定義如下:
Java編程語言允許線程訪問共享變量仅偎,為了確保共享變量能被準確和一致地更新跨蟹,線程應該確保通過排他鎖單獨獲得這個變量。
上面比較繞口橘沥,通俗點講就是說一個變量如果用volatile修飾了窗轩,則Java可以確保所有線程看到這個變量的值是一致的,如果某個線程對volatile修飾的共享變量進行更新座咆,那么其他線程可以立馬看到這個更新痢艺,這就是所謂的線程可見性仓洼。
volatile雖然看起來比較簡單,使用起來無非就是在一個變量前面加上volatile即可堤舒,但是要用好并不容易(LZ承認我至今仍然使用不好色建,在使用時仍然是模棱兩可)。
內(nèi)存模型相關(guān)概念
理解volatile其實還是有點兒難度的植酥,它與Java的內(nèi)存模型有關(guān)镀岛,所以在理解volatile之前我們需要先了解有關(guān)Java內(nèi)存模型的概念,這里只做初步的介紹友驮,后續(xù)LZ會詳細介紹Java內(nèi)存模型漂羊。
操作系統(tǒng)語義
計算機在運行程序時,每條指令都是在CPU中執(zhí)行的卸留,在執(zhí)行過程中勢必會涉及到數(shù)據(jù)的讀寫走越。我們知道程序運行的數(shù)據(jù)是存儲在主存中,這時就會有一個問題耻瑟,讀寫主存中的數(shù)據(jù)沒有CPU中執(zhí)行指令的速度快茂契,如果任何的交互都需要與主存打交道則會大大影響效率,所以就有了CPU高速緩存医增。CPU高速緩存為某個CPU獨有虾攻,只與在該CPU運行的線程有關(guān)。
有了CPU高速緩存雖然解決了效率問題框都,但是它會帶來一個新的問題:數(shù)據(jù)一致性搬素。在程序運行中,會將運行所需要的數(shù)據(jù)復制一份到CPU高速緩存中魏保,在進行運算時CPU不再也主存打交道熬尺,而是直接從高速緩存中讀寫數(shù)據(jù),只有當運行結(jié)束后才會將數(shù)據(jù)刷新到主存中谓罗。舉一個簡單的例子:
i++
當線程運行這段代碼時粱哼,首先會從主存中讀取i( i = 1),然后復制一份到CPU高速緩存中檩咱,然后CPU執(zhí)行 + 1 (2)的操作揭措,然后將數(shù)據(jù)(2)寫入到告訴緩存中,最后刷新到主存中刻蚯。其實這樣做在單線程中是沒有問題的蜂筹,有問題的是在多線程中。如下:
假如有兩個線程A芦倒、B都執(zhí)行這個操作(i++)艺挪,按照我們正常的邏輯思維主存中的i值應該=3,但事實是這樣么?分析如下:
兩個線程從主存中讀取i的值(1)到各自的高速緩存中麻裳,然后線程A執(zhí)行+1操作并將結(jié)果寫入高速緩存中口蝠,最后寫入主存中,此時主存i==2,線程B做同樣的操作津坑,主存中的i仍然=2妙蔗。所以最終結(jié)果為2并不是3。這種現(xiàn)象就是緩存一致性問題疆瑰。
解決緩存一致性方案有兩種:
- 通過在總線加LOCK#鎖的方式
- 通過緩存一致性協(xié)議
但是方案1存在一個問題眉反,它是采用一種獨占的方式來實現(xiàn)的,即總線加LOCK#鎖的話穆役,只能有一個CPU能夠運行寸五,其他CPU都得阻塞,效率較為低下耿币。
第二種方案梳杏,緩存一致性協(xié)議(MESI協(xié)議)它確保每個緩存中使用的共享變量的副本是一致的。其核心思想如下:當某個CPU在寫數(shù)據(jù)時淹接,如果發(fā)現(xiàn)操作的變量是共享變量十性,則會通知其他CPU告知該變量的緩存行是無效的,因此其他CPU在讀取該變量時塑悼,發(fā)現(xiàn)其無效會重新從主存中加載數(shù)據(jù)劲适。
Java內(nèi)存模型
上面從操作系統(tǒng)層次闡述了如何保證數(shù)據(jù)一致性,下面我們來看一下Java內(nèi)存模型厢蒜,稍微研究一下Java內(nèi)存模型為我們提供了哪些保證以及在Java中提供了哪些方法和機制來讓我們在進行多線程編程時能夠保證程序執(zhí)行的正確性减响。
在并發(fā)編程中我們一般都會遇到這三個基本概念:原子性、可見性郭怪、有序性。我們稍微看下volatile
原子性
原子性:即一個操作或者多個操作 要么全部執(zhí)行并且執(zhí)行的過程不會被任何因素打斷刊橘,要么就都不執(zhí)行鄙才。
原子性就像數(shù)據(jù)庫里面的事務一樣,他們是一個團隊促绵,同生共死攒庵。其實理解原子性非常簡單,我們看下面一個簡單的例子即可:
i = 0; ---1
j = i ; ---2
i++; ---3
i = j + 1; ---4
上面四個操作败晴,有哪個幾個是原子操作浓冒,那幾個不是?如果不是很理解尖坤,可能會認為都是原子性操作稳懒,其實只有1才是原子操作,其余均不是慢味。
1—在Java中场梆,對基本數(shù)據(jù)類型的變量和賦值操作都是原子性操作墅冷;
2—包含了兩個操作:讀取i,將i值賦值給j
3—包含了三個操作:讀取i值或油、i + 1 寞忿、將+1結(jié)果賦值給i;
4—同三一樣
在單線程環(huán)境下我們可以認為整個步驟都是原子性操作顶岸,但是在多線程環(huán)境下則不同腔彰,Java只保證了基本數(shù)據(jù)類型的變量和賦值操作才是原子性的(注:在32位的JDK環(huán)境下,對64位數(shù)據(jù)的讀取不是原子性操作*辖佣,如long霹抛、double)。要想在多線程環(huán)境下保證原子性凌简,則可以通過鎖上炎、synchronized來確保。
volatile是無法保證復合操作的原子性
可見性
可見性是指當多個線程訪問同一個變量時雏搂,一個線程修改了這個變量的值藕施,其他線程能夠立即看得到修改的值。
在上面已經(jīng)分析了凸郑,在多線程環(huán)境下裳食,一個線程對共享變量的操作對其他線程是不可見的。
Java提供了volatile來保證可見性芙沥。
當一個變量被volatile修飾后诲祸,表示著線程本地內(nèi)存無效,當一個線程修改共享變量后他會立即被更新到主內(nèi)存中而昨,當其他線程讀取共享變量時救氯,它會直接從主內(nèi)存中讀取。
當然歌憨,synchronize和鎖都可以保證可見性着憨。
有序性
有序性:即程序執(zhí)行的順序按照代碼的先后順序執(zhí)行。
在Java內(nèi)存模型中务嫡,為了效率是允許編譯器和處理器對指令進行重排序甲抖,當然重排序它不會影響單線程的運行結(jié)果,但是對多線程會有影響心铃。
Java提供volatile來保證一定的有序性准谚。最著名的例子就是單例模式里面的DCL(雙重檢查鎖)。這里LZ就不再闡述了去扣。
剖析volatile原理
JMM比較龐大柱衔,不是上面一點點就能夠闡述的。上面簡單地介紹都是為了volatile做鋪墊的。
volatile可以保證線程可見性且提供了一定的有序性秀存,但是無法保證原子性捶码。在JVM底層volatile是采用“內(nèi)存屏障”來實現(xiàn)的。
上面那段話或链,有兩層語義
- 保證可見性惫恼、不保證原子性
- 禁止指令重排序
第一層語義就不做介紹了,下面重點介紹指令重排序澳盐。
在執(zhí)行程序時為了提高性能祈纯,編譯器和處理器通常會對指令做重排序:
- 編譯器重排序。編譯器在不改變單線程程序語義的前提下叼耙,可以重新安排語句的執(zhí)行順序腕窥;
- 處理器重排序。如果不存在數(shù)據(jù)依賴性筛婉,處理器可以改變語句對應機器指令的執(zhí)行順序簇爆;
指令重排序?qū)尉€程沒有什么影響,他不會影響程序的運行結(jié)果爽撒,但是會影響多線程的正確性入蛆。既然指令重排序會影響到多線程執(zhí)行的正確性,那么我們就需要禁止重排序硕勿。那么JVM是如何禁止重排序的呢哨毁?這個問題稍后回答,我們先看另一個原則happens-before源武,happen-before原則保證了程序的“有序性”扼褪,它規(guī)定如果兩個操作的執(zhí)行順序無法從happens-before原則中推到出來,那么他們就不能保證有序性粱栖,可以隨意進行重排序话浇。其定義如下:
同一個線程中的,前面的操作 happen-before 后續(xù)的操作闹究。(即單線程內(nèi)按代碼順序執(zhí)行幔崖。但是,在不影響在單線程環(huán)境執(zhí)行結(jié)果的前提下跋核,編譯器和處理器可以進行重排序,這是合法的叛买。換句話說砂代,這一是規(guī)則無法保證編譯重排和指令重排)。
監(jiān)視器上的解鎖操作 happen-before 其后續(xù)的加鎖操作率挣。(Synchronized 規(guī)則)
對volatile變量的寫操作 happen-before 后續(xù)的讀操作刻伊。(volatile 規(guī)則)
線程的start() 方法 happen-before 該線程所有的后續(xù)操作。(線程啟動規(guī)則)
線程所有的操作 happen-before 其他線程在該線程上調(diào)用 join 返回成功后的操作。
如果 a happen-before b捶箱,b happen-before c智什,則a happen-before c(傳遞性)。
我們著重看第三點volatile規(guī)則:對volatile變量的寫操作 happen-before 后續(xù)的讀操作丁屎。為了實現(xiàn)volatile內(nèi)存語義荠锭,JMM會重排序,其規(guī)則如下:
對happen-before原則有了稍微的了解晨川,我們再來回答這個問題JVM是如何禁止重排序的证九?
觀察加入volatile關(guān)鍵字和沒有加入volatile關(guān)鍵字時所生成的匯編代碼發(fā)現(xiàn),加入volatile關(guān)鍵字時共虑,會多出一個lock前綴指令愧怜。lock前綴指令其實就相當于一個內(nèi)存屏障。內(nèi)存屏障是一組處理指令妈拌,用來實現(xiàn)對內(nèi)存操作的順序限制拥坛。volatile的底層就是通過內(nèi)存屏障來實現(xiàn)的。下圖是完成上述規(guī)則所需要的內(nèi)存屏障:
volatile暫且下分析到這里尘分,JMM體系較為龐大猜惋,不是三言兩語能夠說清楚的,后面會結(jié)合JMM再一次對volatile深入分析音诫。
總結(jié)
volatile看起來簡單惨奕,但是要想理解它還是比較難的,這里只是對其進行基本的了解竭钝。volatile相對于synchronized稍微輕量些梨撞,在某些場合它可以替代synchronized,但是又不能完全取代synchronized香罐,只有在某些場合才能夠使用volatile卧波。使用它必須滿足如下兩個條件:
- 對變量的寫操作不依賴當前值;
- 該變量沒有包含在具有其他變量的不變式中庇茫。
volatile經(jīng)常用于兩個兩個場景:狀態(tài)標記兩港粱、double check
參考資料
- 周志明:《深入理解Java虛擬機》
- 方騰飛:《Java并發(fā)編程的藝術(shù)》
- Java并發(fā)編程:volatile關(guān)鍵字解析
- Java 并發(fā)編程:volatile的使用及其原理