啃碎併發(九):記憶體模型之基礎概述

猿碼道發表於2018-08-30

0 前言

在併發程式設計中,需要解決兩個關鍵問題:

  1. 執行緒之間如何通訊;
  2. 執行緒之間如何同步;

執行緒通訊是指執行緒之間以何種機制來交換資訊。在指令式程式設計中,執行緒之間的通訊機制有兩種:共享記憶體和訊息傳遞

在共享記憶體的併發模型裡,執行緒之間共享程式的公共狀態,執行緒之間通過寫-讀記憶體中的公共狀態來隱式進行通訊。

在訊息傳遞的併發模型裡,執行緒之間沒有公共狀態,執行緒之間必須通過明確的傳送訊息來顯式進行通訊。

執行緒同步是指程式用於控制不同執行緒之間操作發生相對順序的機制

在共享記憶體的併發模型裡,同步是顯式進行的。程式設計師必須顯式指定某個方法或某段程式碼需要線上程之間互斥執行。

在訊息傳遞的併發模型裡,由於訊息的傳送必須在訊息的接收之前,因此同步是隱式進行的。

Java的併發採用的是共享記憶體模型,Java執行緒之間的通訊總是隱式進行,整個通訊過程對程式設計師完全透明。如果你想設計表現良好的併發程式,理解Java記憶體模型是非常重要的。Java記憶體模型規定了如何和何時可以看到由其他執行緒修改過後的共享變數的值,以及在必須時如何同步的訪問共享變數。

1 為什麼要有記憶體模型

在介紹Java記憶體模型之前,先來看一下到底什麼是計算機記憶體模型,然後再來看Java記憶體模型在計算機記憶體模型的基礎上做了哪些事情。要說計算機的記憶體模型,就要說一下一段古老的歷史,看一下為什麼要有記憶體模型。

1.1 CPU和快取一致性

我們應該都知道,計算機在執行程式的時候,每條指令都是在CPU中執行的,而執行的時候,又免不了要和資料打交道。而計算機上面的資料,是存放在主存當中的,也就是計算機的實體記憶體啦。

剛開始,還相安無事的,但是隨著CPU技術的發展,CPU的執行速度越來越快。而由於記憶體的技術並沒有太大的變化,所以從記憶體中讀取和寫入資料的過程和CPU的執行速度比起來差距就會越來越大,這就導致CPU每次操作記憶體都要耗費很多等待時間

這就像一家創業公司,剛開始,創始人和員工之間工作關係其樂融融,但是隨著創始人的能力和野心越來越大,逐漸和員工之間出現了差距,普通員工原來越跟不上CEO的腳步。老闆的每一個命令,傳到到基層員工之後,由於基層員工的理解能力、執行能力的欠缺,就會耗費很多時間。這也就無形中拖慢了整家公司的工作效率。

可是,不能因為記憶體的讀寫速度慢,就不發展CPU技術了吧,總不能讓記憶體成為計算機處理的瓶頸吧。

所以,人們想出來了一個好的辦法,就是在CPU和記憶體之間增加快取記憶體。快取的概念大家都知道,就是儲存一份資料拷貝。它的特點是速度快,記憶體小,並且昂貴

那麼,程式的執行過程就變成了:當程式在執行過程中,會將運算需要的資料從主存複製一份到CPU的快取記憶體當中,那麼CPU進行計算時就可以直接從它的快取記憶體讀取資料和向其中寫入資料,當運算結束之後,再將快取記憶體中的資料重新整理到主存當中

之後,這家公司開始設立中層管理人員,管理人員直接歸CEO領導,領導有什麼指示,直接告訴管理人員,然後就可以去做自己的事情了。管理人員負責去協調底層員工的工作。因為管理人員是瞭解手下的人員以及自己負責的事情的。所以,大多數時候,公司的各種決策,通知等,CEO只要和管理人員之間溝通就夠了。

而隨著CPU能力的不斷提升,一層快取就慢慢的無法滿足要求了,就逐漸的衍生出多級快取。

按照資料讀取順序和與CPU結合的緊密程度,CPU快取可以分為一級快取(L1),二級快取(L3),部分高階CPU還具有三級快取(L3),每一級快取中所儲存的全部資料都是下一級快取的一部分。這三種快取的 技術難度和製造成本是相對遞減的,所以其容量也是相對遞增的

那麼,在有了多級快取之後,程式的執行就變成了:當CPU要讀取一個資料時,首先從一級快取中查詢,如果沒有找到再從二級快取中查詢,如果還是沒有就從三級快取或記憶體中查詢

隨著公司越來越大,老闆要管的事情越來越多,公司的管理部門開始改革,開始出現高層,中層,底層等管理者。一級一級之間逐層管理。

單核CPU只含有一套L1,L2,L3快取。如果CPU含有多個核心,即多核CPU,則每個核心都含有一套L1(甚至和L2)快取,而共享L3(或者和L2)快取

公司也分很多種,有些公司只有一個大Boss,他一個人說了算。但是有些公司有比如聯席總經理、合夥人等機制。

單核CPU就像一家公司只有一個老闆,所有命令都來自於他,那麼就只需要一套管理班底就夠了。

多核CPU就像一家公司是由多個合夥人共同創辦的,那麼,就需要給每個合夥人都設立一套供自己直接領導的高層管理人員,多個合夥人共享使用的是公司的底層員工。

還有的公司,不斷壯大,開始差分出各個子公司。各個子公司就是多個CPU了,互相之前沒有共用的資源。互不影響。

一個單CPU雙核的快取結構

隨著計算機能力不斷提升,開始支援多執行緒。那麼問題就來了。我們分別來分析下單執行緒、多執行緒在單核CPU、多核CPU中的影響。

單執行緒:CPU核心的快取只被一個執行緒訪問。快取獨佔,不會出現訪問衝突等問題。

單核CPU,多執行緒:程式中的多個執行緒會同時訪問程式中的共享資料,CPU將某塊記憶體載入到快取後,不同執行緒在訪問相同的實體地址的時候,都會對映到相同的快取位置,這樣即使發生執行緒的切換,快取仍然不會失效。但由於任何時刻只能有一個執行緒在執行,因此不會出現快取訪問衝突。

多核CPU,多執行緒:每個核都至少有一個L1 快取。多個執行緒訪問程式中的某個共享記憶體,且這多個執行緒分別在不同的核心上執行,則每個核心都會在各自的caehe中保留一份共享記憶體的緩衝。由於多核是可以並行的,可能會出現多個執行緒同時寫各自的快取的情況,而各自的cache之間的資料就有可能不同

在CPU和主存之間增加快取,在多執行緒場景下就可能存在快取一致性問題,也就是說,在多核CPU中,每個核的自己的快取中,關於同一個資料的快取內容可能不一致

如果這家公司的命令都是序列下發的話,那麼就沒有任何問題。

如果這家公司的命令都是並行下發的話,並且這些命令都是由同一個CEO下發的,這種機制是也沒有什麼問題。因為他的命令執行者只有一套管理體系。

如果這家公司的命令都是並行下發的話,並且這些命令是由多個合夥人下發的,這就有問題了。因為每個合夥人只會把命令下達給自己直屬的管理人員,而多個管理人員管理的底層員工可能是公用的。

比如,合夥人1要辭退員工a,合夥人2要給員工a升職,升職後的話他再被辭退需要多個合夥人開會決議。兩個合夥人分別把命令下發給了自己的管理人員。合夥人1命令下達後,管理人員a在辭退了員工後,他就知道這個員工被開除了。而合夥人2的管理人員2這時候在沒得到訊息之前,還認為員工a是在職的,他就欣然的接收了合夥人給他的升職a的命令。

多核CPU多執行緒場景下快取不一致問題

1.2 處理器優化和指令重排

上面提到在CPU和主存之間增加快取,在多執行緒場景下會存在快取一致性問題。除了這種情況,還有一種硬體問題也比較重要。那就是為了使處理器內部的運算單元能夠儘量的被充分利用,處理器可能會對輸入程式碼進行亂序執行處理。這就是處理器優化

除了現在很多流行的處理器會對程式碼進行優化亂序處理,很多程式語言的編譯器也會有類似的優化,比如:Java虛擬機器的即時編譯器(JIT)也會做指令重排

可想而知,如果任由處理器優化和編譯器對指令重排的話,就可能導致各種各樣的問題

關於員工組織調整的情況,如果允許人事部在接到多個命令後進行隨意拆分亂序執行或者重排的話,那麼對於這個員工以及這家公司的影響是非常大的。

1.3 併發程式設計的問題

前面說的和硬體有關的概念你可能聽得有點蒙,還不知道他到底和軟體有啥關係。但是關於併發程式設計的問題你應該有所瞭解,比如:原子性問題,可見性問題和有序性問題

其實,原子性問題,可見性問題和有序性問題,是人們抽象定義出來的。而這個抽象的底層問題就是前面提到的 快取一致性問題、處理器優化問題和指令重排問題 等。快取一致性問題其實就是可見性問題。而處理器優化是可以導致原子性問題的。指令重排即會導致有序性問題

原子性 是指在一個操作中就是CPU不可以在中途暫停然後再排程,既不被中斷操作,要不執行完成,要不就不執行。

可見性 是指當多個執行緒訪問同一個變數時,一個執行緒修改了這個變數的值,其他執行緒能夠立即看得到修改的值。

有序性 是指程式執行的順序按照程式碼的先後順序執行。

2 什麼是記憶體模型

前面提到的,快取一致性問題、處理器器優化的指令重排問題是硬體的不斷升級導致的。那麼,有沒有什麼機制可以很好的解決上面的這些問題呢?

最簡單直接的做法就是廢除處理器和處理器的優化技術、廢除CPU快取,讓CPU直接和主存互動。但是,這麼做雖然可以保證多執行緒下的併發問題。但是,這就有點因噎廢食了。

所以,為了保證併發程式設計中可以滿足原子性、可見性及有序性。有一個重要的概念,那就是——記憶體模型,定義了共享記憶體系統中多執行緒程式讀寫操作行為的規範

通過這些規則來規範對記憶體的讀寫操作,從而保證指令執行的正確性。它與處理器有關、與快取有關、與併發有關、與編譯器也有關。它解決了CPU多級快取、處理器優化、指令重排等導致的記憶體訪問問題,保證了併發場景下的一致性、原子性和有序性。

記憶體模型解決併發問題主要採用兩種方式:限制處理器優化和使用記憶體屏障

3 什麼是Java記憶體模型

前面介紹過了計算機記憶體模型,這是解決多執行緒場景下併發問題的一個重要規範。那麼具體的實現是如何的呢,不同的程式語言,在實現上可能有所不同

我們知道,Java程式是需要執行在Java虛擬機器上面的,Java記憶體模型(Java Memory Model ,JMM)就是一種符合記憶體模型規範的,遮蔽了各種硬體和作業系統的訪問差異的,保證了Java程式在各種平臺下對記憶體的訪問都能保證效果一致的機制及規範

Java記憶體模型規定了 所有的變數都儲存在主記憶體中,每條執行緒還有自己的工作記憶體,執行緒的工作記憶體中儲存了該執行緒中用到的變數的主記憶體副本拷貝,執行緒對變數的所有操作都必須在工作記憶體中進行,而不能直接讀寫主記憶體。不同的執行緒之間也無法直接訪問對方工作記憶體中的變數,執行緒間變數的傳遞均需要自己的工作記憶體和主存之間進行資料同步進行。

而JMM就作用於工作記憶體和主存之間資料同步過程。他規定了如何做資料同步以及什麼時候做資料同步

主記憶體與工作記憶體互動示意

特別需要注意的是,主記憶體和工作記憶體與JVM記憶體結構中的Java堆、棧、方法區等並不是同一個層次的記憶體劃分,無法直接類比

再來總結下,JMM是一種規範,規範了Java虛擬機器與計算機記憶體是如何協同工作的,目的是解決由於多執行緒通過共享記憶體進行通訊時,存在的本地記憶體資料不一致、編譯器會對程式碼指令重排序、處理器會對程式碼亂序執行等帶來的問題。目的是保證併發程式設計場景中的原子性、可見性和有序性。

所以,如果你想設計表現良好的併發程式,理解Java記憶體模型是非常重要的。Java記憶體模型規定了如何和何時可以看到由其他執行緒修改過後的共享變數的值,以及在必須時如何同步的訪問共享變數

3.1 Java記憶體模型抽象

在Java中,所有例項域、靜態域和陣列元素儲存在堆記憶體中,堆記憶體線上程之間共享區域性變數(Local variables),方法定義引數(formal method parameters)和異常處理器引數(exception handler parameters) 不會線上程之間共享,它們不會有記憶體可見性問題,也不受記憶體模型的影響。

Java執行緒之間的通訊由Java記憶體模型(JMM)控制,JMM決定一個執行緒對共享變數的寫入何時對另一個執行緒可見。從抽象的角度來看,JMM定義了執行緒和主記憶體之間的抽象關係:

執行緒之間的共享變數儲存在主記憶體(main memory)中,每個執行緒都有一個私有的本地記憶體(local memory),本地記憶體中儲存了該執行緒以讀/寫共享變數的副本。本地記憶體是JMM的一個抽象概念,並不真實存在。它涵蓋了快取,寫緩衝區,暫存器以及其他的硬體和編譯器優化

Java記憶體模型抽象示意圖

從上圖來看,執行緒A與執行緒B之間如要通訊的話,必須要經歷下面2個步驟:

  1. 首先,執行緒A把本地記憶體A中更新過的共享變數重新整理到主記憶體中去;
  2. 然後,執行緒B到主記憶體中去讀取執行緒A之前已更新過的共享變數;

下面通過示意圖來說明這兩個步驟:

執行緒A與執行緒B之間通訊

如上圖所示,本地記憶體A和B有主記憶體中共享變數x的副本。假設初始時,這三個記憶體中的x值都為0。執行緒A在執行時,把更新後的x值(假設值為1)臨時存放在自己的本地記憶體A中。當執行緒A和執行緒B需要通訊時,執行緒A首先會把自己本地記憶體中修改後的x值重新整理到主記憶體中,此時主記憶體中的x值變為了1。隨後,執行緒B到主記憶體中去讀取執行緒A更新後的x值,此時執行緒B的本地記憶體的x值也變為了1

從整體來看,這兩個步驟實質上是執行緒A在向執行緒B傳送訊息,而且這個通訊過程必須要經過主記憶體。JMM通過控制主記憶體與每個執行緒的本地記憶體之間的互動,來為Java程式設計師提供記憶體可見性保證

3.2 重排序

在執行程式時為了提高效能,編譯器和處理器常常會對指令做重排序。重排序分三種型別:

  1. 編譯器優化的重排序。編譯器在不改變單執行緒程式語義的前提下,可以重新安排語句的執行順序。
  2. 指令級並行的重排序。現代處理器採用了指令級並行技術(Instruction-Level Parallelism, ILP)來將多條指令重疊執行。如果不存在資料依賴性,處理器可以改變語句對應機器指令的執行順序
  3. 記憶體系統的重排序。由於處理器使用快取和讀/寫緩衝區,這使得載入和儲存操作看上去可能是在亂序執行。

從Java原始碼到最終實際執行的指令序列,會分別經歷下面三種重排序:

三種重排序

上述的1屬於編譯器重排序,2和3屬於處理器重排序。這些重排序都可能會導致多執行緒程式出現記憶體可見性問題。

對於編譯器,JMM的編譯器重排序規則會禁止特定型別的編譯器重排序(不是所有的編譯器重排序都要禁止)。

對於處理器重排序,JMM的處理器重排序規則會要求Java編譯器在生成指令序列時,插入特定型別的記憶體屏障(memory barriers,intel稱之為memory fence)指令,通過記憶體屏障指令來禁止特定型別的處理器重排序(不是所有的處理器重排序都要禁止)

JMM屬於語言級的記憶體模型,它確保在不同的編譯器和不同的處理器平臺之上,通過禁止特定型別的編譯器重排序和處理器重排序,為程式設計師提供一致的記憶體可見性保證。

3.3 處理器重排序

現代的處理器使用 寫緩衝區 來臨時儲存向記憶體寫入的資料。寫緩衝區可以保證指令流水線持續執行,它可以避免由於處理器停頓下來等待向記憶體寫入資料而產生的延遲。同時,通過以批處理的方式重新整理寫緩衝區,以及合併寫緩衝區中對同一記憶體地址的多次寫,可以減少對記憶體匯流排的佔用。雖然寫緩衝區有這麼多好處,但每個處理器上的寫緩衝區,僅僅對它所在的處理器可見。這個特性會對記憶體操作的執行順序產生重要的影響:

處理器對記憶體的讀/寫操作的執行順序,不一定與記憶體實際發生的讀/寫操作順序一致!

兩個處理器示例

假設處理器A和處理器B按程式的順序並行執行記憶體訪問,最終卻可能得到 x = y = 0。具體的原因如下圖所示:

處理器A和處理器B並行執行程式

處理器 A 和 B 同時把共享變數寫入在寫緩衝區中(A1、B1),然後再從記憶體中讀取另一個共享變數(A2、B2),最後才把自己寫緩衝區中儲存的髒資料重新整理到記憶體中(A3、B3)。當以這種時序執行時,程式就可以得到 x = y = 0 的結果

從記憶體操作實際發生的順序來看,直到處理器 A 執行 A3 來重新整理自己的寫快取區,寫操作 A1 才算真正執行了。雖然處理器 A 執行記憶體操作的順序為:A1 -> A2,但記憶體操作實際發生的順序卻是:A2 -> A1。此時,處理器 A 的記憶體操作順序被重排序了

這裡的關鍵是,由於寫緩衝區僅對自己的處理器可見,它會導致處理器執行記憶體操作的順序可能會與記憶體實際的操作執行順序不一致。由於現代的處理器都會使用寫緩衝區,因此現代的處理器都會允許對記憶體寫-讀操作重排序

3.4 記憶體屏障指令

下面是常見處理器允許的重排序型別的列表:

常見處理器允許的重排序型別

上表單元格中的“N”表示處理器不允許兩個操作重排序,“Y”表示允許重排序。從上表我們可以看出:常見的處理器都允許Store-Load重排序;常見的處理器都不允許對存在資料依賴的操作做重排序。sparc-TSO和x86擁有相對較強的處理器記憶體模型,它們僅允許對寫-讀操作做重排序(因為它們都使用了寫緩衝區)。

為了保證記憶體可見性,Java 編譯器在生成指令序列的適當位置會插入記憶體屏障指令來禁止特定型別的處理器重排序。JMM 把記憶體屏障指令分為下列四類:

記憶體屏障指令

3.5 happens-before

JSR-133 記憶體模型使用 happens-before 的概念來闡述操作之間的記憶體可見性。在 JMM 中,如果一個操作執行的結果需要對另一個操作可見,那麼這兩個操作之間必須要存在 happens-before 關係。這裡提到的兩個操作既可以是在一個執行緒之內,也可以是在不同執行緒之間。

與程式設計師密切相關的 happens-before 規則如下:

程式順序規則:一個執行緒中的每個操作,happens- before 於該執行緒中的任意後續操作。

監視器鎖規則:對一個監視器鎖的解鎖,happens- before 於隨後對這個監視器鎖的加鎖。

volatile變數規則:對一個volatile域的寫,happens- before 於任意後續對這個volatile域的讀。

傳遞性規則:如果A happens- before B,且B happens- before C,那麼A happens- before C。

注意,兩個操作之間具有happens-before關係,並不意味著前一個操作必須要在後一個操作之前執行!happens-before僅僅要求前一個操作(執行的結果)對後一個操作可見,且前一個操作按順序排在第二個操作之前(the first is visible to and ordered before the second)

happens-before與JMM的關係

如上圖所示,一個happens-before規則通常對應於多個編譯器和處理器重排序規則。對於Java程式設計師來說,happens-before規則簡單易懂,它避免java程式設計師為了理解JMM提供的記憶體可見性保證而去學習複雜的重排序規則以及這些規則的具體實現。

3.6 資料依賴性

如果兩個操作訪問同一個變數,且這兩個操作中有一個為寫操作,此時這兩個操作之間就存在資料依賴性。資料依賴分下列三種型別:

三種型別資料依賴

上面三種情況,只要重排序兩個操作的執行順序,程式的執行結果將會被改變。

前面提到過,編譯器和處理器可能會對操作做重排序。編譯器和處理器在重排序時,會遵守資料依賴性,編譯器和處理器不會改變存在資料依賴關係的兩個操作的執行順序

注意,這裡所說的資料依賴性僅針對單個處理器中執行的指令序列和單個執行緒中執行的操作,不同處理器之間和不同執行緒之間的資料依賴性不被編譯器和處理器考慮

3.7 as-if-serial 語義

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

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

舉個例子:

double pi = 3.14; // A

double r = 1.0; // B

double area = pi * r * r; // C
複製程式碼

上面三個操作的資料依賴關係如下圖所示:

三個操作的資料依賴關係

如上圖所示,A 和 C 之間存在資料依賴關係,同時 B 和 C 之間也存在資料依賴關係。因此在最終執行的指令序列中,C 不能被重排序到 A 和 B 的前面(C 排到 A 和 B 的前面,程式的結果將會被改變)。但 A 和 B 之間沒有資料依賴關係,編譯器和處理器可以重排序 A 和 B 之間的執行順序。下圖是該程式的兩種可能執行順序:

兩種可能的執行順序

在計算機中,軟體技術和硬體技術有一個共同的目標:在不改變程式執行結果的前提下,儘可能的開發並行度。編譯器和處理器遵從這一目標,從 happens-before 的定義我們可以看出,JMM 同樣遵從這一目標。

4 Java記憶體模型實現

瞭解Java多執行緒的朋友都知道,在Java中提供了一系列和併發處理相關的關鍵字,比如:volatilesynchronizedfinalconcurrent 包等。其實這些就是 Java記憶體模型封裝了底層的實現後提供給程式設計師使用的一些關鍵字

在開發多執行緒的程式碼的時候,我們可以直接使用 synchronized 等關鍵字來控制併發,從來就不需要關心底層的編譯器優化、快取一致性等問題。所以,Java記憶體模型,除了定義了一套規範,還提供了一系列原語,封裝了底層實現後,供開發者直接使用

4.1 原子性

在Java中,為了保證原子性,提供了兩個高階的位元組碼指令 monitorentermonitorexit,這兩個位元組碼,在Java中對應的關鍵字就是 synchronized

因此,在Java中可以使用 synchronized 來保證方法和程式碼塊內的操作是原子性的。

4.2 可見性

Java記憶體模型是通過在變數修改後將新值同步回主記憶體,在變數讀取前從主記憶體重新整理變數值的這種依賴主記憶體作為傳遞媒介的方式來實現的

Java中的 volatile 關鍵字提供了一個功能,那就是被其修飾的變數在被修改後可以立即同步到主記憶體,被其修飾的變數在每次是用之前都從主記憶體重新整理。因此,可以使用 volatile 來保證多執行緒操作時變數的可見性

除了 volatile,Java中的 synchronizedfinal 兩個關鍵字也可以實現可見性。只不過實現方式不同,這裡不再展開了。

4.3 有序性

在Java中,可以使用 synchronizedvolatile 來保證多執行緒之間操作的有序性。實現方式有所區別:

volatile關鍵字會禁止指令重排;

synchronized關鍵字保證同一時刻只允許一條執行緒操作;

相關文章