併發---Java內存模型【轉載】

理解Java內存區域與Java內存模型

Java內存區域

Java虛擬機在運行程序時會把其自動管理的內存劃分爲以上幾個區域,每一個區域都有的用途以及建立銷燬的時機,其中藍色部分表明的是全部線程共享的數據區域,而綠色部分表明的是每一個線程的私有數據區域。java

  • 方法區(Method Area):編程

    方法區屬於線程共享的內存區域,又稱Non-Heap(非堆),主要用於存儲已被虛擬機加載的類信息、常量、靜態變量、即時編譯器編譯後的代碼等數據,根據Java 虛擬機規範的規定,當方法區沒法知足內存分配需求時,將拋出OutOfMemoryError 異常。值得注意的是在方法區中存在一個叫運行時常量池(Runtime Constant Pool)的區域,它主要用於存放編譯器生成的各類字面量和符號引用,這些內容將在類加載後存放到運行時常量池中,以便後續使用。數組

  • JVM堆(Java Heap):緩存

    Java 堆也是屬於線程共享的內存區域,它在虛擬機啓動時建立,是Java 虛擬機所管理的內存中最大的一塊,主要用於存放對象實例,幾乎全部的對象實例都在這裏分配內存,注意Java 堆是垃圾收集器管理的主要區域,所以不少時候也被稱作GC 堆,若是在堆中沒有內存完成實例分配,而且堆也沒法再擴展時,將會拋出OutOfMemoryError 異常。安全

  • 程序計數器(Program Counter Register):性能優化

    屬於線程私有的數據區域,是一小塊內存空間,主要表明當前線程所執行的字節碼行號指示器。字節碼解釋器工做時,經過改變這個計數器的值來選取下一條須要執行的字節碼指令,分支、循環、跳轉、異常處理、線程恢復等基礎功能都須要依賴這個計數器來完成。多線程

  • 虛擬機棧(Java Virtual Machine Stacks):架構

    屬於線程私有的數據區域,與線程同時建立,總數與線程關聯,表明Java方法執行的內存模型。每一個方法執行時都會建立一個棧楨來存儲方法的的變量表、操做數棧、動態連接方法、返回值、返回地址等信息。每一個方法從調用直結束就對於一個棧楨在虛擬機棧中的入棧和出棧過程,以下(圖有誤,應該爲棧楨):併發

  • 本地方法棧(Native Method Stacks):app

    本地方法棧屬於線程私有的數據區域,這部分主要與虛擬機用到的 Native 方法相關,通常狀況下,咱們無需關心此區域。

這裏之因此簡要說明這部份內容,注意是爲了區別Java內存模型與Java內存區域的劃分,畢竟這兩種劃分是屬於不一樣層次的概念。

Java內存模型概述

Java內存模型(即Java Memory Model,簡稱JMM)自己是一種抽象的概念,並不真實存在,它描述的是一組規則或規範,經過這組規範定義了程序中各個變量(包括實例字段,靜態字段和構成數組對象的元素)的訪問方式。因爲JVM運行程序的實體是線程,而每一個線程建立時JVM都會爲其建立一個工做內存(有些地方稱爲棧空間),用於存儲線程私有的數據,而Java內存模型中規定全部變量都存儲在主內存,主內存是共享內存區域,全部線程均可以訪問,但線程對變量的操做(讀取賦值等)必須在工做內存中進行,首先要將變量從主內存拷貝的本身的工做內存空間,而後對變量進行操做,操做完成後再將變量寫回主內存,不能直接操做主內存中的變量,工做內存中存儲着主內存中的變量副本拷貝,前面說過,工做內存是每一個線程的私有數據區域,所以不一樣的線程間沒法訪問對方的工做內存,線程間的通訊(傳值)必須經過主內存來完成,其簡要訪問過程以下圖

須要注意的是,JMM與Java內存區域的劃分是不一樣的概念層次,更恰當說JMM描述的是一組規則,經過這組規則控制程序中各個變量在共享數據區域和私有數據區域的訪問方式,JMM是圍繞原子性,有序性、可見性展開的(稍後會分析)。JMM與Java內存區域惟一類似點,都存在共享數據區域和私有數據區域,在JMM中主內存屬於共享數據區域,從某個程度上講應該包括了堆和方法區,而工做內存數據線程私有數據區域,從某個程度上講則應該包括程序計數器、虛擬機棧以及本地方法棧。或許在某些地方,咱們可能會看見主內存被描述爲堆內存,工做內存被稱爲線程棧,實際上他們表達的都是同一個含義。關於JMM中的主內存和工做內存說明以下

  • 主內存

    主要存儲的是Java實例對象,全部線程建立的實例對象都存放在主內存中,無論該實例對象是成員變量仍是方法中的本地變量(也稱局部變量),固然也包括了共享的類信息、常量、靜態變量。因爲是共享數據區域,多條線程對同一個變量進行訪問可能會發現線程安全問題。

  • 工做內存

    主要存儲當前方法的全部本地變量信息(工做內存中存儲着主內存中的變量副本拷貝),每一個線程只能訪問本身的工做內存,即線程中的本地變量對其它線程是不可見的,就算是兩個線程執行的是同一段代碼,它們也會各自在本身的工做內存中建立屬於當前線程的本地變量,固然也包括了字節碼行號指示器、相關Native方法的信息。注意因爲工做內存是每一個線程的私有數據,線程間沒法相互訪問工做內存,所以存儲在工做內存的數據不存在線程安全問題。

弄清楚主內存和工做內存後,接瞭解一下主內存與工做內存的數據存儲類型以及操做方式,根據虛擬機規範,對於一個實例對象中的成員方法而言,若是方法中包含本地變量是基本數據類型(boolean,byte,short,char,int,long,float,double),將直接存儲在工做內存的幀棧結構中,但假若本地變量是引用類型,那麼該變量的引用會存儲在功能內存的幀棧中,而對象實例將存儲在主內存(共享數據區域,堆)中。但對於實例對象的成員變量,無論它是基本數據類型或者包裝類型(Integer、Double等)仍是引用類型,都會被存儲到堆區。至於static變量以及類自己相關信息將會存儲在主內存中。須要注意的是,在主內存中的實例對象能夠被多線程共享,假若兩個線程同時調用了同一個對象的同一個方法,那麼兩條線程會將要操做的數據拷貝一份到本身的工做內存中,執行完成操做後才刷新到主內存,簡單示意圖以下所示:

硬件內存架構與Java內存模型

硬件內存架構

正如上圖所示,通過簡化CPU與內存操做的簡易圖,實際上沒有這麼簡單,這裏爲了理解方便,咱們省去了南北橋並將三級緩存統一爲CPU緩存(有些CPU只有二級緩存,有些CPU有三級緩存)。就目前計算機而言,通常擁有多個CPU而且每一個CPU可能存在多個核心,多核是指在一枚處理器(CPU)中集成兩個或多個完整的計算引擎(內核),這樣就能夠支持多任務並行執行,從多線程的調度來講,每一個線程都會映射到各個CPU核心中並行運行。在CPU內部有一組CPU寄存器,寄存器是cpu直接訪問和處理的數據,是一個臨時放數據的空間。通常CPU都會從內存取數據到寄存器,而後進行處理,但因爲內存的處理速度遠遠低於CPU,致使CPU在處理指令時每每花費不少時間在等待內存作準備工做,因而在寄存器和主內存間添加了CPU緩存,CPU緩存比較小,但訪問速度比主內存快得多,若是CPU老是操做主內存中的同一址地的數據,很容易影響CPU執行速度,此時CPU緩存就能夠把從內存提取的數據暫時保存起來,若是寄存器要取內存中同一位置的數據,直接從緩存中提取,無需直接從主內存取。須要注意的是,寄存器並不每次數據均可以從緩存中取得數據,萬一不是同一個內存地址中的數據,那寄存器還必須直接繞過緩存從內存中取數據。因此並不每次都獲得緩存中取數據,這種現象有個專業的名稱叫作緩存的命中率,從緩存中取就命中,不從緩存中取從內存中取,就沒命中,可見緩存命中率的高低也會影響CPU執行性能,這就是CPU、緩存以及主內存間的簡要交互過程,總而言之當一個CPU須要訪問主存時,會先讀取一部分主存數據到CPU緩存(固然若是CPU緩存中存在須要的數據就會直接從緩存獲取),進而在讀取CPU緩存到寄存器,當CPU須要寫數據到主存時,一樣會先刷新寄存器中的數據到CPU緩存,而後再把數據刷新到主內存中。

Java線程與硬件處理器

瞭解完硬件的內存架構後,接着瞭解JVM中線程的實現原理,理解線程的實現原理,有助於咱們瞭解Java內存模型與硬件內存架構的關係,在Window系統和Linux系統上,Java線程的實現是基於一對一的線程模型,所謂的一對一模型,實際上就是經過語言級別層面程序去間接調用系統內核的線程模型,即咱們在使用Java線程時,Java虛擬機內部是轉而調用當前操做系統的內核線程來完成當前任務。這裏須要瞭解一個術語,內核線程(Kernel-Level Thread,KLT),它是由操做系統內核(Kernel)支持的線程,這種線程是由操做系統內核來完成線程切換,內核經過操做調度器進而對線程執行調度,並將線程的任務映射到各個處理器上。每一個內核線程能夠視爲內核的一個分身,這也就是操做系統能夠同時處理多任務的緣由。因爲咱們編寫的多線程程序屬於語言層面的,程序通常不會直接去調用內核線程,取而代之的是一種輕量級的進程(Light Weight Process),也是一般意義上的線程,因爲每一個輕量級進程都會映射到一個內核線程,所以咱們能夠經過輕量級進程調用內核線程,進而由操做系統內核將任務映射到各個處理器,這種輕量級進程與內核線程間1對1的關係就稱爲一對一的線程模型。以下圖

如圖所示,每一個線程最終都會映射到CPU中進行處理,若是CPU存在多核,那麼一個CPU將能夠並行執行多個線程任務。

Java內存模型與硬件內存架構的關係

經過對前面的硬件內存架構、Java內存模型以及Java多線程的實現原理的瞭解,咱們應該已經意識到,多線程的執行最終都會映射到硬件處理器上進行執行,但Java內存模型和硬件內存架構並不徹底一致。對於硬件內存來講只有寄存器、緩存內存、主內存的概念,並無工做內存(線程私有數據區域)和主內存(堆內存)之分,也就是說Java內存模型對內存的劃分對硬件內存並無任何影響,由於JMM只是一種抽象的概念,是一組規則,並不實際存在,無論是工做內存的數據仍是主內存的數據,對於計算機硬件來講都會存儲在計算機主內存中,固然也有可能存儲到CPU緩存或者寄存器中,所以整體上來講,Java內存模型和計算機硬件內存架構是一個相互交叉的關係,是一種抽象概念劃分與真實物理硬件的交叉。(注意對於Java內存區域劃分也是一樣的道理)

JMM存在的必要性

在明白了Java內存區域劃分、硬件內存架構、Java多線程的實現原理與Java內存模型的具體關係後,接着來談談Java內存模型存在的必要性。因爲JVM運行程序的實體是線程,而每一個線程建立時JVM都會爲其建立一個工做內存(有些地方稱爲棧空間),用於存儲線程私有的數據,線程與主內存中的變量操做必須經過工做內存間接完成,主要過程是將變量從主內存拷貝的每一個線程各自的工做內存空間,而後對變量進行操做,操做完成後再將變量寫回主內存,若是存在兩個線程同時對一個主內存中的實例對象的變量進行操做就有可能誘發線程安全問題。以下圖,主內存中存在一個共享變量x,如今有A和B兩條線程分別對該變量x=1進行操做,A/B線程各自的工做內存中存在共享變量副本x。假設如今A線程想要修改x的值爲2,而B線程卻想要讀取x的值,那麼B線程讀取到的值是A線程更新後的值2仍是更新前的值1呢?答案是,不肯定,即B線程有可能讀取到A線程更新前的值1,也有可能讀取到A線程更新後的值2,這是由於工做內存是每一個線程私有的數據區域,而線程A變量x時,首先是將變量從主內存拷貝到A線程的工做內存中,而後對變量進行操做,操做完成後再將變量x寫回主內,而對於B線程的也是相似的,這樣就有可能形成主內存與工做內存間數據存在一致性問題,假如A線程修改完後正在將數據寫回主內存,而B線程此時正在讀取主內存,即將x=1拷貝到本身的工做內存中,這樣B線程讀取到的值就是x=1,但若是A線程已將x=2寫回主內存後,B線程纔開始讀取的話,那麼此時B線程讀取到的就是x=2,但究竟是哪一種狀況先發生呢?這是不肯定的,這也就是所謂的線程安全問題。

爲了解決相似上述的問題,JVM定義了一組規則,經過這組規則來決定一個線程對共享變量的寫入什麼時候對另外一個線程可見,這組規則也稱爲Java內存模型(即JMM),JMM是圍繞着程序執行的原子性、有序性、可見性展開的,下面咱們看看這三個特性。

Java內存模型的承諾

這裏咱們先來了解幾個概念,即原子性?可見性?有序性?最後再闡明JMM是如何保證這3個特性。

原子性

原子性指的是一個操做是不可中斷的,即便是在多線程環境下,一個操做一旦開始就不會被其餘線程影響。好比對於一個靜態變量int x,兩條線程同時對他賦值,線程A賦值爲1,而線程B賦值爲2,無論線程如何運行,最終x的值要麼是1,要麼是2,線程A和線程B間的操做是沒有干擾的,這就是原子性操做,不可被中斷的特色。有點要注意的是,對於32位系統的來講,long類型數據和double類型數據(對於基本數據類型,byte,short,int,float,boolean,char讀寫是原子操做),它們的讀寫並不是原子性的,也就是說若是存在兩條線程同時對long類型或者double類型的數據進行讀寫是存在相互干擾的,由於對於32位虛擬機來講,每次原子讀寫是32位的,而long和double則是64位的存儲單元,這樣會致使一個線程在寫時,操做完前32位的原子操做後,輪到B線程讀取時,剛好只讀取到了後32位的數據,這樣可能會讀取到一個既非原值又不是線程修改值的變量,它多是「半個變量」的數值,即64位數據被兩個線程分紅了兩次讀取。但也沒必要太擔憂,由於讀取到「半個變量」的狀況比較少見,至少在目前的商用的虛擬機中,幾乎都把64位的數據的讀寫操做做爲原子操做來執行,所以對於這個問題沒必要太在乎,知道這麼回事便可。

理解指令重排

計算機在執行程序時,爲了提升性能,編譯器和處理器的經常會對指令作重排,通常分如下3種

  • 編譯器優化的重排

    編譯器在不改變單線程程序語義的前提下,能夠從新安排語句的執行順序。

  • 指令並行的重排

    現代處理器採用了指令級並行技術來將多條指令重疊執行。若是不存在數據依賴性(即後一個執行的語句無需依賴前面執行的語句的結果),處理器能夠改變語句對應的機器指令的執行順序

  • 內存系統的重排

    因爲處理器使用緩存和讀寫緩存衝區,這使得加載(load)和存儲(store)操做看上去多是在亂序執行,由於三級緩存的存在,致使內存與緩存的數據同步存在時間差。

其中編譯器優化的重排屬於編譯期重排,指令並行的重排和內存系統的重排屬於處理器重排,在多線程環境中,這些重排優化可能會致使程序出現內存可見性問題,下面分別闡明這兩種重排優化可能帶來的問題

編譯器重排

下面咱們簡單看一個編譯器重排的例子:

線程 1             線程 2
1: x2 = a ;      3: x1 = b ;
2: b = 1;         4: a = 2 ;

兩個線程同時執行,分別有一、二、三、4四段執行代碼,其中一、2屬於線程1 , 三、4屬於線程2 ,從程序的執行順序上看,彷佛不太可能出現x1 = 1 和x2 = 2 的狀況,但實際上這種狀況是有可能發現的,由於若是編譯器對這段程序代碼執行重排優化後,可能出現下列狀況

線程 1              線程 2
2: b = 1;          4: a = 2 ; 
1:x2 = a ;        3: x1 = b ;

這種執行順序下就有可能出現x1 = 1 和x2 = 2 的狀況,這也就說明在多線程環境下,因爲編譯器優化重排的存在,兩個線程中使用的變量可否保證一致性是沒法肯定的。

處理器指令重排

先了解一下指令重排的概念,處理器指令重排是對CPU的性能優化,從指令的執行角度來講一條指令能夠分爲多個步驟完成,以下

  • 取指 IF
  • 譯碼和取寄存器操做數 ID
  • 執行或者有效地址計算 EX
  • 存儲器訪問 MEM
  • 寫回 WB

CPU在工做時,須要將上述指令分爲多個步驟依次執行(注意硬件不一樣有可能不同),因爲每個步會使用到不一樣的硬件操做,好比取指時會只有PC寄存器和存儲器,譯碼時會執行到指令寄存器組,執行時會執行ALU(算術邏輯單元)、寫回時使用到寄存器組。爲了提升硬件利用率,CPU指令是按流水線技術來執行的,以下:

從圖中能夠看出當指令1還未執行完成時,第2條指令便利用空閒的硬件開始執行,這樣作是有好處的,若是每一個步驟花費1ms,那麼若是第2條指令須要等待第1條指令執行完成後再執行的話,則須要等待5ms,但若是使用流水線技術的話,指令2只需等待1ms就能夠開始執行了,這樣就能大大提高CPU的執行性能。雖然流水線技術能夠大大提高CPU的性能,但不幸的是一旦出現流水中斷,全部硬件設備將會進入一輪停頓期,當再次彌補中斷點可能須要幾個週期,這樣性能損失也會很大,就比如工廠組裝手機的流水線,一旦某個零件組裝中斷,那麼該零件日後的工人都有可能進入一輪或者幾輪等待組裝零件的過程。所以咱們須要儘可能阻止指令中斷的狀況,指令重排就是其中一種優化中斷的手段,咱們經過一個例子來闡明指令重排是如何阻止流水線技術中斷的

a = b + c ;
d = e + f ;

下面經過彙編指令展現了上述代碼在CPU執行的處理過程

  • LW指令 表示 load,其中LW R1,b表示把b的值加載到寄存器R1中
  • LW R2,c 表示把c的值加載到寄存器R2中
  • ADD 指令表示加法,把R1 、R2的值相加,並存入R3寄存器中。
  • SW 表示 store 即將 R3寄存器的值保持到變量a中
  • LW R4,e 表示把e的值加載到寄存器R4中
  • LW R5,f 表示把f的值加載到寄存器R5中
  • SUB 指令表示減法,把R4 、R5的值相減,並存入R6寄存器中。
  • SW d,R6 表示將R6寄存器的值保持到變量d中

上述即是彙編指令的執行過程,在某些指令上存在X的標誌,X表明中斷的含義,也就是隻要有X的地方就會致使指令流水線技術停頓,同時也會影響後續指令的執行,可能須要通過1個或幾個指令週期纔可能恢復正常,那爲何停頓呢?這是由於部分數據還沒準備好,如執行ADD指令時,須要使用到前面指令的數據R1,R2,而此時R2的MEM操做沒有完成,即未拷貝到存儲器中,這樣加法計算就沒法進行,必須等到MEM操做完成後才能執行,也就所以而停頓了,其餘指令也是相似的狀況。前面闡述過,停頓會形成CPU性能降低,所以咱們應該想辦法消除這些停頓,這時就須要使用到指令重排了,以下圖,既然ADD指令須要等待,那咱們就利用等待的時間作些別的事情,如把LW R4,eLW R5,f 移動到前面執行,畢竟LW R4,eLW R5,f執行並無數據依賴關係,對他們有數據依賴關係的SUB R6,R5,R4指令在R4,R5加載完成後才執行的,沒有影響,過程以下:

正如上圖所示,全部的停頓都完美消除了,指令流水線也無需中斷了,這樣CPU的性能也能帶來很好的提高,這就是處理器指令重排的做用。關於編譯器重排以及指令重排(這兩種重排咱們後面統一稱爲指令重排)相關內容已闡述清晰了,咱們必須意識到對於單線程而已指令重排幾乎不會帶來任何影響,比竟重排的前提是保證串行語義執行的一致性,但對於多線程環境而已,指令重排就可能致使嚴重的程序輪序執行問題,以下

class MixedOrder{
    int a = 0;
    boolean flag = false;
    public void writer(){
        a = 1;
        flag = true;
    }

    public void read(){
        if(flag){
            int i = a + 1;
        }
    }
}

如上述代碼,同時存在線程A和線程B對該實例對象進行操做,其中A線程調用寫入方法,而B線程調用讀取方法,因爲指令重排等緣由,可能致使程序執行順序變爲以下:

線程A                    線程B
 writer:                 read:
 1:flag = true;           1:flag = true;
 2:a = 1;                 2: a = 0 ; //誤讀
                          3: i = 1 ;

因爲指令重排的緣由,線程A的flag置爲true被提早執行了,而a賦值爲1的程序還未執行完,此時線程B,剛好讀取flag的值爲true,直接獲取a的值(此時B線程並不知道a爲0)並執行i賦值操做,結果i的值爲1,而不是預期的2,這就是多線程環境下,指令重排致使的程序亂序執行的結果。所以,請記住,指令重排只會保證單線程中串行語義的執行的一致性,但並不會關心多線程間的語義一致性。

可見性

理解了指令重排現象後,可見性容易了,可見性指的是當一個線程修改了某個共享變量的值,其餘線程是否可以立刻得知這個修改的值。對於串行程序來講,可見性是不存在的,由於咱們在任何一個操做中修改了某個變量的值,後續的操做中都能讀取這個變量值,而且是修改過的新值。但在多線程環境中可就不必定了,前面咱們分析過,因爲線程對共享變量的操做都是線程拷貝到各自的工做內存進行操做後才寫回到主內存中的,這就可能存在一個線程A修改了共享變量x的值,還未寫回主內存時,另一個線程B又對主內存中同一個共享變量x進行操做,但此時A線程工做內存中共享變量x對線程B來講並不可見,這種工做內存與主內存同步延遲現象就形成了可見性問題,另外指令重排以及編譯器優化也可能致使可見性問題,經過前面的分析,咱們知道不管是編譯器優化仍是處理器優化的重排現象,在多線程環境下,確實會致使程序輪序執行的問題,從而也就致使可見性問題。

有序性

有序性是指對於單線程的執行代碼,咱們老是認爲代碼的執行是按順序依次執行的,這樣的理解並無毛病,畢竟對於單線程而言確實如此,但對於多線程環境,則可能出現亂序現象,由於程序編譯成機器碼指令後可能會出現指令重排現象,重排後的指令與原指令的順序未必一致,要明白的是,在Java程序中,假若在本線程內,全部操做都視爲有序行爲,若是是多線程環境下,一個線程中觀察另一個線程,全部操做都是無序的,前半句指的是單線程內保證串行語義執行的一致性,後半句則指指令重排現象和工做內存與主內存同步延遲現象。

JMM提供的解決方案

在理解了原子性,可見性以及有序性問題後,看看JMM是如何保證的,在Java內存模型中都提供一套解決方案供Java工程師在開發過程使用,如原子性問題,除了JVM自身提供的對基本數據類型讀寫操做的原子性外,對於方法級別或者代碼塊級別的原子性操做,可使用synchronized關鍵字或者重入鎖(ReentrantLock)保證程序執行的原子性,關於synchronized的詳解,看博主另一篇文章( 深刻理解Java併發之synchronized實現原理)。而工做內存與主內存同步延遲現象致使的可見性問題,可使用synchronized關鍵字或者volatile關鍵字解決,它們均可以使一個線程修改後的變量當即對其餘線程可見。對於指令重排致使的可見性問題和有序性問題,則能夠利用volatile關鍵字解決,由於volatile的另一個做用就是禁止重排序優化,關於volatile稍後會進一步分析。除了靠sychronized和volatile關鍵字來保證原子性、可見性以及有序性外,JMM內部還定義一套happens-before 原則來保證多線程環境下兩個操做間的原子性、可見性以及有序性。

理解JMM中的happens-before 原則

假若在程序開發中,僅靠sychronized和volatile關鍵字來保證原子性、可見性以及有序性,那麼編寫併發程序可能會顯得十分麻煩,幸運的是,在Java內存模型中,還提供了happens-before 原則來輔助保證程序執行的原子性、可見性以及有序性的問題,它是判斷數據是否存在競爭、線程是否安全的依據,happens-before 原則內容以下

  • 程序順序原則,即在一個線程內必須保證語義串行性,也就是說按照代碼順序執行。

  • 鎖規則 解鎖(unlock)操做必然發生在後續的同一個鎖的加鎖(lock)以前,也就是說,若是對於一個鎖解鎖後,再加鎖,那麼加鎖的動做必須在解鎖動做以後(同一個鎖)。

  • volatile規則 volatile變量的寫,先發生於讀,這保證了volatile變量的可見性,簡單的理解就是,volatile變量在每次被線程訪問時,都強迫從主內存中讀該變量的值,而當該變量發生變化時,又會強迫將最新的值刷新到主內存,任什麼時候刻,不一樣的線程老是可以看到該變量的最新值。

  • 線程啓動規則 線程的start()方法先於它的每個動做,即若是線程A在執行線程B的start方法以前修改了共享變量的值,那麼當線程B執行start方法時,線程A對共享變量的修改對線程B可見

  • 傳遞性 A先於B ,B先於C 那麼A必然先於C

  • 線程終止規則 線程的全部操做先於線程的終結,Thread.join()方法的做用是等待當前執行的線程終止。假設在線程B終止以前,修改了共享變量,線程A從線程B的join方法成功返回後,線程B對共享變量的修改將對線程A可見。

  • 線程中斷規則 對線程 interrupt()方法的調用先行發生於被中斷線程的代碼檢測到中斷事件的發生,能夠經過Thread.interrupted()方法檢測線程是否中斷。

  • 對象終結規則 對象的構造函數執行,結束先於finalize()方法

上述8條原則無需手動添加任何同步手段(synchronized|volatile)便可達到效果,下面咱們結合前面的案例演示這8條原則如何判斷線程是否安全,以下:

class MixedOrder{
    int a = 0;
    boolean flag = false;
    public void writer(){
        a = 1;
        flag = true;
    }

    public void read(){
        if(flag){
            int i = a + 1;
        }
    }
}

一樣的道理,存在兩條線程A和B,線程A調用實例對象的writer()方法,而線程B調用實例對象的read()方法,線程A先啓動而線程B後啓動,那麼線程B讀取到的i值是多少呢?如今依據8條原則,因爲存在兩條線程同時調用,所以程序次序原則不合適。writer()方法和read()方法都沒有使用同步手段,鎖規則也不合適。沒有使用volatile關鍵字,volatile變量原則不適應。線程啓動規則、線程終止規則、線程中斷規則、對象終結規則、傳遞性和本次測試案例也不合適。線程A和線程B的啓動時間雖然有前後,但線程B執行結果倒是不肯定,也是說上述代碼沒有適合8條原則中的任意一條,也沒有使用任何同步手段,因此上述的操做是線程不安全的,所以線程B讀取的值天然也是不肯定的。修復這個問題的方式很簡單,要麼給writer()方法和read()方法添加同步手段,如synchronized或者給變量flag添加volatile關鍵字,確保線程A修改的值對線程B老是可見。

volatile內存語義

volatile在併發編程中很常見,但也容易被濫用,如今咱們就進一步分析volatile關鍵字的語義。volatile是Java虛擬機提供的輕量級的同步機制。volatile關鍵字有以下兩個做用

  • 保證被volatile修飾的共享gong’x變量對全部線程總數可見的,也就是當一個線程修改了一個被volatile修飾共享變量的值,新值總數能夠被其餘線程當即得知。

  • 禁止指令重排序優化。

volatile的可見性

關於volatile的可見性做用,咱們必須意識到被volatile修飾的變量對全部線程總數當即可見的,對volatile變量的全部寫操做老是能馬上反應到其餘線程中,可是對於volatile變量運算操做在多線程環境並不保證安全性,以下

public class VolatileVisibility {
    public static volatile int i =0;

    public static void increase(){
        i++;
    }
}

正如上述代碼所示,i變量的任何改變都會立馬反應到其餘線程中,可是如此存在多條線程同時調用increase()方法的話,就會出現線程安全問題,畢竟i++;操做並不具有原子性,該操做是先讀取值,而後寫回一個新值,至關於原來的值加上1,分兩步完成,若是第二個線程在第一個線程讀取舊值和寫回新值期間讀取i的域值,那麼第二個線程就會與第一個線程一塊兒看到同一個值,並執行相同值的加1操做,這也就形成了線程安全失敗,所以對於increase方法必須使用synchronized修飾,以便保證線程安全,須要注意的是一旦使用synchronized修飾方法後,因爲synchronized自己也具有與volatile相同的特性,便可見性,所以在這樣種狀況下就徹底能夠省去volatile修飾變量。

public class VolatileVisibility {
    public static int i =0;

    public synchronized static void increase(){
        i++;
    }
}

如今來看另一種場景,可使用volatile修飾變量達到線程安全的目的,以下

public class VolatileSafe {

    volatile boolean close;

    public void close(){
        close=true;
    }

    public void doWork(){
        while (!close){
            System.out.println("safe....");
        }
    }
}

因爲對於boolean變量close值的修改屬於原子性操做,所以能夠經過使用volatile修飾變量close,使用該變量對其餘線程當即可見,從而達到線程安全的目的。那麼JMM是如何實現讓volatile變量對其餘線程當即可見的呢?實際上,當寫一個volatile變量時,JMM會把該線程對應的工做內存中的共享變量值刷新到主內存中,當讀取一個volatile變量時,JMM會把該線程對應的工做內存置爲無效,那麼該線程將只能從主內存中從新讀取共享變量。volatile變量正是經過這種寫-讀方式實現對其餘線程可見(但其內存語義實現則是經過內存屏障,稍後會說明)。

volatile禁止重排優化

volatile關鍵字另外一個做用就是禁止指令重排優化,從而避免多線程環境下程序出現亂序執行的現象,關於指令重排優化前面已詳細分析過,這裏主要簡單說明一下volatile是如何實現禁止指令重排優化的。先了解一個概念,內存屏障(Memory Barrier)。
內存屏障,又稱內存柵欄,是一個CPU指令,它的做用有兩個,一是保證特定操做的執行順序,二是保證某些變量的內存可見性(利用該特性實現volatile的內存可見性)。因爲編譯器和處理器都能執行指令重排優化。若是在指令間插入一條Memory Barrier則會告訴編譯器和CPU,無論什麼指令都不能和這條Memory Barrier指令重排序,也就是說經過插入內存屏障禁止在內存屏障先後的指令執行重排序優化。Memory Barrier的另一個做用是強制刷出各類CPU的緩存數據,所以任何CPU上的線程都能讀取到這些數據的最新版本。總之,volatile變量正是經過內存屏障實現其在內存中的語義,便可見性和禁止重排優化。下面看一個很是典型的禁止重排優化的例子DCL,以下:

/**
 * Created by zejian on 2017/6/11.
 * Blog : http://blog.csdn.net/javazejian [原文地址,請尊重原創]
 */
public class DoubleCheckLock {

    private static DoubleCheckLock instance;

    private DoubleCheckLock(){}

    public static DoubleCheckLock getInstance(){

        //第一次檢測
        if (instance==null){
            //同步
            synchronized (DoubleCheckLock.class){
                if (instance == null){
                    //多線程環境下可能會出現問題的地方
                    instance = new DoubleCheckLock();
                }
            }
        }
        return instance;
    }
}

上述代碼一個經典的單例的雙重檢測的代碼,這段代碼在單線程環境下並無什麼問題,但若是在多線程環境下就能夠出現線程安全問題。緣由在於某一個線程執行到第一次檢測,讀取到的instance不爲null時,instance的引用對象可能沒有完成初始化。由於instance = new DoubleCheckLock();能夠分爲如下3步完成(僞代碼)

memory = allocate(); //1.分配對象內存空間
instance(memory);    //2.初始化對象
instance = memory;   //3.設置instance指向剛分配的內存地址,此時instance!=null

因爲步驟1和步驟2間可能會重排序,以下:

memory = allocate(); //1.分配對象內存空間
instance = memory;   //3.設置instance指向剛分配的內存地址,此時instance!=null,可是對象尚未初始化完成!
instance(memory);    //2.初始化對象

因爲步驟2和步驟3不存在數據依賴關係,並且不管重排前仍是重排後程序的執行結果在單線程中並無改變,所以這種重排優化是容許的。可是指令重排只會保證串行語義的執行的一致性(單線程),但並不會關心多線程間的語義一致性。因此當一條線程訪問instance不爲null時,因爲instance實例未必已初始化完成,也就形成了線程安全問題。那麼該如何解決呢,很簡單,咱們使用volatile禁止instance變量被執行指令重排優化便可。

//禁止指令重排優化
  private volatile static DoubleCheckLock instance;

ok~,到此相信咱們對Java內存模型和volatile應該都有了比較全面的認識,總而言之,咱們應該清楚知道,JMM就是一組規則,這組規則意在解決在併發編程可能出現的線程安全問題,並提供了內置解決方案(happen-before原則)及其外部可以使用的同步手段(synchronized/volatile等),確保了程序執行在多線程環境中的應有的原子性,可視性及其有序性。

相關文章
相關標籤/搜索