詳解 JMM 內(nèi)存模型
本文將著重從JMM指令規(guī)范以及如何解決程序可見性和有序性兩個(gè)問題為入口,為讀者深入剖析JMM內(nèi)存模型,希望對(duì)你有幫助。
一、詳解指令重排序問題
1. 什么是重排序問題
代碼在執(zhí)行過程從,計(jì)算機(jī)的不同層級(jí)為了提高最終指令執(zhí)行效率,都可能會(huì)對(duì)執(zhí)行響應(yīng)重排序,以Java程序?yàn)槔?,從編譯到執(zhí)行會(huì)經(jīng)歷:
- 生成指令階段:編譯器重排,該階段JMM通過禁止特定類型的編譯器重排序達(dá)到要求。
- 處理器階段:處理器階段存在指令并行重排序和內(nèi)存系統(tǒng)加載重排序,這種處理器級(jí)別的重排序問題,則是要求編譯器在生成指令階段通過插入內(nèi)存屏障即memory barriers指令禁止特定方式重排序。
2. 編譯器重排序
編譯器(包括 JVM、JIT 編譯器等)重排序即不影響單線程執(zhí)行結(jié)果的情況下,會(huì)針對(duì)性的重排代碼的效率以提高單線程情況下代碼執(zhí)行效率。當(dāng)然這種重排序可能也會(huì)存在一些問題,假設(shè)我們現(xiàn)在有這樣一段代碼:
- 兩個(gè)CPU核心加載到一段先初始化localNum
- 各自分別用用變量x、y讀取讀取對(duì)方的localNum的值
如下圖所示:
極端情況,假設(shè)兩個(gè)CPU都發(fā)生編譯器重排序就可能出現(xiàn)CPU-0先執(zhí)行x=lcalNum2,CPU-1執(zhí)行y=lcalNum1,因?yàn)檫@兩個(gè)本地變量初始化賦值指令被重排序,導(dǎo)致x、y最終被設(shè)置為0:
對(duì)于這種情況,JMM會(huì)針對(duì)性發(fā)生這種重排序的編譯器進(jìn)行禁止來解決這種問題。
3. 指令重排序
現(xiàn)代的處理器會(huì)對(duì)某些指令進(jìn)行重疊執(zhí)行(采用指令級(jí)并行技術(shù)(Instruction-Level Parallelism,ILP),亦或者在不影響執(zhí)行結(jié)果的情況下會(huì)允許Java字節(jié)碼對(duì)應(yīng)的機(jī)器碼指令進(jìn)行順序調(diào)換以提高單線程下代碼的執(zhí)行效率,這種問題的表象和上述情況類似,這里也就不再演示了。
4. 內(nèi)存重排序
該方式排序并不是真正意義上的重排序,即處理器為了提升程序的處理效率,會(huì)將內(nèi)存中的數(shù)據(jù)先加載到自己的cache line上,這使得并發(fā)場(chǎng)景下CPU本地內(nèi)存數(shù)據(jù)可能與內(nèi)存中的數(shù)據(jù)不一致的情況,在JMM上常常表現(xiàn)為主存和本地內(nèi)存的數(shù)據(jù)不一致。
如下圖,兩個(gè)CPU同時(shí)從內(nèi)存中加載到x為0,然后cpu-0執(zhí)行程序中的累加指令,在cpu-0未將指令下回內(nèi)存時(shí),就短暫的出現(xiàn)數(shù)據(jù)不一致的情況:
5. 如何避免指令重排序
這一點(diǎn)其實(shí)在上述各種重排序都已經(jīng)簡(jiǎn)單的說明了:
- 對(duì)于編譯器,會(huì)禁止特定類型的編譯器重排序來避免編譯器重排序在多線程情況下帶來的問題。
- 對(duì)于指令重排序即處理器重排序,JVM生成程序指令序列時(shí),會(huì)根據(jù)情況插入特定的內(nèi)存屏障(Memory Barrier)來相關(guān)指令來告知處理器避免特定類型的指令重排序。
二、詳解Java內(nèi)存模型JMM
1. 什么是JMM模型
為了屏蔽不同操作系統(tǒng)之間操作系統(tǒng)內(nèi)存模型的差異,Java定義了屬于自己的內(nèi)存模型規(guī)范解決這個(gè)問題。 JMM也可以理解為針對(duì)Java并發(fā)編程的一組規(guī)范,抽象了線程和主內(nèi)存之間的關(guān)系,以類似于volatile、synchronized等關(guān)鍵字以解決并發(fā)場(chǎng)景下重排序帶來的問題。
JMM規(guī)定所有示例對(duì)象都必須放置在主存中,所以每個(gè)線程需要操作這些數(shù)據(jù)時(shí)就需要將數(shù)據(jù)拷貝一份到本地內(nèi)存中在進(jìn)行相應(yīng)的操作。
而每個(gè)Java將主存中拷貝的變量在完成操作后寫回主存中會(huì)經(jīng)歷以下過程:
- lock:首先將變量鎖住,將這個(gè)共享變量設(shè)置為線程獨(dú)占變量。
- read:將主存的共享變量讀取到本地內(nèi)存中。
- load:將變量load拷貝一份到本地內(nèi)存中生成共享變量的副本。
- use:將共享變量副本放到執(zhí)行引擎中。
- assign:將共享變量副本賦值給本地內(nèi)存的變量。
- store:將變量放到主內(nèi)存中
- write:寫入主內(nèi)存對(duì)應(yīng)變量中
- unlock:解鎖,該共享變量此時(shí)就可以被其他線程操作了。
同時(shí),JMM模型還規(guī)定這些操作還得符合以下規(guī)范:
- 線程沒有發(fā)任何assign操作的變量不可以寫回主內(nèi)存中。
- 新的變量只能在主內(nèi)存中誕生。這就意味的線程中的變量必須是通過load從主存加載后再通過assign得到的。
- 一個(gè)線程通過lock鎖定主內(nèi)存變量共享變量時(shí),這個(gè)線程可以對(duì)其上無數(shù)次鎖(即線程可重入),其他線程就不能在對(duì)其上鎖了。
- 一個(gè)線程沒有l(wèi)ock一個(gè)共享變量,就不能對(duì)其進(jìn)行unlock。
- 在執(zhí)行use操作前,必須清空本地內(nèi)存,通過load或者assign初始化變量值才可操作本地變量。
2. JVM和JMM有什么區(qū)別
JVM規(guī)定了運(yùn)行時(shí)的java程序的內(nèi)存區(qū)域劃分,例如實(shí)例對(duì)象必須放置在堆區(qū)等。
而JMM則決定了線程和和主內(nèi)存之間的關(guān)系,例如共享變量必須存放在主內(nèi)存中。通過定義一系列規(guī)范和原則簡(jiǎn)化用戶實(shí)現(xiàn)并發(fā)編程的種種操作且確保Java代碼從編譯到轉(zhuǎn)為CPU機(jī)器碼執(zhí)行結(jié)果都是準(zhǔn)確無誤的,也就是說JMM是一種內(nèi)存模型語(yǔ)義的抽象并非實(shí)際的內(nèi)存模型。
3. 什么是happens-before原則?常見的happens-before原則有哪些?
happens-before也是一種JMM內(nèi)存模型用來闡述內(nèi)存可見性的一種規(guī)約,對(duì)應(yīng)的happens-before原則共有8條,而常見的有以下5條:
- 程序順序規(guī)則:寫前面的變量happens-before于后面的代碼。
- 傳遞規(guī)則:A happens-before B,B happens-before C,那么A happens-before C
- volatile 變量規(guī)則:volatile的變量的寫操作, happens-before后續(xù)讀該變量的代碼。
- 線程啟動(dòng)規(guī)則:Thread的start都有先于后面對(duì)于該線程的操作。
- 解鎖規(guī)則:對(duì)一個(gè)鎖的解鎖操作happens-before對(duì)這個(gè)鎖的加鎖操作
對(duì)于不會(huì)影響單線程或者多線程指令重排序操作java編譯器不做要求,即不會(huì)過分干預(yù)編譯器和處理器的大部分優(yōu)化操作,例如下面這段代碼,在單線程情況下,因?yàn)閮烧呗暶鳑]有任何關(guān)聯(lián),處理器為了提高程序執(zhí)行的并行度完全可以允許其以任意順序執(zhí)行,這也就是我們常說的as-if-serial,即沒有強(qiáng)關(guān)聯(lián)的指令,處理器可以根據(jù)自己的優(yōu)化算法執(zhí)行,任意重排序,對(duì)外結(jié)果好像就是串行執(zhí)行一樣:
而對(duì)于某些場(chǎng)景, JMM對(duì)于編譯器或處理的某些會(huì)影響指令重排序的操作進(jìn)行禁止,如下所示,getOne和getTwo先于最后計(jì)算,計(jì)算依賴于前兩個(gè)變量,操作即兩個(gè)get操作happens-before于最后的計(jì)算,但是兩個(gè)get操作沒有強(qiáng)關(guān)聯(lián),所以JVM這兩段代碼進(jìn)行指令重排序的時(shí)候,JMM是允許的,所以執(zhí)行時(shí)getTwo可能會(huì)先于getOne執(zhí)行。
public static void main(String[] args) {
int one = getOne();//1
int two = getTwo();//2
System.out.println(one + two);//3
}
private static int getOne() {
return1;
}
private static int getTwo() {
return2;
}
與之相反就是最后的計(jì)算,因?yàn)橐蕾囉谇皟蓚€(gè)get,所以JMM模型是明確要求禁止這種情況,于是就提出了happens-before原則,即寫前面的變量happens-before于后面的代碼以及A happens-before B,B happens-before C,那么A happens-before C,按照我們的例子就是每一個(gè)get操作都會(huì)按照順序?qū)?,因?yàn)?操作先于2先于3,所以最終執(zhí)行順序就是1、2、3。
4. happens-before和JMM有什么關(guān)系
JMM原則和禁止重排序的遵循的準(zhǔn)則都是基于 happens-before準(zhǔn)則要求,也就是要求針對(duì)編譯器的指令重排序必須根據(jù)該準(zhǔn)則通過某種方式落實(shí),最常見的方式就是在生成執(zhí)行指令前插入內(nèi)存屏障,避免處理器進(jìn)行危險(xiǎn)的指令重排序。 所以,程序員只需理解happens-before原則的抽象即可理解可見性,由此避免去理解底層編譯器和處理器的復(fù)雜實(shí)現(xiàn):
5. JMM規(guī)范如何解決處理器指令重排序問題
為了保證內(nèi)存可見性,編譯器在生成指令指令序列時(shí)通過內(nèi)存屏障指令來禁止特定類型的處理器重排序問題,對(duì)應(yīng)的屏障指令有:
- loadload:先加載load1先于后load2的操作,保證load1讀取的數(shù)據(jù)結(jié)果對(duì)于load2可見。
- loadstore:load1的操作先于后store,保證store2的操作可以看見load1讀取數(shù)據(jù)的最新結(jié)果。
- storestore:store1寫入操作先于store2,保證store1的寫入操作結(jié)果對(duì)于store2可見。
- storeload:先store的操作對(duì)于后load可見,即store操作變量的結(jié)果對(duì)于后續(xù)的load是可見的。
而本質(zhì)上這些內(nèi)存屏障在硬件層也就是Load Barrier和Store Barrier兩個(gè)屏障,大體來說內(nèi)存屏障的主要作用有:
- 組織屏障前后兩個(gè)指令重排序。
- 強(qiáng)制把處理器高速緩沖區(qū)數(shù)據(jù)更新結(jié)果寫回主內(nèi)存,讓其它處理器中緩存數(shù)據(jù)失效,這也就是大名鼎鼎的MESI協(xié)議。
對(duì)于Load Barrier而言,若在指令錢插入Load Barrier,該屏障可讀取數(shù)據(jù)時(shí)強(qiáng)制要求處理器將本地cache line設(shè)置為無效,直接從內(nèi)存中讀取數(shù)據(jù):
而Store Barrier則是強(qiáng)制要求cpu cache line寫入操作要直接從本地cache line強(qiáng)制刷新到內(nèi)存中讓其它核心中的cache line數(shù)據(jù)失效,而JMM規(guī)范就是基于這兩個(gè)硬件屏障的多種組合保證了操作可見性:
對(duì)于java這門語(yǔ)言而言,內(nèi)存屏障最經(jīng)典的運(yùn)用無非是volatile關(guān)鍵字,可以看到下面這段代碼,為了保證volatile變量的可見性,即:
- 在volatile寫的前后分別加入了loadstore和storeload,保證讀取依賴數(shù)據(jù)后在執(zhí)行寫入并更新至主存
- 在volatile變量讀前后分別加入loadload和loadstore保證讀取到正確的數(shù)據(jù)在執(zhí)行后續(xù)的寫,即后續(xù)的寫入操作對(duì)于volatile變量可見
private staticint normalData;
privatestaticvolatileboolean volatileData = false;// volatile確保StoreLoad語(yǔ)義
public static void main(String[] args) throws InterruptedException {
Thread thread1 = new Thread(() -> {
normalData = 1;
//插入loadload屏障,保證上述數(shù)據(jù)改變可見
volatileData = true;
//插入storeload屏障,保證上述數(shù)據(jù)寫入改變可見
});
Thread thread2 = new Thread(() -> {
//插入loadload屏障,保證volatile讀可見之前的讀
while (!volatileData) {
//插入loadstore屏障,保證后續(xù)寫可見volatile變量結(jié)果
}
System.out.println(normalData);
});
thread1.start();
thread2.start();
}