多線程原理分析(二) -- 超詳細

多線程原理分析(二)

​ 上篇文章, 我們從鎖的存儲格式(Header、Mark Word)、鎖升級(偏向鎖、輕量級鎖、重量級鎖)的角度分析了多線程的原理, 這片文章我們從數據修改/加載、JMM、happen-before角度繼續說明多線程原理。

一、包含的知識點

  • 如何保證共享變量的可見性
  • volatile關鍵字是如何保證可見性的
  • 緩存一致性和緩存一致性協議
  • 緩存一致性協議存在的問題(MESI)
  • CPU內存屏障
  • JMM內存模型
  • happens before

二、如何保證共享變量的可見性

2.1 可見性問題

​ 單線程環境下, 如果向一個共享變量寫入一個值, 在沒有其它線程寫干涉的情況下,讀取這個變量的值和寫入的值是一樣的。 但是在多線程操作的情況下, 可能出現讀線程不能及時讀取到其它線程寫入的最新值, 這就是所謂的可見性問題。

​ 併發問題產生的原因是對共享變量併發訪問導致的, 那麼共享變量修改後,如何對其它線程保持可見性呢 , 即如何實現跨線程寫入的內存可見性 ?

  • volatile(可見性)
  • synchronized(可見性、有序性、原子性)

2.2 volatile 關鍵字是如何保證可見性的

三、可見性本質

​ 操作系統處理能力由CPU、內存、IO設備決定, 而這三者之間對數據的處理能力相差很大, 爲了最大化的利用CPU資源, 計算機從多方面做了很多優化, 包含下面內容

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

​ 上面的優化提升了計算機的性能, 但是也同時帶來了線程安全問題。

3.1 緩存一致性

​ 計算機絕大部分運算不能只依賴處理器完成, 還需要和內存進行交互, 這個操作是很難避免的, 但是存儲設備和處理器之間運算速度相差很大, 爲了減少這種衝突, 增加了一層**高速緩存**,通過高速緩存很好的解決了處理器和內存速度差異的矛盾,但是更高的複雜性帶來了緩存一致性問題。

​ 在多CPU中, 線程可能運行在不同的CPU中, 並且每個線程都有自己的高速緩存, 同一份原始數據可能被緩存到多個CPU中, 在不同CPU中執行的不同線程, 可能看到的緩存值不一樣, 這就是**緩存一致性問題**,操作系統通過下面的方式來解決緩存一致性問題

  • 總賢鎖

    在多CPU情況下, 當其中一個處理器要對共享內存進行操作時, 會對總線發出LOCK信號, 這個信號會使得其它處理器無法訪問共享內存, 同時也不能訪問其它內存地址的數據, 鎖的力度有點大, 開銷比較大

  • 緩存鎖

    相對於總線鎖, 鎖的粒度過大,緩存鎖很好的控制了鎖的粒度。緩存鎖基於**緩存一致性協議**, 保證被多個CPU緩存的同一份數據是一致的。

3.2 緩存一致性協議

​ 爲了保證數據訪問的一致性, 處理器在訪問緩存時, 需要遵循一些協議, 包括MSI、MESI、MOSI, 其中最常見是MESI。MESI表示緩存行四種狀態的縮寫, 每個緩存的緩存控制器不僅知道自己的讀寫操作, 也監聽其它Cache的讀寫操作。

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

對於MESI協議, 從CPU的讀寫角度會遵循以下原則,

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

​ 抽象的操作數據結構如下:

在這裏插入圖片描述

3.3 MESI存在的問題

CPU緩存行狀態變更是如何通知其它CPU的呢 ?

​ 如果一個CPU要對緩存在緩存中的共享變量進行寫入操作, 會通過消息傳遞的方式,發送一個失效消息給其它緩存了該數據的CPU, 並且需要等到他們的ACK, 但是在這個時間段內,當前線程都是處於阻塞狀態。

​ 爲了避免阻塞帶來的資源浪費, CPU中引入了Store Buffer;當CPU需要寫入共享變量時, 會直接先將數據寫入Store Buffer中, 等接收到所有其它CPU的ACK後, 再將緩存在Store Buffer中的數據同步到主內存。

在這裏插入圖片描述
在這裏插入圖片描述

​ 引入Store Buffer之後, 帶來了下面的問題

  1. 數據從Store Buffer寫入主內存時機不確定, 需要等到緩存失效ACK全部確認後纔會數據同步, 而這是一個異步操作。
  2. 引入Store Buffer後, 處理器先嚐試從store buffer中讀取值, 如果store buffer中有數據, 則直接從store buffer中讀取, 否則從緩存行中讀取 。
  3. 系統會對指令進行重排序, 重排序會帶來可見性問題。

3.4 CPU內存屏障

​ 內存屏障是將store buffer中指令寫入到內存, 使得其它訪問同一共享內存的線程具有可見性。CPU內存屏障分類

  • 讀屏障(Load Memory Barrier)

    讀屏障之後的讀操作都在讀屏障之後執行, 配合寫屏障, 使得寫屏障之前對內存的更新操作都對讀操作可見。

  • 寫屏障(Store Memory Barrier)

    告訴處理器在寫入屏障之前, 所有存儲在存儲緩存(Store Buffer)中的數據需要同步到主內存,即寫屏障之前的指令對屏障之後的讀或寫操作是可見的。

  • 全屏障(Full Memory Barrier)

    只有屏障之前的內存讀寫操作都提交到內存後, 纔會執行屏障之後的讀寫操作。

請查看下面關於cpu內存屏障使用的僞代碼,

value = 0 ;
void cpu01() {
  value = 1 ;
  storeMemoryBarrier();
  terminal = true ;
}

void cpu02() {
  if (terminal) {
    loadMemoryBarrier();
    assert value = 1 ;
  }
}

四. Java內存模型

​ 多線程情況下,數據的不一致的根本問題是: 緩存、指令重排序, 爲了解決重排序問題, 提出了JMM(Java Memory Model),通過合理的禁用緩存、禁止重排序, 以達到可見性、有序性的目的。

​ JMM定義了共享內存中多線程讀寫操作的行爲規範, 實現了共享變量存儲到內存以及從內存讀取共享變量的細節, 通過這些規範來保證指令執行的正確性, 解決了CPU多級緩存、處理器優化、指令重排序導致內存訪問問題, 確保併發場景下的可見性 。

​ JMM把底層問題抽象到JVM層面, **基於CPU層面提供的內存屏障指令、限制編譯器重排序**來解決併發問題 。

​ JMM抽象模型, 將內存分爲主內存、工作內存,

  • 主內存, 所有線程共享的, 包含: 實例對象、靜態字段、數組對象等存儲在堆內存中的變量。
  • 工作內存, 線程獨佔的, 線程對變量的所有操作都在工作內存中進行, 不能直接讀寫主內存中的變量, 線程之間共享變量值的傳遞基於主內存來完成 。

4.1 JMM解決的問題

JMM 是如何解決可見性、有序性問題 ?

​ JMM提供了一些禁用緩存、禁止重排序方式,來解決可見性、有序性問題。比如: volatile、synchronized

JMM 是如何解決順序一致性問題 ?

​ 爲了提高程序的執行性能, 編譯器、處理器會對指令做重排序, 重排序包含三個方面

  • 編譯器優化重排序
  • 指令並行重排序
  • 內存系統重排序

編譯器重排序, JMM禁止了特定類型編譯器重排序 。

處理器重排序, JMM要求編譯器生成指令時,需要插入內存屏障來禁止處理器重排序 。

4.2 JMM內存屏障分類

屏障類型 指令示例 說明
LoadLoad Barriers load01; LoadLoad; load02 確保load01數據加載優先於load02及所有後續加載指令
StoreStore Barriers store01; StoreStore; store02 確保store01數據存儲優先於store02及所有後續存儲指令
LoadStore Barriers load01; LoadStore; store02 確保load01數據加載優先於store02及後續存儲指令
StoreLoad Barriers store01; StoreLoad; load02 確保store01數據存儲優先於load02及所有後續加載指令,這條內存屏障指令是一個全能型屏障

五、happen before

​ 定義: 表示前一個操作的結果對於後續操作是可見的(它是一種表達多線程之間對於內存的可見性), 也就是如果一個操作結果對另一個操作可見, 那麼這兩個操作之間必須存在happens-before關係, 這兩個操作可以是同一個線程也可以是不同線程 。

Happen-before程序順序規則

  • as-if-serial 原則。線程中每個操作happens-before於該線程中的任意後續操作,單線程中代碼順序不管怎麼變, 執行結果都是不變的。
  • volatile原則。對volatile修飾的變量進行寫操作, 一定happens-before後續對這個變量的讀操作。
  • 傳遞性原則。如果 1 happens-before 2 , 2 happens-before 3 ; 那麼 1 happens-before 3
  • start原則。如果ThreadA執行ThreadB.start()操作, 那麼ThreadA的操作 happens-before ThreadB中任意操作。
  • join原則。如果ThreadA執行ThreadB.join()操作, 那麼ThreadB的任意操作 happens-before ThreadA操作。
  • 監視器鎖原則。對一個鎖的解鎖操作 一定 happens-before 之後對這個鎖的加鎖。

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