Java併發基礎三:Java內存模型(JMM)

前言

在併發編程需要處理的兩個關鍵問題是:線程之間如何通信線程之間如何同步。通信 是指線程之間以何種機制來交換信息。在命令式編程中,線程之間的通信機制有兩種:共享內存 和 消息傳遞同步: 是指程序用於控制不同線程之間操作發生相對順序的機制。前篇文章介紹了物理機爲了提高效率,引入三級緩存和相關解決緩存一致性的方案,本篇將介紹Java內存模型是什麼,爲什麼需要Java內存模型,以及Java內存模型解決了什麼問題。

一、JMM誕生背景

不同架構的物理計算機可以有不一樣的內存模型,Java 虛擬機也有自己的內存模型。雖然java程序所有的運行都是在虛擬機中,涉及到的內存等信息都是虛擬機的一部分,但實際也是物理機的,只不過是虛擬機作爲最外層的容器統一做了處理。虛擬機的內存模型,以及多線程的場景下與物理機的情況是很相似的,可以類比參考。

結合前面介紹的物理機的處理器處理內存的問題,可以類比總結出 JVM 內存操作的問題。下面介紹的 Java 內存模型的執行處理解決的兩個問題:1、工作內存數據一致性 2、指令重排序導致運行結果與預期一致性。

爲了更好解決上面提到的系列問題,內存模型被總結提出,我們可以把內存模型理解爲在特定操作協議下,對特定的內存或高速緩存進行讀寫訪問的過程抽象。

二、Java內存模型概念

Java 虛擬機規範中試圖定義一種 Java 內存模型(Java Memory Model,簡稱 JMM),來屏蔽掉各種硬件和操作系統的內存訪問差異,以實現讓 Java 程序在各種平臺下都能達到一致的內存訪問效果,不必因爲不同平臺上的物理機的內存模型的差異,對各平臺定製化開發程序。

更具體一點說,Java內存模型是一種規範,他規範了java虛擬機與計算機內存如何協調工作 ,定義程序中變量的訪問規則,他規定了一個線程如何及何時看到其他線程修改過的變量的值,以及在必須時,如何同步的訪問共享變量。
在這裏插入圖片描述
Java內存模型的主要目標是定義程序中變量的訪問規則。即在虛擬機中將變量存儲到主內存或者將變量從主內存取出這樣的底層細節。需要注意的是這裏的變量跟我們寫java程序中的變量不是完全等同的。這裏的變量是指實例字段,靜態字段,構成數組對象的元素,但是不包括局部變量和方法參數(因爲這是線程私有的)。這裏可以簡單的認爲主內存是java虛擬機內存區域中的堆,局部變量和方法參數是在虛擬機棧中定義的。 但是在堆中的變量如果在多線程中都使用,就涉及到了堆和不同虛擬機棧中變量的值的一致性問題了。

三、Java 內存模型結構

主內存

Java 內存模型規定了所有變量都存儲在主內存(Main Memory)中(此處的主內存與介紹物理硬件的主內存名字一樣,兩者可以互相類比,但此處僅是虛擬機內存的一部分)。

工作內存

每條線程都有自己的工作內存(Working Memory,又稱本地內存,可與前面介紹的處理器高速緩存類比),線程的工作內存中保存了該線程使用到的變量的主內存中的共享變量的副本拷貝。
工作內存是 JMM 的一個抽象概念,並不真實存在。它涵蓋了緩存,寫緩衝區,寄存器以及其他的硬件和編譯器優化。Java 內存模型抽象示意圖如下:
在這裏插入圖片描述

從上圖來看,如果線程 A 和線程 B 要通信的話,要如下兩個步驟:
1、線程 A 需要將本地內存 A 中的共享變量副本刷新到主內存去
2、線程 B 去主內存讀取線程 A 之前已更新過的共享變量
從整體上看,這兩個步驟是線程 1 在向線程 2 發消息,這個通信過程必須經過主內存。
JMM 通過控制主內存與每個線程本地內存之間的交互,來爲各個線程提供共享變量的可見性。

四、Java 內存間的交互操作

在理解 Java 內存模型的系列協議、特殊規則之前,我們先理解 Java 中內存間的交互操作。關於主內存與工作內存之間的具體交互協議,即一個變量如何從主內存拷貝到工作內存、如何從工作內存同步回主內存之類的實現細節,Java 內存模型中定義了下面 8 種操作來完成。
虛擬機實現時必須保證下面介紹的每種操作都是原子的,不可再分的(對於 double 和 long 型的變量來說,load、store、read、和 write 操作在某些平臺上允許有例外)。
在這裏插入圖片描述
8 種基本操作,如上圖:
lock (鎖定) ,作用於主內存的變量,它把一個變量標識爲一條線程獨佔的狀態。
unlock (解鎖) ,作用於主內存的變量,它把一個處於鎖定狀態的變量釋放出來,釋放後的變量纔可以被其他線程鎖定。
read (讀取) ,作用於主內存的變量,它把一個變量的值從主內存傳輸到線程的工作內存中,以便隨後的 load 動作使用。
load (載入) ,作用於工作內存的變量,它把 read 操作從主內存中得到的變量值放入工作內存的變量副本中。
use (使用) ,作用於工作內存的變量,它把工作內存中一個變量的值傳遞給Java虛擬機執行引擎,每當虛擬機遇到一個需要使用到變量的值的字節碼指令時就會執行這個操作。
assign (賦值) ,作用於工作內存的變量,它把一個從執行引擎接收到的值賦給工作內存的變量,每當虛擬機遇到一個給變量賦值的字節碼指令時執行這個操作。
store (存儲) ,作用於工作內存的變量,它把工作內存中一個變量的值傳送到主內存中,以便隨後 write 操作使用。
write (寫入) ,作用於主內存的變量,它把 Store 操作從工作內存中得到的變量的值放入主內存的變量中。

JMM 在執行前面介紹 8 種基本操作時,爲了保證內存間數據一致性,JMM 中規定需要滿足以下規則:

規則 1: 如果要把一個變量從主內存中複製到工作內存,就需要按順序的執行 read 和 load 操作,如果把變量從工作內存中同步回主內存中,就要按順序的執行 store 和 write 操作。但 Java 內存模型只要求上述操作必須按順序執行,而沒有保證必須是連續執行。
規則 2: 不允許 read 和 load、store 和 write 操作之一單獨出現。
規則 3: 不允許一個線程丟棄它的最近 assign 的操作,即變量在工作內存中改變了之後必須同步到主內存中。
規則 4: 不允許一個線程無原因的(沒有發生過任何 assign 操作)把數據從工作內存同步回主內存中。
規則 5: 一個新的變量只能在主內存中誕生,不允許在工作內存中直接使用一個未被初始化(load 或 assign )的變量。
即對一個變量實施 use 和 store 操作之前,必須先執行過了 load 或 assign 操作。
規則 6: 一個變量在同一個時刻只允許一條線程對其進行 lock 操作,但 lock 操作可以被同一條線程重複執行多次,多次執行 lock 後,只有執行相同次數的 unlock 操作,變量纔會被解鎖。所以 lock 和 unlock 必須成對出現。
規則 7: 如果對一個變量執行 lock 操作,將會清空工作內存中此變量的值,在執行引擎使用這個變量前需要重新執行 load 或 assign 操作初始化變量的值。
規則 8: 如果一個變量事先沒有被 lock 操作鎖定,則不允許對它執行 unlock 操作;也不允許去 unlock 一個被其他線程鎖定的變量。
規則 9: 對一個變量執行 unlock 操作之前,必須先把此變量同步到主內存中(執行 store 和 write 操作)。

看起來這些規則有些繁瑣,其實也不難理解:

規則 1、規則 2,工作內存中的共享變量作爲主內存的副本,主內存變量的值同步到工作內存需要 read 和 load 一起使用。工作內存中的變量的值同步回主內存需要 store 和 write 一起使用,這 2 組操作各自都是一個固定的有序搭配,不允許單獨出現。

規則 3、規則 4,由於工作內存中的共享變量是主內存的副本,爲保證數據一致性,當工作內存中的變量被字節碼引擎重新賦值,必須同步回主內存。如果工作內存的變量沒有被更新,不允許無原因同步回主內存。

規則 5,由於工作內存中的共享變量是主內存的副本,必須從主內存誕生。

規則 6、7、8、9,爲了併發情況下安全使用變量,線程可以基於 lock 操作獨佔主內存中的變量,其他線程不允許使用或 unlock 該變量,直到變量被線程 unlock。

五、內存交互基本操作的 3 個特性

Java 內存模型是圍繞着在併發過程中如何處理這 3 個特性來建立的,這裏先給出定義和基本實現的簡單介紹,後面會逐步展開分析。

原子性(Atomicity)

原子性,即一個操作或者多個操作要麼全部執行並且執行的過程不會被任何因素打斷,要麼就都不執行。即使在多個線程一起執行的時候,一個操作一旦開始,就不會被其他線程所幹擾。

可見性(Visibility)

可見性是指當多個線程訪問同一個變量時,一個線程修改了這個變量的值,其他線程能夠立即看得到修改的值。
正如上面“交互操作流程”中所說明的一樣,JMM 是通過在線程 1 變量工作內存修改後將新值同步回主內存
線程 2在變量讀取前從主內存刷新變量值,這種依賴主內存作爲傳遞媒介的方式來實現可見性。

有序性(Ordering)
有序性規則表現在以下兩種場景:

  • 線程內,從某個線程的角度看方法的執行,指令會按照一種叫“串行”(as-if-serial)的方式執行,此種方式已經應用於順序編程語言。
  • 線程間,這個線程“觀察”到其他線程併發地執行非同步的代碼時,由於指令重排序優化,任何代碼都有可能交叉執行。

通俗理解:Java程序中天然的有序性可以總結爲一句話:如果在本線程內觀察,所有操作都是有序的;如果在一個線程中觀察另一個線程,所有的操作都是無序的

Java語言提供了volatile和synchronized兩個關鍵字來保證線程之間操作的有序性和可見性。
JMM關於volatile和synchronized有序性保證:

  • volatile的有序性: volatile本身包含了禁止指令重拍序的語義。
  • synchronized的有序性: synchronize的有序性是由“一個變量同一時刻只允許一條線程對其進行lock操作”這條規則獲得的,這個規則決定了持有同一個鎖的兩個同步塊只能串行地進入。

JMM關於synchronized可見性兩條規定:

  • 線程解鎖前,必須把共享變量的最新值刷新到主內存
  • 線程加鎖時,將清空工作內存中共享變量的值,從而使用共享變量時需要從主內存中重新讀取最新的值(注意-加鎖與解鎖是同一把鎖)

JMM關於volatile可見性兩條規定:通過加入內存屏障和禁止重排序優化來實現

  • 對volatile寫操作時,會在寫操作後加一個store屏障指令,將本地內存中的共享變量值刷新到主內存
  • 對volatile讀操作時,會在讀操作前加一條load屏障指令,從主內存中讀取共享變量

總結: Java 內存模型的一系列運行規則看起來有點繁瑣,但總結起來,是圍繞原子性、可見性、有序性特徵建立。歸根究底,是爲實現共享變量的在多個線程的工作內存的數據一致性,多線程併發,指令重排序優化的環境中程序能如預期運行。

六、volatile原理

關鍵字volatile可以說是Java虛擬機提供的最輕量級的同步機制。被volatile修飾的變量有以下三個特性:
(1)可見性
volatile變量,用來確保將變量的更新操作通知到其他線程。
(2)禁止指令重拍序
當把變量聲明爲volatile類型後,編譯器與運行時都會注意到這個變量是共享的,因此不會將該變量上的操作與其他內存操作一起重排序,底層原理是內存屏障,包括cpu的內存屏障和編譯器的內存屏障
(3)不保證原子性

volatile關鍵字解決的問題就是:當一個線程寫入該值後,另一個線程讀取的必定是新值。

volatile保證了修飾的共享變量在轉換爲彙編語言時,會加上一個以lock爲前綴的指令,當CPU發現這個指令時,立即會做兩件事情:

  • 將當前內核中線程工作內存中該共享變量刷新到主存;
  • 通知其他內核裏緩存的該共享變量內存地址無效;重新從主內存中讀

volatile 可以保證可見性和有序性,但是當線程2已經使用舊值完成了運算指令,且將要回寫到內存時,是不能保證原子性的。即:極端情況-多個線程同時使用舊值完成運算指令,把更新後的變量值同時刷新回主內存,可能導致得到的值不是預期結果。

舉個例子:定義 volatile int count = 0,2 個線程同時執行 count++ 操作,每個線程都執行 500
次,最終結果小於 1000。

原因是每個線程執行 count++ 需要以下 3 個步驟:

  • 線程從主內存讀取最新的 count 的值。
  • 執行引擎把 count 值加 1,並賦值給線程工作內存。
  • 線程工作內存把 count值保存到主內存。

有可能某一時刻 2 個線程在步驟 1 讀取到的值都是 100,執行完步驟 2 得到的值都是 101,最後刷新了 2 次 101 保存到主內存。

volatile 型變量實現原理

具體實現方式是在編譯期生成字節碼時,會在指令序列中增加內存屏障來保證,下面是基於保守策略的 JMM 內存屏障插入策略:
在這裏插入圖片描述
在每個 volatile 寫操作的前面插入一個 StoreStore 屏障。該屏障除了保證了屏障之前的寫操作和該屏障之後的寫操作不能重排序,還會保證了 volatile 寫操作之前,任何的讀寫操作都會先於 volatile 被提交。

在每個 volatile 寫操作的後面插入一個 StoreLoad 屏障。 該屏障除了使 volatile 寫操作不會與之後的讀操作重排序外,還會刷新處理器緩存,使 volatile 變量的寫更新對其他線程可見。

在每個 volatile 讀操作的後面插入一個 LoadLoad 屏障。 該屏障除了使 volatile 讀操作不會與之前的寫操作發生重排序外,還會刷新處理器緩存,使 volatile 變量讀取的爲最新值。

在每個 volatile 讀操作的後面插入一個 LoadStore 屏障。 該屏障除了禁止了 volatile 讀操作與其之後的任何寫操作進行重排序,還會刷新處理器緩存,使其他線程 volatile 變量的寫更新對 volatile 讀操作的線程可見。

volatile 型變量使用場景
總結起來,就是“一次寫入,到處讀取”,某一線程負責更新變量,其他線程只讀取變量(不更新變量),並根據變量的新值執行相應邏輯。例如狀態標誌位更新,觀察者模型變量值發佈。

七、JMM相關規則

什麼是happen-before規則

定義: happen-before 關係,是Java內存模型中保證多線程可見性的機制,在Java內存模型中,happens-before 應該翻譯成:前一個操作的結果可以被後續的操作可見。講白點就是前面一個操作把變量a賦值爲1,那後面一個操作肯定能知道a已經變成了1。

Java內存模型中,允許編譯器和處理器對指令進行重排序,以提高性能,但是重排序過程不會影響單線程的執行,卻會影響到多線程併發執行的正確性。Java內存模型有一些先天的有序性,不需要其他手段就能保證有序性。這個保證機制就是happen-before原則。如果兩個操作的執行順序不能從happen-before原則推導出來,那麼就不能保證有序性以及可見性。虛擬機可以隨意的對他們進行重排序。

目的: 爲了解決多線程的可見性問題,就搞出了happens-before原則,讓線程之間遵守這些原則。編譯器還會優化我們的語句,所以等於是給了編譯器優化的約束。不能讓它優化的不知道東南西北了!

規則:

程序次序規則: 在一個線程內一段代碼的執行結果是有序的。就是還會指令重排,但是隨便它怎麼排,結果是按照我們代碼的順序生成的不會變!

管程鎖定規則: 就是無論是在單線程環境還是多線程環境,對於同一個鎖來說,一個線程對這個鎖解鎖之後,另一個線程獲取了這個鎖都能看到前一個線程的操作結果!(管程是一種通用的同步原語,synchronized就是管程的實現)

volatile變量規則: 就是如果一個線程先去寫一個volatile變量,然後一個線程去讀這個變量,那麼這個寫操作的結果一定對讀的這個線程可見。

線程啓動規則: 在主線程A執行過程中,啓動子線程B,那麼線程A在啓動子線程B之前對共享變量的修改結果對線程B可見。

線程終止規則: 在主線程A執行過程中,子線程B終止,那麼線程B在終止之前對共享變量的修改結果在線程A中可見。

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

傳遞規則: 這個簡單的,就是happens-before原則具有傳遞性,即A happens-before B , B happens-before C,那麼A happens-before C。

對象終結規則: 這個也簡單的,就是一個對象的初始化的完成,也就是構造函數執行的結束一定 happens-before它的finalize()方法。

這幾條規則就是面向我們這些開發人員的,掌握了這幾條規則能讓我們更好的開發出符合我們預期的併發程序的代碼!

什麼是as-if-serial 語義

as-if-serial 語義的意思指: 不管怎麼重排序(編譯器和處理器爲了提高並行度),(單線程)程序的執行結果不能被改變。編譯器,runtime 和處理器都必須遵守 as-if-serial 語義。
單線程程序是按程序的順序來執行的。as-if-serial語義使單線程程序員無需擔心重排序會 干擾他們,也無需擔心內存可見性問題。

爲了遵守 as-if-serial 編譯器和處理器不會對存在數據依賴關係的操作做重排序,因爲這種重排序會改變執行結果。但是如果操作之間沒有數據依賴關係,這些操作就可能被編譯器和處理器重排序。
舉個例子:

1double pi = 3.14;     //A
2double r  = 1.0;       //B
3double area = pi * r * r;     //C

A 和 C 之間存在數據依賴關係,同時 B 和 C 之間也存在數據依賴關係。因此在最終執行的指令序列中,C 不能被重排序到 A 和 B 的前面(C 排到 A 和 B 的前面,程序的結果將會被改變)。但 A 和 B 之間沒有數據依賴關係,編譯器和處理器可以重排序 A 和 B 之間的執行順序。

文章參考:
https://www.cnblogs.com/zhiji6/p/10037690.html
https://mp.weixin.qq.com/s/YIaeYc1XE-iN62XzvXKI6Q

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章