深入併發-內存模型

從硬件層面瞭解可見性

一臺計算機中最核心的組件是 CPU、內存、以及 I/O 設備。在整個計算機的發展歷程中,除了 CPU、內存以及 I/O 設備不斷迭代升級來提升計算機處理性能之外,還有一個非常核心的矛盾點,就是這三者在處理速度的差異。CPU 的計算速度是非常快的,內存次之、最後是 IO 設備比如磁盤。而在絕大部分的程序中,一定會存在內存訪問,有些可能還會存在 I/O 設備的訪問。
爲了提升計算性能,CPU 從單核升級到了多核甚至用到了超線程技術最大化提高 CPU 的處理性能,但是僅僅提升 CPU 性能還不夠,如果後面兩者的處理性能沒有跟上,意味着整體的計算效率取決於最慢的設備。爲了平衡三者的速度差異,最大化的利用 CPU 提升性能,從硬件、操作系統、編譯器等方面都做出了很多的優化

  1. CPU 增加了高速緩存
  2. 操作系統增加了進程、線程。通過 CPU 的時間片切換最大化的提升 CPU 的使用率
  3. 編譯器的指令優化,更合理的去利用好 CPU 的高速緩存

CPU高速緩存

線程是 CPU 調度的最小單元,線程設計的目的最終仍然是更充分的利用計算機處理的效能,但是絕大部分的運算任務不能只依靠處理器“計算”就能完成,處理器還需要與內存交互,比如讀取運算數據、存儲運算結果,這個 I/O 操作是很難消除的。而由於計算機的存儲設備與處理器的運算速度差距非常大,所以現代計算機系統都會增加一層讀寫速度儘可能接近處理器運算速度的高速緩存來作爲內存和處理器之間的緩衝:將運算需要使用的數據複製到緩存中,讓運算能快速進行,當運算結束後再從緩存同步到內存之中。

在這裏插入圖片描述
在多CPU的系統中,每個CPU都有多級緩存,一般分爲L1、L2、L3緩存,因爲這些緩存的存在提供了數據的訪問性能,也減輕了數據總線上數據傳輸的壓力,同時也帶來了很多新的挑戰。比如由於在多 CPU 種,每個線程可能會運行在不同的 CPU 內,並且每個線程擁有自己的高速緩存。同一份數據可能會被緩存到多個 CPU 中,如果在不同 CPU 中運行的不同線程看到同一份內存的緩存值不一樣就會存在緩存不一致的問題。

緩存一致性

爲了解決緩存不一致的問題,在 CPU 層面做了很多事情,主要提供了兩種解決辦法

  1. 總線鎖
  2. 緩存鎖

在多 cpu 下,當其中一個處理器要對共享內存進行操作的時候,在總線上發出一個 LOCK 信號,這個信號使得其他處理器無法通過總線來訪問到共享內存中的數據。總線鎖把 CPU 和內存之間的通信鎖住了,這使得鎖定期間,其他處理器不能操作其他內存地址的數據,所以總線鎖定的開銷比較大,這種機制顯然是不合適的。
如何優化呢?最好的方法就是控制鎖的保護粒度,我們只需要保證對於被多個 CPU 緩存的同一份數據是一致的就行。所以引入了緩存鎖,它核心機制是基於緩存一致性協議來實現的。

緩存一致性協議

爲了達到數據訪問的一致,需要各個處理器在訪問緩存時遵循一些協議,在讀寫時根據協議來操作,常見的協議有 MSI,MESI,MOSI 等。最常見的就是 MESI 協議。接下來給大家簡單講解一下 MESI,MESI 表示緩存行的四種狀態,分別是

  1. M(Modify) 表示共享數據只緩存在當前 CPU 緩存中,並且是被修改狀態,也就是緩存的數據和主內存中的數據不一致
  2. E(Exclusive) 表示緩存的獨佔狀態,數據只緩存在當前 CPU 緩存中,並且沒有被修改
  3. S(Shared) 表示數據可能被多個 CPU 緩存,並且各個緩存中的數據和主內存數據一致
  4. I(Invalid) 表示緩存已經失效

在 MESI 協議中,每個緩存的緩存控制器不僅知道自己的讀寫操作,而且也監聽(snoop)其它 Cache 的讀寫操作。對於 MESI 協議,從 CPU 讀寫角度來說會遵循以下原則:

  • CPU 讀請求:緩存處於 M、E、S 狀態都可以被讀取,I 狀態 CPU 只能從主存中讀取數據
  • CPU 寫請求:緩存處於 M、E 狀態纔可以被寫。對於 S 狀態的寫,需要將其他 CPU 中緩存行置爲無效纔可寫

使用總線鎖和緩存鎖機制之後,CPU 對於內存的操作大概可以抽象成下面這樣的結構。從而達到緩存一致性效果
在這裏插入圖片描述

MESI 優化帶來的可見性問題

如果 CPU0 要對一個在緩存中共享的變量進行寫入,首先需要發送一個失效的消息給到其他緩存了該數據的 CPU。並且要等到他們的確認回執。CPU0 在這段時間內都會處於阻塞狀態。爲了避免阻塞帶來的資源浪費。在 cpu 中引入了 Store Bufferes。
在這裏插入圖片描述
CPU0 只需要在寫入共享數據時,直接把數據寫入到 store bufferes 中,同時發送 invalidate 消息,然後繼續去處理其他指令。當收到其他所有 CPU 發送了 invalidate acknowledge 消息時,再將 store bufferes 中的數據數據存儲至 cache line 中。最後再從緩存行同步到主內存。但是這種優化存在兩個問題

  1. 數據什麼時候提交是不確定的,因爲需要等待其他 cpu 給回覆纔會進行數據同步。這裏其實是一個異步操作
  2. 引入了 storebufferes 後,處理器會先嚐試從 storebuffer 中讀取值,如果 storebuffer 中有數據,則直接從 storebuffer 中讀取,否則就再從緩存行中讀取

所以在 CPU 層面提供了 memory barrier(內存屏障)的指令,從硬件層面來看這個 memroy barrier 就是 CPU flush store bufferes 中的指令。軟件層面可以決定在適當的地方來插入內存屏障。

CPU 層面的內存屏障

內存屏障就是將 store bufferes 中的指令寫入到內存,從而使得其他訪問同一共享內存的線程的可見性。
X86 的 memory barrier 指令包括 lfence(讀屏障) sfence(寫屏障) mfence(全屏障)

  • Store Memory Barrier(寫屏障) 告訴處理器在寫屏障之前的所有已經存儲在存儲緩存(store bufferes)中的數據同步到主內存,簡單來說就是使得寫屏障之前的指令的結果對屏障之後的讀或者寫是可見的
  • Load Memory Barrier(讀屏障) 處理器在讀屏障之後的讀操作,都在讀屏障之後執行。配合寫屏障,使得寫屏障之前的內存更新對於讀屏障之後的讀操作是可見的
  • Full Memory Barrier(全屏障) 確保屏障前的內存讀寫操作的結果提交到內存之後,再執行屏障後的讀寫操作

內存屏障的作用可以通過防止 CPU 對內存的亂序訪問來保證共享數據在多線程並行執行下的可見性。

JMM

什麼是 JMM

JMM 全稱是 Java Memory Model。通過前面的分析發現,導致可見性問題的根本原因是緩存以及重排序。 而 JMM 實際上就是提供了合理的禁用緩存以及禁止重排序的方法。所以它最核心的價值在於解決可見性和有序性。
JMM 並沒有限制執行引擎使用處理器的寄存器或者高速緩存來提升指令執行速度,也沒有限制編譯器對指令進行重排序,也就是說在 JMM 中,也會存在緩存一致性問題和指令重排序問題。只是 JMM 把底層的問題抽象到 JVM 層面,再基於 CPU 層面提供的內存屏障指令,以及限制編譯器的重排序來解決併發問題。
JMM 抽象模型分爲主內存、工作內存;主內存是所有線程共享的,一般是實例對象、靜態字段、數組對象等存儲在堆內存中的變量。工作內存是每個線程獨佔的,線程對變量的所有操作都必須在工作內存中進行,不能直接讀寫主內存中的變量,線程之間的共享變量值的傳遞都是基於主內存來完成。
Java 內存模型底層實現可以簡單的認爲:通過內存屏障(memory barrier)禁止重排序,即時編譯器根據具體的底層體系架構,將這些內存屏障替換成具體的 CPU 指令。對於編譯器而言,內存屏障將限制它所能做的重排序優化。而對於處理器而言,內存屏障將會導致緩存的刷新操作。比如,對於 volatile,編譯器將在 volatile 字段的讀寫操作前後各插入一些內存屏障。

根本問題

原子性

問題:多線程指令交叉執行
解決:在java中提供了兩個高級的字節碼指令monitorenter和monitorexit,在Java中對應的Synchronized來保證代碼塊內的操作是原子的。

有序性

問題:由指令重排序造成,編譯器和處理器指令重排的目的是爲了最大化的提高CPU利用率以及性能,CPU的亂序執行優化在單核時代並不影響正確性。多核時代的多線程能夠在不同的核心上實現真正的並行,一旦線程之間共享數據,就可能會出現一些不可預料的問題。
解決:在Java中,可以使用synchronized和volatile來保證多線程之間操作的有序性。實現方式有所區別:volatile關鍵字會禁止指令重排,而synchronized關鍵字保證同一時刻只允許一條線程操作。

可見性

問題: 緩存數據一致性問題,一個處理器的緩存回寫到內存會導致其他處理器的緩存無效。
解決:Java中的volatile關鍵字提供了一個功能,那就是被其修飾的變量在被修改後可以立即同步到主內存,被其修飾的變量在每次是用之前都從主內存刷新。因此,可以使用volatile來保證多線程操作時變量的可見性。除了volatile,Java中的 synchronized 和 final 兩個關鍵字也可以實現可見性。

重排序

爲了提高程序的執行性能,編譯器和處理器都會對指令做重排序,其中處理器的重排序在前面已經分析過了。所謂的重排序其實就是指執行的指令順序。
編譯器的重排序指的是程序編寫的指令在編譯之後,指令可能會產生重排序來優化程序的執行性能。從源代碼到最終執行的指令,可能會經過三種重排序。
在這裏插入圖片描述
2 和 3 屬於處理器重排序,這些重排序可能會導致可見性問題。處理器重排序,JMM 會要求編譯器生成指令時,會插入內存屏障來禁止處理器重排序。編譯器的重排序,JMM 提供了禁止特定類型的編譯器重排序。

JMM 層面的內存屏障

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

  • LoadLoad Barriers: load1 LoadLoad load2 , 確保load1數據的裝載優先於load2及所有後續裝載指令的裝載
  • LoadStore Barries:load1 loadstore store2, 確保load1數據裝載優先於store2以及後續的存儲指令刷新到內存
  • StoreStore Barriers,store1 storestore store2 , 確保store1數據對其他處理器可見優先於store2及所有後續存儲指令的存儲
  • StoreLoad Barries, store1 storeload load2, 確保store1數據對其他處理器變得可見, 優先於load2及所有後續裝載指令的裝載;這條內存屏障指令是一個全能型的屏障,它同時具有其他3條屏障的效果。

HappenBefore

前一個操作的結果對於後續操作是可見的,所以它是一種表達多個線程之間對於內存的可見性。我們可以認爲在 JMM 中,如果一個操作執行的結果需要對另一個操作可見,那麼這兩個操作必須要存在 happens-before 關係。這兩個操作可以是同一個線程,也可以是不同的線程。

MM 中有哪些方法建立 happen-before 規則

  1. 程序順序規則:一個線程中的所有操作 happens-before 於該線程中的任意後續操作;
  2. volatile 變量規則:對於 volatile 修飾的變量的寫操作一定 happen-before 後續對於 volatile 變量的讀操作;
  3. 傳遞性規則:如果 1 happens-before 2 3happensbefore 4,那麼傳遞性規則表示: 1 happens-before 4;
  4. start 規則:如果線程 A 執行操作 ThreadB.start(),那麼線程 A 的 ThreadB.start()操作 happens-before 線程 B 中的後續操作;
  5. join 規則:如果線程 A 執行操作 ThreadB.join()併成功返回,那麼線程 B 中的任意操作 happens-before 於線程 A 後續任何操作;
  6. 監視器鎖的規則:對一個鎖的解鎖 happens-before 於隨後對這個鎖的加鎖;

Volatile

Volatile如何保證可見性?

對於聲明瞭volatile的變量進行寫操作,JVM就會向處理器發送一條Lock前綴的指令,把這個變量所在的緩存行的數據寫回到系統內存會觸發總線鎖或者緩存鎖。通過MESI的緩存一致性協議,來保證多CPU下的各個高速緩存中的數據的一致性。每個處理器通過嗅探在總線上傳播的數據來檢查自己緩存的值是不是過期了,當處理器發現自己緩存行對應的內存地址被修改,就會將當前處理器的緩存行設置成無效狀態。

volatile防止指令重排序?

在編譯器層面通過對增加 volatile 關鍵字的變量增加一層 StoreLoad Barries 內存屏障,取消編譯器層面的緩存和重排序。保證編譯程序時在優化屏障之前的指令不會在優化屏障之後執行。這就保證了編譯時期的優化不會影響到實際代碼邏輯順序。

總結

  • 保證了不同線程對這個變量進行操作時的可見性,即一個線程修改了某個變量的值,這新值對其他線程來說是立即可見的。(解決可見性)
  • 禁止進行指令重排序。(解決有序性)
  • volatile 只能保證對單次讀/寫的原子性,而不能對 i++ 這種操作保證原子性。(不解決原子性)
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章