引言
本篇文章結(jié)合我個(gè)人對(duì)Java內(nèi)存模型的理解以及相關(guān)書(shū)籍資料為前提全面剖析JMM內(nèi)存模型,本文的書(shū)寫(xiě)思路先闡述JVM內(nèi)存模型、硬件與OS(操作系統(tǒng))內(nèi)存區(qū)域架構(gòu)、Java多線程原理以及Java內(nèi)存模型JMM之間的串聯(lián)關(guān)系之后再對(duì)Java內(nèi)存模型進(jìn)行進(jìn)一步剖析,因?yàn)榇蟛糠中』锇樵诿枋鯦ava內(nèi)存模型JMM時(shí)總是和JVM內(nèi)存模型的概念相互混淆,那么本文的目的就是幫助各位小伙伴徹底理解JMM內(nèi)存模型。(本人文章都是以個(gè)人理解+相關(guān)書(shū)籍資料為前提進(jìn)行撰寫(xiě),如果錯(cuò)誤或疑問(wèn)歡迎各位看官評(píng)論區(qū)留言糾正,謝謝?。?/p>
一、徹底理解JVM內(nèi)存模型與Java內(nèi)存模型JMM的區(qū)別
1.1、JVM內(nèi)存模型(JVM內(nèi)存區(qū)域劃分)
眾所周知,Java程序如果想要運(yùn)行那么必須是要建立在JVM的前提下的,Java使用JVM虛擬機(jī)屏蔽了像C那樣直接與操作系統(tǒng)或者OS接觸,讓Java語(yǔ)言操作全部建立在JVM的基礎(chǔ)之上從而做到了無(wú)視平臺(tái),一次編譯到處運(yùn)行。

JVM在運(yùn)行Java程序時(shí)會(huì)把自己管理的內(nèi)存劃分為以上區(qū)域(運(yùn)行時(shí)數(shù)據(jù)區(qū)),每個(gè)區(qū)域都有各自的用途以及在Java程序運(yùn)行時(shí)發(fā)揮著自己的作用,而其實(shí)運(yùn)行時(shí)數(shù)據(jù)區(qū)又會(huì)將運(yùn)行時(shí)數(shù)據(jù)區(qū)劃分為線程私有區(qū)以及線程共享區(qū)(GC不會(huì)發(fā)生在線程私有區(qū)),以下為各大區(qū)域具體作用:
方法區(qū)(Method Area):
方法區(qū)(在Java8之后方法區(qū)的概念更改為元數(shù)據(jù)空間)屬于線程共享的內(nèi)存區(qū)域,又稱Non-Heap(非堆),主要用于存儲(chǔ)已被虛擬機(jī)加載的類信息、常量、靜態(tài)變量、即時(shí)編譯器編譯后的代碼等數(shù)據(jù),根據(jù)Java 虛擬機(jī)規(guī)范的規(guī)定,當(dāng)方法區(qū)無(wú)法滿足內(nèi)存分配需求時(shí),將拋出OutOfMemoryError 異常。值得注意的是在方法區(qū)中存在一個(gè)叫運(yùn)行時(shí)常量池(Runtime Constant Pool)的區(qū)域,它主要用于存放編譯器生成的各種字面量和符號(hào)引用,這些內(nèi)容將在類加載后存放到運(yùn)行時(shí)常量池中,以便后續(xù)使用。
JVM堆(Java Heap):
Java 堆也是屬于線程共享的內(nèi)存區(qū)域,它在虛擬機(jī)啟動(dòng)時(shí)創(chuàng)建,是Java 虛擬機(jī)所管理的內(nèi)存中最大的一塊,主要用于存放對(duì)象實(shí)例,幾乎所有的對(duì)象實(shí)例都在這里分配內(nèi)存(并不是所有新建對(duì)象new Object()在分配時(shí)都會(huì)在堆中),注意Java 堆是垃圾收集器管理的主要區(qū)域,因此很多時(shí)候也被稱做GC 堆,如果在堆中沒(méi)有內(nèi)存完成實(shí)例分配,并且堆也無(wú)法再擴(kuò)展時(shí),將會(huì)拋出OutOfMemoryError 異常。
程序計(jì)數(shù)器(Program Counter Register):
屬于線程私有的數(shù)據(jù)區(qū)域,是一小塊內(nèi)存空間,主要代表當(dāng)前線程所執(zhí)行的字節(jié)碼行號(hào)指示器。字節(jié)碼解釋器工作時(shí),通過(guò)改變這個(gè)計(jì)數(shù)器的值來(lái)選取下一條需要執(zhí)行的字節(jié)碼指令,分支、循環(huán)、跳轉(zhuǎn)、異常處理、線程恢復(fù)等基礎(chǔ)功能都需要依賴這個(gè)計(jì)數(shù)器來(lái)完成,主要作用其實(shí)就是因?yàn)镃PU的時(shí)間片在調(diào)度線程工作時(shí)會(huì)發(fā)生“中斷”某個(gè)線程讓另外一個(gè)線程開(kāi)始工作,那么當(dāng)這個(gè)“中斷”的線程重新被CPU調(diào)度時(shí)如何得知上次執(zhí)行到那行代碼了?就是通過(guò)負(fù)責(zé)此類的程序計(jì)數(shù)器來(lái)得知。
虛擬機(jī)棧(Java Virtual Machine Stacks):
屬于線程私有的數(shù)據(jù)區(qū)域,與線程同時(shí)創(chuàng)建,總數(shù)與線程關(guān)聯(lián),代表Java方法執(zhí)行的內(nèi)存模型。當(dāng)線程開(kāi)始執(zhí)行時(shí),每個(gè)方法執(zhí)行時(shí)都會(huì)創(chuàng)建一個(gè)棧楨來(lái)存儲(chǔ)方法的的變量表、操作數(shù)棧、動(dòng)態(tài)鏈接方法、返回值、返回地址等信息。每個(gè)方法從調(diào)用直結(jié)束就對(duì)于一個(gè)棧楨在虛擬機(jī)棧中的入棧和出棧過(guò)程,如下:

本地方法棧(Native Method Stacks):
本地方法棧屬于線程私有的數(shù)據(jù)區(qū)域,這部分主要與虛擬機(jī)用到的 C所編寫(xiě)的 Native 方法相關(guān),當(dāng)有程序需要調(diào)用 Native 方法時(shí),JVM會(huì)在本地方法棧中維護(hù)著一張本地方法登記表,這里只是做登記是哪個(gè)線程調(diào)用的哪個(gè)本地方法接口,并不會(huì)在本地方法棧中直接發(fā)生調(diào)用,因?yàn)檫@里只是做一個(gè)調(diào)用登記,而真正的調(diào)用需要通過(guò)本地方法接口去調(diào)用本地方法庫(kù)中C編寫(xiě)的函數(shù),一般情況下,我們無(wú)需關(guān)心此區(qū)域。
之所以說(shuō)這塊的內(nèi)容是需要讓大家理解清楚JVM內(nèi)存模型和JMM內(nèi)存模型是完全兩個(gè)不同的概念,JVM內(nèi)存模型是處于Java的JVM虛擬機(jī)層面的,實(shí)際上對(duì)于操作系統(tǒng)來(lái)說(shuō),本質(zhì)上JVM還是存在于主存中,而JMM是Java語(yǔ)言與OS和硬件架構(gòu)層面的,主要作用是規(guī)定硬件架構(gòu)與Java語(yǔ)言的內(nèi)存模型,而本質(zhì)上不存在JMM這個(gè)東西,JMM只是一種規(guī)范,并不能說(shuō)是某些技術(shù)實(shí)現(xiàn)。
1.2、Java內(nèi)存模型JMM概述
Java內(nèi)存模型(即Java Memory Model,簡(jiǎn)稱JMM)本身是一種抽象的概念,并不真實(shí)存在,它描述的是一組規(guī)則或規(guī)范,通過(guò)這組規(guī)范定義了程序中各個(gè)變量(包括實(shí)例字段,靜態(tài)字段和構(gòu)成數(shù)組對(duì)象的元素)的訪問(wèn)方式。由于JVM運(yùn)行程序的實(shí)體是線程,而每個(gè)線程創(chuàng)建時(shí)JVM都會(huì)為其創(chuàng)建一個(gè)工作內(nèi)存(有些地方稱為??臻g),用于存儲(chǔ)線程私有的數(shù)據(jù),而Java內(nèi)存模型中規(guī)定所有變量都存儲(chǔ)在主內(nèi)存,主內(nèi)存是共享內(nèi)存區(qū)域,所有線程都可以訪問(wèn),但線程如果想要對(duì)一個(gè)變量讀取賦值等操作那么必須在工作內(nèi)存中進(jìn)行,所以線程想操作變量時(shí)首先要將變量從主內(nèi)存拷貝的自己的工作內(nèi)存空間,然后對(duì)變量進(jìn)行操作,操作完成后再將變量刷寫(xiě)回主內(nèi)存,不能直接操作主內(nèi)存中的變量,工作內(nèi)存中存儲(chǔ)著主內(nèi)存中的變量副本拷貝(PS:有些小伙伴可能會(huì)疑惑,Java中線程在執(zhí)行一個(gè)方法時(shí)就算里面引用或者創(chuàng)建了對(duì)象,他不是也存在堆中嗎?棧內(nèi)存儲(chǔ)的不僅僅只是對(duì)象的引用地址嗎?這里簡(jiǎn)單說(shuō)一下,當(dāng)線程真正運(yùn)行到這一行時(shí)會(huì)根據(jù)局部表中的對(duì)象引用地址去找到主存中的真實(shí)對(duì)象,然后會(huì)將對(duì)象拷貝到自己的工作內(nèi)存再操作.....,但是當(dāng)所操作的對(duì)象是一個(gè)大對(duì)象時(shí)(1MB+)并不會(huì)完全拷貝,而是將自己操作和需要的那部分成員拷貝),前面說(shuō)過(guò),工作內(nèi)存是每個(gè)線程的私有數(shù)據(jù)區(qū)域,因此不同的線程間無(wú)法訪問(wèn)對(duì)方的工作內(nèi)存,線程間的通信(傳值)必須通過(guò)主內(nèi)存來(lái)完成,其簡(jiǎn)要訪問(wèn)過(guò)程如下圖:

重點(diǎn)注意!?。MM與JVM內(nèi)存區(qū)域的劃分是不同的概念層次,在理解JMM的時(shí)候不要帶著JVM的內(nèi)存模型去理解,更恰當(dāng)說(shuō)JMM描述的是一組規(guī)則,通過(guò)這組規(guī)則控制程Java序中各個(gè)變量在共享數(shù)據(jù)區(qū)域和私有數(shù)據(jù)區(qū)域的訪問(wèn)方式,JMM是圍繞原子性,有序性、可見(jiàn)性拓展延伸的。JMM與Java內(nèi)存區(qū)域唯一相似點(diǎn),都存在共享數(shù)據(jù)區(qū)域和私有數(shù)據(jù)區(qū)域,在JMM中主內(nèi)存屬于共享數(shù)據(jù)區(qū)域,從某個(gè)程度上講應(yīng)該包括了堆和方法區(qū),而工作內(nèi)存數(shù)據(jù)線程私有數(shù)據(jù)區(qū)域,從某個(gè)程度上講則應(yīng)該包括程序計(jì)數(shù)器、虛擬機(jī)棧以及本地方法棧?;蛟S在某些地方,我們可能會(huì)看見(jiàn)主內(nèi)存被描述為堆內(nèi)存,工作內(nèi)存被稱為線程棧,實(shí)際上他們表達(dá)的都是同一個(gè)含義。關(guān)于JMM中的主內(nèi)存和工作內(nèi)存說(shuō)明如下:
主內(nèi)存: 主要存儲(chǔ)的是Java實(shí)例對(duì)象,所有線程創(chuàng)建的實(shí)例對(duì)象都存放在主內(nèi)存中(除開(kāi)開(kāi)啟了逃逸分析和標(biāo)量替換的棧上分配和TLAB分配),不管該實(shí)例對(duì)象是成員變量還是方法中的本地變量(也稱局部變量),當(dāng)然也包括了共享的類信息、常量、靜態(tài)變量。由于是共享數(shù)據(jù)區(qū)域,多條線程對(duì)同一個(gè)變量進(jìn)行非原子性操作時(shí)可能會(huì)發(fā)現(xiàn)線程安全問(wèn)題。
工作內(nèi)存: 主要存儲(chǔ)當(dāng)前方法的所有本地變量信息(工作內(nèi)存中存儲(chǔ)著主內(nèi)存中的變量副本拷貝),每個(gè)線程只能訪問(wèn)自己的工作內(nèi)存,即線程中的本地變量對(duì)其它線程是不可見(jiàn)的,就算是兩個(gè)線程執(zhí)行的是同一段代碼,它們也會(huì)各自在自己的工作內(nèi)存中創(chuàng)建屬于當(dāng)前線程的本地變量,當(dāng)然也包括了字節(jié)碼行號(hào)指示器、相關(guān)Native方法的信息。注意由于工作內(nèi)存是每個(gè)線程的私有數(shù)據(jù),線程間無(wú)法相互訪問(wèn)工作內(nèi)存,線程之間的通訊還是需要依賴于主存,因此存儲(chǔ)在工作內(nèi)存的數(shù)據(jù)不存在線程安全問(wèn)題。
弄清楚主內(nèi)存和工作內(nèi)存后,接了解一下主內(nèi)存與工作內(nèi)存的數(shù)據(jù)存儲(chǔ)類型以及操作方式,根據(jù)虛擬機(jī)規(guī)范,對(duì)于一個(gè)實(shí)例對(duì)象中的成員方法而言,如果方法中包含本地變量是基本數(shù)據(jù)類型(boolean,byte,short,char,int,long,float,double),將直接存儲(chǔ)在工作內(nèi)存的幀棧結(jié)構(gòu)中的局部變量表,但倘若本地變量是引用類型,那么該對(duì)象的在內(nèi)存中的具體引用地址將會(huì)被存儲(chǔ)在工作內(nèi)存的幀棧結(jié)構(gòu)中的局部變量表,而對(duì)象實(shí)例將存儲(chǔ)在主內(nèi)存(共享數(shù)據(jù)區(qū)域,堆)中。但對(duì)于實(shí)例對(duì)象的成員變量,不管它是基本數(shù)據(jù)類型或者包裝類型(Integer、Double等)還是引用類型,都會(huì)被存儲(chǔ)到堆區(qū)(棧上分配與TLAB分配除外)。至于static變量以及類本身相關(guān)信息將會(huì)存儲(chǔ)在主內(nèi)存中。需要注意的是,在主內(nèi)存中的實(shí)例對(duì)象可以被多線程共享,倘若兩條線程同時(shí)調(diào)用了同一個(gè)類的同一個(gè)方法,那么兩條線程會(huì)將要操作的數(shù)據(jù)拷貝一份到自己的工作內(nèi)存中,執(zhí)行完成操作后才刷新到主內(nèi)存,簡(jiǎn)單示意圖如下所示:




二、計(jì)算機(jī)硬件內(nèi)存架構(gòu)、OS與Java多線程實(shí)現(xiàn)原理及Java內(nèi)存模型
2.1、計(jì)算機(jī)硬件內(nèi)存架構(gòu)

正如上圖所示,經(jīng)過(guò)簡(jiǎn)化CPU與內(nèi)存操作的簡(jiǎn)易圖,實(shí)際上沒(méi)有這么簡(jiǎn)單,這里為了理解方便,我們省去了南北橋。就目前計(jì)算機(jī)而言,一般擁有多個(gè)CPU并且每個(gè)CPU可能存在多個(gè)核心,多核是指在一枚處理器(CPU)中集成兩個(gè)或多個(gè)完整的計(jì)算引擎(內(nèi)核),這樣就可以支持多任務(wù)并行執(zhí)行,從多線程的調(diào)度來(lái)說(shuō),每個(gè)線程都會(huì)映射到各個(gè)CPU核心中并行運(yùn)行。在CPU內(nèi)部有一組CPU寄存器,寄存器是cpu直接訪問(wèn)和處理的數(shù)據(jù),是一個(gè)臨時(shí)放數(shù)據(jù)的空間。一般CPU都會(huì)從內(nèi)存取數(shù)據(jù)到寄存器,然后進(jìn)行處理,但由于內(nèi)存的處理速度遠(yuǎn)遠(yuǎn)低于CPU,導(dǎo)致CPU在處理指令時(shí)往往花費(fèi)很多時(shí)間在等待內(nèi)存做準(zhǔn)備工作,于是在寄存器和主內(nèi)存間添加了CPU緩存,CPU緩存比較小,但訪問(wèn)速度比主內(nèi)存快得多,如果CPU總是操作主內(nèi)存中的同一址地的數(shù)據(jù),很容易影響CPU執(zhí)行速度,此時(shí)CPU緩存就可以把從內(nèi)存提取的數(shù)據(jù)暫時(shí)保存起來(lái),如果寄存器要取內(nèi)存中同一位置的數(shù)據(jù),直接從緩存中提取,無(wú)需直接從主內(nèi)存取。需要注意的是,寄存器并不每次數(shù)據(jù)都可以從緩存中取得數(shù)據(jù),萬(wàn)一不是同一個(gè)內(nèi)存地址中的數(shù)據(jù),那寄存器還必須直接繞過(guò)緩存從內(nèi)存中取數(shù)據(jù)。所以并不每次都得到緩存中取數(shù)據(jù),這種現(xiàn)象有個(gè)專業(yè)的名稱叫做緩存的命中率,從緩存中取就命中,不從緩存中取從內(nèi)存中取,就沒(méi)命中,可見(jiàn)緩存命中率的高低也會(huì)影響CPU執(zhí)行性能,這就是CPU、緩存以及主內(nèi)存間的簡(jiǎn)要交互過(guò)程,總而言之當(dāng)一個(gè)CPU需要訪問(wèn)主存時(shí),會(huì)先讀取一部分主存數(shù)據(jù)到CPU緩存(當(dāng)然如果CPU緩存中存在需要的數(shù)據(jù)就會(huì)直接從緩存獲取),進(jìn)而在讀取CPU緩存到寄存器,當(dāng)CPU需要寫(xiě)數(shù)據(jù)到主存時(shí),同樣會(huì)先刷新寄存器中的數(shù)據(jù)到CPU緩存,然后再把數(shù)據(jù)刷新到主內(nèi)存中。實(shí)則就類似于Appcalition(Java) --> Cache(Redis) --> DB(MySQL)的關(guān)系,Java程序的性能由于DB需要走磁盤(pán)受到了影響,導(dǎo)致Java程序在處理請(qǐng)求時(shí)需要等到DB的處理結(jié)果,而此時(shí)負(fù)責(zé)處理該請(qǐng)求的線程一直處于阻塞等待狀態(tài),只有當(dāng)DB處理結(jié)果返回了再繼續(xù)負(fù)責(zé)工作,那么實(shí)際上整個(gè)模型中的問(wèn)題是:DB的速度跟不上Java程序的性能,導(dǎo)致整個(gè)請(qǐng)求處理起來(lái)變的很慢,但是實(shí)際上在DB處理的過(guò)程Java的線程是處于阻塞不工作的狀態(tài)的,那么實(shí)際上是沒(méi)有必要的,因?yàn)檫@樣最終會(huì)導(dǎo)致整體系統(tǒng)的吞吐量下降,此時(shí)我們可以加入Cache(Redis)來(lái)提升程序響應(yīng)效率,從而整體提升系統(tǒng)吞吐和性能。(實(shí)際上我們做性能優(yōu)化的目的就是讓系統(tǒng)的每個(gè)層面處理的速度加快,而架構(gòu)實(shí)際上就是設(shè)計(jì)一套能夠吞吐更大量的請(qǐng)求的系統(tǒng))。
2.2、OS與JVM線程關(guān)系及Java線程實(shí)現(xiàn)原理
在以上的闡述中我們大致了解完了硬件的內(nèi)存架構(gòu)和JVM內(nèi)存模型以及Java內(nèi)存模型之后,接著了解Java中線程的實(shí)現(xiàn)原理,理解線程的實(shí)現(xiàn)原理,有助于我們了解Java內(nèi)存模型與硬件內(nèi)存架構(gòu)的關(guān)系,在Windows OS和Linux OS上,Java線程的實(shí)現(xiàn)是基于一對(duì)一的線程模型,所謂的一對(duì)一模型,實(shí)際上就是通過(guò)語(yǔ)言級(jí)別層面程序去間接調(diào)用系統(tǒng)內(nèi)核的線程模型,即我們?cè)谑褂肑ava線程時(shí),比如:new Thread(Runnable);JVM內(nèi)部是轉(zhuǎn)而調(diào)用當(dāng)前操作系統(tǒng)的內(nèi)核線程來(lái)完成當(dāng)前Runnable任務(wù)。這里需要了解一個(gè)術(shù)語(yǔ),內(nèi)核線程(Kernel-Level Thread,KLT),它是由操作系統(tǒng)內(nèi)核(Kernel)支持的線程,這種線程是由操作系統(tǒng)內(nèi)核來(lái)完成線程切換,內(nèi)核通過(guò)操作調(diào)度器進(jìn)而對(duì)線程執(zhí)行調(diào)度,并將線程的任務(wù)映射到各個(gè)處理器上。每個(gè)內(nèi)核線程可以視為內(nèi)核的一個(gè)分身,這也就是操作系統(tǒng)可以同時(shí)處理多任務(wù)的原因。由于我們編寫(xiě)的多線程程序?qū)儆谡Z(yǔ)言層面的,程序一般不會(huì)直接去調(diào)用內(nèi)核線程,取而代之的是一種輕量級(jí)的進(jìn)程(Light Weight Process),也是通常意義上的線程,由于每個(gè)輕量級(jí)進(jìn)程都會(huì)映射到一個(gè)內(nèi)核線程,因此我們可以通過(guò)輕量級(jí)進(jìn)程調(diào)用內(nèi)核線程,進(jìn)而由操作系統(tǒng)內(nèi)核將任務(wù)映射到各個(gè)處理器,這種輕量級(jí)進(jìn)程與內(nèi)核線程間1對(duì)1的關(guān)系就稱為Java程序中的線程與OS的一對(duì)一模型。如下圖:

Java程序中的每個(gè)線程都會(huì)經(jīng)過(guò)OS被映射到CPU中進(jìn)行處理,當(dāng)然,如果CPU存在多核,那么一個(gè)CPU同時(shí)也能并行調(diào)度執(zhí)行多個(gè)線程。
2.3、JMM與硬件內(nèi)存架構(gòu)的關(guān)系
通過(guò)對(duì)前面的JVM內(nèi)存模型、Java內(nèi)存模型JMM、硬件內(nèi)存架構(gòu)以及Java多線程的實(shí)現(xiàn)原理,我們可以發(fā)現(xiàn),多線程的執(zhí)行最終都會(huì)映射到硬件處理器上進(jìn)行執(zhí)行,但Java內(nèi)存模型和硬件內(nèi)存架構(gòu)并不完全一致。對(duì)于硬件內(nèi)存來(lái)說(shuō)只有寄存器、緩存內(nèi)存、主內(nèi)存的概念,并沒(méi)有工作內(nèi)存(線程私有數(shù)據(jù)區(qū)域)和主內(nèi)存(堆內(nèi)存)之分,也就是說(shuō)Java內(nèi)存模型對(duì)內(nèi)存的劃分對(duì)硬件內(nèi)存并沒(méi)有任何影響,因?yàn)镴MM只是一種抽象的概念,是一組規(guī)則,并不實(shí)際存在,不管是工作內(nèi)存的數(shù)據(jù)還是主內(nèi)存的數(shù)據(jù),對(duì)于計(jì)算機(jī)硬件來(lái)說(shuō)都會(huì)存儲(chǔ)在計(jì)算機(jī)主內(nèi)存中,當(dāng)然也有可能存儲(chǔ)到CPU緩存或者寄存器中,因此總體上來(lái)說(shuō),Java內(nèi)存模型和計(jì)算機(jī)硬件內(nèi)存架構(gòu)是一個(gè)相互交叉的關(guān)系,是一種抽象概念劃分與真實(shí)物理硬件的交叉。(注意對(duì)于JVM內(nèi)存區(qū)域劃分也是同樣的道理)

2.4、為什么需要有JMM的存在?
接著來(lái)談?wù)凧ava內(nèi)存模型存在的必要性,因?yàn)槲覀內(nèi)W(xué)習(xí)某個(gè)知識(shí)的話要做知其然知其所以然。由于線程是OS的最小操作單位,那么所有的程序運(yùn)行時(shí)的實(shí)體本質(zhì)上都是是一條條線程,Java程序需要運(yùn)行在OS上也不例外,而每個(gè)線程創(chuàng)建時(shí)JVM都會(huì)為其創(chuàng)建一個(gè)工作內(nèi)存(有些地方稱為??臻g),用于存儲(chǔ)線程私有的數(shù)據(jù),線程如果想要操作主存中的某個(gè)變量,那么必須通過(guò)工作內(nèi)存間接完成,主要過(guò)程是將變量從主內(nèi)存拷貝的線程自己的工作內(nèi)存空間,然后對(duì)變量先在工作內(nèi)存中進(jìn)行操作,操作完成后再將變量刷寫(xiě)回主內(nèi)存,如果存在兩個(gè)線程同時(shí)對(duì)一個(gè)主內(nèi)存中的實(shí)例對(duì)象的變量進(jìn)行操作就有可能誘發(fā)線程安全問(wèn)題。如下圖,主內(nèi)存中存在一個(gè)共享變量int i = 0,
第一種情況(左圖):
現(xiàn)在有A和B兩條線程分別對(duì)該變量i進(jìn)行操作,A/B線程各自的都會(huì)先將主存中的i拷貝到自己的工作內(nèi)存存儲(chǔ)為共享變量副本i,然后再對(duì)i進(jìn)行自增操作,那么假設(shè)此時(shí)A/B同時(shí)將主存中i=0拷貝到自己的工作內(nèi)存中進(jìn)行操作,那么其實(shí)A在自己工作內(nèi)存中的i進(jìn)行自增操作是對(duì)B的工作內(nèi)存的副本i不可見(jiàn)的,那么A做了自增操作之后會(huì)將結(jié)果1刷寫(xiě)回主存,此時(shí)B也做了i++操作,那么實(shí)際上B刷寫(xiě)回主存的值也是基于之前從主存中拷貝到自己工作內(nèi)存的值i=0,那么實(shí)際上B刷寫(xiě)回主存的值也是1,但是實(shí)際上我是兩條線程都對(duì)主存中 i 進(jìn)行了自增操作,理想結(jié)果應(yīng)該是i=2,但是此時(shí)的情況結(jié)果確實(shí)i=1。
第二種情況(右圖):
假設(shè)現(xiàn)在A線程想要修改 i 的值為2,而B(niǎo)線程卻想要讀取 i 的值,那么B線程讀取到的值是A線程更新后的值2還是更新前的值1呢?答案是不確定,即B線程有可能讀取到A線程更新前的值1,也有可能讀取到A線程更新后的值2,這是因?yàn)楣ぷ鲀?nèi)存是每個(gè)線程私有的數(shù)據(jù)區(qū)域,而線程A修改變量 i 時(shí),首先是將變量從主內(nèi)存拷貝到A線程的工作內(nèi)存中,然后對(duì)變量進(jìn)行操作,操作完成后再將變量 i 寫(xiě)回主內(nèi),而對(duì)于B線程的也是類似的,這樣就有可能造成主內(nèi)存與工作內(nèi)存間數(shù)據(jù)存在一致性問(wèn)題,假如A線程修改完后正在將數(shù)據(jù)寫(xiě)回主內(nèi)存,而B(niǎo)線程此時(shí)正在讀取主內(nèi)存,即將i=1拷貝到自己的工作內(nèi)存中,這樣B線程讀取到的值就是x=1,但如果A線程已將x=2寫(xiě)回主內(nèi)存后,B線程才開(kāi)始讀取的話,那么此時(shí)B線程讀取到的就是x=2,但到底是哪種情況先發(fā)生呢?這是不確定的。
所以如上兩種情況對(duì)于程序來(lái)說(shuō)是不應(yīng)該的,假設(shè)把這個(gè)變量i換成淘寶雙十一的商品庫(kù)存數(shù),A/B線程換成參加雙十一的用戶,那么這樣會(huì)導(dǎo)致的問(wèn)題就是對(duì)于淘寶業(yè)務(wù)團(tuán)隊(duì)來(lái)說(shuō),可能會(huì)導(dǎo)致超賣(mài),重復(fù)賣(mài)等問(wèn)題的出現(xiàn),這會(huì)由于因?yàn)榧夹g(shù)上的問(wèn)題導(dǎo)致出現(xiàn)業(yè)務(wù)經(jīng)濟(jì)上的損失,尤其是是在類似于淘寶雙十一此類的大促活動(dòng)上此類問(wèn)題如果不控制恰當(dāng),出現(xiàn)問(wèn)題的風(fēng)險(xiǎn)會(huì)成倍增長(zhǎng),其實(shí)這也就是所謂的線程安全問(wèn)題。

為了解決類似如上闡述的問(wèn)題,JVM定義了一組規(guī)則,通過(guò)這組規(guī)則來(lái)決定一個(gè)線程對(duì)共享變量的寫(xiě)入何時(shí)對(duì)另一個(gè)線程可見(jiàn),這組規(guī)則也稱為Java內(nèi)存模型(JMM),JMM整體是圍繞著程序執(zhí)行的原子性、有序性、可見(jiàn)性展開(kāi)的,下面我們看看這三個(gè)特性。
2.5、Java內(nèi)存模型JMM圍繞的三大特性
2.5.1、原子性
原子性指的是一個(gè)操作是不可中斷的,即使是在多線程環(huán)境下,一個(gè)操作一旦開(kāi)始就不會(huì)被其他線程影響。比如對(duì)于一個(gè)靜態(tài)變量int i = 0,兩條線程同時(shí)對(duì)他賦值,線程A操作為 i = 1,而線程B操作為 i = 2,不管線程如何運(yùn)行,最終 i 的值要么是1,要么是2,線程A和線程B間的操作是沒(méi)有干擾的,這就是原子性操作,不可被中斷的特點(diǎn)。
有點(diǎn)要注意的是,對(duì)于32位系統(tǒng)的來(lái)說(shuō),long類型數(shù)據(jù)和double類型數(shù)據(jù)(對(duì)于基本數(shù)據(jù)類型,byte,short,int,float,boolean,char讀寫(xiě)是原子操作),它們的讀寫(xiě)并非原子性的,也就是說(shuō)如果存在兩條線程同時(shí)對(duì)long類型或者double類型的數(shù)據(jù)進(jìn)行讀寫(xiě)是存在相互干擾的,因?yàn)閷?duì)于32位虛擬機(jī)來(lái)說(shuō),每次原子讀寫(xiě)是32位的,而long和double則是64位的存儲(chǔ)單元,這樣會(huì)導(dǎo)致一個(gè)線程在寫(xiě)時(shí),操作完前32位的原子操作后,輪到B線程讀取時(shí),恰好只讀取到了后32位的數(shù)據(jù),這樣可能會(huì)讀取到一個(gè)既非原值又不是線程修改值的變量,它可能是“半個(gè)變量”的數(shù)值,即64位數(shù)據(jù)被兩個(gè)線程分成了兩次讀取。但也不必太擔(dān)心,因?yàn)樽x取到“半個(gè)變量”的情況比較少見(jiàn),至少在目前的商用的虛擬機(jī)中,幾乎都把64位的數(shù)據(jù)的讀寫(xiě)操作作為原子操作來(lái)執(zhí)行,因此對(duì)于這個(gè)問(wèn)題不必太在意,知道這么回事即可。
那么其實(shí)本質(zhì)上原子性操作指的就是一組大操作要么就全部執(zhí)行成功,要么就全部失敗,舉個(gè)例子:下單:{增加訂單,減庫(kù)存} 那么對(duì)于用戶來(lái)說(shuō)下單是一個(gè)操作,那么系統(tǒng)就必須保證下單操作的原子性,要么就增加訂單和減庫(kù)存全部成功,不存在增加訂單成功,減庫(kù)存失敗,那么這個(gè)例子從宏觀上來(lái)就就是一個(gè)原子性操作,非原子性操作反之,線程安全問(wèn)題產(chǎn)生的根本原因也是由于多線程情況下對(duì)一個(gè)共享資源進(jìn)行非原子性操作導(dǎo)致的。
但是有個(gè)點(diǎn)在我們深入研究Java的并發(fā)編程以及在研究可見(jiàn)性之前時(shí)需要注意的,就是計(jì)算機(jī)在程序執(zhí)行的時(shí)候?qū)λ膬?yōu)化操作 -- 指令重排。計(jì)算機(jī)在執(zhí)行程序時(shí),為了提高性能,編譯器和處理器的常常會(huì)對(duì)指令做重排,一般分以下3種:
- 編譯器優(yōu)化的重排: 編譯器在不改變單線程程序語(yǔ)義的前提下,可以重新安排語(yǔ)句的執(zhí)行順序。
- 指令并行的重排: 現(xiàn)代處理器采用了指令級(jí)并行技術(shù)來(lái)將多條指令重疊執(zhí)行。如果不存在數(shù)據(jù)依賴性(即后一個(gè)執(zhí)行的語(yǔ)句無(wú)需依賴前面執(zhí)行的語(yǔ)句的結(jié)果),處理器可以改變語(yǔ)句對(duì)應(yīng)的機(jī)器指令的執(zhí)行順序。
-
內(nèi)存系統(tǒng)的重排: 由于處理器使用緩存和讀寫(xiě)緩存沖區(qū),這使得加載(load)和存儲(chǔ)(store)操作看上去可能是在亂序執(zhí)行,因?yàn)槿?jí)緩存的存在,導(dǎo)致內(nèi)存與緩存的數(shù)據(jù)同步存在時(shí)間差。
其中編譯器優(yōu)化的重排屬于編譯期重排,指令并行的重排和內(nèi)存系統(tǒng)的重排屬于處理器重排,在多線程環(huán)境中,這些重排優(yōu)化可能會(huì)導(dǎo)致程序出現(xiàn)內(nèi)存可見(jiàn)性問(wèn)題,下面分別闡明這兩種重排優(yōu)化可能帶來(lái)的問(wèn)題。
2.5.1.1、編譯器優(yōu)化指令重排
int a = 0;
int b = 0;
//線程A 線程B
代碼1:int x = a; 代碼3:int y = b;
代碼2:b = 1; 代碼4:a = 2;
此時(shí)有4行代碼1、2、3、4,其中1、2屬于線程A,其中3、4屬于線程B,兩個(gè)線程同時(shí)執(zhí)行,從程序的執(zhí)行上來(lái)看由于并行執(zhí)行的原因最終的結(jié)果 x = 0;y=0; 本質(zhì)上是不會(huì)出現(xiàn) x = 2;y = 1; 這種結(jié)果,但是實(shí)際上來(lái)說(shuō)這種情況是有概率出現(xiàn)的,因?yàn)榫幾g器一般會(huì)對(duì)一些代碼前后不影響、耦合度為0的代碼行進(jìn)行編譯器優(yōu)化的指令重排,假設(shè)此時(shí)編譯器對(duì)這段代碼指令重排優(yōu)化之后,可能會(huì)出現(xiàn)如下情況:
//線程A 線程B
代碼2:b = 1; 代碼4:a = 2;
代碼1:int x = a; 代碼3:int y = b;
這種情況下再結(jié)合之前的線程安全問(wèn)題一起理解,那么就可能出現(xiàn) x = 2;y = 1; 這種結(jié)果,這也就說(shuō)明在多線程環(huán)境下,由于編譯器會(huì)對(duì)代碼做指令重排的優(yōu)化的操作(因?yàn)橐话愦a都是由上往下執(zhí)行,指令重排是OS對(duì)單線程運(yùn)行的優(yōu)化),最終導(dǎo)致在多線程環(huán)境下時(shí)多個(gè)線程使用變量能否保證一致性是無(wú)法確定的(PS:編譯器重排的基礎(chǔ)是代碼不存在依賴性時(shí)才會(huì)進(jìn)行的,而依賴性可分為兩種:數(shù)據(jù)依賴(int a = 1;int b = a;)和控制依賴(boolean f = ture;if(f){sout("123");}))。
2.5.1.2、處理器指令重排
先了解一下指令重排的概念,處理器指令重排是對(duì)CPU的性能優(yōu)化,從指令的執(zhí)行角度來(lái)說(shuō)一條指令可以分為多個(gè)步驟完成,如下:
取指:IF
譯碼和取寄存器操作數(shù):ID
執(zhí)行或者有效地址計(jì)算:EX
存儲(chǔ)器訪問(wèn):MEM
寫(xiě)回:WB
CPU在工作時(shí),需要將上述指令分為多個(gè)步驟依次執(zhí)行(注意硬件不同有可能不一樣),由于每一個(gè)步會(huì)使用到不同的硬件操作,比如取指時(shí)會(huì)只有PC寄存器和存儲(chǔ)器,譯碼時(shí)會(huì)執(zhí)行到指令寄存器組,執(zhí)行時(shí)會(huì)執(zhí)行ALU(算術(shù)邏輯單元)、寫(xiě)回時(shí)使用到寄存器組。為了提高硬件利用率,CPU指令是按流水線技術(shù)來(lái)執(zhí)行的,如下:

(流水線技術(shù):類似于工廠中的生產(chǎn)流水線,工人們各司其職,做完自己的就往后面?zhèn)?,然后開(kāi)始一個(gè)新的,做完了再往后面?zhèn)鬟f.....而指令執(zhí)行也是一樣的,如果等到一條指令執(zhí)行完畢之后再開(kāi)始下一條的執(zhí)行,就好比工廠的生產(chǎn)流水線,先等到一個(gè)產(chǎn)品生產(chǎn)完畢之后再開(kāi)始下一個(gè),效率非常低下并且浪費(fèi)人工,這樣一條流水線上同時(shí)只會(huì)有一個(gè)工人在做事,其他的看著,只有當(dāng)這個(gè)產(chǎn)品走了最后一個(gè)人手上了并且最后一個(gè)工人完成了組裝之后第一個(gè)工人再開(kāi)始第二個(gè)產(chǎn)品的工作)
從圖中可以看出當(dāng)指令1還未執(zhí)行完成時(shí),第2條指令便利用空閑的硬件開(kāi)始執(zhí)行,這樣做是有好處的,如果每個(gè)步驟花費(fèi)1ms,那么如果第2條指令需要等待第1條指令執(zhí)行完成后再執(zhí)行的話,則需要等待5ms,但如果使用流水線技術(shù)的話,指令2只需等待1ms就可以開(kāi)始執(zhí)行了,這樣就能大大提升CPU的執(zhí)行性能。雖然流水線技術(shù)可以大大提升CPU的性能,但不幸的是一旦出現(xiàn)流水中斷,所有硬件設(shè)備將會(huì)進(jìn)入一輪停頓期,當(dāng)再次彌補(bǔ)中斷點(diǎn)可能需要幾個(gè)周期,這樣性能損失也會(huì)很大,就好比工廠組裝手機(jī)的流水線,一旦某個(gè)零件組裝中斷,那么該零件往后的工人都有可能進(jìn)入一輪或者幾輪等待組裝零件的過(guò)程。因此我們需要盡量阻止指令中斷的情況,指令重排就是其中一種優(yōu)化中斷的手段,我們通過(guò)一個(gè)例子來(lái)闡明指令重排是如何阻止流水線技術(shù)中斷的,如下:
i = a + b;
y = c - d;

| 指令 | 描述 |
|---|---|
| LW R1,a | LW指令表示 load,其中LW R1,a表示把a(bǔ)的值加載到寄存器R1中 |
| LW R2,b | 表示把b的值加載到寄存器R2中 |
| ADD R3,R1,R2 | ADD指令表示加法,把R1 、R2的值相加,并存入R3寄存器中。 |
| SW i,R3 | SW表示 store 即將 R3寄存器的值保持到變量i中 |
| LW R4,c | 表示把c的值加載到寄存器R4中 |
| LW R5,d | 表示把d的值加載到寄存器R5中 |
| SUB R6,R4,R5 | SUB指令表示減法,把R4、R5的值相減,并存入R6寄存器中。 |
| SW y,R6 | 表示將R6寄存器的值保持到變量y中 |
上述便是匯編指令的執(zhí)行過(guò)程,在某些指令上存在X的標(biāo)志,X代表中斷的含義,也就是只要有X的地方就會(huì)導(dǎo)致指令流水線技術(shù)停頓,同時(shí)也會(huì)影響后續(xù)指令的執(zhí)行,可能需要經(jīng)過(guò)1個(gè)或幾個(gè)指令周期才可能恢復(fù)正常,那為什么停頓呢?這是因?yàn)椴糠謹(jǐn)?shù)據(jù)還沒(méi)準(zhǔn)備好,如執(zhí)行ADD指令時(shí),需要使用到前面指令的數(shù)據(jù)R1,R2,而此時(shí)R2的MEM操作沒(méi)有完成,即未拷貝到存儲(chǔ)器中,這樣加法計(jì)算就無(wú)法進(jìn)行,必須等到MEM操作完成后才能執(zhí)行,也就因此而停頓了,其他指令也是類似的情況。前面講過(guò),停頓會(huì)造成CPU性能下降,因此我們應(yīng)該想辦法消除這些停頓,這時(shí)就需要使用到指令重排了,如下圖,既然ADD指令需要等待,那我們就利用等待的時(shí)間做些別的事情,如把LW R4,c 和 LW R5,d 移動(dòng)到前面執(zhí)行,畢竟LW R4,c 和 LW R5,d執(zhí)行并沒(méi)有數(shù)據(jù)依賴關(guān)系,對(duì)他們有數(shù)據(jù)依賴關(guān)系的SUB R6,R5,R4指令在R4,R5加載完成后才執(zhí)行的,沒(méi)有影響,過(guò)程如下:


正如上圖所示,所有的停頓都完美消除了,指令流水線也無(wú)需中斷了,這樣CPU的性能也能帶來(lái)很好的提升,這就是處理器指令重排的作用。關(guān)于編譯器重排以及指令重排(這兩種重排我們后面統(tǒng)一稱為指令重排)相關(guān)內(nèi)容已闡述清晰了,我們必須意識(shí)到對(duì)于單線程而已指令重排幾乎不會(huì)帶來(lái)任何影響,比竟重排的前提是保證串行語(yǔ)義執(zhí)行的一致性,但對(duì)于多線程環(huán)境而已,指令重排就可能導(dǎo)致嚴(yán)重的程序輪序執(zhí)行問(wèn)題,如下:
int a = 0;
boolean f = false;
public void methodA(){
a = 1;
f = true;
}
public void methodB(){
if(f){
int i = a + 1;
}
}
如上述代碼,同時(shí)存在線程A和線程B對(duì)該實(shí)例對(duì)象進(jìn)行操作,其中A線程調(diào)用methodA方法,而B(niǎo)線程調(diào)用methodB方法,由于指令重排等原因,可能導(dǎo)致程序執(zhí)行順序變?yōu)槿缦拢?/p>
線程A 線程B
methodA: methodB:
代碼1:f= true; 代碼1:f= true;
代碼2:a = 1; 代碼2: a = 0 ; //讀取到了未更新的a
代碼3: i = a + 1;
由于指令重排的原因,線程A的f置為true被提前執(zhí)行了,而線程A還在執(zhí)行a=1,此時(shí)因?yàn)閒=true了,所以線程B正好讀取f的值為true,直接獲取a的值,而此時(shí)線程A還在自己的工作內(nèi)存中對(duì)當(dāng)中拷貝過(guò)來(lái)的變量副本a進(jìn)行賦值操作,結(jié)果還未刷寫(xiě)到主存,那么此時(shí)線程B讀取到的a值還是為0,那么拷貝到線程B工作內(nèi)存的a=0;然后并在自己的工作內(nèi)存中執(zhí)行了 i = a + 1操作,而此時(shí)線程B因?yàn)樘幚砥鞯闹噶钪嘏旁蜃x取a是為0的,導(dǎo)致最終 i 結(jié)果的值為1,而不是預(yù)期的2,這就是多線程環(huán)境下,指令重排導(dǎo)致的程序亂序執(zhí)行的結(jié)果。因此,請(qǐng)記住,指令重排只會(huì)保證單線程中串行語(yǔ)義的執(zhí)行的一致性,能夠在單線程環(huán)境下通過(guò)指令重排優(yōu)化程序,消除CPU停頓,但是并不會(huì)關(guān)心多線程間的語(yǔ)義一致性。
2.5.2、可見(jiàn)性
經(jīng)過(guò)前面的闡述,如果真正理解了指令重排現(xiàn)象之后的小伙伴再來(lái)理解可見(jiàn)性容易了,可見(jiàn)性指的是當(dāng)一個(gè)線程修改了某個(gè)共享變量的值,其他線程是否能夠馬上得知這個(gè)修改的值。對(duì)于串行程序來(lái)說(shuō),可見(jiàn)性是不存在的,因?yàn)槲覀冊(cè)谌魏我粋€(gè)操作中修改了某個(gè)變量的值,后續(xù)的操作中都能讀取這個(gè)變量值,并且是修改過(guò)的新值。但在多線程環(huán)境中可就不一定了,前面我們分析過(guò),由于線程對(duì)共享變量的操作都是線程拷貝到各自的工作內(nèi)存進(jìn)行操作后才寫(xiě)回到主內(nèi)存中的,這就可能存在一個(gè)線程A修改了共享變量 i 的值,還未寫(xiě)回主內(nèi)存時(shí),另外一個(gè)線程B又對(duì)主內(nèi)存中同一個(gè)共享變量 i 進(jìn)行操作,但此時(shí)A線程工作內(nèi)存中共享變量 i 對(duì)線程B來(lái)說(shuō)并不可見(jiàn),這種工作內(nèi)存與主內(nèi)存同步延遲現(xiàn)象就造成了可見(jiàn)性問(wèn)題,另外指令重排以及編譯器優(yōu)化也可能導(dǎo)致可見(jiàn)性問(wèn)題,通過(guò)前面的分析,我們知道無(wú)論是編譯器優(yōu)化還是處理器優(yōu)化的重排現(xiàn)象,在多線程環(huán)境下,確實(shí)會(huì)導(dǎo)致程序輪序執(zhí)行的問(wèn)題,從而也就導(dǎo)致可見(jiàn)性問(wèn)題。
2.5.3、有序性
有序性是指對(duì)于單線程的執(zhí)行代碼,我們總是認(rèn)為代碼的執(zhí)行是按順序依次執(zhí)行的,這樣的理解如果是放在單線程環(huán)境下沒(méi)有問(wèn)題,畢竟對(duì)于單線程而言確實(shí)如此,代碼由編碼的順序從上往下執(zhí)行,就算發(fā)生指令重排序,由于所有硬件優(yōu)化的前提都是必須遵守as-if-serial語(yǔ)義,所以不管怎么排序,都不會(huì)且不能影響單線程程序的執(zhí)行結(jié)果,我們將這稱之為有序執(zhí)行。反之,對(duì)于多線程環(huán)境,則可能出現(xiàn)亂序現(xiàn)象,因?yàn)槌绦蚓幾g成機(jī)器碼指令后可能會(huì)出現(xiàn)指令重排現(xiàn)象,重排后的指令與原指令的順序未必一致。要明白的是,在Java程序中,倘若在本線程內(nèi),所有操作都視為有序行為,如果是多線程環(huán)境下,一個(gè)線程中觀察另外一個(gè)線程,所有操作都是無(wú)序的,前半句指的是單線程內(nèi)保證串行語(yǔ)義執(zhí)行的一致性,后半句則指指令重排現(xiàn)象和工作內(nèi)存與主內(nèi)存同步延遲現(xiàn)象。
2.6、Java中JMM是怎么去解決如上問(wèn)題的?
在真正的理解了如上所以內(nèi)容之后,再來(lái)看Java為我們提供的解決方案,如原子性問(wèn)題,除了JVM自身提供的對(duì)基本數(shù)據(jù)類型讀寫(xiě)操作的原子性外,對(duì)于方法級(jí)別或者代碼塊級(jí)別的原子性操作,可以使用synchronized關(guān)鍵字或者Lock鎖接口的實(shí)現(xiàn)類來(lái)保證程序執(zhí)行的原子性,關(guān)于synchronized的詳解(能保證三特性不能禁止指令重排),后續(xù)我們會(huì)講到。而工作內(nèi)存與主內(nèi)存同步延遲現(xiàn)象導(dǎo)致的可見(jiàn)性問(wèn)題,可以使用加鎖或者Volatile關(guān)鍵字解決,它們都可以使一個(gè)線程修改后的變量立即對(duì)其他線程可見(jiàn)。對(duì)于指令重排導(dǎo)致的可見(jiàn)性問(wèn)題和有序性問(wèn)題,則可以利用volatile關(guān)鍵字解決,因?yàn)関olatile的另外一個(gè)作用就是禁止重排序優(yōu)化,關(guān)于volatile稍后會(huì)進(jìn)一步分析。除了靠sychronized和volatile關(guān)鍵字(volatile關(guān)鍵字不能保證原子性,只能保證的是禁止指令重排與可見(jiàn)性問(wèn)題)來(lái)保證原子性、可見(jiàn)性以及有序性外,JMM內(nèi)部還定義一套happens-before 原則來(lái)保證多線程環(huán)境下兩個(gè)操作間的原子性、可見(jiàn)性以及有序性。
2.7、Java內(nèi)存模型JMM中的happens-before 原則
2.7.1、線程在執(zhí)行的過(guò)程中與內(nèi)存的交互
不過(guò)在了解JMM中的happens-before 原則之前先對(duì)于線程執(zhí)行過(guò)程中與內(nèi)存的交互操作要有一個(gè)簡(jiǎn)單的認(rèn)知,Java程序在執(zhí)行的過(guò)程中實(shí)際上就是OS在調(diào)度JVM的“線程”執(zhí)行,而在執(zhí)行的過(guò)程中是與內(nèi)存的交互操作,而內(nèi)存交互操作有8種(虛擬機(jī)實(shí)現(xiàn)必須保證每一個(gè)操作都是原子的,不可在分的,對(duì)于double和long類型的變量來(lái)說(shuō),load、store、read和write操作在某些平臺(tái)上允許例外):
- lock(鎖定):作用于主內(nèi)存的變量,把一個(gè)變量標(biāo)識(shí)為線程獨(dú)占狀態(tài);
- unlock(解鎖):作用于主內(nèi)存的變量,它把一個(gè)處于鎖定狀態(tài)的變量釋放出來(lái),釋放后的變量才可以被其他線程鎖定;
- read(讀?。鹤饔糜谥鲀?nèi)存變量,它把一個(gè)變量的值從主內(nèi)存?zhèn)鬏數(shù)骄€程的工作內(nèi)存中,以便隨后的load動(dòng)作使用;
- load(載入):作用于工作內(nèi)存的變量,它把read操作從主存中變量放入工作內(nèi)存中;
- use(使用):作用于工作內(nèi)存中的變量,它把工作內(nèi)存中的變量傳輸給執(zhí)行引擎,每當(dāng)虛擬機(jī)遇到一個(gè)需要使用到變量的值,就會(huì)使用到這個(gè)指令;
- assign(賦值):作用于工作內(nèi)存中的變量,它把一個(gè)從執(zhí)行引擎中接受到的值放入工作內(nèi)存的變量副本中;
- store(存儲(chǔ)):作用于主內(nèi)存中的變量,它把一個(gè)從工作內(nèi)存中一個(gè)變量的值傳送到主內(nèi)存中,以便后續(xù)的write使用;
- write(寫(xiě)入):作用于主內(nèi)存中的變量,它把store操作從工作內(nèi)存中得到的變量的值放入主內(nèi)存的變量中
JMM對(duì)這八種指令的使用,制定了如下規(guī)則:
- 1)、不允許read和load、store和write操作之一單獨(dú)出現(xiàn)。即使用了read必須load,使用了store必須write;
- 2)、不允許線程丟棄他最近的assign操作,即工作變量的數(shù)據(jù)改變了之后,必須告知主存;
- 3)、不允許一個(gè)線程將沒(méi)有assign的數(shù)據(jù)從工作內(nèi)存同步回主內(nèi)存;
- 4)、一個(gè)新的變量必須在主內(nèi)存中誕生,不允許工作內(nèi)存直接使用一個(gè)未被初始化的變量。就是懟變量實(shí)施use、store操作之前,必須經(jīng)過(guò)assign和load操作;
- 5)、一個(gè)變量同一時(shí)間只有一個(gè)線程能對(duì)其進(jìn)行l(wèi)ock。多次lock后,必須執(zhí)行相同次數(shù)的unlock才能解鎖;
- 6)、如果對(duì)一個(gè)變量進(jìn)行l(wèi)ock操作,會(huì)清空所有工作內(nèi)存中此變量的值,在執(zhí)行引擎使用這個(gè)變量前,必須重新load或assign操作初始化變量的值;
- 7)、如果一個(gè)變量沒(méi)有被lock,就不能對(duì)其進(jìn)行unlock操作。也不能unlock一個(gè)被其他線程鎖住的變量;
- 8)、對(duì)一個(gè)變量進(jìn)行unlock操作之前,必須把此變量同步回主內(nèi)存;
JMM對(duì)這八種操作規(guī)則和對(duì)volatile的一些特殊規(guī)則就能確定哪里操作是線程安全,哪些操作是線程不安全的了。但是這些規(guī)則實(shí)在復(fù)雜,很難在實(shí)踐中直接分析。所以一般我們也不會(huì)通過(guò)上述規(guī)則進(jìn)行分析。更多的時(shí)候,使用JMM中的happens-before 規(guī)則來(lái)進(jìn)行分析。
2.7.2、JMM中的happens-before 原則
假如在多線程開(kāi)發(fā)過(guò)程中我們都需要通過(guò)加鎖或者volatile來(lái)解決這些問(wèn)題的話那么編寫(xiě)程序的時(shí)候會(huì)非常麻煩,而且加鎖其實(shí)本質(zhì)上是讓多線程的并行執(zhí)行變?yōu)榱舜袌?zhí)行,這樣會(huì)大大的影響程序的性能,那么其實(shí)真的需要嘛?不需要,因?yàn)樵贘MM中還為我們提供了happens-before 原則來(lái)輔助保證程序執(zhí)行的原子性、可見(jiàn)性以及有序性的問(wèn)題,它是判斷數(shù)據(jù)是否存在競(jìng)爭(zhēng)、線程是否安全的依據(jù),happens-before 原則內(nèi)容如下:
- 一、程序順序原則: 即在一個(gè)線程內(nèi)必須保證語(yǔ)義串行性,也就是說(shuō)按照代碼順序執(zhí)行。
- 二、鎖規(guī)則: 解鎖(unlock)操作必然發(fā)生在后續(xù)的同一個(gè)鎖的加鎖(lock)之前,也就是說(shuō),如果對(duì)于一個(gè)鎖解鎖后,再加鎖,那么加鎖的動(dòng)作必須在解鎖動(dòng)作之后(同一個(gè)鎖)。
- 三、volatile規(guī)則: volatile變量的寫(xiě),先發(fā)生于讀,這保證了volatile變量的可見(jiàn)性,簡(jiǎn)單的理解就是,volatile變量在每次被線程訪問(wèn)時(shí),都強(qiáng)迫從主內(nèi)存中讀該變量的值,而當(dāng)該變量發(fā)生變化時(shí),又會(huì)強(qiáng)迫將最新的值刷新到主內(nèi)存,任何時(shí)刻,不同的線程總是能夠看到該變量的最新值。
- 四、線程啟動(dòng)規(guī)則: 線程的start()方法先于它的每一個(gè)動(dòng)作,即如果線程A在執(zhí)行線程B的start方法之前修改了共享變量的值,那么當(dāng)線程B執(zhí)行start方法時(shí),線程A對(duì)共享變量的修改對(duì)線程B可見(jiàn)。
- 五、傳遞性優(yōu)先級(jí)規(guī)則: A先于B ,B先于C 那么A必然先于C。
- 六、線程終止規(guī)則: 線程的所有操作先于線程的終結(jié),Thread.join()方法的作用是等待當(dāng)前執(zhí)行的線程終止。假設(shè)在線程B終止之前,修改了共享變量,線程A從線程B的join方法成功返回后,線程B對(duì)共享變量的修改將對(duì)線程A可見(jiàn)。
- 七、線程中斷規(guī)則: 對(duì)線程 interrupt()方法的調(diào)用先行發(fā)生于被中斷線程的代碼檢測(cè)到中斷事件的發(fā)生,可以通過(guò)Thread.interrupted()方法檢測(cè)線程是否中斷。
-
八、對(duì)象終結(jié)規(guī)則: 對(duì)象的構(gòu)造函數(shù)執(zhí)行,結(jié)束先于finalize()方法。
happens-before 原則無(wú)需添加任何手段來(lái)保證,這是由JMM規(guī)定的,Java程序默認(rèn)遵守如上八條原則,下面我們?cè)偻ㄟ^(guò)之前的案例重新認(rèn)識(shí)這八條原則是如何判斷線程是否會(huì)出現(xiàn)安全問(wèn)題:
int a = 0;
boolean f = false;
public void methodA(){
a = 1;
f = true;
}
public void methodB(){
if(f){
int i = a + 1;
}
}
同樣的道理,存在兩條線程A和B,線程A調(diào)用實(shí)例對(duì)象的methodA()方法,而線程B調(diào)用實(shí)例對(duì)象的methodB()方法,線程A先啟動(dòng)而線程B后啟動(dòng),那么線程B讀取到的i值是多少呢?現(xiàn)在依據(jù)8條原則,由于存在兩條線程同時(shí)調(diào)用,因此程序次序原則不合適。methodA()方法和methodB()方法都沒(méi)有使用同步手段,鎖規(guī)則也不合適。沒(méi)有使用volatile關(guān)鍵字,volatile變量原則不適應(yīng)。線程啟動(dòng)規(guī)則、線程終止規(guī)則、線程中斷規(guī)則、對(duì)象終結(jié)規(guī)則、傳遞性和本次測(cè)試案例也不合適。線程A和線程B的啟動(dòng)時(shí)間雖然有先后,但線程B執(zhí)行結(jié)果卻是不確定,也是說(shuō)上述代碼沒(méi)有適合8條原則中的任意一條,也沒(méi)有使用任何同步手段,所以上述的操作是線程不安全的,因此線程B讀取的值自然也是不確定的。修復(fù)這個(gè)問(wèn)題的方式很簡(jiǎn)單,要么給methodA()方法和methodB()方法添加同步手段(加鎖)或者給共享變量添加volatile關(guān)鍵字修飾,保證該變量在被一個(gè)線程修改后總對(duì)其他線程可見(jiàn)。
三、Volatile關(guān)鍵字
3.1、Volatile關(guān)鍵字保證的可見(jiàn)性
Volatile是Java提供的輕量級(jí)同步工具,它能保證可見(jiàn)性和做到禁止指令重排做到有序性,但是它不能保證原子性,如果你的程序必須做到原子性的話那么可以考慮使用JUC的原子包下的原子類(后續(xù)篇章會(huì)講到)或者加鎖的方式來(lái)保證,但是我們假設(shè)如果使用volatile來(lái)修飾共享變量,那么它能夠保證的是一個(gè)線程對(duì)它所修飾的變量進(jìn)行更改操作后總是能對(duì)其他線程可見(jiàn),如下:
volatile int i = 0;
public void add(){
i++;
}
對(duì)于如上代碼,我們?nèi)魏尉€程調(diào)用add()方法之后對(duì) i 進(jìn)行i++ 操作之后都是對(duì)其他線程可見(jiàn)的,但是這段代碼不存在線程安全問(wèn)題嗎?存在,為什么?因?yàn)?i++ 并不是原子性操作, i++實(shí)際上是三個(gè)操作的組成,從主存讀取值、工作內(nèi)存中+1操作、操作結(jié)果刷寫(xiě)回主存三步操作所組成的,它們?nèi)街衅渲幸粭l線程在執(zhí)行任何一步的時(shí)候都有可能被打斷,那么還是會(huì)出現(xiàn)線程安全問(wèn)題(具體參考上述線程安全問(wèn)題第一種情況),但是我們要清楚,此時(shí)如果有多條線程調(diào)用add()方法,那么此時(shí)還是會(huì)出現(xiàn)線程安全問(wèn)題,如果想要解決還是需要使用sync或者lock或者原子類來(lái)保證,volatile關(guān)鍵字只能禁止指令重排以及可見(jiàn)性。
那么我們?cè)賮?lái)看一個(gè)案例,此類場(chǎng)景可以使用volatile關(guān)鍵字修飾變量達(dá)到線程安全的目的,如下:
volatile boolean flag;
public void toTrue(){
flag = true;
}
public void methodA(){
while(!flag){
System.out.println("我是false....false.....false.......");
}
}
由于對(duì)于boolean變量flag值的修改屬于原子性操作,因此可以通過(guò)使用volatile修飾變量flag,使用該變量對(duì)其他線程立即可見(jiàn),從而達(dá)到線程安全的目的。那么JMM是如何實(shí)現(xiàn)讓volatile變量對(duì)其他線程立即可見(jiàn)的呢?實(shí)際上,當(dāng)寫(xiě)一個(gè)volatile變量時(shí),JMM會(huì)把該線程對(duì)應(yīng)的工作內(nèi)存中的共享變量值刷新到主內(nèi)存中,當(dāng)讀取一個(gè)volatile變量時(shí),JMM會(huì)把該線程對(duì)應(yīng)的工作內(nèi)存置為無(wú)效,那么該線程將只能從主內(nèi)存中重新讀取共享變量。volatile變量正是通過(guò)這種寫(xiě)-讀方式實(shí)現(xiàn)對(duì)其他線程可見(jiàn)(但其內(nèi)存語(yǔ)義實(shí)現(xiàn)則是通過(guò)內(nèi)存屏障,稍后會(huì)說(shuō)明)。
3.2、Volatile關(guān)鍵字怎么做到禁止指令重排序的?
volatile關(guān)鍵字另一個(gè)作用就是禁止編譯器或者處理器對(duì)進(jìn)行指令重排優(yōu)化,從而避免多線程環(huán)境下程序出現(xiàn)亂序執(zhí)行的現(xiàn)象,那么volatile是如何實(shí)現(xiàn)禁止指令重排優(yōu)化的。先了解一個(gè)概念,內(nèi)存屏障(Memory Barrier)。
內(nèi)存屏障,又稱內(nèi)存柵欄,是一個(gè)CPU指令,它的作用有兩個(gè),一是保證特定操作的執(zhí)行順序,二是保證某些變量的內(nèi)存可見(jiàn)性(利用該特性實(shí)現(xiàn)volatile的內(nèi)存可見(jiàn)性)。由于編譯器和處理器都能執(zhí)行指令重排優(yōu)化。如果在指令間插入一條Memory Barrier則會(huì)告訴編譯器和CPU,不管什么指令都不能和這條Memory Barrier指令重排序,也就是說(shuō)通過(guò)插入內(nèi)存屏障禁止在內(nèi)存屏障前后的指令執(zhí)行重排序優(yōu)化。Memory Barrier的另外一個(gè)作用是強(qiáng)制刷出各種CPU的緩存數(shù)據(jù),因此任何CPU上的線程都能讀取到這些數(shù)據(jù)的最新版本。
| 屏障類型 | 指令示例 | 說(shuō)明 |
|---|---|---|
| LoadLoad Barriers | Load1; LoadLoad; Load2; | 確保Load1指令數(shù)據(jù)的裝載之前發(fā)生于Load2及后續(xù)所有裝載指令的數(shù)據(jù)裝載。 |
| StoreStore Barriers | Store1; StoreStore; Store2; | 確保Store1數(shù)據(jù)的存儲(chǔ)對(duì)其他處理器可見(jiàn)(刷新到內(nèi)存中)并之前發(fā)生于Store2及后續(xù)所有存儲(chǔ)指令的數(shù)據(jù)寫(xiě)入。 |
| LoadStore Barriers | Load1; LoadStore; Store2; | 確保Load1指令數(shù)據(jù)的裝載之前發(fā)生于Store2及后續(xù)所有存儲(chǔ)指令的數(shù)據(jù)寫(xiě)入。 |
| StoreLoad Barriers | Store1; StoreLoad; Load2; | 確保Store1數(shù)據(jù)的存儲(chǔ)對(duì)其他處理器可見(jiàn)(刷新到內(nèi)存中)并之前發(fā)生于Load2及后續(xù)所有裝載指令的數(shù)據(jù)裝載。StoreLoad Barriers會(huì)使該屏障之前的所有內(nèi)存訪問(wèn)指令(存儲(chǔ)和裝載)完成之后,才執(zhí)行該屏障之后的內(nèi)存訪問(wèn)指令。 |
Java編譯器在生成指令序列的適當(dāng)位置會(huì)插入內(nèi)存屏障指令來(lái)禁止特定類型的處理器重排序,從而讓程序按我們預(yù)想的流程去執(zhí)行。
JMM把內(nèi)存屏障指令分為4類,StoreLoad Barriers是一個(gè)“全能型”的屏障,它同時(shí)具有其他3個(gè)屏障的效果?,F(xiàn)代的多處理器大多支持該屏障(其他類型的屏障不一定被所有處理器支持)。
總之,volatile變量正是通過(guò)內(nèi)存屏障實(shí)現(xiàn)其在內(nèi)存中的語(yǔ)義,即可見(jiàn)性和禁止重排優(yōu)化。案例如下:
public class Singleton{
private static Singleton singleton;
private Singleton(){}
public static Singleton getInstance(){
if(singleton == null){
synchronized(Singleton.class){
if(singleton == null){
singleton = new Singleton();
}
}
}
}
}
上述代碼一個(gè)經(jīng)典的雙重檢測(cè)的單例模式的代碼,這段代碼在單線程環(huán)境下并沒(méi)有什么問(wèn)題,但如果在多線程環(huán)境下就可以出現(xiàn)線程安全問(wèn)題。原因在于某一個(gè)線程執(zhí)行到第一次檢測(cè),讀取到的singleton不為null時(shí),singleton的引用對(duì)象可能沒(méi)有完成初始化。因?yàn)閟ingleton= new Singleton();可以分為以下3步完成(偽代碼)
memory = allocate(); //1.分配對(duì)象內(nèi)存空間
singleton(memory); //2.初始化對(duì)象
singleton = memory; //3.設(shè)置singleton指向剛分配的內(nèi)存地址,此時(shí)singleton != null
由于步驟1和步驟2間可能會(huì)重排序,如下:
memory = allocate(); //1.分配對(duì)象內(nèi)存空間
singleton = memory; //3.設(shè)置singleton指向剛分配的內(nèi)存地址,此時(shí)singleton != null
singleton(memory); //2.初始化對(duì)象
由于步驟2和步驟3不存在數(shù)據(jù)依賴關(guān)系,而且無(wú)論重排前還是重排后程序的執(zhí)行結(jié)果在單線程中并沒(méi)有改變,因此這種重排優(yōu)化是允許的。但是指令重排只會(huì)保證串行語(yǔ)義的執(zhí)行的一致性(單線程),但并不會(huì)關(guān)心多線程間的語(yǔ)義一致性。所以當(dāng)一條線程訪問(wèn)singleton不為null時(shí),由于singleton實(shí)例未必已初始化完成,也就造成了線程安全問(wèn)題。那么該如何解決呢,很簡(jiǎn)單,我們使用volatile禁止singleton變量被執(zhí)行指令重排優(yōu)化即可。
private volatile static Singleton singleton;
四、總結(jié)
哪么到這里如果是認(rèn)真閱讀的小伙伴其實(shí)通過(guò)對(duì)這篇文章的理解,相信對(duì)Java內(nèi)存模型JMM已經(jīng)有了一個(gè)清晰的認(rèn)知,那么其實(shí)這篇文章是我們?cè)谔骄縅ava并發(fā)編程時(shí)的第一道門(mén)檻,我后續(xù)也會(huì)繼續(xù)發(fā)布有關(guān)并發(fā)專題相關(guān)的文章,如果你對(duì)于文章中的某些點(diǎn)有其他看法或者文章中你認(rèn)為存在問(wèn)題或者你對(duì)文章中某些點(diǎn)存在任何疑問(wèn),歡迎你評(píng)論區(qū)留言一起探討,謝謝!
五、參考資料與書(shū)籍
- 《深入理解JVM虛擬機(jī)》
- 《Java并發(fā)編程之美》
- 《Java高并發(fā)程序設(shè)計(jì)》
- 《億級(jí)流量網(wǎng)站架構(gòu)核心技術(shù)》
- 《Java并發(fā)編程實(shí)戰(zhàn)》