全面理解Java記憶體模型(JMM)及volatile關鍵字

weixin_46217160發表於2020-10-07

本篇主要結合博主個人對Java記憶體模型的理解以及相關書籍內容的分析作為前提,對JMM進行較為全面的分析,本篇的寫作思路是先闡明Java記憶體區域劃分、硬體記憶體架構、Java多執行緒的實現原理與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):

    本地方法棧屬於執行緒私有的資料區域,這部分主要與虛擬機器用到的 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)操作看上去可能是在亂序執行,因為三級快取的存在,導致記憶體與快取的資料同步存在時間差。

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

編譯器重排

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

<span style="color:#000000"><code class="language-java">執行緒 <span style="color:#006666 !important">1</span>             執行緒 <span style="color:#006666 !important">2</span>
<span style="color:#006666 !important">1</span>: x2 = a ;      <span style="color:#006666 !important">3</span>: x1 = b ;
<span style="color:#006666 !important">2</span>: b = <span style="color:#006666 !important">1</span>;         <span style="color:#006666 !important">4</span>: a = <span style="color:#006666 !important">2</span> ;</code></span>
  • 1
  • 2
  • 3

兩個執行緒同時執行,分別有1、2、3、4四段執行程式碼,其中1、2屬於執行緒1 , 3、4屬於執行緒2 ,從程式的執行順序上看,似乎不太可能出現x1 = 1 和x2 = 2 的情況,但實際上這種情況是有可能發現的,因為如果編譯器對這段程式程式碼執行重排優化後,可能出現下列情況

<span style="color:#000000"><code class="language-java">執行緒 <span style="color:#006666 !important">1</span>              執行緒 <span style="color:#006666 !important">2</span>
<span style="color:#006666 !important">2</span>: b = <span style="color:#006666 !important">1</span>;          <span style="color:#006666 !important">4</span>: a = <span style="color:#006666 !important">2</span> ; 
<span style="color:#006666 !important">1</span>:x2 = a ;        <span style="color:#006666 !important">3</span>: x1 = b ;</code></span>
  • 1
  • 2
  • 3

這種執行順序下就有可能出現x1 = 1 和x2 = 2 的情況,這也就說明在多執行緒環境下,由於編譯器優化重排的存在,兩個執行緒中使用的變數能否保證一致性是無法確定的。

處理器指令重排

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

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

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

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

<span style="color:#000000"><code class="language-java">a = b + c ;
d = e + f ;</code></span>
  • 1
  • 2

下面通過彙編指令展示了上述程式碼在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,e 和 LW R5,f 移動到前面執行,畢竟LW R4,e 和 LW R5,f執行並沒有資料依賴關係,對他們有資料依賴關係的SUB R6,R5,R4指令在R4,R5載入完成後才執行的,沒有影響,過程如下:

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

<span style="color:#000000"><code>class MixedOrder{
    <span style="color:#000088 !important">int</span> a = <span style="color:#006666 !important">0</span>;
    <span style="color:#000088 !important">boolean</span> flag = <span style="color:#000088 !important">false</span>;
    <span style="color:#000088 !important">public</span> <span style="color:#000088 !important">void</span> <span style="color:#009900 !important">writer</span>(){
        a = <span style="color:#006666 !important">1</span>;
        flag = <span style="color:#000088 !important">true</span>;
    }

    <span style="color:#000088 !important">public</span> <span style="color:#000088 !important">void</span> <span style="color:#009900 !important">read</span>(){
        <span style="color:#000088 !important">if</span>(flag){
            <span style="color:#000088 !important">int</span> i = a + <span style="color:#006666 !important">1</span>;
        }
    }
}</code></span>
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14

如上述程式碼,同時存線上程A和執行緒B對該例項物件進行操作,其中A執行緒呼叫寫入方法,而B執行緒呼叫讀取方法,由於指令重排等原因,可能導致程式執行順序變為如下:

<span style="color:#000000"><code> 執行緒<span style="color:#009900 !important">A</span>                    執行緒<span style="color:#009900 !important">B</span>
 writer:                 read:
 <span style="color:#006666 !important">1</span><span style="color:#009900 !important">:flag</span> = <span style="color:#000088 !important">true</span>;           <span style="color:#006666 !important">1</span><span style="color:#009900 !important">:flag</span> = <span style="color:#000088 !important">true</span>;
 <span style="color:#006666 !important">2</span><span style="color:#009900 !important">:a</span> = <span style="color:#006666 !important">1</span>;                 <span style="color:#006666 !important">2</span><span style="color:#009900 !important">:</span> a = <span style="color:#006666 !important">0</span> ; <span style="color:#008800 !important">//</span>誤讀
                          <span style="color:#006666 !important">3</span><span style="color:#009900 !important">:</span> i = <span style="color:#006666 !important">1</span> ;</code></span>
  • 1
  • 2
  • 3
  • 4
  • 5

由於指令重排的原因,執行緒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條原則如何判斷執行緒是否安全,如下:

<span style="color:#000000"><code>class MixedOrder{
    <span style="color:#000088 !important">int</span> a = <span style="color:#006666 !important">0</span>;
    <span style="color:#000088 !important">boolean</span> flag = <span style="color:#000088 !important">false</span>;
    <span style="color:#000088 !important">public</span> <span style="color:#000088 !important">void</span> <span style="color:#009900 !important">writer</span>(){
        a = <span style="color:#006666 !important">1</span>;
        flag = <span style="color:#000088 !important">true</span>;
    }

    <span style="color:#000088 !important">public</span> <span style="color:#000088 !important">void</span> <span style="color:#009900 !important">read</span>(){
        <span style="color:#000088 !important">if</span>(flag){
            <span style="color:#000088 !important">int</span> i = a + <span style="color:#006666 !important">1</span>;
        }
    }
}</code></span>
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14

同樣的道理,存在兩條執行緒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變數運算操作在多執行緒環境並不保證安全性,如下

<span style="color:#000000"><code><span style="color:#000088 !important">public</span> <span style="color:#000088 !important">class</span> VolatileVisibility {
    <span style="color:#000088 !important">public</span> <span style="color:#000088 !important">static</span> <span style="color:#000088 !important">volatile</span> <span style="color:#000088 !important">int</span> i =<span style="color:#006666 !important">0</span>;

    <span style="color:#000088 !important">public</span> <span style="color:#000088 !important">static</span> <span style="color:#000088 !important">void</span> <span style="color:#009900 !important">increase</span>(){
        i++;
    }
}</code></span>
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7

正如上述程式碼所示,i變數的任何改變都會立馬反應到其他執行緒中,但是如此存在多條執行緒同時呼叫increase()方法的話,就會出現執行緒安全問題,畢竟i++;操作並不具備原子性,該操作是先讀取值,然後寫回一個新值,相當於原來的值加上1,分兩步完成,如果第二個執行緒在第一個執行緒讀取舊值和寫回新值期間讀取i的域值,那麼第二個執行緒就會與第一個執行緒一起看到同一個值,並執行相同值的加1操作,這也就造成了執行緒安全失敗,因此對於increase方法必須使用synchronized修飾,以便保證執行緒安全,需要注意的是一旦使用synchronized修飾方法後,由於synchronized本身也具備與volatile相同的特性,即可見性,因此在這樣種情況下就完全可以省去volatile修飾變數。

<span style="color:#000000"><code><span style="color:#000088 !important">public</span> <span style="color:#000088 !important">class</span> VolatileVisibility {
    <span style="color:#000088 !important">public</span> <span style="color:#000088 !important">static</span> <span style="color:#000088 !important">int</span> i =<span style="color:#006666 !important">0</span>;

    <span style="color:#000088 !important">public</span> synchronized <span style="color:#000088 !important">static</span> <span style="color:#000088 !important">void</span> <span style="color:#009900 !important">increase</span>(){
        i++;
    }
}</code></span>
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7

現在來看另外一種場景,可以使用volatile修飾變數達到執行緒安全的目的,如下

<span style="color:#000000"><code><span style="color:#000088 !important">public</span> <span style="color:#000088 !important">class</span> VolatileSafe {

    <span style="color:#000088 !important">volatile</span> boolean close;

    <span style="color:#000088 !important">public</span> <span style="color:#000088 !important">void</span> <span style="color:#009900 !important">close</span>(){
        close=<span style="color:#000088 !important">true</span>;
    }

    <span style="color:#000088 !important">public</span> <span style="color:#000088 !important">void</span> <span style="color:#009900 !important">doWork</span>(){
        <span style="color:#000088 !important">while</span> (!close){
            System.<span style="color:#000088 !important">out</span>.println(<span style="color:#009900 !important">"safe...."</span>);
        }
    }
}</code></span>
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14

由於對於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,如下:

<span style="color:#000000"><code class="language-java"><span style="color:#880000 !important">/**
 * Created by zejian on 2017/6/11.
 * Blog : http://blog.csdn.net/javazejian [原文地址,請尊重原創]
 */</span>
<span style="color:#000088 !important">public</span> <span style="color:#000088 !important">class</span> <span style="color:#4f4f4f !important">DoubleCheckLock</span> {

    <span style="color:#000088 !important">private</span> <span style="color:#000088 !important">static</span> DoubleCheckLock instance;

    <span style="color:#000088 !important">private</span> <span style="color:#009900 !important">DoubleCheckLock</span>(){}

    <span style="color:#000088 !important">public</span> <span style="color:#000088 !important">static</span> DoubleCheckLock <span style="color:#009900 !important">getInstance</span>(){

        <span style="color:#880000 !important"><em>//第一次檢測</em></span>
        <span style="color:#000088 !important">if</span> (instance==<span style="color:#000088 !important">null</span>){
            <span style="color:#880000 !important"><em>//同步</em></span>
            <span style="color:#000088 !important">synchronized</span> (DoubleCheckLock.class){
                <span style="color:#000088 !important">if</span> (instance == <span style="color:#000088 !important">null</span>){
                    <span style="color:#880000 !important"><em>//多執行緒環境下可能會出現問題的地方</em></span>
                    instance = <span style="color:#000088 !important">new</span> DoubleCheckLock();
                }
            }
        }
        <span style="color:#000088 !important">return</span> instance;
    }
}</code></span>
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25

上述程式碼一個經典的單例的雙重檢測的程式碼,這段程式碼在單執行緒環境下並沒有什麼問題,但如果在多執行緒環境下就可以出現執行緒安全問題。原因在於某一個執行緒執行到第一次檢測,讀取到的instance不為null時,instance的引用物件可能沒有完成初始化。因為instance = new DoubleCheckLock();可以分為以下3步完成(虛擬碼)

<span style="color:#000000"><code><span style="color:#000088 !important">memory</span> = allocate(); <span style="color:#880000 !important"><em>//1.分配物件記憶體空間</em></span>
<span style="color:#000088 !important">instance</span>(<span style="color:#000088 !important">memory</span>);    <span style="color:#880000 !important"><em>//2.初始化物件</em></span>
<span style="color:#000088 !important">instance</span> = <span style="color:#000088 !important">memory</span>;   <span style="color:#880000 !important"><em>//3.設定instance指向剛分配的記憶體地址,此時instance!=null</em></span></code></span>
  • 1
  • 2
  • 3

由於步驟1和步驟2間可能會重排序,如下:

<span style="color:#000000"><code><span style="color:#000088 !important">memory</span> = allocate(); <span style="color:#880000 !important"><em>//1.分配物件記憶體空間</em></span>
<span style="color:#000088 !important">instance</span> = <span style="color:#000088 !important">memory</span>;   <span style="color:#880000 !important"><em>//3.設定instance指向剛分配的記憶體地址,此時instance!=null,但是物件還沒有初始化完成!</em></span>
<span style="color:#000088 !important">instance</span>(<span style="color:#000088 !important">memory</span>);    <span style="color:#880000 !important"><em>//2.初始化物件</em></span></code></span>
  • 1
  • 2
  • 3

由於步驟2和步驟3不存在資料依賴關係,而且無論重排前還是重排後程式的執行結果在單執行緒中並沒有改變,因此這種重排優化是允許的。但是指令重排只會保證序列語義的執行的一致性(單執行緒),但並不會關心多執行緒間的語義一致性。所以當一條執行緒訪問instance不為null時,由於instance例項未必已初始化完成,也就造成了執行緒安全問題。那麼該如何解決呢,很簡單,我們使用volatile禁止instance變數被執行指令重排優化即可。

<span style="color:#000000"><code>  <span style="color:#880000 !important"><em>//禁止指令重排優化</em></span>
  <span style="color:#000088 !important">private</span> <span style="color:#000088 !important">volatile</span> <span style="color:#000088 !important">static</span> DoubleCheckLock instance;</code></span>
  • 1
  • 2

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

相關文章