高併發學習之03JMM(Java內存模型)

1.硬件層面上的概覽

要想了解Java中的內存模型必須要先知道CPU和內存的關係

1.1.CPU的多級緩存

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

  • L1 Cache,一級緩存,本地 core 的緩存,分成 32K 的數據緩存 L1d 和 32k 指令緩存 L1i,訪問 L1 需要 3cycles,耗時大約 1ns;
  • L2 Cache,二級緩存,本地 core 的緩存,被設計爲 L1 緩存與共享的 L3 緩存
    之間的緩衝,大小爲 256K,訪問 L2 需要 12cycles,耗時大約 3ns;
  • L3 Cache,三級緩存,在同插槽的所有 core 共享 L3 緩存,分爲多個 2M 的
    段,訪問 L3 需要 38cycles,耗時大約 12ns;

1.2.緩存一致性問題

CPU-0 讀取主存的數據,緩存到 CPU-0 的高速緩存中,CPU-1 也做了同樣的事情,而 CPU-1 把 count 的值修改成了 2,並且同步到 CPU-1 的高速緩存,但是這個修改以後的值並沒有寫入到主存中,CPU-0 訪問該字節,由於緩存沒有更新,所以仍然是之前的值,就會導致數據不一致的問題引發這個問題的原因是因爲多核心 CPU 情況下存在指令並行執行,而各個CPU 核心之間的數據不共享從而導致緩存一致性問題,爲了解決這個問題,CPU 生產廠商提供了相應的解決方案

1.2.1 總線鎖

當一個 CPU 對其緩存中的數據進行操作的時候,往總線中發送一個 Lock 信號。其他處理器的請求將會被阻塞,那麼該處理器可以獨佔共享內存。總線鎖相當於把 CPU 和內存之間的通信鎖住了,所以這種方式會導致 CPU 的性能下降,所以 P6 系列以後的處理器,出現了另外一種方式,就是緩存鎖。

1.2.2 緩存鎖

如果緩存在處理器緩存行中的內存區域在 LOCK 操作期間被鎖定,當它執行鎖操作回寫內存時,處理不在總線上聲明 LOCK 信號,而是修改內部的緩存地址,然後通過緩存一致性機制來保證操作的原子性,因爲緩存一致性機制會阻止同時修改被兩個以上處理器緩存的內存區域的數據,當其他處理器回寫已經被鎖定的緩存行的數據時會導致該緩存行無效。所以如果聲明瞭 CPU 的鎖機制,會生成一個 LOCK 指令,會產生兩個作用:

  1. Lock 前綴指令會引起引起處理器緩存回寫到內存,在 P6 以後的處理器中,LOCK 信號一般不鎖總線,而是鎖緩存
  2. 一個處理器的緩存回寫到內存會導致其他處理器的緩存無效
1.2.3 緩存一致性協議

處理器上有一套完整的協議,來保證 Cache 的一致性,比較經典的應該就是MESI 協議了,它的方法是在 CPU 緩存中保存一個標記位,這個標記爲有四種
狀態

  • M(Modified) 修改緩存,當前 CPU 緩存已經被修改,表示已經和內存中的
    數據不一致了
  • I(Invalid) 失效緩存,說明 CPU 的緩存已經不能使用了
  • E(Exclusive) 獨佔緩存,當前 cpu 的緩存和內存中數據保持一直,而且其他處理器沒有緩存該數據
  • S(Shared) 共享緩存,數據和內存中數據一致,並且該數據存在多個 cpu緩存中

每個 Core 的 Cache 控制器不僅知道自己的讀寫操作,也監聽其它 Cache 的讀寫操作,嗅探(snooping)"協議
CPU 的讀取會遵循幾個原則

  1. 如果緩存的狀態是 I,那麼就從內存中讀取,否則直接從緩存讀取
  2. 如果緩存處於 M 或者 E 的 CPU 嗅探到其他 CPU 有讀的操作,就把自己的緩存寫入到內存,並把自己的狀態設置爲 S
  3. 只有緩存狀態是 M 或 E 的時候,CPU 纔可以修改緩存中的數據,修改後,緩
    存狀態變爲 MC

1.3. CPU優化-運行時指令重排

除了增加高速緩存以爲,爲了更充分利用處理器內內部的運算單元,處理器可能會對輸入的代碼進行亂序執行優化,處理器會在計算之後將亂序執行的結果充足,保證該結果與順序執行的結果一直,但並不保證程序中各個語句計算的先後順序與輸入代碼中的順序一致,這個是處理器的優化執行。
重排序分3種類型。

  • 編譯器優化的重排序。編譯器在不改變單線程程序語義的前提下,可以重新安排語句
    的執行順序。
  • 指令級並行的重排序。現代處理器採用了指令級並行技術(Instruction-Level
    Parallelism,ILP)來將多條指令重疊執行。如果不存在數據依賴性,處理器可以改變語句對應
    機器指令的執行順序。
  • 內存系統的重排序。由於處理器使用緩存和讀/寫緩衝區,這使得加載和存儲操作看上
    去可能是在亂序執行。
    重排序的順序
    CPU指令重排並非隨便重排,需要遵守as-if-serial語義。
    as-if-serial語義的意思指:不管怎麼重排序(編譯器和處理器爲了提高並行度),(單線程)
    程序的執行結果不能被改變。編譯器,運行時和處理器都必須遵守as-if-serial語義。
    也就是說:編譯器和處理器不會對存在數據依賴關係的操作做重排序。
    舉個簡單的例子:
public class Demo1{
    public static void main(String[] args){
        int x = 500; //1
        int y = 100;	//2
        int a = x / y; //3
        int b = 50;  //4
        System.out.println(a + b); //5
    }
}

這段代碼中通過編譯器進行字節碼重排序優化,順序會是 1->2>4>3>5

1.3.1 帶來的兩個問題
  1. CPU高速緩存下有一個問題:
    緩存中的數據與主內存的數據並不是實時同步的,各CPU(或CPU核心)間緩存的數據也不是實時同步。在同一個時間點,各CPU所看到同一內存地址的數據的值可能是不一致的。
  2. CPU執行指令重排序優化下有一個問題:
    雖然遵守了as-if-serial語義,單僅在單CPU自己執行的情況下能保證結果正確。多核多線程中,指令邏輯無法分辨因果關聯,可能出現亂序執行,導致程序運行結果錯誤。

1.3.2 內存屏障(Memory Barrier)

處理器提供了兩個內存屏障指令(Memory Barrier)用於解決上述兩個問題:

  • 寫內存屏障(Store Memory Barrier):在指令後插入Store Barrier,能讓寫入緩存中的最新
    數據更新寫入主內存,讓其他線程可見。
    強制寫入主內存,這種顯示調用,CPU就不會因爲性能考慮而去對指令重排。
    如圖:所有在storestore barrier指令之後的store指令,都必須在storestore barrier屏障之前的指令執行完後再被執行。寫內存屏障
  • 讀內存屏障(Load Memory Barrier):在指令前插入Load Barrier,可以讓高速緩存中的數
    據失效,強制從新從主內存加載數據。
    強制讀取主內存內容,讓CPU緩存與主內存保持一致,避免了緩存導致的一致性問題
    如圖:強制所有在load barrier讀屏障之後的load指令,都在loadbarrier屏障之後執行
    讀內存屏障

2.JMM

前面說的和硬件有關的概念你可能聽得有點蒙,還不知道他到底和軟件有啥關係,其實原子性、可見性、有序性問題,是我們抽象出來的概念,他們的核心本質就是剛剛提到的緩存一致性問題、處理器優化問題導致的指令重排序問題。比如緩存一致性就導致可見性問題、處理器的亂序執行會導致原子性問題、指令重排會導致有序性問題。爲了解決這些問題,所以在 JVM 中引入了 JMM 的概念。

2.1JMM(Java 內存模型)

內存模型定義了共享內存系統中多線程程序讀寫操作行爲的規範,來屏蔽各種硬件和操作系統的內存訪問差異,來實現 Java 程序在各個平臺下都能達到一致的內存訪問效果。Java 內存模型的主要目標是定義程序中各個變量的訪問規則,也就是在虛擬機中將變量存儲到內存以及從內存中取出變量(這裏的變量,指的是共享變量,也就是實例對象、靜態字段、數組對象等存儲在堆內存中的變量。而對於局部變量這類的,屬於線程私有,不會被共享)這類的底層細節。通過這些規則來規範對內存的讀寫操作,從而保證指令執行的正確性。它與處理器有關、與緩存有關、與併發有關、與編譯器也有關。他解決了 CPU多級緩存、處理器優化、指令重排等導致的內存訪問問題,保證了併發場景下的可見性、原子性和有序性。
內存模型解決併發問題主要採用兩種方式:限制處理器優化和使用內存屏障。
Java 內存模型定義了線程和內存的交互方式,在 JMM 抽象模型中,分爲主內存、工作內存
JMM內存空間
主內存是所有線程共享的,工作內存是每個線程獨有的。線程對變量的所有操作(讀取、賦值)都必須在工作內存中進行,不能直接讀寫主內存中的變量。並且不同的線程之間無法訪問對方工作內存中的變量,線程間的變量值的傳遞都需要通過主內存來完成,他們三者的交互關係如下:
線程與工作內存和主內存關係
所以,總的來說,JMM 是一種規範,目的是解決由於多線程通過共享內存進行通信時,存在的本地內存數據不一致、編譯器會對代碼指令重排序、處理器會對代碼亂序執行等帶來的問題。目的是保證併發編程場景中的原子性、可見性和有序性.

2.2 JVM 與JMM關係

jvm內存模型和java內存模型是兩回事。 java內存模型是爲了解決多線程對共享數據訪問保持一致性,即規定了jvm怎麼協調虛擬內存和主內存關係

對於JMM與JVM本身的內存模型,參照《深入理解Java虛擬機》周志明的解釋,這兩者本沒有關係。如果一定要勉強對應,那從變量、主內存、工作內存的定義來看,主內存主要對應於Java堆中的對象實例數據部分,而工作內存則對應於虛擬機棧中的部分區域,。從更低層次上說,主內存就是物理內存,而爲了獲取更好的執行速度,虛擬機(甚至是硬件系統本身的優化措施)可能會讓工作內存優先存儲於寄存器和高速緩存中,因爲運行時主要訪問—讀寫的是工作內存。

2.3 JMM與硬件內存關係

JMM可以在硬件CPU緩存中也可以在主內存中,JMM本身就是爲了解決多線程對共享數據訪問保持一致性,即規定了jvm怎麼協調虛擬內存和主內存關係。

2.4 JMM怎麼解決原子性、可見性、有序性的問題?

在Java中提供了一系列和併發處理相關的關鍵字,比如volatile、Synchronized、final、J.U.C等,這些就是Java內存模型封裝了底層的實現後提供給開發人員使用的關鍵字,在開發多線程代碼的時候,我們可以直接使用synchronized等關鍵詞來控制併發,使得我們不需要關心底層的編譯器優化、緩存一致性的問題了,所以在Java內存模型中,除了定義了一套規範,還提供了開放的指令在底層進行封裝後,提供給開發人員使用。

  • 原子性保障
    在java中提供了兩個高級的字節碼指令monitorenter和monitorexit,在Java中對應的Synchronized來保證代碼塊內的操作是原子的
  • 可見性
    Java中的volatile關鍵字提供了一個功能,那就是被其修飾的變量在被修改後可以立即同步到主內存,被其修飾的變量在每次是用之前都從主內存刷新。因此,可以使用volatile來保證多線程操作時變量的可見性。除了volatile,Java中的synchronized和final兩個關鍵字也可以實現可見性
  • 有序性
    在Java中,可以使用synchronized和volatile來保證多線程之間操作的有序性。實現方式有所區別:volatile關鍵字會禁止指令重排。synchronized關鍵字保證同一時刻只允許一條線程操作。

2.5 JMM內存屏障

在JMM中把內存屏障指令分爲4類,通過在不同的語義下使用不同的內存屏障來進制特定類型的處理器重排序,從而來保證內存的可見性
JMM內存屏障類型

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