深入理解Java內(nèi)存模型(JMM)及Volatile關(guān)鍵字
前言
并發(fā)編程從操作系統(tǒng)底層工作整體認(rèn)識開始
上一篇我們從操作系統(tǒng)底層工作的整體了解了并發(fā)編程在硬件以及操作系統(tǒng)層面的一些知識,本篇我們繼續(xù)來學(xué)習(xí)JMM模型以及Volatile關(guān)鍵字的那些面試必問的一些知識點。
什么是JMM模型?
Java 內(nèi)存模型(Java Memory Model 簡稱JMM)是一種抽象的概念,并不真實存在,它描述的一組規(guī)則或規(guī)范,通過這組規(guī)范定義了程序中各個變量(包括實例字段、靜態(tài)字段和構(gòu)成數(shù)組對象的元素)的訪問方式。JVM運行程序的實體是線程,而每個線程創(chuàng)建時 JVM 都會為其創(chuàng)建一個工作內(nèi)存(有些地方稱為??臻g),用于存儲線程私有的數(shù)據(jù),而Java 內(nèi)存模型中規(guī)定所有變量都存儲在主內(nèi)存,其主內(nèi)存是共享內(nèi)存區(qū)域,所有線程都可以訪問,但線程對變量的操作(讀取賦值等)必須在工作內(nèi)存中進(jìn)行,首先要將變量從主內(nèi)存考吧到增加的工作內(nèi)存空間,然后對變量進(jìn)行操作,操作完成后再將變量寫回主內(nèi)存,不能直接操作主內(nèi)存中的變量,工作內(nèi)存中存儲這主內(nèi)存中的變量副本拷貝,工作內(nèi)存是每個線程的私有數(shù)據(jù)區(qū)域,因此不同的線程間無法訪問對方的工作內(nèi)存,線程間的通信(傳值)必須通過主內(nèi)存來完成。
JMM 不同于 JVM 內(nèi)存區(qū)域模式
JMM 與 JVM 內(nèi)存區(qū)域的劃分是不同的概念層次,更恰當(dāng)說 JMM 描述的是一組規(guī)則,通過這組規(guī)則控制各個變量在共享數(shù)據(jù)區(qū)域內(nèi)和私有數(shù)據(jù)區(qū)域的訪問方式,JMM是圍繞原子性、有序性、可見性展開。JMM 與 Java 內(nèi)存區(qū)域唯一相似點,都存在共享數(shù)據(jù)區(qū)域和私有數(shù)據(jù)區(qū)域,在 JMM 中主內(nèi)存屬于共享數(shù)據(jù)區(qū)域,從某個程度上講應(yīng)該包括了堆和方法區(qū),而工作內(nèi)存數(shù)據(jù)線程私有數(shù)據(jù)區(qū)域,從某個程度上講則應(yīng)該包括程序計數(shù)器、虛擬機(jī)棧以及本地方法棧。
線程、工作內(nèi)存、主內(nèi)存工作交互圖(基于JMM規(guī)范),如下:

主內(nèi)存
主要存儲的是Java實例對象,所有線程創(chuàng)建的實例對象都存放在主內(nèi)存中,不管該實例對象是成員變量還是方法中的本地變量(也稱局部變量),當(dāng)然也包括了共享的類信息、常量、靜態(tài)變量。由于是共享數(shù)據(jù)區(qū)域,多個線程同一個變量進(jìn)行訪問可能會發(fā)送線程安全問題。
工作內(nèi)存
主要存儲當(dāng)前方法的所有本地變量信息(工作內(nèi)存中存儲著主內(nèi)存中的變量副本拷貝),每個線程只能訪問自己的工作內(nèi)存,即線程中的本地變量對其他線程是不可見的,就算是兩個線程執(zhí)行的是同一段代碼,它們也會在各自的工作內(nèi)存中創(chuàng)建屬于當(dāng)前線程的本地變量,當(dāng)然也包括了字節(jié)碼行號指示器、相關(guān)Native方法的信息。注意由于工作內(nèi)存是每個線程的私有數(shù)據(jù),線程間無法相互訪問工作內(nèi)存,因此存儲在工作內(nèi)存的數(shù)據(jù)不存在線程安全問題。
根據(jù) JVM 虛擬機(jī)規(guī)范主內(nèi)存與工作內(nèi)存的數(shù)據(jù)存儲類型以及操作方式,對于一個實例對象中的成員方法而言,如果方法中包括本地變量是基本數(shù)據(jù)類型(boolean、type、short、char、int、long、float、double),將直接存儲在工作內(nèi)存的幀棧中,而對象實例將存儲在主內(nèi)存(共享數(shù)據(jù)區(qū)域,堆)中。但對于實例對象的成員變量,不管它是基本數(shù)據(jù)類型或者包裝類型(Integer、Double等)還是引用類型,都會被存儲到堆區(qū)。至于 static 變量以及類本身相關(guān)信息將會存儲在主內(nèi)存中。
需要注意的是,在主內(nèi)存中的實例對象可以被多線程共享,倘若兩個線程同時調(diào)用類同一個對象的同一個方法,那么兩個線程會將要操作的數(shù)據(jù)拷貝一份到直接的工作內(nèi)存中,執(zhí)行晚操作后才刷新到主內(nèi)存。模型如下圖所示:
Java 內(nèi)存模型與硬件內(nèi)存架構(gòu)的關(guān)系
通過對前面的硬件內(nèi)存架構(gòu)、Java內(nèi)存模型以及Java多線程的實現(xiàn)原理的了解,我們應(yīng)該已經(jīng)意識到,多線程的執(zhí)行最終都會映射到硬件處理器上進(jìn)行執(zhí)行,但Java內(nèi)存模型和硬件內(nèi)存架構(gòu)并不完全一致。對于硬件內(nèi)存來說只有寄存器、緩存內(nèi)存、主內(nèi)存的概念,并沒有工作內(nèi)存(線程私有數(shù)據(jù)區(qū)域)和主內(nèi)存(堆內(nèi)存)之分,也就是說 Java 內(nèi)存模型對內(nèi)存的劃分對硬件內(nèi)存并沒有任何影響,因為 JMM 只是一種抽象的概念,是一組規(guī)則,并不實際存在,不管是工作內(nèi)存的數(shù)據(jù)還是主內(nèi)存的數(shù)據(jù),對于計算機(jī)硬件來說都會存儲在計算機(jī)主內(nèi)存中,當(dāng)然也有可能存儲到 CPU 緩存或者寄存器中,因此總體上來說,Java 內(nèi)存模型和計算機(jī)硬件內(nèi)存架構(gòu)是一個相互交叉的關(guān)系,是一種抽象概念劃分與真實物理硬件的交叉。(注意對于Java內(nèi)存區(qū)域劃分也是同樣的道理)

JMM 存在的必要性
在明白了 Java 內(nèi)存區(qū)域劃分、硬件內(nèi)存架構(gòu)、Java多線程的實現(xiàn)原理與Java內(nèi)存模型的具體關(guān)系后,接著來談?wù)凧ava內(nèi)存模型存在的必要性。
由于JVM運行程序的實體是線程,而每個線程創(chuàng)建時 JVM 都會為其創(chuàng)建一個工作內(nèi)存(有些地方稱為??臻g),用于存儲線程私有的數(shù)據(jù),線程與主內(nèi)存中的變量操作必須通過工作內(nèi)存間接完成,主要過程是將變量從主內(nèi)存拷貝的每個線程各自的工作內(nèi)存空間,然后對變量進(jìn)行操作,操作完成后再將變量寫回主內(nèi)存,如果存在兩個線程同時對一個主內(nèi)存中的實例對象的變量進(jìn)行操作就有可能誘發(fā)線程安全問題。
假設(shè)主內(nèi)存中存在一個共享變量 x ,現(xiàn)在有 A 和 B 兩個線程分別對該變量 x=1 進(jìn)行操作, A/B線程各自的工作內(nèi)存中存在共享變量副本 x 。假設(shè)現(xiàn)在 A 線程想要修改 x 的值為 2,而 B 線程卻想要讀取 x 的值,那么 B 線程讀取到的值是 A 線程更新后的值 2 還是更新錢的值 1 呢?
答案是:不確定。即 B 線程有可能讀取到 A 線程更新錢的值 1,也有可能讀取到 A 線程更新后的值 2,這是因為工作內(nèi)存是每個線程私有的數(shù)據(jù)區(qū)域,而線程 A 操作變量 x 時,首先是將變量從主內(nèi)存拷貝到 A 線程的工作內(nèi)存中,然后對變量進(jìn)行操作,操作完成后再將變量 x寫回主內(nèi)存。而對于 B 線程的也是類似的,這樣就有可能造成主內(nèi)存與工作內(nèi)存間數(shù)據(jù)存在一致性問題,假設(shè)直接的工作內(nèi)存中,這樣 B 線程讀取到的值就是 x=1 ,但是如果 A 線程已將 x=2 寫回主內(nèi)存后,B線程才開始讀取的話,那么此時 B 線程讀取到的就是 x=2 ,但到達(dá)是那種情況先發(fā)送呢?
如下圖所示案例:

以上關(guān)于主內(nèi)存與工作內(nèi)存直接的具體交互協(xié)議,即一個變量如何從主內(nèi)存拷貝到工作內(nèi)存,如何從工作內(nèi)存同步到主內(nèi)存之間的實現(xiàn)細(xì)節(jié),Java內(nèi)存模型定義來以下八種操作來完成。
數(shù)據(jù)同步八大原子操作
- lock(鎖定):作用于主內(nèi)存的變量,把一個變量標(biāo)記為一個線程獨占狀態(tài);
- unlock(解鎖):作用于主內(nèi)存的變量,把一個處于鎖定狀態(tài)的變量釋放出來,釋放后的變量才可以被其他線程鎖定;
- read(讀取):作用于主內(nèi)存的變量,把一個變量值從主內(nèi)存?zhèn)鬏數(shù)骄€程的工作內(nèi)存中,以后隨后的load工作使用;
- load(載入):作用于工作內(nèi)存的變量,它把read操作從主內(nèi)存中得到的變量值放入工作內(nèi)存的變量;
- use(使用):作用于工作內(nèi)存的變量,把工作內(nèi)存中的一個變量值傳遞給執(zhí)行引擎;
- assign(賦值):作用于工作內(nèi)存的變量,它把一個從執(zhí)行引擎接收到的值賦給工作內(nèi)存的變量;
- store(存儲):作用于工作內(nèi)存的變量,把工作內(nèi)存中的一個變量的值傳送到主內(nèi)存中,以便隨后的write的操作;
- wirte(寫入):作用于工作內(nèi)存的變量,它把store操作從工作內(nèi)存中的一個變量值傳送到主內(nèi)存的變量中。
- 如果要把一個變量從主內(nèi)存中復(fù)制到工作內(nèi)存中,就需要按順序地執(zhí)行 read 和 load 操作;
- 如果把變量從工作內(nèi)存中同步到主內(nèi)存中,就需要按順序地執(zhí)行 store 和 write 操作。
但Java 內(nèi)存模型只要求上述操作必須按順序執(zhí)行,而沒有保證必須是連續(xù)執(zhí)行。

同步規(guī)則分析
- 不允許一個線程無原因地(沒有發(fā)生任何 assign 操作)把數(shù)據(jù)從工作內(nèi)存同步回主內(nèi)存中;
- 一個新的變量只能在主內(nèi)存中誕生,不允許在工作內(nèi)存中直接使用一個未被初始化(load 或者 assign)的變量。即就是對一個變量實施 use 和 store 操作之前,必須先自行 assign 和 load 操作;
- 一個變量在同一時刻只允許一條線程對其進(jìn)行 lock 操作,但 lock 操作可不被同一線程重復(fù)執(zhí)行多次,多次執(zhí)行 lock 后,只有執(zhí)行相同次數(shù) unlock 操作,變量才會被解鎖。lock 和 unlock 必須成對出現(xiàn);
- 如果對一個變量執(zhí)行 lock 操作,將會清空工作內(nèi)存中此變量的值,在執(zhí)行引擎使用變量之前需要重新執(zhí)行 load 或 assign 操作初始化變量的值;
- 如果一個變量事先沒有被 lock 操作鎖定,則不允許對它執(zhí)行 unlock 操作;也不允許去 unlock 一個被其他線程鎖定的變量;
- 對一個變量執(zhí)行 unlock 操作之前,必須先把此變量同步到主內(nèi)存中(執(zhí)行store 和 write 操作)。
并發(fā)編程的可見性、原子性與有序性問題
原子性
原子性指的是一個操作不可中斷,即使是在多線程環(huán)境下,一個操作一旦開始就不會被其他線程影響。
在Java中,對于基本數(shù)據(jù)類型的變量的讀取和賦值操作是原子性操作需要注意的是:對于32位系統(tǒng)來說,long 類型數(shù)據(jù)和 double 類型數(shù)據(jù)(對于基本類型數(shù)據(jù):byte、short、int、float、boolean、char 讀寫是原子操作),它們的讀寫并非原子性的,也就是說如果存在兩條線程同時對 long 類型或者 double 類型的數(shù)據(jù)進(jìn)行讀寫是存在相互干擾的,因為對于32位虛擬機(jī)來說,每次原子讀寫是32位,而 long 和 double 則是64位的存儲單元,這樣回導(dǎo)致一個線程在寫時,操作完成前32位的原子操作后,輪到B線程讀取時,恰好只讀取來后32位的數(shù)據(jù),這樣可能回讀取到一個即非原值又不是線程修改值的變量,它可能是“半個變量”的數(shù)值,即64位數(shù)據(jù)被兩個線程分成了兩次讀取。但也不必太擔(dān)心,因為讀取到“半個變量”的情況比較少,至少在目前的商用虛擬機(jī)中,幾乎都把64位的數(shù)據(jù)的讀寫操作作為原子操作來執(zhí)行,因此對于這個問題不必太在意,知道怎么回事即可。
- X=10; //原子性(簡單的讀取、將數(shù)字賦值給變量)
- Y = x; //變量之間的相互賦值,不是原子操作
- X++; //對變量進(jìn)行計算操作
- X=x+1;
可見性
理解了指令重排現(xiàn)象后,可見性容易理解了??梢娦灾傅氖钱?dāng)一個線程修改了某個共享變量的值,其他線程是否能夠馬上得知這個修改的值。對于串行程序來說,可見性是不存在的,因為我們在任何一個操作中修改了某個變量的值,后續(xù)的操作中都能讀取到這個變量,并且是修改過的新值。
但在多線程環(huán)境中可就不一定了,前面我們分析過,由于線程對共享變量的操作都是線程拷貝到各自的工作內(nèi)存進(jìn)行操作后才寫回到主內(nèi)存中的,這就可能存在一個線程A修改了共享變量 x 的值,還未寫回主內(nèi)存時,另外一個線程B又對主內(nèi)存中同一個共享變量 x 進(jìn)行操作,但此時A線程工作內(nèi)存中共享變量 x 對線程B來說并不可見,這種工作內(nèi)存與主內(nèi)存同步延遲現(xiàn)象就會造成可見性問題,另外指令重排以及編譯器優(yōu)化也可能回導(dǎo)致可見性問題,通過前面的分析,我們知道無論是編譯器優(yōu)化還是處理器優(yōu)化的重排現(xiàn)象,在多線程環(huán)境下,確實回導(dǎo)致程序亂序執(zhí)行的問題,從而也就導(dǎo)致可見性問題。
有序性
有序性是指對于單線程的執(zhí)行代碼,我們總是認(rèn)為代碼的執(zhí)行是按順序依次執(zhí)行的,這樣的理解并沒有毛病,比較對于單線程而言確實如此,但對于多線程環(huán)境,則可能出現(xiàn)亂序現(xiàn)象,因為程序編譯稱機(jī)器碼指令后可能回出現(xiàn)指令重排現(xiàn)象,重排后的指令與原指令的順序未必一致,要明白的是,在Java程序中,倘若在本線程內(nèi),所有操作都視為有序行為,如果是多線程環(huán)境下,一個線程中觀察另外一個線程,所有操作都是無序的,前半句指的是單線程內(nèi)保證串行語義執(zhí)行的一致性,后半句則指令重排現(xiàn)象和工作內(nèi)存與主內(nèi)存同步延遲現(xiàn)象。
JMM如何解決原子性、可見性和有序性問題
原子性問題
除了 JVM 自身提供的對基本數(shù)據(jù)類型讀寫操作的原子性外,可以通過 synchronized 和 Lock 實現(xiàn)原子性。因為 synchronized 和 Lock 能夠保證任一時刻只有一個線程訪問該代碼塊。
可見性問題
volatile 關(guān)鍵字可以保證可見性。當(dāng)一個共享變量被 volatile 關(guān)鍵字修飾時,它會保證修改的值立即被其他的線程看到,即修改的值立即更新到主存中,當(dāng)其他線程需要讀取時,它會去內(nèi)存中讀取新值。synchronized 和 Lock 也可以保證可見性,因為它們可以保證任一時刻只有一個線程能訪問共享資源,并在其釋放鎖之前將修改的變量刷新到內(nèi)存中。
有序性問題
在Java里面,可以通過 volatile 關(guān)鍵字來保證一定的“有序性”。另外可以通過 synchronized 和 Lock 來保證有序性,很顯然,synchronized 和 Lock 保證每個時刻是只有一個線程執(zhí)行同步代碼,相當(dāng)于是讓線程順序執(zhí)行同步代碼,自然就保證來有序性。
Java內(nèi)存模型
每個線程都有自己的工作內(nèi)存,線程對變量的所有操作都必須在工作內(nèi)存中進(jìn)行,而不能直接對主內(nèi)存進(jìn)行操作。并且每個線程不能訪問其他線程的工作內(nèi)存。Java 內(nèi)存模型具有一些先天的“有序性”,即不需要通過任何手段就能夠得到保證的有序性,這個通常也稱為 happens-before 原則。如果兩個操作的執(zhí)行次序無法從 happens-before 原則推導(dǎo)出來,那么它們就不能保證它們的有序性,虛擬機(jī)可以隨意地對它們進(jìn)行重排序。
指令重排序
Java語言規(guī)范規(guī)定 JVM 線程內(nèi)部維持順序化語義。即只要程序的最終結(jié)果與它順序化情況的結(jié)果相等,那么指令的執(zhí)行順序可以與代碼順序不一致,此過程叫做指令的重排序。
指令重排序的意義是什么?JVM能根據(jù)處理特性(CPU多級緩存、多核處理器等)適當(dāng)?shù)膶C(jī)器指令進(jìn)行重排序,使機(jī)器指令更更符合CPU的執(zhí)行特性,最大限度的發(fā)揮機(jī)器性能。
下圖為從源碼到最終執(zhí)行的指令序列示意圖:

as-if-serial 語義
as-if-serial 語義的意思是:不管怎么重排序(編譯器和處理器為了提高并行度),(單線程)程序的執(zhí)行結(jié)果不能被改變。編譯器、runtime和處理器都必須遵守 as-if-serial 語義。
為了遵守 as-if-serial 語義,編譯器和處理器不會對存在數(shù)據(jù)依賴關(guān)系的操作做重排序,因為這種重排序會改變執(zhí)行結(jié)果。但是,如果操作之間不存在數(shù)據(jù)依賴關(guān)系,這些操作就可能被編譯器和處理器重排序。
happens-before 原則
只靠 synchronized 和 volatile 關(guān)鍵字來保證原子性、可見性以及有序性,那么編寫并發(fā)程序可能會顯得十分麻煩,幸運的是,從JDK 5 開始,Java 使用新的 JSR-133 內(nèi)存模型,提供了 happens-before 原則 來輔助保證程序執(zhí)行的原子性、可見性和有序性的問題,它是判斷數(shù)據(jù)十分存在競爭、線程十分安全的一句。happens-before 原則內(nèi)容如下:
- 程序順序原則,即在一個線程內(nèi)必須保證語義串行,也就是說按照代碼順序執(zhí)行。
- 鎖規(guī)則,解鎖(unlock)操作必然發(fā)生在后續(xù)的同一個鎖的加鎖(lock)之前,也就是說,如果對于一個鎖解鎖后,再加鎖,那么加鎖的動作必須在解鎖動作之后(同一個鎖)。
- volatile規(guī)則, volatile變量的寫,先發(fā)生于讀,這保證了volatile變量的可見性,簡單理解就是,volatile變量在每次被線程訪問時,都強(qiáng)迫從主內(nèi)存中讀該變量的值,而當(dāng)該變量發(fā)生變化時,又會強(qiáng)迫將最新的值刷新到主內(nèi)存,任何時刻,不同的線程總是能夠看到該變量的最新值。
- 線程啟動規(guī)則,線程的 start() 方法先于它的每一個動作,即如果線程A在執(zhí)行線程B的 start 方法之前修改了共享變量的值,那么當(dāng)線程B執(zhí)行start方法時,線程A對共享變量的修改對線程B可見。
- 傳遞性,A先于B,B先于C,那么A必然先于C。
- 線程終止原則,線程的所有操作先于線程的終結(jié),Thread.join() 方法的作用是等待當(dāng)前執(zhí)行的線程終止。假設(shè)在線程B終止之前,修改了共享變量,線程A從線程B的join方法成功返回,線程B對共享變量的修改將對線程A可見。
- 線程中斷規(guī)則,對線程 interrupt() 方法的調(diào)用先行發(fā)生于被中斷線程的代碼檢查到中斷事件的發(fā)生,可以通過 Thread.interrupted() 方法檢測線程十分中斷。
- 對象終結(jié)規(guī)則,對象的構(gòu)造函數(shù)執(zhí)行,結(jié)束先于 finalize() 方法。
finalize()是Object中的方法,當(dāng)垃圾回收器將要回收對象所占內(nèi)存之前被調(diào)用,即當(dāng)一個對象被虛擬機(jī)宣告死亡時會先調(diào)用它finalize()方法,讓此對象處理它生前的最后事情(這個對象可以趁這個時機(jī)掙脫死亡的命運)。
volatile 內(nèi)存語義
volatile 是Java虛擬機(jī)提供的輕量級的同步機(jī)制。volatile 關(guān)鍵字有如下兩個作用:
- 保證被 volatile 修飾的共享變量對所有線程總是可見的,也就是當(dāng)一個線程修改了被 volatile 修飾共享變量的值,新值總是可以被其他線程立即得知。
- 緊張指令重排序優(yōu)化。
volatile 的可見性
關(guān)于 volatile 的可見性作用,我們必須意思到被 volatile 修飾的變量對所有線程總是立即可見的,對于 volatile 變量的所有寫操作總是能立刻反應(yīng)到其他線程中。
案例:線程A改變 initFlag 屬性之后,線程B馬上感知到
- package com.niuh.jmm;
- import lombok.extern.slf4j.Slf4j;
- /**
- * @description: -server -Xcomp -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -XX:CompileCommand=compileonly,*Jmm03_CodeVisibility.refresh
- * -Djava.compiler=NONE
- **/
- @Slf4j
- public class Jmm03_CodeVisibility {
- private static boolean initFlag = false;
- private volatile static int counter = 0;
- public static void refresh() {
- log.info("refresh data.......");
- initFlag = true;
- log.info("refresh data success.......");
- }
- public static void main(String[] args) {
- // 線程A
- Thread threadA = new Thread(() -> {
- while (!initFlag) {
- //System.out.println("runing");
- counter++;
- }
- log.info("線程:" + Thread.currentThread().getName()
- + "當(dāng)前線程嗅探到initFlag的狀態(tài)的改變");
- }, "threadA");
- threadA.start();
- // 中間休眠500hs
- try {
- Thread.sleep(500);
- } catch (InterruptedException e) {
- e.printStackTrace();
- }
- // 線程B
- Thread threadB = new Thread(() -> {
- refresh();
- }, "threadB");
- threadB.start();
- }
- }
結(jié)合前面介紹的數(shù)據(jù)同步八大原子操作,我們來分析下:
線程A啟動后:
- 第一步:執(zhí)行read操作,作用于主內(nèi)存,將變量initFlag從主內(nèi)存拷貝一份,這時候還沒有放到工作內(nèi)存中,而是放在了總線里。如下圖
- 第二步:執(zhí)行l(wèi)oad操作,作用于工作內(nèi)存,將上一步拷貝的變量,放入工作內(nèi)存中;
- 第三步:執(zhí)行use(使用)操作,作用于工作內(nèi)存,把工作內(nèi)存中的變量傳遞給執(zhí)行引擎,對于線程A來說,執(zhí)行引擎會判斷initFlag = true嗎?不等于,循環(huán)一直進(jìn)行
執(zhí)行過程如下圖:

線程B啟動后:
- 第一步:執(zhí)行read操作,作用于主內(nèi)存,從主內(nèi)存拷貝initFlag變量,這時候拷貝的變量還沒有放到工作內(nèi)存中,這一步是為了load做準(zhǔn)備;
- 第二步:執(zhí)行l(wèi)oad操作,作用于工作內(nèi)存,將拷貝的變量放入到工作內(nèi)存中;
- 第三步:執(zhí)行use操作,作用于工作內(nèi)存,將工作內(nèi)存的變量傳遞給執(zhí)行引擎,執(zhí)行引擎判斷while(!initFlag),那么執(zhí)行循環(huán)體;
- 第四步:執(zhí)行assign操作,作用于工作內(nèi)存,把從執(zhí)行引擎接收的值賦值給工作內(nèi)存的變量,即設(shè)置 inifFlag = true ;
- 第五步:執(zhí)行store操作,作用于工作內(nèi)存,將工作內(nèi)存中的變量 initFlag = true 傳遞給主內(nèi)存;
- 第六步:執(zhí)行write操作,作用于工作內(nèi)存,將變量寫入到主內(nèi)存中。

volatile 無法保證原子性
- //示例
- public class VolatileVisibility {
- public static volatile int i =0;
- public static void increase(){
- i++;
- }
- }
在并發(fā)場景下, i 變量的任何改變都會立馬反應(yīng)到其他線程中,但是如此存在多線程同時調(diào)用 increase() 方法的化,就會出現(xiàn)線程安全問題,畢竟 i++ 操作并不具備原子性,該操作是先讀取值,然后寫回一個新值,相當(dāng)于原來的值加上1,分兩部完成。如果第二個線程在第一個線程讀取舊值和寫回新值期間讀取 i 的值,那么第二個線程就會于第一個線程一起看到同一個值,并執(zhí)行相同值的加1操作,這也就造成了線程安全失敗,因此對于 increase 方法必須使用 synchronized 修飾,以便保證線程安全,需要注意的是一旦使用 synchronized 修飾方法后,由于 sunchronized 本身也具備于 volatile 相同的特性,即可見性,因此在這樣的情況下就完全可以省去 volatile 修飾變量。
案例:起了10個線程,每個線程加到1000,10個線程,一共是10000
- package com.niuh.jmm;
- /**
- * volatile可以保證可見性, 不能保證原子性
- */
- public class Jmm04_CodeAtomic {
- private volatile static int counter = 0;
- static Object object = new Object();
- public static void main(String[] args) {
- for (int i = 0; i < 10; i++) {
- Thread thread = new Thread(() -> {
- for (int j = 0; j < 1000; j++) {
- synchronized (object) {
- counter++;//分三步- 讀,自加,寫回
- }
- }
- });
- thread.start();
- }
- try {
- Thread.sleep(3000);
- } catch (InterruptedException e) {
- e.printStackTrace();
- }
- System.out.println(counter);
- }
- }
而實際結(jié)果,不到10000, 原因是: 有并發(fā)操作.
這時候, 如果我在counter上加關(guān)鍵字volatile, 可以保證原子性么?
- private volatile static int counter = 0;
我們發(fā)現(xiàn), 依然不是10000, 這說明volatile不能保證原子性.
每個線程, 只有一個操作, counter++, 為什么不能保證原子性呢?
其實counter++不是一步完成的. 他是分為多步完成的. 我們用下面的圖來解釋

線程A通過read, load將變量加載到工作內(nèi)存, 通過user將變量發(fā)送到執(zhí)行引擎, 執(zhí)行引擎執(zhí)行counter++,這時線程B啟動了, 通過read, load將變量加載到工作內(nèi)存, 通過user將變量發(fā)送到執(zhí)行引擎, 然后執(zhí)行復(fù)制操作assign, stroe, write操作. 我們看到這是經(jīng)過了n個步驟. 雖然看起來就是簡單的一句話.
當(dāng)線程B執(zhí)行store將數(shù)據(jù)回傳到主內(nèi)存的時候, 同時會通知線程A, 丟棄counter++, 而這時counter已經(jīng)自加了1, 將自加后的counter丟掉, 就導(dǎo)致總數(shù)據(jù)少1.
volatile 禁止重排優(yōu)化
volatile 關(guān)鍵字另一個作用就是禁止指令重排優(yōu)化,從而避免多線程環(huán)境下程序出現(xiàn)亂序執(zhí)行的現(xiàn)象,關(guān)于指令重排優(yōu)化前面已經(jīng)分析過,這里主要簡單說明一下 volatile 是如何實現(xiàn)禁止指令重排優(yōu)化的。先了解一個概念,內(nèi)存屏障(Memory Barrier)
硬件層的內(nèi)存屏障
Intel 硬件提供了一系列的內(nèi)存屏障,主要又:
- ifence,是一種 Load Barrier 讀屏障;
- sfence,是一種 Store Barrier 寫屏障;
- mfence,是一種全能型的屏障,具備 ifence 和 sfence 的能力;
- Lock 前綴,Lock 不是一種內(nèi)存屏障,但是它能完成類似內(nèi)存屏障的功能。Lock 會對 CPU總線和高速緩存加鎖,可以理解為 CPU 指令級的一種鎖。它后面可以跟 ADD、ADC、AND、BTC、BTR、BTS、CMPXCHG、CMPXCH8B、DEC、INC、NEG、NOT、OR、SBB、SUB、XOR、XADD、and XCHG 等指令。
不同硬件實現(xiàn)內(nèi)存屏障的方式不同,Java 內(nèi)存模型屏蔽了這些底層硬件平臺的差異,由 JVM 來為不同平臺生產(chǎn)相應(yīng)的機(jī)器碼。JVM中提供了四類內(nèi)存屏障指令:

內(nèi)存屏障,又稱內(nèi)存柵欄,是一個CPU指令,它的作用有兩個:
- 一是保證特定操作的執(zhí)行順序;
- 二是保證某些變量的內(nèi)存可見性(利用該特性實現(xiàn) volatile 的內(nèi)存可見性)。
由于編譯器和處理器都能執(zhí)行指令重排優(yōu)化。如果在指令間插入一條 Memory Barrier 則會高速編譯器和CPU,不管什么指令都不能和這條 Memory Barrier 指令重排序,也就是說通過插入內(nèi)存屏障禁止在內(nèi)存屏障前后的指令執(zhí)行重排序優(yōu)化。
Memory Barrier 的另外一個作用是強(qiáng)制刷出各種 CPU 的緩存數(shù)據(jù),因此任何 CPU 上的線程都能讀取到這些數(shù)據(jù)的最新版本。
總之,volatile 變量正是通過內(nèi)存屏障實現(xiàn)其內(nèi)存中的語義,即可見性和禁止重排優(yōu)化。
下面看一個非常典型的禁止重排優(yōu)化的例子DCL,如下:
- public class DoubleCheckLock {
- private volatile static DoubleCheckLock instance;
- private DoubleCheckLock(){}
- public static DoubleCheckLock getInstance(){
- //第一次檢測
- if (instance==null){
- //同步
- synchronized (DoubleCheckLock.class){
- if (instance == null){
- //多線程環(huán)境下可能會出現(xiàn)問題的地方
- instance = new DoubleCheckLock();
- }
- }
- }
- return instance;
- }
- }
上述代碼一個經(jīng)典的單例的雙重檢測的代碼,這段代碼在單線程環(huán)境下并沒什么問題,但如果在多線程環(huán)境下就可能會出現(xiàn)線程安全的問題。因為在于某一線程執(zhí)行到第一次檢測,讀取到 instance 不為 null 時,instance 的引用對象可能還沒有完成初始化。
- 關(guān)于 單例模式 可以查看 設(shè)計模式系列—單例設(shè)計模式
因為 instance = new DoubleCheckLock(); 可以分為以下3步完成(偽代碼)
- memory = allocate(); // 1.分配對象內(nèi)存空間
- instance(memory); // 2.初始化對象
- instance = memory; // 3.設(shè)置instance指向剛分配的內(nèi)存地址,此時instance != null
由于步驟1 和步驟2 間可能會重排序,如下:
- memory=allocate();//1.分配對象內(nèi)存空間
- instance=memory;//3.設(shè)置instance指向剛分配的內(nèi)存地址,此時instance!=null,但是對象還沒有初始化完成!
- instance(memory);//2.初始化對象
由于步驟2和步驟3不存在數(shù)據(jù)依賴關(guān)系,而且無論重排前還是重排后程序的指向結(jié)果在單線程中并沒有改變,因此這種重排優(yōu)化是允許的。但是指令重排只會保證串行語義執(zhí)行的一致性(單線程),但并不會關(guān)心多線程間的語義一致性。所以當(dāng)一條線程訪問 instance 不為 null 時,由于 instance 實例未必已經(jīng)初始化完成,也就造成來線程安全問題。那么該如何解決呢,很簡單,我們使用 volatile 禁止 instance 變量被執(zhí)行指令重排優(yōu)化即可。
- //禁止指令重排優(yōu)化
- private volatile static DoubleCheckLock instance;
volatile 內(nèi)存語義的實現(xiàn)
前面提到過重排序分為編譯器重排序和處理器重排序。為來實現(xiàn) volatile 內(nèi)存語義,JMM 會分別限制這兩種類型的重排序類型。
下面是JMM針對編譯器制定的 volatile 重排序規(guī)則表。

舉例來說,第二行最后一個單元格的意思是:在程序中,當(dāng)?shù)谝粋€操作為普通變量的讀或者寫時,如果第二個操作為 volatile 寫,則編譯器不能重排序這兩個操作。
從上圖可以看出:
- 當(dāng)?shù)诙€操作是 volatile 寫時,不管第一個操作是什么,都不能重排序。這個規(guī)則確保了 volatile 寫之前的操作不會被編譯器重排序到 volatile 寫之后。
- 當(dāng)?shù)谝粋€操作是 volatile 讀時,不管第二個操作是什么,都不能重排序。這個規(guī)則確保了 volatile 讀之后的操作不會被編譯器重排序到 volatie 讀之前。
- 當(dāng)?shù)谝粋€操作是 volatile 寫,第二個操作是 volatile 讀或?qū)憰r,不能重排序。
為了實現(xiàn) volatile 的內(nèi)存語義,編譯在生成字節(jié)碼時,會在指令序列中插入內(nèi)存屏障來禁止特定類型的處理器重排序。對于編譯器來說,發(fā)現(xiàn)一個最優(yōu)布置來最小化插入屏障的總數(shù)幾乎不可能。為此,JMM 采取保守策略。下面是基于保守策略的JMM內(nèi)存屏障插入策略。
- 在每個volatile寫操作的前面插入一個StoreStore屏障;
- 在每個volatile寫操作的后面插入一個StoreLoad屏障;
- 在每個volatile讀操作的后面插入一個LoadLoad屏障;
- 在每個volatile讀操作的后面插入一個LoadStore屏障;
上述內(nèi)存屏障插入策略非常保守,但它可以保證在任一處理器平臺,任意的程序中都能得到正確的 volatile 內(nèi)存語義。
下面是保守策略下,volatile 寫插入內(nèi)存屏障后生成的指令序列示意圖

上圖中 StoreStore 屏障可以保證在volatile 寫之前,其前面的所有普通寫操作已經(jīng)對任意處理器可見來。這是因為StoreStore屏障將保障上面所有的普通寫在 volatile 寫之前刷新到主內(nèi)存。
這里比較有意思的是,volatile 寫后面的 StoreLoad 屏障。此屏障的作用是避免 volatile 寫與后面可能有的 volatile 讀/寫操作重排序。因為編譯器常常無法準(zhǔn)確判斷在一個 volatile 寫的后面十分需要插入一個 StoreLoad 屏障(比如,一個volatile寫之后方法立即return)。為來保證能正確實現(xiàn) volatile 的內(nèi)存語義,JMM 在采取了保守策略:在每個 volatile 寫的后面,或者每個 volatile 讀的前面插入一個 StoreLoad 屏障。從整體執(zhí)行效率的角度考慮,JMM最終選擇了在每個 volatile 寫的后面插入一個 StoreLoad 屏障,因為volatile寫-讀內(nèi)存語義的常見使用模式是:一個寫線程寫 volatile 變量,多個線程讀同一個 volatile 變量。當(dāng)讀線程的數(shù)量大大超過寫線程時,選擇在 volatile 寫之后插入 StoreLoad 屏障將帶來可觀的執(zhí)行效率的提升。從這里可以看到JMM在實現(xiàn)上的一個特點:首先確保正確性,然后再去追求執(zhí)行效率。
下圖是在保守策略下,volatile 讀插入內(nèi)存屏障后生成的指令序列示意圖

上圖中 LoadLoad 屏障用來禁止處理器把上面的 volatile讀 與下面的普通讀重排序。LoadStore 屏障用來禁止處理器把上面的volatile讀與下面的普通寫重排序。
上述 volatile寫 和 volatile讀的內(nèi)存屏障插入策略非常保守。在實際執(zhí)行時,只要不改變 volatile寫-讀的內(nèi)存語義,編譯器可以根據(jù)具體情況省略不必要的屏障。
下面通過具體的示例代碼進(jìn)行說明。
- class VolatileBarrierExample {
- int a;
- volatile int v1 = 1;
- volatile int v2 = 2;
- void readAndWrite() {
- int i = v1; // 第一個volatile讀
- int j = v2; // 第二個volatile讀
- a = i + j; // 普通寫
- v1 = i + 1; // 第一個volatile寫
- v2 = j * 2; // 第二個 volatile寫
- }
- }
針對 readAndWrite() 方法,編譯器在生成字節(jié)碼時可以做如下的優(yōu)化。

注意,最后的 StoreLoad 屏障不能省略。因為第二個 volatile 寫之后,方法立即 return。此時編譯器可能無法準(zhǔn)確判斷斷定后面十分會有 volatile 讀或?qū)?,為了安全起見,編譯器通常會在這里插入一個 StoreLoad 屏障。
上面的優(yōu)化針對任意處理器平臺,由于不同的處理器有不同“松緊度”的處理器內(nèi)存模型,內(nèi)存屏障的插入還可以根據(jù)具體的處理器內(nèi)存模型繼續(xù)優(yōu)化。以X86處理完為例,上圖中除最后的 StoreLoad 屏障外,其他的屏障都會被省略。
前面保守策略下的 volatile 讀和寫,在 X86 處理器平臺可以優(yōu)化如下圖所示。X86處理器僅會對讀-寫操作做重排序。X86 不會對讀-讀、讀-寫 和 寫-寫 做重排序,因此在 X86 處理器中會省略掉這3種操作類型對應(yīng)的內(nèi)存屏障。在 X86 中,JMM僅需在 volatile 寫后面插入一個 StoreLoad 屏障即可正確實現(xiàn) volatile寫-讀的內(nèi)存語義,這意味著在 X86 處理器中,volatile寫的開銷比volatile讀的開銷會大很多(因為執(zhí)行StoreLoad的屏障開銷會比較大)
參考資料
- 《并發(fā)編程的藝術(shù)》
PS:以上代碼提交在 Github :
https://github.com/Niuh-Study/niuh-juc-final.git