1 基本概念
在上一篇文章Java內(nèi)存區(qū)域 中纤壁,我們講了JVM為了更好的管理內(nèi)存,將Java進(jìn)程的內(nèi)存劃分成了幾個(gè)功能榕订、用途不同的區(qū)域析显,所以很多人會(huì)認(rèn)為劃分后的內(nèi)存布局就是Java內(nèi)存模型。嚴(yán)格來說肚豺,這個(gè)說法是不準(zhǔn)確的溃斋,不過大家在交流的時(shí)候直接說成內(nèi)存模型好像也無傷大雅。那究極什么才是嚴(yán)格意義上的Java內(nèi)存模型呢吸申?
Java內(nèi)存模型(Java Memory Model梗劫,簡(jiǎn)稱JMM)本身是一個(gè)抽象的概念,不是真實(shí)存在的截碴,它描述的是一組規(guī)則梳侨,Java內(nèi)存訪問內(nèi)存都需要遵循這組規(guī)則。在深入了解之前日丹,我們先來看看JMM里有幾個(gè)基本概念:
- 工作內(nèi)存走哺。由于Java程序是單進(jìn)程程序,故Java并發(fā)大多值的都是線程級(jí)別的并發(fā)哲虾,即線程是程序執(zhí)行的最小單位丙躏。JMM規(guī)定了每個(gè)線程都有一個(gè)屬于自己的工作內(nèi)存择示,線程對(duì)本地局部變量的操作都直接在工作線程上執(zhí)行,而對(duì)共享變量的操作需先從主內(nèi)存(馬上就介紹主內(nèi)存)中拷貝一份到工作內(nèi)存晒旅,然后在工作內(nèi)存中對(duì)該變量進(jìn)行操作栅盲,完成之后再寫回主內(nèi)存。
- 主內(nèi)存废恋。主內(nèi)存主要存儲(chǔ)的是實(shí)例對(duì)象谈秫,所有線程創(chuàng)建的實(shí)例對(duì)象都存儲(chǔ)在主內(nèi)存中(這句話在新版本的Java中會(huì)不太準(zhǔn)確,因?yàn)樵谛碌腏VM中鱼鼓,有些特殊情況會(huì)使得對(duì)象實(shí)例被分配在棧上拟烫,成為線程私有的實(shí)例對(duì)象)。主內(nèi)存還包括了一些常量蚓哩,靜態(tài)變量构灸,類的元信息等,總之岸梨,主內(nèi)存就是被多個(gè)線程共享的內(nèi)存喜颁。
其大致結(jié)構(gòu)可以看看下圖:
借用了CSDN博主@zejian_ 的圖片
根據(jù)虛擬機(jī)規(guī)范,對(duì)于一個(gè)實(shí)例的方法曹阔,如果該方法包含的本地變量(包括參數(shù))是基本數(shù)據(jù)類型半开,那么對(duì)應(yīng)的值將被存儲(chǔ)在工作內(nèi)存中(也可以理解為在虛擬機(jī)棧幀中),如果是引用類型赃份,那么引用本身也會(huì)被存儲(chǔ)在工作內(nèi)存中寂拆,而其指向的實(shí)例對(duì)象會(huì)被存儲(chǔ)在主內(nèi)存中,被各個(gè)線程共享抓韩。而對(duì)于實(shí)例的字段纠永,無論是基本類型還是引用類型,都會(huì)直接存儲(chǔ)到主內(nèi)存中谒拴。如下圖所示:
了解了上述內(nèi)容尝江,我們知道實(shí)例對(duì)象在JMM的控制下是存儲(chǔ)在主內(nèi)存的,也就是被多個(gè)線程共享的英上,每個(gè)線程要操作對(duì)象就必須拷貝一份到工作內(nèi)存中炭序,然后進(jìn)行操作,最后再寫回內(nèi)存苍日。我想說到這了惭聂,大家不難看出這就是導(dǎo)致線程安全問題的原因,關(guān)于線程安全問題相恃,我的博客里有一些文章辜纲,大家可以去看看。
2 為什么要有Java內(nèi)存模型
JMM只是一組規(guī)則,并不是真實(shí)存在的侨歉。即使上面的圖畫得再漂亮屋摇,在底層都只是一塊內(nèi)存(即使現(xiàn)在的個(gè)人計(jì)算機(jī)都能插多個(gè)內(nèi)存條揩魂,但是操作系統(tǒng)還是把他們抽象成一整塊連續(xù)的存儲(chǔ))幽邓。那這組規(guī)則是什么呢?上面我提到過JVM把內(nèi)存劃分成了可共享區(qū)域和線程私有區(qū)域火脉,這回導(dǎo)致線程安全問題牵舵,JMM的存在就是為了解決這個(gè)問題。
這里我不得不再次說一下:JMM只是一組規(guī)則倦挂,JVM將內(nèi)存劃分為幾個(gè)區(qū)域畸颅,分為線程私有的和線程共享的區(qū)域,而作為工作內(nèi)存和主內(nèi)存其實(shí)還是這些區(qū)域方援,也就是說它們主內(nèi)存没炒、工作內(nèi)存和方法區(qū)、虛擬機(jī)棧犯戏、程序計(jì)數(shù)器送火、本地方法棧、堆等式有交叉關(guān)系的先匪。JMM之所以再做抽象种吸,分為主內(nèi)存和工作內(nèi)存,主要目的就是為了更好的描述這組規(guī)則呀非。
JMM定義了一組規(guī)則坚俗,通過這組規(guī)則來決定一個(gè)線程對(duì)共享變量的寫入何時(shí)對(duì)另一個(gè)線程可見。JMM是圍繞程序執(zhí)行的原子性岸裙,可見性和順序性來展開的猖败,下面我們來一一分析。
2.1 原子性
原子性指的是一個(gè)或者一組操作即使在多線程環(huán)境下也不可被中斷降允,一旦開始就不能被其他線程所影響恩闻,即一旦操作開始,那么直到該操作結(jié)束拟糕,CPU都不可以被其他線程占用判呕。這是一個(gè)很重要的特性,至于如何做到送滞,我這里簡(jiǎn)單大致的說一下:我們知道線程的執(zhí)行是需要CPU做調(diào)度的侠草,引發(fā)線程切換的因素有多個(gè),例如當(dāng)前線程時(shí)間片用完了犁嗅、被阻塞了等等边涕,但總歸來說,他們被切換的根本原因就是發(fā)生了中斷,所以功蜓,我們可以在操作準(zhǔn)備開始的時(shí)候园爷,屏蔽中斷,結(jié)束的時(shí)候再打開中斷式撼,這樣就實(shí)現(xiàn)了原子性童社。
我想,通過上面的描述應(yīng)該不難理解原子性對(duì)于線程安全的作用了吧著隆。通過保證操作的原子性扰楼,就可以避免其他線程的干擾,從而保證線程安全美浦。例如JDK里有java.util.concurrent.atomic包弦赖,該包下有很多Atomic打頭的類,通常我們稱作“原子類”浦辨。使用這些類可以很輕松的解決一部分線程安全問題蹬竖,例如在并發(fā)環(huán)境下做計(jì)數(shù):
public class Counter {
//使用原生類型,在并發(fā)環(huán)境下會(huì)發(fā)生線程安全問題
//private static int counter = 0;
//使用原子類可以保證線程安全
private static final AtomicInteger counter = new AtomicInteger(0);
public void addCount() {
counter.getAndIncrement();
}
}
除了使用“原子類”流酬,一般還使鎖來保證原子性币厕,線程執(zhí)行操作之前需要先獲取鎖,操作完成之后需要釋放鎖康吵。在Java里劈榨,鎖有內(nèi)置鎖和顯式鎖,內(nèi)置鎖就是synchronized晦嵌,這是一個(gè)可重入鎖同辣,同一線程不需要重復(fù)獲取鎖。顯式鎖就是 java.util.concurrent.locks 包下的相關(guān)類惭载,例如 ReentrantLock旱函,ReadWriteLock ,ReentrantReadWriteLock等描滔。
2.2 可見性
可見性即當(dāng)一個(gè)線程修改了某個(gè)共享變量的值棒妨,其他線程能夠馬上得知這個(gè)修改的值。對(duì)于串行程序含长,可見性是沒什么意義的券腔,因?yàn)閱尉€程環(huán)境下程序是順序執(zhí)行的(在并發(fā)環(huán)境下,每個(gè)線程執(zhí)行也是順序執(zhí)行的拘泞,但是因?yàn)闀r(shí)序的問題纷纫,所以整體看起來就不是順序執(zhí)行的了),不存在修改無法得知的情況陪腌。Java提供了volatile關(guān)鍵字來保證可見性辱魁,在這里先不說烟瞧,文章后面會(huì)詳細(xì)講到volatile。
2.3 順序性
在可見性那里提到過一些染簇,順序性指的程序的執(zhí)行是按照順序有序執(zhí)行的参滴。在單線程環(huán)境下,確實(shí)如此锻弓,沒有毛病砾赔。但是到多線程的環(huán)境下,對(duì)于每個(gè)線程自己來說弥咪,自己本身確實(shí)是順序執(zhí)行的过蹂,這也沒毛病聚至,但是如果一個(gè)線程觀察另一個(gè)線程甚亭,那么所有的操作都是無序的。
2.4 happens-before原則
除了使用鎖來保證原子性和使用volatile之外,在JMM中,還提供了happens-before原則來輔助我們。我們可以在happens-before前后加一些詞語來修飾枯冈,這樣會(huì)便于理解誉尖。即“在同一個(gè)線程中丢间,書寫在前面的操作happens-before書寫在后面的操作”饮六。happens-before原則共有8個(gè)臂外,如下:
- 單線程happen-before原則:在同一個(gè)線程中,書寫在前面的操作happen-before后面的操作。
- 鎖的happen-before原則:同一個(gè)鎖的unlock操作happen-before此鎖的lock操作谭溉。
- volatile的happen-before原則:對(duì)一個(gè)volatile變量的寫操作happen-before對(duì)此變量的任意操作(當(dāng)然也包括寫操作了)柜与。
- happen-before的傳遞性原則:如果A操作 happen-before B操作迁匠,B操作happen-before C操作,那么A操作happen-before C操作。
- 線程啟動(dòng)的happen-before原則:同一個(gè)線程的start方法happen-before此線程的其它方法愿卸。
- 線程中斷的happen-before原則:對(duì)線程interrupt方法的調(diào)用happen-before被中斷線程的檢測(cè)到中斷發(fā)送的代碼灵临。
- 線程終結(jié)的happen-before原則:線程中的所有操作都happen-before線程的終止檢測(cè)。
- 對(duì)象創(chuàng)建的happen-before原則:一個(gè)對(duì)象的初始化完成先于他的finalize方法調(diào)用趴荸。
happens-before原則主要是輔助我們判斷代碼是否是線程安全的儒溉,如果以上原則任何一個(gè)都不滿足就意味著我們應(yīng)該重新審視一下代碼,并作出想應(yīng)修改來保證代碼在并發(fā)環(huán)境下的線程安全赊舶。關(guān)于happens-before更多的解釋睁搭,網(wǎng)上有不少好文章,在此不再贅述笼平。
3 volatile
現(xiàn)在來看看volatile關(guān)鍵字,在Java并發(fā)程序中舔痪,經(jīng)常能看到volatile的身影寓调,但也容易被濫用。volatile是JVM提供的輕量級(jí)同步機(jī)制锄码,主要有兩個(gè)作用:
- 保證可見性夺英,當(dāng)一個(gè)線程修改了有volatile修飾的變量,這個(gè)修改會(huì)立刻反應(yīng)到主內(nèi)存中滋捶,換句話說痛悯,當(dāng)其他線程訪問該變量時(shí),總是會(huì)得到新的值重窟。
- 禁止指令重排载萌。
3.1 保證可見性
可見性上面已經(jīng)說過了,在此說說訪問volatile的流程巡扇,理解了流程扭仁,就能理解為什么volatile能保證可見性了。我們知道厅翔,每個(gè)線程都有自己的工作內(nèi)存乖坠,操作變量的時(shí)候需要先到主內(nèi)存復(fù)制一份拷貝到工作內(nèi)存中,完成操作后再寫回主內(nèi)存刀闷,在線程寫回主內(nèi)存之前熊泵,其他線程是無法得知修改的仰迁,這就造成了其他線程有可能讀取到的值是一個(gè)過期無效的值,從而導(dǎo)致線程安全問題顽分。而有volatile修飾的變量稍有不同徐许,線程在對(duì)volatile變量進(jìn)行修改的時(shí)候,完事之后會(huì)立即刷新到主內(nèi)存中怯邪,其他線程讀取的時(shí)候也會(huì)被迫去主內(nèi)存中取值绊寻。從宏觀上看,就好像其他線程能看到當(dāng)前線程修改之后的值一樣悬秉,這就保證了可見性澄步。
3.2 禁止指令重排
指令重排是編譯器的優(yōu)化操作,編譯器可能會(huì)對(duì)一些沒有依賴關(guān)系代碼做重新排序和泌,導(dǎo)致編譯后的代碼和我們編寫的代碼順序上有一些差異(說到這村缸,我想起了JS的變量提升,將一些沒有依賴關(guān)系的變量聲明提升到代碼頂端)武氓,如下所示:
int x = 1; //1
System.out.Println(x); //2
int y = 2; //3
如果允許編譯器做指令重排梯皿,那么編譯后的代碼順序可能是下面這樣的:
int x = 1; //1
int y = 2; //3
System.out.Println(x); //2
這就是Java的指令重排。
為什么需要做指令重排呢县恕?編譯器既然做了东羹,就肯定是有原因的,要不然費(fèi)這勁干哈忠烛!因?yàn)橹嘏判蛑笥欣谥噶畹膱?zhí)行属提,從而提供程序的性能。CPU執(zhí)行指令是采用流水線的方式美尸,這種方式可以提高CPU的利用率冤议,在CPU執(zhí)行指令的時(shí)候有可能會(huì)因?yàn)橐蕾囮P(guān)系而出現(xiàn)“停頓”,這將使得CPU在這個(gè)時(shí)鐘周期內(nèi)無事可干师坎,導(dǎo)致CPU的利用率降低恕酸,程序總體性能會(huì)受到影響。指令重排后胯陋,會(huì)處理這些依賴關(guān)系蕊温,最終會(huì)減少CPU停頓次數(shù),最好的情況下完全消除停頓惶岭,使得CPU利用率最大化寿弱。關(guān)于指令重排的更加詳細(xì)的解釋,可以看看全面理解Java內(nèi)存模型(JMM)及volatile關(guān)鍵字 這篇文章的指令重排部分按灶,該博主解釋的非常好症革,清晰易懂,推薦多多關(guān)注鸯旁。
在單線程環(huán)境下噪矛,指令重排當(dāng)然沒問題量蕊,但是在多線程并發(fā)環(huán)境下,指令重排可能會(huì)導(dǎo)致線程安全問題艇挨。就拿面試巢信冢考的單例模式來講吧,單例模式至少有7種寫法缩滨,我們來看看雙重檢查鎖的寫法(Double-Check Lock势就,簡(jiǎn)稱DCL):
public class DCL {
private static DCL instance;
private DCL() {
}
public static DCL getInstance() {
if (instance == null) {
synchronized (DCL.class) {
if (instance == null) {
instance = new DCL();
}
}
}
return instance;
}
}
私有字段、私有構(gòu)造函數(shù)脉漏,公有的靜態(tài)方法獲取實(shí)例苞冯,整個(gè)類只有一個(gè)入口點(diǎn),好像沒什么問題侧巨。在靜態(tài)方法里舅锄,先判斷instance是否為null,不為null就直接返回司忱,為null再進(jìn)入if邏輯里皇忿,然后用內(nèi)置鎖鎖住整個(gè)類,開辟了一個(gè)臨界區(qū)坦仍,其他線程此時(shí)就不能訪問了鳍烁,當(dāng)前線程再判斷instance是否為null,之所以要再次判斷是因?yàn)榫€程進(jìn)入臨界區(qū)之前繁扎,進(jìn)入第一個(gè)if邏輯之后可能會(huì)被其他線程搶占CPU老翘,其他線程有可能獲取到鎖并完成了對(duì)instance的初始化,為了防止這種情況锻离,在里面再做一次if判斷來保證不會(huì)重復(fù)初始化instance,這就是雙重檢查這個(gè)名字的由來墓怀。
但是汽纠,這樣真的沒問題嗎?答案是不傀履!這里有可能會(huì)因?yàn)橹噶钪嘏艑?dǎo)致獲取到的值是null虱朵。instance = new DCL()不是一個(gè)原子操作,而是分三步操作:
- 為對(duì)象分配內(nèi)存空間
- 初始化對(duì)象
- 將instance引用指向剛剛分配的地址
這里第2步和第3步?jīng)]有依賴關(guān)系钓账,所以編譯器在做指令重排的時(shí)候可能會(huì)將2和3的順序做一個(gè)調(diào)換碴犬,變成這樣:
- 為對(duì)象分配內(nèi)存空間
- 將instance引用指向剛剛分配的地址
- 初始化對(duì)象
這就可能導(dǎo)致一種情況,當(dāng)前線程執(zhí)行到“將instance引用指向剛剛分配的地址”這一步梆暮,此時(shí)被其他線程搶占CPU服协,其他線程進(jìn)入方法,做第一個(gè)if判斷啦粹,此時(shí)的結(jié)果會(huì)是false偿荷,然后就直接走到方法最后返回instance了窘游,但此時(shí)instance沒有初始化完成,也就是說此時(shí)的instance是無效的跳纳!為了解決這個(gè)問題忍饰,我們可以給instance添加volatile關(guān)鍵字,此時(shí)volatile關(guān)鍵字的作用就是禁止指令重排寺庄,這樣就解決了這個(gè)問題艾蓝。
4 final
final除了用來約束常量,使方法不能被重寫斗塘,類不能繼承之外赢织。還有一些規(guī)則,規(guī)則主要有兩個(gè):
- final寫:“構(gòu)造函數(shù)內(nèi)對(duì)一個(gè)final的寫入”與“之后把這個(gè)被構(gòu)造對(duì)象賦值給其他引用”之間不能重排序逛拱。
- final讀:“初次讀一個(gè)包含final字段的對(duì)象”與“之后初次讀該對(duì)象的final字段”之間不能重排序敌厘。
對(duì)于寫來說,如果一個(gè)final字段在構(gòu)造函數(shù)內(nèi)才被寫入值朽合,那么這個(gè)寫入操作必須要發(fā)生在把構(gòu)造完成的對(duì)象賦值給其他引用之前俱两。例如在多線程環(huán)境下,A線程調(diào)用構(gòu)造函數(shù)曹步,在構(gòu)造函數(shù)里對(duì)final字段進(jìn)行寫入操作宪彩,B線程不能提前把該對(duì)象實(shí)例賦值給其他引用,即保證final字段一定先被初始化讲婚。
這里需要注意尿孔,我們不能將final應(yīng)用到上述的DCL類,雖然final的寫規(guī)則確實(shí)能防止instance = new DCL()的三個(gè)步驟的重排序筹麸,但是并不適用于DCL類活合,因?yàn)閒inal字段要么在聲明的時(shí)候直接寫入,要么在初始化塊或者構(gòu)造函數(shù)類寫入物赶,顯然DCL類不符合這個(gè)規(guī)則白指。關(guān)于使用final的例子,可以看看單例模式的“懶漢”形式酵紫,“懶漢”形式之所以是線程安全的告嘲,就是因?yàn)閒inal的這個(gè)規(guī)則。
對(duì)于讀來說奖地,初次讀包含final字段的對(duì)象和初次讀該對(duì)象的fianl字段之間存在間接的依賴關(guān)系橄唬,這個(gè)final讀規(guī)則就保證了要讀某個(gè)對(duì)象的final域,必須寫讀這個(gè)包含這個(gè)fianl字段的對(duì)象参歹,這個(gè)規(guī)則比較自然仰楚,我們覺得這應(yīng)該是理所當(dāng)然的。大多數(shù)編譯器也確實(shí)不會(huì)對(duì)他們做重排序,所以缸血,這個(gè)規(guī)則就是用來處理那些比較“皮”的編譯器蜜氨。
final字段也不應(yīng)該發(fā)生“逸出”,這其實(shí)主要是針對(duì)final字段是引用類型捎泻。換句話說飒炎,final字段在構(gòu)造函數(shù)執(zhí)行期間,不應(yīng)該被其他線程訪問到笆豁,否則上述規(guī)則就都沒有了意義郎汪。
5 小結(jié)
Java內(nèi)存模型不同于Java內(nèi)存區(qū)域的劃分,Java內(nèi)存模型描述的是一組規(guī)則闯狱,是一個(gè)抽象的概念煞赢,工作內(nèi)存、主內(nèi)存什么的都是抽象出來的哄孤,并不是真實(shí)存在的照筑,只是為了更好的描述規(guī)則而已。JMM的規(guī)則主要是圍繞原子性瘦陈,可見性和順序性來展開的凝危,理解這三個(gè)性質(zhì)可以更好的理解JMM。保證原子性可以采取鎖等同步手段晨逝,保證可見性可以利用volatile蛾默。volatile不僅能保證可見性,還能防止重排序捉貌,這在多線程環(huán)境下非常重要支鸡。final也有一些規(guī)則來防止重排序,但是范圍沒有volatie那么寬趁窃,僅僅只針對(duì)部分場(chǎng)景牧挣,final的這個(gè)特性經(jīng)常被忽略。