成人免费xxxxx在线视频软件_久久精品久久久_亚洲国产精品久久久_天天色天天色_亚洲人成一区_欧美一级欧美三级在线观看

深入理解Java內存模型(JMM)及Volatile關鍵字

開發 后端
本篇我們繼續來學習JMM模型以及Volatile關鍵字的那些面試必問的一些知識點。

 

前言

并發編程從操作系統底層工作整體認識開始

上一篇我們從操作系統底層工作的整體了解了并發編程在硬件以及操作系統層面的一些知識,本篇我們繼續來學習JMM模型以及Volatile關鍵字的那些面試必問的一些知識點。

什么是JMM模型?

Java 內存模型(Java Memory Model 簡稱JMM)是一種抽象的概念,并不真實存在,它描述的一組規則或規范,通過這組規范定義了程序中各個變量(包括實例字段、靜態字段和構成數組對象的元素)的訪問方式。JVM運行程序的實體是線程,而每個線程創建時 JVM 都會為其創建一個工作內存(有些地方稱為??臻g),用于存儲線程私有的數據,而Java 內存模型中規定所有變量都存儲在主內存,其主內存是共享內存區域,所有線程都可以訪問,但線程對變量的操作(讀取賦值等)必須在工作內存中進行,首先要將變量從主內存考吧到增加的工作內存空間,然后對變量進行操作,操作完成后再將變量寫回主內存,不能直接操作主內存中的變量,工作內存中存儲這主內存中的變量副本拷貝,工作內存是每個線程的私有數據區域,因此不同的線程間無法訪問對方的工作內存,線程間的通信(傳值)必須通過主內存來完成。

JMM 不同于 JVM 內存區域模式

JMM 與 JVM 內存區域的劃分是不同的概念層次,更恰當說 JMM 描述的是一組規則,通過這組規則控制各個變量在共享數據區域內和私有數據區域的訪問方式,JMM是圍繞原子性、有序性、可見性展開。JMM 與 Java 內存區域唯一相似點,都存在共享數據區域和私有數據區域,在 JMM 中主內存屬于共享數據區域,從某個程度上講應該包括了堆和方法區,而工作內存數據線程私有數據區域,從某個程度上講則應該包括程序計數器、虛擬機棧以及本地方法棧。

線程、工作內存、主內存工作交互圖(基于JMM規范),如下:


主內存

主要存儲的是Java實例對象,所有線程創建的實例對象都存放在主內存中,不管該實例對象是成員變量還是方法中的本地變量(也稱局部變量),當然也包括了共享的類信息、常量、靜態變量。由于是共享數據區域,多個線程同一個變量進行訪問可能會發送線程安全問題。

工作內存

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

根據 JVM 虛擬機規范主內存與工作內存的數據存儲類型以及操作方式,對于一個實例對象中的成員方法而言,如果方法中包括本地變量是基本數據類型(boolean、type、short、char、int、long、float、double),將直接存儲在工作內存的幀棧中,而對象實例將存儲在主內存(共享數據區域,堆)中。但對于實例對象的成員變量,不管它是基本數據類型或者包裝類型(Integer、Double等)還是引用類型,都會被存儲到堆區。至于 static 變量以及類本身相關信息將會存儲在主內存中。

需要注意的是,在主內存中的實例對象可以被多線程共享,倘若兩個線程同時調用類同一個對象的同一個方法,那么兩個線程會將要操作的數據拷貝一份到直接的工作內存中,執行晚操作后才刷新到主內存。模型如下圖所示:

  

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

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


JMM 存在的必要性

在明白了 Java 內存區域劃分、硬件內存架構、Java多線程的實現原理與Java內存模型的具體關系后,接著來談談Java內存模型存在的必要性。

由于JVM運行程序的實體是線程,而每個線程創建時 JVM 都會為其創建一個工作內存(有些地方稱為??臻g),用于存儲線程私有的數據,線程與主內存中的變量操作必須通過工作內存間接完成,主要過程是將變量從主內存拷貝的每個線程各自的工作內存空間,然后對變量進行操作,操作完成后再將變量寫回主內存,如果存在兩個線程同時對一個主內存中的實例對象的變量進行操作就有可能誘發線程安全問題。

假設主內存中存在一個共享變量 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 線程的也是類似的,這樣就有可能造成主內存與工作內存間數據存在一致性問題,假設直接的工作內存中,這樣 B 線程讀取到的值就是 x=1 ,但是如果 A 線程已將 x=2 寫回主內存后,B線程才開始讀取的話,那么此時 B 線程讀取到的就是 x=2 ,但到達是那種情況先發送呢?

如下圖所示案例:


以上關于主內存與工作內存直接的具體交互協議,即一個變量如何從主內存拷貝到工作內存,如何從工作內存同步到主內存之間的實現細節,Java內存模型定義來以下八種操作來完成。

數據同步八大原子操作

  1. lock(鎖定):作用于主內存的變量,把一個變量標記為一個線程獨占狀態;
  2. unlock(解鎖):作用于主內存的變量,把一個處于鎖定狀態的變量釋放出來,釋放后的變量才可以被其他線程鎖定;
  3. read(讀取):作用于主內存的變量,把一個變量值從主內存傳輸到線程的工作內存中,以后隨后的load工作使用;
  4. load(載入):作用于工作內存的變量,它把read操作從主內存中得到的變量值放入工作內存的變量;
  5. use(使用):作用于工作內存的變量,把工作內存中的一個變量值傳遞給執行引擎;
  6. assign(賦值):作用于工作內存的變量,它把一個從執行引擎接收到的值賦給工作內存的變量;
  7. store(存儲):作用于工作內存的變量,把工作內存中的一個變量的值傳送到主內存中,以便隨后的write的操作;
  8. wirte(寫入):作用于工作內存的變量,它把store操作從工作內存中的一個變量值傳送到主內存的變量中。
  • 如果要把一個變量從主內存中復制到工作內存中,就需要按順序地執行 read 和 load 操作;
  • 如果把變量從工作內存中同步到主內存中,就需要按順序地執行 store 和 write 操作。

但Java 內存模型只要求上述操作必須按順序執行,而沒有保證必須是連續執行。


同步規則分析

  1. 不允許一個線程無原因地(沒有發生任何 assign 操作)把數據從工作內存同步回主內存中;
  2. 一個新的變量只能在主內存中誕生,不允許在工作內存中直接使用一個未被初始化(load 或者 assign)的變量。即就是對一個變量實施 use 和 store 操作之前,必須先自行 assign 和 load 操作;
  3. 一個變量在同一時刻只允許一條線程對其進行 lock 操作,但 lock 操作可不被同一線程重復執行多次,多次執行 lock 后,只有執行相同次數 unlock 操作,變量才會被解鎖。lock 和 unlock 必須成對出現;
  4. 如果對一個變量執行 lock 操作,將會清空工作內存中此變量的值,在執行引擎使用變量之前需要重新執行 load 或 assign 操作初始化變量的值;
  5. 如果一個變量事先沒有被 lock 操作鎖定,則不允許對它執行 unlock 操作;也不允許去 unlock 一個被其他線程鎖定的變量;
  6. 對一個變量執行 unlock 操作之前,必須先把此變量同步到主內存中(執行store 和 write 操作)。

并發編程的可見性、原子性與有序性問題

原子性

原子性指的是一個操作不可中斷,即使是在多線程環境下,一個操作一旦開始就不會被其他線程影響。

在Java中,對于基本數據類型的變量的讀取和賦值操作是原子性操作需要注意的是:對于32位系統來說,long 類型數據和 double 類型數據(對于基本類型數據:byte、short、int、float、boolean、char 讀寫是原子操作),它們的讀寫并非原子性的,也就是說如果存在兩條線程同時對 long 類型或者 double 類型的數據進行讀寫是存在相互干擾的,因為對于32位虛擬機來說,每次原子讀寫是32位,而 long 和 double 則是64位的存儲單元,這樣回導致一個線程在寫時,操作完成前32位的原子操作后,輪到B線程讀取時,恰好只讀取來后32位的數據,這樣可能回讀取到一個即非原值又不是線程修改值的變量,它可能是“半個變量”的數值,即64位數據被兩個線程分成了兩次讀取。但也不必太擔心,因為讀取到“半個變量”的情況比較少,至少在目前的商用虛擬機中,幾乎都把64位的數據的讀寫操作作為原子操作來執行,因此對于這個問題不必太在意,知道怎么回事即可。

  1. X=10; //原子性(簡單的讀取、將數字賦值給變量)  
  2. Y = x; //變量之間的相互賦值,不是原子操作 
  3. X++; //對變量進行計算操作 
  4. X=x+1; 

 可見性

理解了指令重排現象后,可見性容易理解了??梢娦灾傅氖钱斠粋€線程修改了某個共享變量的值,其他線程是否能夠馬上得知這個修改的值。對于串行程序來說,可見性是不存在的,因為我們在任何一個操作中修改了某個變量的值,后續的操作中都能讀取到這個變量,并且是修改過的新值。

但在多線程環境中可就不一定了,前面我們分析過,由于線程對共享變量的操作都是線程拷貝到各自的工作內存進行操作后才寫回到主內存中的,這就可能存在一個線程A修改了共享變量 x 的值,還未寫回主內存時,另外一個線程B又對主內存中同一個共享變量 x 進行操作,但此時A線程工作內存中共享變量 x 對線程B來說并不可見,這種工作內存與主內存同步延遲現象就會造成可見性問題,另外指令重排以及編譯器優化也可能回導致可見性問題,通過前面的分析,我們知道無論是編譯器優化還是處理器優化的重排現象,在多線程環境下,確實回導致程序亂序執行的問題,從而也就導致可見性問題。

有序性

有序性是指對于單線程的執行代碼,我們總是認為代碼的執行是按順序依次執行的,這樣的理解并沒有毛病,比較對于單線程而言確實如此,但對于多線程環境,則可能出現亂序現象,因為程序編譯稱機器碼指令后可能回出現指令重排現象,重排后的指令與原指令的順序未必一致,要明白的是,在Java程序中,倘若在本線程內,所有操作都視為有序行為,如果是多線程環境下,一個線程中觀察另外一個線程,所有操作都是無序的,前半句指的是單線程內保證串行語義執行的一致性,后半句則指令重排現象和工作內存與主內存同步延遲現象。

JMM如何解決原子性、可見性和有序性問題

原子性問題

除了 JVM 自身提供的對基本數據類型讀寫操作的原子性外,可以通過 synchronized 和 Lock 實現原子性。因為 synchronized 和 Lock 能夠保證任一時刻只有一個線程訪問該代碼塊。

可見性問題

volatile 關鍵字可以保證可見性。當一個共享變量被 volatile 關鍵字修飾時,它會保證修改的值立即被其他的線程看到,即修改的值立即更新到主存中,當其他線程需要讀取時,它會去內存中讀取新值。synchronized 和 Lock 也可以保證可見性,因為它們可以保證任一時刻只有一個線程能訪問共享資源,并在其釋放鎖之前將修改的變量刷新到內存中。

有序性問題

在Java里面,可以通過 volatile 關鍵字來保證一定的“有序性”。另外可以通過 synchronized 和 Lock 來保證有序性,很顯然,synchronized 和 Lock 保證每個時刻是只有一個線程執行同步代碼,相當于是讓線程順序執行同步代碼,自然就保證來有序性。

Java內存模型

每個線程都有自己的工作內存,線程對變量的所有操作都必須在工作內存中進行,而不能直接對主內存進行操作。并且每個線程不能訪問其他線程的工作內存。Java 內存模型具有一些先天的“有序性”,即不需要通過任何手段就能夠得到保證的有序性,這個通常也稱為 happens-before 原則。如果兩個操作的執行次序無法從 happens-before 原則推導出來,那么它們就不能保證它們的有序性,虛擬機可以隨意地對它們進行重排序。

指令重排序

Java語言規范規定 JVM 線程內部維持順序化語義。即只要程序的最終結果與它順序化情況的結果相等,那么指令的執行順序可以與代碼順序不一致,此過程叫做指令的重排序。

指令重排序的意義是什么?JVM能根據處理特性(CPU多級緩存、多核處理器等)適當的對機器指令進行重排序,使機器指令更更符合CPU的執行特性,最大限度的發揮機器性能。

下圖為從源碼到最終執行的指令序列示意圖:


as-if-serial 語義

as-if-serial 語義的意思是:不管怎么重排序(編譯器和處理器為了提高并行度),(單線程)程序的執行結果不能被改變。編譯器、runtime和處理器都必須遵守 as-if-serial 語義。

為了遵守 as-if-serial 語義,編譯器和處理器不會對存在數據依賴關系的操作做重排序,因為這種重排序會改變執行結果。但是,如果操作之間不存在數據依賴關系,這些操作就可能被編譯器和處理器重排序。

happens-before 原則

只靠 synchronized 和 volatile 關鍵字來保證原子性、可見性以及有序性,那么編寫并發程序可能會顯得十分麻煩,幸運的是,從JDK 5 開始,Java 使用新的 JSR-133 內存模型,提供了 happens-before 原則 來輔助保證程序執行的原子性、可見性和有序性的問題,它是判斷數據十分存在競爭、線程十分安全的一句。happens-before 原則內容如下:

  1. 程序順序原則,即在一個線程內必須保證語義串行,也就是說按照代碼順序執行。
  2. 鎖規則,解鎖(unlock)操作必然發生在后續的同一個鎖的加鎖(lock)之前,也就是說,如果對于一個鎖解鎖后,再加鎖,那么加鎖的動作必須在解鎖動作之后(同一個鎖)。
  3. volatile規則, volatile變量的寫,先發生于讀,這保證了volatile變量的可見性,簡單理解就是,volatile變量在每次被線程訪問時,都強迫從主內存中讀該變量的值,而當該變量發生變化時,又會強迫將最新的值刷新到主內存,任何時刻,不同的線程總是能夠看到該變量的最新值。
  4. 線程啟動規則,線程的 start() 方法先于它的每一個動作,即如果線程A在執行線程B的 start 方法之前修改了共享變量的值,那么當線程B執行start方法時,線程A對共享變量的修改對線程B可見。
  5. 傳遞性,A先于B,B先于C,那么A必然先于C。
  6. 線程終止原則,線程的所有操作先于線程的終結,Thread.join() 方法的作用是等待當前執行的線程終止。假設在線程B終止之前,修改了共享變量,線程A從線程B的join方法成功返回,線程B對共享變量的修改將對線程A可見。
  7. 線程中斷規則,對線程 interrupt() 方法的調用先行發生于被中斷線程的代碼檢查到中斷事件的發生,可以通過 Thread.interrupted() 方法檢測線程十分中斷。
  8. 對象終結規則,對象的構造函數執行,結束先于 finalize() 方法。

finalize()是Object中的方法,當垃圾回收器將要回收對象所占內存之前被調用,即當一個對象被虛擬機宣告死亡時會先調用它finalize()方法,讓此對象處理它生前的最后事情(這個對象可以趁這個時機掙脫死亡的命運)。

volatile 內存語義

volatile 是Java虛擬機提供的輕量級的同步機制。volatile 關鍵字有如下兩個作用:

  1. 保證被 volatile 修飾的共享變量對所有線程總是可見的,也就是當一個線程修改了被 volatile 修飾共享變量的值,新值總是可以被其他線程立即得知。
  2. 緊張指令重排序優化。

volatile 的可見性

關于 volatile 的可見性作用,我們必須意思到被 volatile 修飾的變量對所有線程總是立即可見的,對于 volatile 變量的所有寫操作總是能立刻反應到其他線程中。

案例:線程A改變 initFlag 屬性之后,線程B馬上感知到

  1. package com.niuh.jmm; 
  2.  
  3. import lombok.extern.slf4j.Slf4j; 
  4.  
  5. /** 
  6.  * @description: -server -Xcomp -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -XX:CompileCommand=compileonly,*Jmm03_CodeVisibility.refresh 
  7.  * -Djava.compiler=NONE 
  8.  **/ 
  9. @Slf4j 
  10. public class Jmm03_CodeVisibility { 
  11.  
  12.     private static boolean initFlag = false
  13.  
  14.     private volatile static int counter = 0; 
  15.  
  16.     public static void refresh() { 
  17.         log.info("refresh data......."); 
  18.         initFlag = true
  19.         log.info("refresh data success......."); 
  20.     } 
  21.  
  22.     public static void main(String[] args) { 
  23.         // 線程A 
  24.         Thread threadA = new Thread(() -> { 
  25.             while (!initFlag) { 
  26.                 //System.out.println("runing"); 
  27.                 counter++; 
  28.             } 
  29.             log.info("線程:" + Thread.currentThread().getName() 
  30.                     + "當前線程嗅探到initFlag的狀態的改變"); 
  31.         }, "threadA"); 
  32.         threadA.start(); 
  33.  
  34.         // 中間休眠500hs 
  35.         try { 
  36.             Thread.sleep(500); 
  37.         } catch (InterruptedException e) { 
  38.             e.printStackTrace(); 
  39.         } 
  40.  
  41.         // 線程B 
  42.         Thread threadB = new Thread(() -> { 
  43.             refresh(); 
  44.         }, "threadB"); 
  45.         threadB.start(); 
  46.     } 

 結合前面介紹的數據同步八大原子操作,我們來分析下:

線程A啟動后:

  • 第一步:執行read操作,作用于主內存,將變量initFlag從主內存拷貝一份,這時候還沒有放到工作內存中,而是放在了總線里。如下圖
  • 第二步:執行load操作,作用于工作內存,將上一步拷貝的變量,放入工作內存中;
  • 第三步:執行use(使用)操作,作用于工作內存,把工作內存中的變量傳遞給執行引擎,對于線程A來說,執行引擎會判斷initFlag = true嗎?不等于,循環一直進行

執行過程如下圖:

 

線程B啟動后:

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

volatile 無法保證原子性

  1. //示例 
  2. public class VolatileVisibility { 
  3.     public static volatile int i =0; 
  4.     public static void increase(){ 
  5.         i++; 
  6.     } 

 在并發場景下, i 變量的任何改變都會立馬反應到其他線程中,但是如此存在多線程同時調用 increase() 方法的化,就會出現線程安全問題,畢竟 i++ 操作并不具備原子性,該操作是先讀取值,然后寫回一個新值,相當于原來的值加上1,分兩部完成。如果第二個線程在第一個線程讀取舊值和寫回新值期間讀取 i 的值,那么第二個線程就會于第一個線程一起看到同一個值,并執行相同值的加1操作,這也就造成了線程安全失敗,因此對于 increase 方法必須使用 synchronized 修飾,以便保證線程安全,需要注意的是一旦使用 synchronized 修飾方法后,由于 sunchronized 本身也具備于 volatile 相同的特性,即可見性,因此在這樣的情況下就完全可以省去 volatile 修飾變量。

案例:起了10個線程,每個線程加到1000,10個線程,一共是10000

  1. package com.niuh.jmm; 
  2.  
  3. /** 
  4.  * volatile可以保證可見性, 不能保證原子性 
  5.  */ 
  6. public class Jmm04_CodeAtomic { 
  7.  
  8.     private volatile static int counter = 0; 
  9.     static Object object = new Object(); 
  10.  
  11.     public static void main(String[] args) { 
  12.  
  13.         for (int i = 0; i < 10; i++) { 
  14.             Thread thread = new Thread(() -> { 
  15.                 for (int j = 0; j < 1000; j++) { 
  16.                     synchronized (object) { 
  17.                         counter++;//分三步- 讀,自加,寫回 
  18.                     } 
  19.                 } 
  20.             }); 
  21.             thread.start(); 
  22.         } 
  23.  
  24.         try { 
  25.             Thread.sleep(3000); 
  26.         } catch (InterruptedException e) { 
  27.             e.printStackTrace(); 
  28.         } 
  29.  
  30.         System.out.println(counter); 
  31.  
  32.     } 

 而實際結果,不到10000, 原因是: 有并發操作.

這時候, 如果我在counter上加關鍵字volatile, 可以保證原子性么? 

  1. private volatile static int counter = 0; 

我們發現, 依然不是10000, 這說明volatile不能保證原子性.

每個線程, 只有一個操作, counter++, 為什么不能保證原子性呢?

其實counter++不是一步完成的. 他是分為多步完成的. 我們用下面的圖來解釋


線程A通過read, load將變量加載到工作內存, 通過user將變量發送到執行引擎, 執行引擎執行counter++,這時線程B啟動了, 通過read, load將變量加載到工作內存, 通過user將變量發送到執行引擎, 然后執行復制操作assign, stroe, write操作. 我們看到這是經過了n個步驟. 雖然看起來就是簡單的一句話.

當線程B執行store將數據回傳到主內存的時候, 同時會通知線程A, 丟棄counter++, 而這時counter已經自加了1, 將自加后的counter丟掉, 就導致總數據少1.

volatile 禁止重排優化

volatile 關鍵字另一個作用就是禁止指令重排優化,從而避免多線程環境下程序出現亂序執行的現象,關于指令重排優化前面已經分析過,這里主要簡單說明一下 volatile 是如何實現禁止指令重排優化的。先了解一個概念,內存屏障(Memory Barrier)

硬件層的內存屏障

Intel 硬件提供了一系列的內存屏障,主要又:

  1. ifence,是一種 Load Barrier 讀屏障;
  2. sfence,是一種 Store Barrier 寫屏障;
  3. mfence,是一種全能型的屏障,具備 ifence 和 sfence 的能力;
  4. Lock 前綴,Lock 不是一種內存屏障,但是它能完成類似內存屏障的功能。Lock 會對 CPU總線和高速緩存加鎖,可以理解為 CPU 指令級的一種鎖。它后面可以跟 ADD、ADC、AND、BTC、BTR、BTS、CMPXCHG、CMPXCH8B、DEC、INC、NEG、NOT、OR、SBB、SUB、XOR、XADD、and XCHG 等指令。

 不同硬件實現內存屏障的方式不同,Java 內存模型屏蔽了這些底層硬件平臺的差異,由 JVM 來為不同平臺生產相應的機器碼。JVM中提供了四類內存屏障指令:


內存屏障,又稱內存柵欄,是一個CPU指令,它的作用有兩個:

  1. 一是保證特定操作的執行順序;
  2. 二是保證某些變量的內存可見性(利用該特性實現 volatile 的內存可見性)。

由于編譯器和處理器都能執行指令重排優化。如果在指令間插入一條 Memory Barrier 則會高速編譯器和CPU,不管什么指令都不能和這條 Memory Barrier 指令重排序,也就是說通過插入內存屏障禁止在內存屏障前后的指令執行重排序優化。

Memory Barrier 的另外一個作用是強制刷出各種 CPU 的緩存數據,因此任何 CPU 上的線程都能讀取到這些數據的最新版本。

總之,volatile 變量正是通過內存屏障實現其內存中的語義,即可見性和禁止重排優化。

下面看一個非常典型的禁止重排優化的例子DCL,如下:

  1. public class DoubleCheckLock { 
  2.     private volatile static DoubleCheckLock instance; 
  3.     private DoubleCheckLock(){} 
  4.     public static DoubleCheckLock getInstance(){ 
  5.         //第一次檢測 
  6.         if (instance==null){ 
  7.             //同步 
  8.             synchronized (DoubleCheckLock.class){ 
  9.                 if (instance == null){ 
  10.                     //多線程環境下可能會出現問題的地方 
  11.                     instance = new  DoubleCheckLock(); 
  12.                 } 
  13.             } 
  14.         } 
  15.         return instance; 
  16.     } 

 上述代碼一個經典的單例的雙重檢測的代碼,這段代碼在單線程環境下并沒什么問題,但如果在多線程環境下就可能會出現線程安全的問題。因為在于某一線程執行到第一次檢測,讀取到 instance 不為 null 時,instance 的引用對象可能還沒有完成初始化。

  • 關于 單例模式 可以查看 設計模式系列—單例設計模式

因為 instance = new DoubleCheckLock(); 可以分為以下3步完成(偽代碼) 

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

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

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

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

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

 volatile 內存語義的實現

前面提到過重排序分為編譯器重排序和處理器重排序。為來實現 volatile 內存語義,JMM 會分別限制這兩種類型的重排序類型。

下面是JMM針對編譯器制定的 volatile 重排序規則表。

 

舉例來說,第二行最后一個單元格的意思是:在程序中,當第一個操作為普通變量的讀或者寫時,如果第二個操作為 volatile 寫,則編譯器不能重排序這兩個操作。

從上圖可以看出:

  • 當第二個操作是 volatile 寫時,不管第一個操作是什么,都不能重排序。這個規則確保了 volatile 寫之前的操作不會被編譯器重排序到 volatile 寫之后。
  • 當第一個操作是 volatile 讀時,不管第二個操作是什么,都不能重排序。這個規則確保了 volatile 讀之后的操作不會被編譯器重排序到 volatie 讀之前。
  • 當第一個操作是 volatile 寫,第二個操作是 volatile 讀或寫時,不能重排序。

為了實現 volatile 的內存語義,編譯在生成字節碼時,會在指令序列中插入內存屏障來禁止特定類型的處理器重排序。對于編譯器來說,發現一個最優布置來最小化插入屏障的總數幾乎不可能。為此,JMM 采取保守策略。下面是基于保守策略的JMM內存屏障插入策略。

  • 在每個volatile寫操作的前面插入一個StoreStore屏障;
  • 在每個volatile寫操作的后面插入一個StoreLoad屏障;
  • 在每個volatile讀操作的后面插入一個LoadLoad屏障;
  • 在每個volatile讀操作的后面插入一個LoadStore屏障;

上述內存屏障插入策略非常保守,但它可以保證在任一處理器平臺,任意的程序中都能得到正確的 volatile 內存語義。

下面是保守策略下,volatile 寫插入內存屏障后生成的指令序列示意圖


上圖中 StoreStore 屏障可以保證在volatile 寫之前,其前面的所有普通寫操作已經對任意處理器可見來。這是因為StoreStore屏障將保障上面所有的普通寫在 volatile 寫之前刷新到主內存。

這里比較有意思的是,volatile 寫后面的 StoreLoad 屏障。此屏障的作用是避免 volatile 寫與后面可能有的 volatile 讀/寫操作重排序。因為編譯器常常無法準確判斷在一個 volatile 寫的后面十分需要插入一個 StoreLoad 屏障(比如,一個volatile寫之后方法立即return)。為來保證能正確實現 volatile 的內存語義,JMM 在采取了保守策略:在每個 volatile 寫的后面,或者每個 volatile 讀的前面插入一個 StoreLoad 屏障。從整體執行效率的角度考慮,JMM最終選擇了在每個 volatile 寫的后面插入一個 StoreLoad 屏障,因為volatile寫-讀內存語義的常見使用模式是:一個寫線程寫 volatile 變量,多個線程讀同一個 volatile 變量。當讀線程的數量大大超過寫線程時,選擇在 volatile 寫之后插入 StoreLoad 屏障將帶來可觀的執行效率的提升。從這里可以看到JMM在實現上的一個特點:首先確保正確性,然后再去追求執行效率。

下圖是在保守策略下,volatile 讀插入內存屏障后生成的指令序列示意圖

 

上圖中 LoadLoad 屏障用來禁止處理器把上面的 volatile讀 與下面的普通讀重排序。LoadStore 屏障用來禁止處理器把上面的volatile讀與下面的普通寫重排序。

上述 volatile寫 和 volatile讀的內存屏障插入策略非常保守。在實際執行時,只要不改變 volatile寫-讀的內存語義,編譯器可以根據具體情況省略不必要的屏障。

下面通過具體的示例代碼進行說明。

  1. class VolatileBarrierExample { 
  2.        int a; 
  3.        volatile int v1 = 1; 
  4.        volatile int v2 = 2; 
  5.        void readAndWrite() { 
  6.            int i = v1;      // 第一個volatile讀 
  7.            int j = v2;       // 第二個volatile讀 
  8.            a = i + j;         // 普通寫 
  9.            v1 = i + 1;       // 第一個volatile寫 
  10.            v2 = j * 2;       // 第二個 volatile寫 
  11.        } 

 針對 readAndWrite() 方法,編譯器在生成字節碼時可以做如下的優化。 

 

注意,最后的 StoreLoad 屏障不能省略。因為第二個 volatile 寫之后,方法立即 return。此時編譯器可能無法準確判斷斷定后面十分會有 volatile 讀或寫,為了安全起見,編譯器通常會在這里插入一個 StoreLoad 屏障。

上面的優化針對任意處理器平臺,由于不同的處理器有不同“松緊度”的處理器內存模型,內存屏障的插入還可以根據具體的處理器內存模型繼續優化。以X86處理完為例,上圖中除最后的 StoreLoad 屏障外,其他的屏障都會被省略。

前面保守策略下的 volatile 讀和寫,在 X86 處理器平臺可以優化如下圖所示。X86處理器僅會對讀-寫操作做重排序。X86 不會對讀-讀、讀-寫 和 寫-寫 做重排序,因此在 X86 處理器中會省略掉這3種操作類型對應的內存屏障。在 X86 中,JMM僅需在 volatile 寫后面插入一個 StoreLoad 屏障即可正確實現 volatile寫-讀的內存語義,這意味著在 X86 處理器中,volatile寫的開銷比volatile讀的開銷會大很多(因為執行StoreLoad的屏障開銷會比較大) 

 

參考資料

  • 《并發編程的藝術》

PS:以上代碼提交在 Github :

https://github.com/Niuh-Study/niuh-juc-final.git

 

責任編輯:姜華 來源: 今日頭條
相關推薦

2022-06-29 08:05:25

Volatile關鍵字類型

2019-09-04 14:14:52

Java編程數據

2020-07-17 20:15:03

架構JMMvolatile

2023-10-04 00:04:00

C++extern

2020-11-12 10:53:36

volatile

2023-11-05 12:05:35

JVM內存

2015-03-24 13:28:52

Java Java Strin內存模型

2022-06-22 08:02:11

CPU操作系統Java

2012-03-01 12:50:03

Java

2023-09-24 13:58:20

C++1auto

2024-02-26 10:36:59

C++開發關鍵字

2025-06-13 08:00:00

Java并發編程volatile

2011-06-14 13:26:27

volatile

2023-09-19 22:47:39

Java內存

2011-06-21 09:50:51

volatile

2009-06-29 18:14:23

Java多線程volatile關鍵字

2021-09-08 17:42:45

JVM內存模型

2023-10-27 07:47:58

Java語言順序性

2018-12-18 14:08:01

Java內存volatile

2022-07-06 08:05:52

Java對象JVM
點贊
收藏

51CTO技術棧公眾號

主站蜘蛛池模板: 中文字幕一区二区三区不卡 | 免费一级欧美在线观看视频 | 亚洲精品二区 | 黄色免费网站在线看 | 精品亚洲一区二区三区四区五区高 | 国精产品一品二品国精在线观看 | 中文字幕成人av | 久久久久国产一区二区三区四区 | 亚洲一区在线日韩在线深爱 | 色偷偷888欧美精品久久久 | 在线国产一区二区 | 欧美一区二区在线 | 久久99视频 | 国产一极毛片 | 日韩在线不卡视频 | 毛片一级黄色 | 欧美高清视频一区 | 精久久久 | 久久中文字幕电影 | 日本午夜免费福利视频 | 亚洲精品电影网在线观看 | 欧美日韩亚洲国产 | 欧美视频一区二区三区 | 亚洲一区二区三区在线视频 | 亚洲高清视频一区二区 | 97国产一区二区精品久久呦 | 免费黄色片视频 | 免费人成在线观看网站 | 亚洲综合一区二区三区 | www.嫩草| 日韩aⅴ片 | 国产三级| 亚洲一区 | 日本高清精品 | 国产男女视频网站 | 神马久久久久久久久久 | 欧日韩不卡在线视频 | 不卡一区二区在线观看 | 国产免费一区二区 | 蜜桃一区二区三区 | 麻豆久久久9性大片 |