天天看點

深入了解java記憶體模型 程曉明_深入了解Java記憶體模型

本文為關于Java記憶體模型,通過對《Java并發程式設計的藝術》一書以及一些相關文章的學習整理成的筆記。因這一塊知識互相交叉,對于初學者來說會比較難理出一個清晰的結構,第一次接觸學習時會感覺很混亂。遂整理出此文。如有錯誤,歡迎指正,謝謝。

1. 并發程式設計的關鍵問題

在并發程式設計中,需要處理兩個關鍵問題:

線程之間如何通信、同步

在指令式程式設計中,有兩種通信機制:共享記憶體并發模型和消息傳遞并發模型:

  1. 共享記憶體 線程之間共享程式的公共狀态,通過讀-寫記憶體中的公共狀态進行隐式通信。
  2. 消息傳遞 線程之間沒有公共狀态,必須通過發送消息來顯示進行通信。

在消息傳遞并發模型中,因為消息的發送肯定在消息的接收之前,是以同步是隐式進行的。但在共享記憶體并發模型中,同步是顯式的。程式員必須明确指定某個方法或代碼段需要線上程之間互斥執行。

Java的并發采用的是共享記憶體模型,如果不了解線程之間的通信機制,可能會遇到很多問題,這時候JMM的存在和對JMM的了解就非常重要了。

2. Java記憶體模型

Java記憶體模型,即JMM(Java Memory Model),是一個抽象的概念,描述了一組規範,來

控制Java線程之間的通信

。JMM決定一個線程對共享變量的寫入何時對另一個線程可見——也就是定義了線程和主記憶體之間的抽象關系。

線程之間的共享變量儲存在主記憶體中,每個線程都有一個私有的本地記憶體。線程不能直接操作主記憶體變量,必須通過本地記憶體來處理。線程首先将變量從主記憶體拷貝到自己的本地記憶體,然後對變量進行操作,再将變量寫回主記憶體。

注意:本地記憶體是抽象概念,并不實際存在,它涵蓋了緩存、寫緩沖區、寄存器以及其他的硬體和編譯器優化

深入了解java記憶體模型 程曉明_深入了解Java記憶體模型

如圖,如果主記憶體中有一個變量

x=0

,線程AB各對其進行一次

+1

操作。正常情況下,線程A先拷貝

x=0

到本地記憶體,然後

+1

,再寫回主記憶體,此時

x=1

。線程B再從主記憶體讀取到已經更新的變量,拷貝

x=1

到本地記憶體,

+1

後寫回主記憶體,最終

x=2

可以看到,線程A寫回主記憶體和線程B從主記憶體讀取實質上是

線程A向線程B發送消息

(“看清楚了啊,我修改過x的值了,已經+1了,現在x是1不是0了,你别弄錯了”)。

但這隻是理想狀态下,現實中當兩個線程恰好同時讀取到了主記憶體的

x=0

,同時

+1

後寫回主記憶體,最終的結果是

x=1

,這顯然是不對的。這正是我們學習JMM的意義,

JMM會通過控制主記憶體與每個線程的本地記憶體之間的互動,來為我們提供記憶體可見性保證

。(記憶體可見性:一個線程對共享變量的修改,能夠及時被其他線程看到)

3. 順序一緻性模型

正式進入JMM内容之前,還需要了解兩個概念,第一個是

順序一緻性模型

順序一緻性模型是一個理論參考模型,為程式員提供了極強的記憶體可見性保證。在這個理論模型下,(不管是單線程還是多線程)程式永遠按照程式員看到的順序依次執行。在設計的時候,處理器的記憶體模型和程式設計語言的記憶體模型都會以順序一緻性記憶體模型作為參照。它有兩大特征:

  1. 一個線程中的所有操作必須按照程式的順序來執行。
  2. (不管程式是否同步) 所有線程都隻能看到一個單一的操作執行順序。每個操作都必須原子執行且立刻對所有的線程可見。

也就是說,在順序一緻性模型中,有一個唯一的全局記憶體,同一時間隻能由一個線程使用。并且每個線程必須按照程式的順序執行記憶體讀寫操作。

4. 重排序

第二個需要了解的概念是

重排序

在計算機中,軟體技術和硬體技術有一個共同的目标:

在不改變程式執行結果的前提下

盡可能提高并行度

,來提高性能。編譯器和處理器常常會對指令進行重排序。比如說,我們代碼中寫了

A B C

三行代碼,當計算機執行的時候,有可能會重排序為

B C A

或者

C B A

來執行。如果重排序之後,程式執行結果被變了那當然是不行的,是以就有了

as-if-serial

語義,來保證程式的執行結果不會被改變。

4.1 as-if-serial語義

as-if-serial

語義的含義是:無論怎麼重排序,(單線程)程式的執行結果不會改變。

為了遵守 as-if-serial 語義,編譯器和處理器不會對存在

資料依賴關系

的操作做重排序。

4.1.1 資料依賴性

如果兩個操作通路同一個變量,且這兩個操作中有一個為寫操作,此時這兩個操作之間就存在

資料依賴性

。如表所示:

深入了解java記憶體模型 程曉明_深入了解Java記憶體模型

可以看到,這三種情況,如果重排序兩個操作的執行順序,程式的執行結果會發生改變。

編譯器和處理器重排序時不會改變存在資料依賴關系的兩個操作的執行順序。

  • 注意,這裡隻針對單個處理器、單個線程中執行的操作。不同處理器、線程之間的資料依賴性不被考慮。

如果操作間不存在資料依賴性,則會被重排序,例如下面計算圓的面積例子中,操作1和2被重排序後,不會改變執行結果:

1       double pi = 3.14;
2       double r = 1.0;
3       double area = pi * r * r;
           

1       double r = 1.0;
2       double pi = 3.14;
3       double area = pi * r * r;
           

結果相同

但操作3和1,2之間都存在資料依賴性,是以3不能被重排序到1或2之前。

4.1.2 控制依賴性

還需要了解的一個概念是:

控制依賴性

。看這樣一段代碼:

if (flag) { 
    int i = a * a;
}
           

像這樣存在控制依賴關系的操作會影響指令序列的并行度。是以編譯器和處理器會采用“猜測執行”來應對。執行程式的線程可以提前讀取并計算

a * a

,然後把結果臨時儲存到重排序緩沖中,到if判斷為真時,在把結果寫入變量i中。

重排序後可能的執行順序會是這樣:

深入了解java記憶體模型 程曉明_深入了解Java記憶體模型

4.2 重排序對多線程的影響

as-if-serial語義隻保證單線程下,重排序不會對程式執行結果造成改變。但如果在多線程環境下呢?

重排序會對多線程程式造成什麼影響,看下邊的例子。

// flag 用于标記變量a是否已經被寫入
 class ReorderExample {
 
        int a = 0;
        boolean flag = false;
        
        public void writer() {
                a = 1;      // 1
                flag = true;    // 2 
        } 
        
        Public void reader() {
                if (flag) {     // 3
                        int i = a * a;      // 4
                        ……
                }
        }
}
           

假設有兩個線程A和B,A首先執行

writer()

方法,随後B線程接着執行

reader()

方法。線程B在執行操作4時,能否看到線程A在操作1對共享變量a的寫入呢?

答案是:不一定能看到。為什麼呢?

操作1和2之間沒有資料依賴關系,可以被重排序(同樣3和4也可以)

  • 當操作1和2重排序時
深入了解java記憶體模型 程曉明_深入了解Java記憶體模型

可以看到,當線程B判斷flag為真,讀取變量a時,變量a還沒有被線程A寫入。程式執行結果是錯誤的。

  • 當操作3和4重排序時
深入了解java記憶體模型 程曉明_深入了解Java記憶體模型

可以看到重排序後,有可能線程B先計算出

a * a

的值并臨時存儲之後(控制依賴性),線程A才給變量a指派,程式執行結果當然是錯誤的。

5. JMM存在的作用和意義

這時候就輪到JMM出場了。

5.1 JMM的保證

在單線程的Java程式中,編譯器和處理器在重排序時已經做了順序一緻性的保證,程式總是按順序依次執行的。同樣也不存在記憶體可見性問題,因為我們上一個操作對變量的任何修改,之後的操作都能讀取到被修改的新值。

但在多線程的情況下就不一樣了。由于重排序的存在,一個線程觀察另外一個線程,所有的操作都是無序的。而由于工作記憶體的存在,也會存在記憶體可見性問題。

針對這些情況,JMM向我們保證:

如果程式是正确同步的

,程式的執行将具有順序一緻性——程式的執行結果與該程式在順序一緻性記憶體模型中的執行結果相同。這裡的同步是廣義上的同步,包括對常用同步原語(synchronized、volatile和final)的正确使用。

JMM通過内部手段和外部手段來達到目的。

5.1.1 内部手段:happens-before原則

happens-before是JMM最核心的概念。

在JMM中,如果一個操作執行的結果需要對另一個操作可見,那麼這兩個操作之間必須存在happens-before關系。注意,這裡所說既可以是單線程,

也可以是多線程

。(A happens-before B 也就是 A 發生于 B 之前)主要規則如下:

  • 程式順序規則:一個線程中的每個操作,必須發生于該線程中的任意後續操作(也就是單線程下程式按照代碼順序執行)。
  • 螢幕鎖規則:對一個鎖的解鎖,必須發生于随後對這個鎖的加鎖之前。
  • volatile變量規則:對一個volatile域的寫,發生于對該域的讀之前。(volatile:簡單來講,被volatile修飾的變量每次被讀取時都會強制從主記憶體中讀取,而對它的寫,會強制将新值重新整理到主記憶體。)
  • 線程啟動規則:線程的start()方法先于它的其他任一動作。(線上程A執行start()方法之前其他線程修改了共享變量,該修改線上程A執行start()方法時對線程A可見)
  • 線程終止規則:線程的所有操作先于線程的終結。
  • 對象終結規則:對象構造函數的執行,先于finalize()方法。
  • 傳遞性規則:如果A先于B,B先于C,那麼A一定先于C。
注:上述規則為JMM内部保證,即使在多線程環境下也不需要我們添加任何同步手段。
但兩個具有happens-before關系的操作,并不意味着前一個操作必須在後一個操作之前執行。

happens-before隻要求前一個操作(執行的結果)對後一個操作可見,且前一個操作按順序排在後一個操作之前。這是為什麼呢?接着往下看。

首先需要了解一點,JMM在設計時,需要考慮兩個方面。一是程式員希望記憶體模型更易于了解、易于程式設計(強記憶體模型)。另一方面,編譯器和處理器希望記憶體模型更自由,以進行更多的優化(弱記憶體模型)。JMM設計的目标就是找到這兩個方面的平衡點。

再來看之前計算圓面積的例子,

1       double pi = 3.14;
2       double r = 1.0;
3       double area = pi * r * r;
           

可以看到這裡存在3個happens-before關系:①>②,②>③,①>③。但其實 ②>③,①>③ 是必要的,而 ①>② 是不必要的。

JMM将happens-before規則要求禁止的重排序分為兩類:

  1. 會改變程式執行結果的重排序
  2. 不會改變程式執行結果的重排序

而JMM隻會要求編譯器和處理器禁止第一類重排序。JMM讓程式員認為程式是按照①>②>③的順序執行的,但實則不然。

5.1.2 外部手段:volatile、鎖、final域、

除了happens-before規則,JMM還提供了volatile、synchronize、final、鎖這些機制來同步線程,保證程式在多線程環境下的正确執行,這就是另一部分内容了,本文不再詳談。

5.2 JMM的意義

JMM實際上遵循的是順序一緻性的基本原則,隻要執行結果不變,随你怎麼重排序,怎麼優化都行

。這樣一來,既給了編譯器和處理器最大的自由,又通過happens-before規則給了程式員最清晰簡單的保證。

本質上來講,happens-before 與 as-if-serial 是一回事,他們存在的意義是為了在不改變程式執行結果的前提下,盡可能地提高程式執行的并行度。

6. 結語

OK,關于Java記憶體模型的分享到這裡就結束了。一句話總結:JMM就是一組規則,這組規則意在解決在并發程式設計可能出現的線程安全問題,并提供了内置解決方案(happen-before原則)及其外部可使用的同步手段(synchronized/volatile等),確定了程式執行在多線程環境中的應有的原子性,可視性及其有序性。

7. 參考資料

  1. 《Java并發程式設計的藝術》
  2. 全面了解Java記憶體模型(JMM)及volatile關鍵字——zejian_