(一)玩命死磕Java內(nèi)存模型(JMM)與Volatile關(guān)鍵字底層原理

引言

本篇文章結(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)行。

image.png

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ò)程,如下:

image.png

本地方法棧(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ò)程如下圖:

image.png

重點(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)單示意圖如下所示:

image.png

image.png

image.png

image.png

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

2.1、計(jì)算機(jī)硬件內(nèi)存架構(gòu)

image.png

正如上圖所示,經(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ì)一模型。如下圖:

image.png

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ū)域劃分也是同樣的道理)

image.png

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)題。

image.png

為了解決類似如上闡述的問(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í)行的,如下:


image.png

(流水線技術(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;     
image.png
指令 描述
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ò)程如下:

image.png

image.png

正如上圖所示,所有的停頓都完美消除了,指令流水線也無(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)》
最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書(shū)系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

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

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