併發編程系列之基礎篇(四)—深入理解java內存模型和volatile

併發編程系列之基礎篇(四)—深入理解java內存模型和volatile

前言

大家好,牧碼心從此係列開始將給大家推薦java多線程方面內容,今天給大家推薦一篇併發編程系列之基礎篇(四)—深入理解java內存模型和volatile的文章,希望對你有所幫助。內容如下:

  • 內存模型概要
  • 指令重排
  • 幾大特性
  • volatile內存語義
  • volatile內存語義實現

內存模型概要

  • JMM(內存模型)定義
    Java內存模型(Java Memory Model簡稱JMM)是一種抽象的概念,並不真實存在,它描述的是一組規則或規範,通過這組規範定義了程序中各個變量(包括實例字段,靜態字段和構成數組對象的元素)的訪問方式。試圖屏蔽各種硬件和操作系統的內存訪問差異,以實現讓 Java 程序在各種平臺下都能達到一致的內存訪問效果。

  • 線程,工作內存以及主內存的聯繫
    JVM中線程,工作內存,主內存在數據,通信是存在內在聯繫的,具體的如交互圖(基於JMM)所示:
    線程,工作內存,主內存的交互圖
    從圖中我們可以看到主內存,工作內存以及線程之間的交互流程和通信方式,具體說明下:
    JVM中每個線程創建時JVM都會爲其創建一個工作內存,用於存儲線程私有的數據,而Java內存模型中規定所有變量都存儲在主內存,主內存是共享內存區域,所有線程都可以訪問,但線程對變量的操作(讀取賦值等)必須在工作內存中進行,需要將變量從主內存拷貝的自己的工作內存空間,然後對變量進行操作,操作完成後再將變量寫回主內存,不能直接操作主內存中的變量,而工作內存是每個線程的私有數據區域,因此不同的線程間無法訪問對方的工作內存,線程間的通信(傳值)必須通過主內存來完成。

    • 主內存
      一個共享數據區域,主要存儲的是實例對象,共享的類信息、常量、靜態變量等。在主內存中多線程對同一個變量進行訪問可能會發生線程安全問題。
    • 工作內存
      一個私有的數據區域,主要存儲從主內存中變量拷貝的副本。每個線程只能訪問自己的工作內存,即線程中的本地變量對其它線程是不可見的。由於工作內存是每個線程的私有數據,線程間無法相互訪問工作內存,因此存儲在工作內存的數據不存在線程安全問題。
  • JVM內存模型和硬件內存架構
    JVM內存模型和硬件內存架構並不完全一致。對於硬件內存來說只有寄存器、緩存內存、主內存的概念,並沒有工作內存(線程私有數據區域)和主內存(堆內存)之分,也就是說JVM內存模型對內存的劃分對硬件內存並沒有任何影響,因爲JMM只是一種抽象的概念,是一組規則,並不實際存在,不管是工作內存的數據還是主內存的數據,對於計算機硬件來說都會存儲在計算機主內存中,當然也有可能存儲到CPU緩存或者寄存器中,因此總體上來說,Java內存模型和計算機硬件內存架構是一個相互交叉的關係,是一種抽象概念劃分與真實物理硬件的交叉。關係如圖:
    JVM內存模型和硬件內存架構關係圖> 注意JMM與JVM內存區域劃分的區別,它們的相似點都有共享區域和私有區域。

  • JMM作用
    在分析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,但到底是哪種情況先發生呢?案例如圖:
場景交互圖
以上場景關於主內存與工作內存之間的具體交互協議,即一個變量如何從主內存拷貝到工作內存、如何從工作內存同步到主內存之間的實現細節,Java內存模型定義了以下八種操作來完成。
(1)lock(鎖定): 作用於主內存的變量,把一個變量標記爲一條線程獨佔狀態;
(2)unlock(解鎖): 作用於主內存的變量,把一個處於鎖定狀態的變量釋放出來,釋放後的變量纔可以被其他線程鎖定;
(3)read(讀取): 作用於主內存的變量,把一個變量值從主內存傳輸到線程的工作內存中,以便隨後的load動作使用;
(4)load(載入): 作用於工作內存的變量,它把read操作從主內存中得到的變量值放入工作內存的變量副本中;
(5)use(使用): 作用於工作內存的變量,把工作內存中的一個變量值傳遞給執行引擎;
(6)assign(賦值): 作用於工作內存的變量,它把一個從執行引擎接收到的值賦給工作內存的變量;
(7)store(存儲): 作用於工作內存的變量,把工作內存中的一個變量的值傳送到主內存中,以便隨後的write的操作;
(8)write(寫入): 作用於工作內存的變量,它把store操作從工作內存中的一個變量的值傳送到主內存的變量中;
如果要把一個變量從主內存中複製到工作內存中,就需要按順序地執行read和load操作,如果把變量從工作內存中同步到主內存中,就需要按順序地執行store和write操作。但Java內存模型只要求上述操作必須按順序執行,而沒有保證必須是連續執行。

內存模型三大特性

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

注:java中對基本數據類型的變量的讀取和賦值操作是原子性操作有點要注意的是,對於32位系統的來說,long類型數據和double類型數 據,它們的讀寫並非原子性的,也就是說如果存在兩條線程同時對long類型或者double類型的數據進行讀寫是存在相互干擾的,因爲對於32位虛擬機來說,每次原子讀寫是32位的,而long和double則是64位的存儲單元,這樣會導致一個線程在寫時,操作完前32位的原子操作後,輪到B線程讀取時,恰好只讀取到了後32位的數據,這樣可能會讀取到一個既非原值又不是線程修改值的變量,它可能是“半個變量”的數值,即64位數據被兩個線程分成了兩次讀取。

  • 可見性
    可見性指當一個線程修改了共享變量的值,其它線程能夠立即得知這個修改。Java 內存模型是通過在變量修改後將新值同步回主內存,在變量讀取前從主內存刷新變量值來實現可見性的。JMM 內部的實現通常是依賴於所謂的內存屏障,通過禁止某些重排序的方式,提供內存可見性保證,也就是實現了各種 happen-before 規則。與此同時,更多複雜度在於,需要儘量確保各種編譯器、各種體系結構的處理器,都能夠提供一致的行爲。保證可見性的方式:

    • volatile關鍵字:強制將該變量自己和當時其他變量的狀態都刷出緩存,同步回主內存。
    • synchronized方式:對一個變量執行 unlock 操作之前,必須把變量值同步回主內存;
    • final方式:被 final 關鍵字修飾的字段在構造器中一旦初始化完成,並且沒有發生 this 逃逸(其它線程通過 this 引用訪問到初始化了一半的對象),那麼其它線程就能看見 final 字段的值。
  • 有序性
    有序性是指在本線程內觀察,所有操作都是有序的。在一個線程觀察另一個線程,所有操作都是無序的,無序是因爲發生了指令重排序。在 Java 內存模型中,允許編譯器和處理器對指令進行重排序,重排序過程不會影響到單線程程序的執行,卻會影響到多線程併發執行的正確性。保證有序性方式:

    • volatile 關鍵字:通過添加內存屏障的方式來禁止指令重排,即重排序時不能把後面的指令放到內存屏障之前
    • synchronized 方式:保證每個時刻只有一個線程執行同步代碼,相當於是讓線程順序執行同步代碼

指令重排

Java內存模型具備一些先天的“有序性”,即不需要通過任何手段就能夠得到保證的有序性,這個通常也稱爲happens-before 原則(具體見happens-before 原則)。如果兩個操作的執行次序無法從happens-before原則推導出來,那麼它們就不能保證它們的有序性,虛擬機可以隨意地對它們進行重排序。

  • 指令重排序的作用
    JVM線程內部維持順序化語義。即只要保證程序的最終結果與它順序化情況的結果相等,指令的執行順序可以與代碼順序不一致,此過程叫指令的重排序JVM能根據處理器特性(CPU多級緩存系統、多核處理器等)適當的對機器指令進行重排序,使機器指令能更符合CPU的執行特性,最大限度的發揮機器性能。下圖爲從源碼到最終執行的指令序列示意圖:
    源碼到最終執行的指令序列示意圖
  • as-if-serial語義
    不管怎麼重排序(編譯器和處理器爲了提高並行度),程序的執行結果不能被改變。編譯器和處理器都必須遵守as-if-serial語義。爲了遵守as-if-serial語義,編譯器和處理器不會對存在數據依賴關係的操作做重排序,因爲這種重排序會改變執行結果。但是如果操作之間不存在數據依賴關係,這些操作就可能被編譯器和處理器重排序。
  • happens-before 原則
    只靠sychronized和volatile關鍵字來保證原子性、可見性以及有序性,那麼編寫併發程序可能會顯得十分麻煩,幸運的是,從JDK 5開始,Java使用新的JSR-133內存模型,提供了happens-before 原則來輔助保證程序執行的原子性、可見性以及有序性的問題,它是判斷數據是否存在競爭、線程是否安全的依據,happens-before 原則內容如下:
  1. 程序順序原則: 即在一個線程內必須保證語義串行性,也就是說按照代碼順序執行。
  2. 鎖規則: 解鎖(unlock)操作必然發生在後續的同一個鎖的加鎖(lock)之前,也就是說,如果對於一個鎖解鎖後,再加鎖,那麼加鎖的動作必須在解鎖動作之後(同一個鎖)。
  3. volatile規則: volatile變量的寫,先發生於讀,這保證了volatile變量的可見性,簡單的理解就是,volatile變量在每次被線程訪問時,都強迫從主內存中讀該變量的值,而當該變量發生變化時,又會強迫將最新的值刷新到主內存,任何時刻,不同的線程總是能夠看到該變量的最新值。
  4. 傳遞性規則: 傳遞性 A先於B ,B先於C 那麼A必然先於C。

volatile 內存語義

volatile是Java虛擬機提供的輕量級的同步機制。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修飾變量。

  • volatile禁止重排優化
    volatile禁止指令重排優化是避免多線程環境下程序出現亂序執行的現象。
    關於指令重排優化前面已詳細分析過,這裏主要簡單說明一下volatile是如何實現禁止指令重排優化的。先了解一個概念,內存屏障(Memory Barrier)。內存屏障,又稱內存柵欄,是一個CPU指令,它的作用有兩個,一是保證特定操作的執行順序,二是保證某些變量的內存可見性。內存屏障說明:
    內存屏障說明
    由於編譯器和處理器都能執行指令重排優化。如果在指令間插入一條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;

volatile 內存語義實現

  • 重排序規則
    爲了實現volatile內存語義,JMM會分別限制編譯器重排序和處理器重排序的重排序類型,下面是JMM針對編譯器制定的volatile重排序規則表:
    volatile重排序規則表
    說明:如第三行最後一個單元格的意思是:在程序中,當第一個操作爲普通變量的讀或寫時,如果第二個操作爲volatile寫,則編譯器不能重排序這兩個操作。
    從圖中我們可以看出:
  • 當第二個操作是volatile寫時,不管第一個操作是什麼,都不能重排序。這個規則確保volatile寫之前的操作不會被編譯器重排序到volatile寫之後。
  • 當第一個操作是volatile讀時,不管第二個操作是什麼,都不能重排序。這個規則確保volatile讀之後的操作不會被編譯器重排序到volatile讀之前。
  • 當第一個操作是volatile寫,第二個操作是volatile讀時,不能重排序。
  • 實現原理
    爲了實現volatile的內存語義,編譯器在生成字節碼時,會在指令序列中插入內存屏障來禁止特定類型的處理器重排序。對於編譯器來說,發現一個最優佈置來最小化插入屏障的總數幾乎不可能。爲此,JMM採取保守策略。下面是基於保守策略的JMM內存屏障插入策略。
在每個volatile寫操作的前面插入一個StoreStore屏障。
在每個volatile寫操作的後面插入一個StoreLoad屏障。
在每個volatile讀操作的後面插入一個LoadLoad屏障。
在每個volatile讀操作的後面插入一個LoadStore屏障。

雖然上述內存屏障插入策略非常保守,但它可以保證在任意處理器平臺,任意的程序中都能得到正確的volatile內存語義。
下面是保守策略下,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讀插入內存屏障後生成的指令序列示意圖:
volatile讀插入內存屏障後生成的指令序列示意圖

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

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

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()方法,編譯器在生成字節碼時可以做如下的優化流程:
優化流程

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

參考

  • https://blog.csdn.net/javazejian/article/details/72772461
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章