【JVM盲點補漏系列】「併發編程的難題和挑戰」深入理解JMM及JVM內存模型知識體系

併發編程的難題和挑戰

在併發編程的技術領域中,對於我們而言的難題主要有兩個:

  1. 多線程之間如何進行通信和線程之間如何同步,通信是指線程之間以何種機制來交換信息。

多線程的線程通信機制

在命令式編程中,線程之間的通信機制有兩種:共享內存消息傳遞

  • 共享內存的方式,多線程之間共享公共的狀態(變量),那麼線程之間通過寫/讀內存中的公共狀態(變量)來隱式進行通信。在此模式下,同步實現是隱式進行的,由於消息的發送必須在消息的接收之前。
  • 消息傳遞的方式,多線程之間沒有公共的狀態(變量),那麼線程之間必須通過明確的傳遞狀態(變量)來顯式進行通信。在此模式下,同步實現是顯式進行的,必須顯式指定某個方法或某段代碼需要在線程之間互斥執行。

Java中的同步模式是什麼?

同步機制是指程序用於控制不同線程之間操作發生相對順序的機制。

Java生態中的併發編程模型採用的是共享內存模型,因此在Java線程之間的通信總是隱式進行, 整個通信過程對開發者是黑盒的,如果編寫多線程程序的開發者不深入理解這種隱式模式下的線程之間通信機制,就會會出現內存可見性和一致性的問題,我們統稱爲線程不安全問題。

存在內存可見問題

Java應用程序中, 所有實例域、靜態域和數組元素存儲在堆內存中, 堆內存在線程之間共享。會存在這內存可見性問題。

不存在內存可見問題

局部變量(Local variables) , 方法定義參數(java語言規範稱之爲formal method parameters) 和異常處理器參數(exception handler parameters) 不會在線程之間共享,它們不會有內存可見性問題,也不受內存模型的影響。

所以,我們在開發多線程場景下的程序的時候主要需要關注的就是內存可見問題變量,包含:實例域、靜態域和數組元素。

而爲了降低併發編程的難度和門檻,這些線程之間的數據同步和通信控制就交由一個特定的數據模型進行控制和管理,我們稱之爲Java內存模型(JMM)。

Java內存模型(JMM)

JMM決定在程序運行中,一個線程對共享變量的寫入何時對另一個線程可見。

JMM定義了線程和主內存之間的抽象關係

線程之間的共享變量存儲在主內存中,每個線程都有一個私有的本地內存 , 本地內存中存儲了該線程以讀/寫共享變量的副本。

本地內存是JMM的一個抽象概念, 並不真實存在。它涵蓋了緩存, 寫緩衝區, 寄存器以及其他的硬件和編譯器優化。

Java 內存模型的抽象示意圖如下:

由上圖可見,線程A與線程B之間如要數據通信,需要有以下兩個步驟:

  1. 線程A把本地內存A中更新過的共享變量刷新到主內存中去。
  2. 線程B到主內存中去讀取線程A之前已更新過的共享變量

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

如上圖所示,本地內存A和B有主內存中共享變量x的副本。假設初始時,這三個內存中的x值都爲0。

  1. 線程A在執行時,把更新後的x值,臨時存放在自己的本地內存A中。
  2. 線程A和線程B需要通信時,線程A首先會把自己本地內存中修改後的x值刷新到主內存中,此時主內存中的x值變了。
  3. 線程B到主內存中去讀取線程A更新後的x值,此時線程B的本地內存的x值也變了。

總結一下就是,這兩個步驟數據角度而言是線程A在向線程B發送消息,而且這個通信過程必須要經過主內存。JMM通過控制主內存與每個線程的本地內存之間的交互, 來爲程序提供內存可見性保證。

線程不安全因素之一(指令重排序問題)

基於上述所說的場景之下,JVM爲了在執行程序時爲了提高性能,編譯器和處理器常常會對指令做重排序。在此我們將按照重排序的執行時間前後分爲重排序分三種類型,如下圖所示。

  • 第一步屬於編譯器重排序:編譯器優化的重排序,編譯器在不改變單線程程序語義的前提下,可以重新安排語句的執行順序。

  • 第二步屬於處理器重排序:指令級並行的重排序,現代處理器採用了指令級並行技術(Instruction-Level Parallelism, ILP) 來將多條指令重疊執行。如果不存在數據依賴性, 處理器可以改變語句對應機器指令的執行順序。

  • 第三步屬於處理器重排序:內存系統的重排序。由於處理器使用緩存和讀/寫緩衝區,這使得加載和存儲操作看上去可能是在亂序執行,此處特別是針對與本地內存和共享主存之間的更新操作的一致性和可見性

這些重排序都可能會導致多線程程序出現內存可見性問題。

JMM解決重排序的線程不安全問題

解決編譯器級別重排序

  • JMM的編譯器重排序規則會禁止特定類型的編譯器重排序,此處注意:不是所有的編譯器重排序都要禁止

解決處理器級別重排序

  • JMM的處理器重排序規則會要求java編譯器在生成指令序列時, 插入特定類型的內存屏障(memory barriers, 也可以稱之爲memory fence)指令, 通過 內存屏障 指令來禁止特定類型的處理器重排序此處注意:不是所有的處理器重排序都要禁止)

總結一下,針對於JMM屬於語言級的內存模型, 它確保在不同的編譯器和不同的處理器平臺之上,通過禁止特定類型的編譯器重排序和處理器重排序,從而實現了內存的可見性以及一致性。

處理器重排序與內存屏障指令

上面說了其實是通過插入了內存屏障指令,從而控制住了對應的處理器級別的指令重排。

線程不安全因素之一(寫緩存處理模式)

  • 現代的處理器使用寫緩衝區來臨時保存向內存寫入的數據,寫緩衝區可以保證指令流水線持續運行,它可以避免由於處理器停頓下來等待向內存寫入數據而產生的延遲。

  • 通過以批處理的方式刷新寫緩衝區,以及合併寫緩衝區中對同一內存地址的多次寫,可以減少對內存總線的佔用。雖然寫緩衝區有這麼多好處,但每個處理器上的寫緩衝區,僅僅對它所在的處理器可見。

這個特性會對內存操作的執行順序產生重要的影響,處理器對內存的讀/寫操作的執行順序,不一定與內存實際發生的讀/寫操作順序一致。

  1. 處理器A處理器B可以同時把共享變量寫入自己的寫緩衝區(A1,B1)
  2. 從內存中讀取另一個共享變量(A2,B2)
  3. 最後才把自己寫緩存區中保存的髒數據刷新到內存中(A3,B3)。

從內存操作實際發生的順序來看,直到處理器A執行A3來刷新自己的寫緩存區,寫操作A1纔算真正執行了。雖然處理器A執行內存操作的順序爲:A1->A2,但內存操作實際發生的順序卻是:A2->A1。此時,處理器A的內存操作順序被重排序了(處理器B的情況和處理器A一樣)。

由於現代的處理器都會使用寫緩衝區,因此現代的處理器都會允許對寫-讀操作重排序。常見的處理器都允許Store-Load重排序,常見的處理器都不允許對存在數據依賴的操作做重排序。

內存屏障指令

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

內存屏障類型 指令示例 備註
LoadLoad Barries Load1\LoadLoad\Load2 確保Load1數據的裝載,之前於Load2及所有後續裝載指令的裝載
StoreStore Barries Store1\StoreStore\Store2 確保Store1數據對其他處理器可見(刷新到內存),之前於Store2及所有後續存儲指令的存儲。
LoadStore Barriers Load1\ LoadStore\Store2 確保Load1數據裝載, 之前於Store2及所有後續的存儲指令刷新到內存
StoreLoad Barriers Store1\StoreLoad\Load2 確保Storel數據對其他處理器變得可見(指刷新到內存),之前於Load2及所有後續裝載指令的裝載。StoreLoad Barriers會使該屏障之前的所有內存訪問指令(存儲和裝載指令)完成之後,才執行該屏障之後的內存訪問指令。

**StoreLoad Barriers是一個“全能型”的屏障, 它同時具有其他三個屏障的效果。現代的多處理器大都支持該屏障(其他類型的屏障不一定被所有處理器支持)。執行該屏障開銷會很昂貴,因爲當前處理器通常要把寫緩衝區中的數據全部刷新到內存中(buffer fully flush) **。

未完善待續!

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