天天看點

深入了解(5)Java記憶體模型(JMM)及volatile關鍵字了解Java記憶體區域與Java記憶體模型硬體記憶體架構與Java記憶體模型JMM存在的必要性Java記憶體模型的承諾volatile記憶體語義

原文:https://blog.csdn.net/javazejian/article/details/72772461

深入了解(1)Java注解類型(@Annotation)

深入了解(2)Java枚舉類型(enum)

深入了解(3)Java類加載器(ClassLoader)

深入了解(4)Java類型資訊(Class對象)與反射機制

深入了解(5)Java記憶體模型(JMM)及volatile關鍵字

深入了解(6)Java并發AQS的共享鎖的實作(基于信号量Semaphore)

深入了解(7)Java無鎖CAS與Unsafe類及其并發包Atomic

深入了解(8)Java并發之synchronized實作原理

深入了解(9)Java基于并發AQS的(獨占鎖)重入鎖(ReetrantLock)及其Condition實作原理

深入了解(10)java并發之阻塞隊列LinkedBlockingQueue與ArrayBlockingQueue

文章目錄

  • 了解Java記憶體區域與Java記憶體模型
      • Java記憶體區域
      • Java記憶體模型概述
  • 硬體記憶體架構與Java記憶體模型
      • 硬體記憶體架構
      • Java線程與硬體處理器
      • Java記憶體模型與硬體記憶體架構的關系
  • JMM存在的必要性
  • Java記憶體模型的承諾
      • 原子性
      • 了解指令重排
        • 編譯器優化的重排
      • 編譯器重排
        • 處理器指令重排
        • 處理器指令重排
      • 有序性
      • JMM提供的解決方案
      • 了解JMM中的happens-before 原則
  • volatile記憶體語義
      • volatile的可見性
      • volatile禁止重排優化

了解Java記憶體區域與Java記憶體模型

Java記憶體區域

深入了解(5)Java記憶體模型(JMM)及volatile關鍵字了解Java記憶體區域與Java記憶體模型硬體記憶體架構與Java記憶體模型JMM存在的必要性Java記憶體模型的承諾volatile記憶體語義

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方法執行的記憶體模型。每個方法執行時都會建立一個棧桢來存儲方法的的變量表、操作數棧、動态連結方法、傳回值、傳回位址等資訊。每個方法從調用直結束就對于一個棧桢在虛拟機棧中的入棧和出棧過程,如下(圖有誤,應該為棧桢)

深入了解(5)Java記憶體模型(JMM)及volatile關鍵字了解Java記憶體區域與Java記憶體模型硬體記憶體架構與Java記憶體模型JMM存在的必要性Java記憶體模型的承諾volatile記憶體語義
  • 本地方法棧(Native Method Stacks):

本地方法棧屬于線程私有的資料區域,這部分主要與虛拟機用到的 Native 方法相關,一般情況下,我們無需關心此區域。

這裡之是以簡要說明這部分内容,注意是為了差別Java記憶體模型與Java記憶體區域的劃分,畢竟這兩種劃分是屬于不同層次的概念。

Java記憶體模型概述

Java記憶體模型(即Java Memory Model,簡稱JMM)本身是一種抽象的概念,并不真實存在,它描述的是一組規則或規範,通過這組規範定義了程式中各個變量(包括執行個體字段,靜态字段和構成數組對象的元素)的通路方式。由于JVM運作程式的實體是線程,而每個線程建立時JVM都會為其建立一個工作記憶體(有些地方稱為棧空間),用于存儲線程私有的資料,而Java記憶體模型中規定所有變量都存儲在主記憶體,主記憶體是共享記憶體區域,所有線程都可以通路,但線程對變量的操作(讀取指派等)必須在工作記憶體中進行,首先要将變量從主記憶體拷貝的自己的工作記憶體空間,然後對變量進行操作,操作完成後再将變量寫回主記憶體,不能直接操作主記憶體中的變量,工作記憶體中存儲着主記憶體中的變量副本拷貝,前面說過,工作記憶體是每個線程的私有資料區域,是以不同的線程間無法通路對方的工作記憶體,線程間的通信(傳值)必須通過主記憶體來完成,其簡要通路過程如下圖

深入了解(5)Java記憶體模型(JMM)及volatile關鍵字了解Java記憶體區域與Java記憶體模型硬體記憶體架構與Java記憶體模型JMM存在的必要性Java記憶體模型的承諾volatile記憶體語義

需要注意的是,JMM與Java記憶體區域的劃分是不同的概念層次,更恰當說JMM描述的是一組規則,通過這組規則控制程式中各個變量在共享資料區域和私有資料區域的通路方式,JMM是圍繞原子性,有序性、可見性展開的(稍後會分析)。JMM與Java記憶體區域唯一相似點,都存在共享資料區域和私有資料區域,在JMM中主記憶體屬于共享資料區域,從某個程度上講應該包括了堆和方法區,而工作記憶體資料線程私有資料區域,從某個程度上講則應該包括程式計數器、虛拟機棧以及本地方法棧。或許在某些地方,我們可能會看見主記憶體被描述為堆記憶體,工作記憶體被稱為線程棧,實際上他們表達的都是同一個含義。關于JMM中的主記憶體和工作記憶體說明如下

  • 主記憶體

主要存儲的是Java執行個體對象,所有線程建立的執行個體對象都存放在主記憶體中,不管該執行個體對象是成員變量還是方法中的本地變量(也稱局部變量),當然也包括了共享的類資訊、常量、靜态變量。由于是共享資料區域,多條線程對同一個變量進行通路可能會發現線程安全問題。

  • 工作記憶體

主要存儲目前方法的所有本地變量資訊(工作記憶體中存儲着主記憶體中的變量副本拷貝),每個線程隻能通路自己的工作記憶體,即線程中的本地變量對其它線程是不可見的,就算是兩個線程執行的是同一段代碼,它們也會各自在自己的工作記憶體中建立屬于目前線程的本地變量,當然也包括了位元組碼行号訓示器、相關Native方法的資訊。注意由于工作記憶體是每個線程的私有資料,線程間無法互相通路工作記憶體,是以存儲在工作記憶體的資料不存線上程安全問題。

弄清楚主記憶體和工作記憶體後,接了解一下主記憶體與工作記憶體的資料存儲類型以及操作方式,根據虛拟機規範,對于一個執行個體對象中的成員方法而言,如果方法中包含本地變量是基本資料類型(boolean,byte,short,char,int,long,float,double),将直接存儲在工作記憶體的幀棧結構中,但倘若本地變量是引用類型,那麼該變量的引用會存儲在功能記憶體的幀棧中,而對象執行個體将存儲在主記憶體(共享資料區域,堆)中。但對于執行個體對象的成員變量,不管它是基本資料類型或者包裝類型(Integer、Double等)還是引用類型,都會被存儲到堆區。至于static變量以及類本身相關資訊将會存儲在主記憶體中。需要注意的是,在主記憶體中的執行個體對象可以被多線程共享,倘若兩個線程同時調用了同一個對象的同一個方法,那麼兩條線程會将要操作的資料拷貝一份到自己的工作記憶體中,執行完成操作後才重新整理到主記憶體,簡單示意圖如下所示:

深入了解(5)Java記憶體模型(JMM)及volatile關鍵字了解Java記憶體區域與Java記憶體模型硬體記憶體架構與Java記憶體模型JMM存在的必要性Java記憶體模型的承諾volatile記憶體語義

硬體記憶體架構與Java記憶體模型

硬體記憶體架構

深入了解(5)Java記憶體模型(JMM)及volatile關鍵字了解Java記憶體區域與Java記憶體模型硬體記憶體架構與Java記憶體模型JMM存在的必要性Java記憶體模型的承諾volatile記憶體語義

正如上圖所示,經過簡化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的關系就稱為一對一的線程模型。如下圖

深入了解(5)Java記憶體模型(JMM)及volatile關鍵字了解Java記憶體區域與Java記憶體模型硬體記憶體架構與Java記憶體模型JMM存在的必要性Java記憶體模型的承諾volatile記憶體語義

如圖所示,每個線程最終都會映射到CPU中進行處理,如果CPU存在多核,那麼一個CPU将可以并行執行多個線程任務。

Java記憶體模型與硬體記憶體架構的關系

通過對前面的硬體記憶體架構、Java記憶體模型以及Java多線程的實作原理的了解,我們應該已經意識到,多線程的執行最終都會映射到硬體處理器上進行執行,但Java記憶體模型和硬體記憶體架構并不完全一緻。對于硬體記憶體來說隻有寄存器、緩存記憶體、主記憶體的概念,并沒有工作記憶體(線程私有資料區域)和主記憶體(堆記憶體)之分,也就是說Java記憶體模型對記憶體的劃分對硬體記憶體并沒有任何影響,因為JMM隻是一種抽象的概念,是一組規則,并不實際存在,不管是工作記憶體的資料還是主記憶體的資料,對于計算機硬體來說都會存儲在計算機主記憶體中,當然也有可能存儲到CPU緩存或者寄存器中,是以總體上來說,Java記憶體模型和計算機硬體記憶體架構是一個互相交叉的關系,是一種抽象概念劃分與真實實體硬體的交叉。(注意對于Java記憶體區域劃分也是同樣的道理)

深入了解(5)Java記憶體模型(JMM)及volatile關鍵字了解Java記憶體區域與Java記憶體模型硬體記憶體架構與Java記憶體模型JMM存在的必要性Java記憶體模型的承諾volatile記憶體語義

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,但到底是哪種情況先發生呢?這是不确定的,這也就是所謂的線程安全問題。

深入了解(5)Java記憶體模型(JMM)及volatile關鍵字了解Java記憶體區域與Java記憶體模型硬體記憶體架構與Java記憶體模型JMM存在的必要性Java記憶體模型的承諾volatile記憶體語義

為了解決類似上述的問題,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 ;
           

兩個線程同時執行,分别有1、2、3、4四段執行代碼,其中1、2屬于線程1 , 3、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指令是按流水線技術來執行的,如下:

處理器指令重排

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

  • 取指 IF
  • 譯碼和取寄存器操作數 ID
  • 執行或者有效位址計算 EX
  • 存儲器通路 MEM
  • 寫回 WB

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

深入了解(5)Java記憶體模型(JMM)及volatile關鍵字了解Java記憶體區域與Java記憶體模型硬體記憶體架構與Java記憶體模型JMM存在的必要性Java記憶體模型的承諾volatile記憶體語義

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

a = b + c ;

d = e + f ;

1

2

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

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

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

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

深入了解(5)Java記憶體模型(JMM)及volatile關鍵字了解Java記憶體區域與Java記憶體模型硬體記憶體架構與Java記憶體模型JMM存在的必要性Java記憶體模型的承諾volatile記憶體語義
  • 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加載完成後才執行的,沒有影響,過程如下:

深入了解(5)Java記憶體模型(JMM)及volatile關鍵字了解Java記憶體區域與Java記憶體模型硬體記憶體架構與Java記憶體模型JMM存在的必要性Java記憶體模型的承諾volatile記憶體語義

正如上圖所示,所有的停頓都完美消除了,指令流水線也無需中斷了,這樣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,這就是多線程環境下,指令重排導緻的程式亂序執行的結果。是以,請記住,指令重排隻會保證單線程中串行語義的執行的一緻性,但并不會關心多線程間的語義一緻性。

有序性

有序性是指對于單線程的執行代碼,我們總是認為代碼的執行是按順序依次執行的,這樣的了解并沒有毛病,畢竟對于單線程而言确實如此,但對于多線程環境,則可能出現亂序現象,因為程式編譯成機器碼指令後可能會出現指令重排現象,重排後的指令與原指令的順序未必一緻,要明白的是,在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++;
    }
}
           

現在來看另外一種場景,可以使用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,如下:

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等),確定了程式執行在多線程環境中的應有的原子性,可視性及其有序性。