Java虛擬機(jī)(二):Java內(nèi)存模型

1 基本概念

在上一篇文章Java內(nèi)存區(qū)域 中,我們講了JVM為了更好的管理內(nèi)存,將Java進(jìn)程的內(nèi)存劃分成了幾個功能、用途不同的區(qū)域,所以很多人會認(rèn)為劃分后的內(nèi)存布局就是Java內(nèi)存模型。嚴(yán)格來說,這個說法是不準(zhǔn)確的,不過大家在交流的時候直接說成內(nèi)存模型好像也無傷大雅。那究極什么才是嚴(yán)格意義上的Java內(nèi)存模型呢?

Java內(nèi)存模型(Java Memory Model,簡稱JMM)本身是一個抽象的概念,不是真實(shí)存在的,它描述的是一組規(guī)則,Java內(nèi)存訪問內(nèi)存都需要遵循這組規(guī)則。在深入了解之前,我們先來看看JMM里有幾個基本概念:

  • 工作內(nèi)存。由于Java程序是單進(jìn)程程序,故Java并發(fā)大多值的都是線程級別的并發(fā),即線程是程序執(zhí)行的最小單位。JMM規(guī)定了每個線程都有一個屬于自己的工作內(nèi)存,線程對本地局部變量的操作都直接在工作線程上執(zhí)行,而對共享變量的操作需先從主內(nèi)存(馬上就介紹主內(nèi)存)中拷貝一份到工作內(nèi)存,然后在工作內(nèi)存中對該變量進(jìn)行操作,完成之后再寫回主內(nèi)存。
  • 主內(nèi)存。主內(nèi)存主要存儲的是實(shí)例對象,所有線程創(chuàng)建的實(shí)例對象都存儲在主內(nèi)存中(這句話在新版本的Java中會不太準(zhǔn)確,因為在新的JVM中,有些特殊情況會使得對象實(shí)例被分配在棧上,成為線程私有的實(shí)例對象)。主內(nèi)存還包括了一些常量,靜態(tài)變量,類的元信息等,總之,主內(nèi)存就是被多個線程共享的內(nèi)存。

其大致結(jié)構(gòu)可以看看下圖:

借用了CSDN博主@zejian_ 的圖片

根據(jù)虛擬機(jī)規(guī)范,對于一個實(shí)例的方法,如果該方法包含的本地變量(包括參數(shù))是基本數(shù)據(jù)類型,那么對應(yīng)的值將被存儲在工作內(nèi)存中(也可以理解為在虛擬機(jī)棧幀中),如果是引用類型,那么引用本身也會被存儲在工作內(nèi)存中,而其指向的實(shí)例對象會被存儲在主內(nèi)存中,被各個線程共享。而對于實(shí)例的字段,無論是基本類型還是引用類型,都會直接存儲到主內(nèi)存中。如下圖所示:

了解了上述內(nèi)容,我們知道實(shí)例對象在JMM的控制下是存儲在主內(nèi)存的,也就是被多個線程共享的,每個線程要操作對象就必須拷貝一份到工作內(nèi)存中,然后進(jìn)行操作,最后再寫回內(nèi)存。我想說到這了,大家不難看出這就是導(dǎo)致線程安全問題的原因,關(guān)于線程安全問題,我的博客里有一些文章,大家可以去看看。

2 為什么要有Java內(nèi)存模型

JMM只是一組規(guī)則,并不是真實(shí)存在的。即使上面的圖畫得再漂亮,在底層都只是一塊內(nèi)存(即使現(xiàn)在的個人計算機(jī)都能插多個內(nèi)存條,但是操作系統(tǒng)還是把他們抽象成一整塊連續(xù)的存儲)。那這組規(guī)則是什么呢?上面我提到過JVM把內(nèi)存劃分成了可共享區(qū)域和線程私有區(qū)域,這回導(dǎo)致線程安全問題,JMM的存在就是為了解決這個問題。

這里我不得不再次說一下:JMM只是一組規(guī)則,JVM將內(nèi)存劃分為幾個區(qū)域,分為線程私有的和線程共享的區(qū)域,而作為工作內(nèi)存和主內(nèi)存其實(shí)還是這些區(qū)域,也就是說它們主內(nèi)存、工作內(nèi)存和方法區(qū)、虛擬機(jī)棧、程序計數(shù)器、本地方法棧、堆等式有交叉關(guān)系的。JMM之所以再做抽象,分為主內(nèi)存和工作內(nèi)存,主要目的就是為了更好的描述這組規(guī)則。

JMM定義了一組規(guī)則,通過這組規(guī)則來決定一個線程對共享變量的寫入何時對另一個線程可見。JMM是圍繞程序執(zhí)行的原子性,可見性和順序性來展開的,下面我們來一一分析。

2.1 原子性

原子性指的是一個或者一組操作即使在多線程環(huán)境下也不可被中斷,一旦開始就不能被其他線程所影響,即一旦操作開始,那么直到該操作結(jié)束,CPU都不可以被其他線程占用。這是一個很重要的特性,至于如何做到,我這里簡單大致的說一下:我們知道線程的執(zhí)行是需要CPU做調(diào)度的,引發(fā)線程切換的因素有多個,例如當(dāng)前線程時間片用完了、被阻塞了等等,但總歸來說,他們被切換的根本原因就是發(fā)生了中斷,所以,我們可以在操作準(zhǔn)備開始的時候,屏蔽中斷,結(jié)束的時候再打開中斷,這樣就實(shí)現(xiàn)了原子性。

我想,通過上面的描述應(yīng)該不難理解原子性對于線程安全的作用了吧。通過保證操作的原子性,就可以避免其他線程的干擾,從而保證線程安全。例如JDK里有java.util.concurrent.atomic包,該包下有很多Atomic打頭的類,通常我們稱作“原子類”。使用這些類可以很輕松的解決一部分線程安全問題,例如在并發(fā)環(huán)境下做計數(shù):

public class Counter {
    
    //使用原生類型,在并發(fā)環(huán)境下會發(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,這是一個可重入鎖,同一線程不需要重復(fù)獲取鎖。顯式鎖就是 java.util.concurrent.locks 包下的相關(guān)類,例如 ReentrantLock,ReadWriteLock ,ReentrantReadWriteLock等。

2.2 可見性

可見性即當(dāng)一個線程修改了某個共享變量的值,其他線程能夠馬上得知這個修改的值。對于串行程序,可見性是沒什么意義的,因為單線程環(huán)境下程序是順序執(zhí)行的(在并發(fā)環(huán)境下,每個線程執(zhí)行也是順序執(zhí)行的,但是因為時序的問題,所以整體看起來就不是順序執(zhí)行的了),不存在修改無法得知的情況。Java提供了volatile關(guān)鍵字來保證可見性,在這里先不說,文章后面會詳細(xì)講到volatile。

2.3 順序性

在可見性那里提到過一些,順序性指的程序的執(zhí)行是按照順序有序執(zhí)行的。在單線程環(huán)境下,確實(shí)如此,沒有毛病。但是到多線程的環(huán)境下,對于每個線程自己來說,自己本身確實(shí)是順序執(zhí)行的,這也沒毛病,但是如果一個線程觀察另一個線程,那么所有的操作都是無序的。

2.4 happens-before原則

除了使用鎖來保證原子性和使用volatile之外,在JMM中,還提供了happens-before原則來輔助我們。我們可以在happens-before前后加一些詞語來修飾,這樣會便于理解。即“在同一個線程中,書寫在前面的操作happens-before書寫在后面的操作”。happens-before原則共有8個,如下:

  • 單線程happen-before原則:在同一個線程中,書寫在前面的操作happen-before后面的操作。
  • 鎖的happen-before原則:同一個鎖的unlock操作happen-before此鎖的lock操作。
  • volatile的happen-before原則:對一個volatile變量的寫操作happen-before對此變量的任意操作(當(dāng)然也包括寫操作了)。
  • happen-before的傳遞性原則:如果A操作 happen-before B操作,B操作happen-before C操作,那么A操作happen-before C操作。
  • 線程啟動的happen-before原則:同一個線程的start方法happen-before此線程的其它方法。
  • 線程中斷的happen-before原則:對線程interrupt方法的調(diào)用happen-before被中斷線程的檢測到中斷發(fā)送的代碼。
  • 線程終結(jié)的happen-before原則:線程中的所有操作都happen-before線程的終止檢測。
  • 對象創(chuàng)建的happen-before原則:一個對象的初始化完成先于他的finalize方法調(diào)用。

happens-before原則主要是輔助我們判斷代碼是否是線程安全的,如果以上原則任何一個都不滿足就意味著我們應(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ī)制,主要有兩個作用:

  • 保證可見性,當(dāng)一個線程修改了有volatile修飾的變量,這個修改會立刻反應(yīng)到主內(nèi)存中,換句話說,當(dāng)其他線程訪問該變量時,總是會得到新的值。
  • 禁止指令重排。

3.1 保證可見性

可見性上面已經(jīng)說過了,在此說說訪問volatile的流程,理解了流程,就能理解為什么volatile能保證可見性了。我們知道,每個線程都有自己的工作內(nèi)存,操作變量的時候需要先到主內(nèi)存復(fù)制一份拷貝到工作內(nèi)存中,完成操作后再寫回主內(nèi)存,在線程寫回主內(nèi)存之前,其他線程是無法得知修改的,這就造成了其他線程有可能讀取到的值是一個過期無效的值,從而導(dǎo)致線程安全問題。而有volatile修飾的變量稍有不同,線程在對volatile變量進(jìn)行修改的時候,完事之后會立即刷新到主內(nèi)存中,其他線程讀取的時候也會被迫去主內(nèi)存中取值。從宏觀上看,就好像其他線程能看到當(dāng)前線程修改之后的值一樣,這就保證了可見性。

3.2 禁止指令重排

指令重排是編譯器的優(yōu)化操作,編譯器可能會對一些沒有依賴關(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)這勁干哈!因為重排序之后有利于指令的執(zhí)行,從而提供程序的性能。CPU執(zhí)行指令是采用流水線的方式,這種方式可以提高CPU的利用率,在CPU執(zhí)行指令的時候有可能會因為依賴關(guān)系而出現(xiàn)“停頓”,這將使得CPU在這個時鐘周期內(nèi)無事可干,導(dǎo)致CPU的利用率降低,程序總體性能會受到影響。指令重排后,會處理這些依賴關(guān)系,最終會減少CPU停頓次數(shù),最好的情況下完全消除停頓,使得CPU利用率最大化。關(guān)于指令重排的更加詳細(xì)的解釋,可以看看全面理解Java內(nèi)存模型(JMM)及volatile關(guān)鍵字 這篇文章的指令重排部分,該博主解釋的非常好,清晰易懂,推薦多多關(guān)注。

在單線程環(huán)境下,指令重排當(dāng)然沒問題,但是在多線程并發(fā)環(huán)境下,指令重排可能會導(dǎo)致線程安全問題。就拿面試常考的單例模式來講吧,單例模式至少有7種寫法,我們來看看雙重檢查鎖的寫法(Double-Check Lock,簡稱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í)例,整個類只有一個入口點(diǎn),好像沒什么問題。在靜態(tài)方法里,先判斷instance是否為null,不為null就直接返回,為null再進(jìn)入if邏輯里,然后用內(nèi)置鎖鎖住整個類,開辟了一個臨界區(qū),其他線程此時就不能訪問了,當(dāng)前線程再判斷instance是否為null,之所以要再次判斷是因為線程進(jìn)入臨界區(qū)之前,進(jìn)入第一個if邏輯之后可能會被其他線程搶占CPU,其他線程有可能獲取到鎖并完成了對instance的初始化,為了防止這種情況,在里面再做一次if判斷來保證不會重復(fù)初始化instance,這就是雙重檢查這個名字的由來。

但是,這樣真的沒問題嗎?答案是不!這里有可能會因為指令重排導(dǎo)致獲取到的值是null。instance = new DCL()不是一個原子操作,而是分三步操作:

  1. 為對象分配內(nèi)存空間
  2. 初始化對象
  3. 將instance引用指向剛剛分配的地址

這里第2步和第3步?jīng)]有依賴關(guān)系,所以編譯器在做指令重排的時候可能會將2和3的順序做一個調(diào)換,變成這樣:

  1. 為對象分配內(nèi)存空間
  2. 將instance引用指向剛剛分配的地址
  3. 初始化對象

這就可能導(dǎo)致一種情況,當(dāng)前線程執(zhí)行到“將instance引用指向剛剛分配的地址”這一步,此時被其他線程搶占CPU,其他線程進(jìn)入方法,做第一個if判斷,此時的結(jié)果會是false,然后就直接走到方法最后返回instance了,但此時instance沒有初始化完成,也就是說此時的instance是無效的!為了解決這個問題,我們可以給instance添加volatile關(guān)鍵字,此時volatile關(guān)鍵字的作用就是禁止指令重排,這樣就解決了這個問題。

4 final

final除了用來約束常量,使方法不能被重寫,類不能繼承之外。還有一些規(guī)則,規(guī)則主要有兩個:

  • final寫:“構(gòu)造函數(shù)內(nèi)對一個final的寫入”與“之后把這個被構(gòu)造對象賦值給其他引用”之間不能重排序。
  • final讀:“初次讀一個包含final字段的對象”與“之后初次讀該對象的final字段”之間不能重排序。

對于寫來說,如果一個final字段在構(gòu)造函數(shù)內(nèi)才被寫入值,那么這個寫入操作必須要發(fā)生在把構(gòu)造完成的對象賦值給其他引用之前。例如在多線程環(huán)境下,A線程調(diào)用構(gòu)造函數(shù),在構(gòu)造函數(shù)里對final字段進(jìn)行寫入操作,B線程不能提前把該對象實(shí)例賦值給其他引用,即保證final字段一定先被初始化。

這里需要注意,我們不能將final應(yīng)用到上述的DCL類,雖然final的寫規(guī)則確實(shí)能防止instance = new DCL()的三個步驟的重排序,但是并不適用于DCL類,因為final字段要么在聲明的時候直接寫入,要么在初始化塊或者構(gòu)造函數(shù)類寫入,顯然DCL類不符合這個規(guī)則。關(guān)于使用final的例子,可以看看單例模式的“懶漢”形式,“懶漢”形式之所以是線程安全的,就是因為final的這個規(guī)則。

對于讀來說,初次讀包含final字段的對象和初次讀該對象的fianl字段之間存在間接的依賴關(guān)系,這個final讀規(guī)則就保證了要讀某個對象的final域,必須寫讀這個包含這個fianl字段的對象,這個規(guī)則比較自然,我們覺得這應(yīng)該是理所當(dāng)然的。大多數(shù)編譯器也確實(shí)不會對他們做重排序,所以,這個規(guī)則就是用來處理那些比較“皮”的編譯器。

final字段也不應(yīng)該發(fā)生“逸出”,這其實(shí)主要是針對final字段是引用類型。換句話說,final字段在構(gòu)造函數(shù)執(zhí)行期間,不應(yīng)該被其他線程訪問到,否則上述規(guī)則就都沒有了意義。

5 小結(jié)

Java內(nèi)存模型不同于Java內(nèi)存區(qū)域的劃分,Java內(nèi)存模型描述的是一組規(guī)則,是一個抽象的概念,工作內(nèi)存、主內(nèi)存什么的都是抽象出來的,并不是真實(shí)存在的,只是為了更好的描述規(guī)則而已。JMM的規(guī)則主要是圍繞原子性,可見性和順序性來展開的,理解這三個性質(zhì)可以更好的理解JMM。保證原子性可以采取鎖等同步手段,保證可見性可以利用volatile。volatile不僅能保證可見性,還能防止重排序,這在多線程環(huán)境下非常重要。final也有一些規(guī)則來防止重排序,但是范圍沒有volatie那么寬,僅僅只針對部分場景,final的這個特性經(jīng)常被忽略。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容