深入理解Java內存模型


本文爲《Java併發編程的藝術》一書以及一些相關文章的學習筆記。因這一塊知識相互交叉,比較難理出一個清晰的結構,第一次接觸學習時會感覺很混亂。遂整理出此文。如有錯誤,歡迎指正,謝謝。

併發編程的關鍵問題

在併發編程中,需要處理兩個關鍵問題:線程之間如何通信、同步

在命令式編程中,有兩種通信機制:共享內存併發模型和消息傳遞併發模型。

  1. 共享內存
    線程之間共享程序的公共狀態,通過讀-寫內存中的公共狀態進行隱式通信。
  2. 消息傳遞
    線程之間沒有公共狀態,必須通過發送消息來顯示進行通信。

在消息傳遞併發模型中,因爲消息的發送肯定在消息的接收之前,所以同步是隱式進行的。但在共享內存併發模型中,同步是顯式的。程序員必須明確指定某個方法或代碼段需要在線程之間互斥執行。

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

Java內存模型

Java內存模型,即JMM(Java Memory Model),是一個抽象的概念,描述了一組規範,來控制Java線程之間的通信。JMM決定一個線程對共享變量的寫入何時對另一個線程可見——也就是定義了線程和主內存之間的抽象關係。

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

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

如果主內存中有一個變量x=0,線程AB各對其進行一次+1操作。正常情況下,線程A先拷貝x=0到本地內存,然後+1後再寫回主內存,此時x=1。B線程再從主內存讀取到已經更新的變量,拷貝x=1到本地內存,+1後寫回主內存,最終x=2。可以看到,線程A寫回主內存和線程B從主內存讀取實質上是線程A向線程B發送消息(看清楚了啊,我已經+1了,現在x是1不是0了,你別加錯了)。

上邊只是理想狀態下,現實中當兩個線程同時讀取到了主內存的x=0,+1後寫回主內存,最終的結果是x=1,這顯然是不對的。這也是我們學習JMM的意義,JMM會通過控制主內存與每個線程的本地內存之間的交互,來爲我們提供內存可見性保證。(內存可見性:一個線程對共享變量的修改,能夠及時被其他線程看到)

順序一致性模型

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

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

也就是說,在順序一致性模型中,有一個唯一的全局內存,同一時間只能由一個線程使用。並且每個線程必須按照程序的順序執行內存讀寫操作。

重排序

在計算機中,軟件技術和硬件技術有一個共同的目標:在不改變程序執行結果的前提下,儘可能提高並行度,來提高性能。編譯器和處理器常常會對指令進行重排序。但上文說到,編譯器和處理器都要參照順序一致性模型,所以需要as-if-serial語義,來保證程序的執行結果不會被改變。

as-if-serial語義

無論怎麼重排序,(單線程)程序的執行結果不會改變。爲了遵守as-if-serial語義,編譯器和處理器不會對存在數據依賴關係的操作做重排序。

數據依賴性

如果兩個操作訪問同一個變量,且這兩個操作中有一個爲寫操作,此時這兩個操作之間就存在數據依賴性。如表所示:

名稱 代碼示例 說明
寫後讀 a = 1; b = a; 寫一個變量之後,再讀該變量
寫後寫 a = 1; a = 2; 寫一個變量之後,再寫該變量
讀後寫 a = b; b = 1; 讀一個變量之後,再寫該變量

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

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

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

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

1       double pi = 3.14;
②       double r = 1.0;
③       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之前。

控制依賴性
if (flag) { 
    int i = a * a;
}

上邊這樣存在控制依賴關係的操作會影響指令序列的並行度。因此編譯器和處理器會採用“猜測執行”來應對。執行程序的線程可以提前讀取並計算a * a,然後把結果臨時保存到重排序緩衝中,到if判斷爲真時,在把結果寫入變量i中。可能的執行順序如下:

線程
temp = a * a
if flag = true
int i = temp
重排序對多線程的影響

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

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

假設有兩個線程A和B,A首先執行writer()方法,隨後B線程接着執行reader()方法。線程B在執行操作4時,能否看到線程A在操作1對共享變量a的寫入呢?

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

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

  • 當操作1和2重排序時
順序 線程A 線程B
1 flag = true;
2 if (flag)
3 int i = a * a;
4 a = 1;

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

  • 當操作3和4重排序時
順序 線程A 線程B
1 temp = a * a
2 a = 1;
3 flag = true;
4 if (flag)
5 int i = temp;

可以看到重排序後,線程B先計算出 a * a 的值並臨時存儲之後(上文中控制依賴性),線程A纔給變量a賦值,程序執行結果當然是錯誤的。

JMM存在的意義和作用

JMM的保證

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

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

針對這些情況,JMM向我們保證:如果程序是正確同步的,程序的執行將具有順序一致性——程序的執行結果與該程序在順序一致性內存模型中的執行結果相同。這裏的同步是廣義上的同步,包括對常用同步原語(synchronized、volatile和final)的正確使用。

內部手段: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讓程序員認爲程序是按照①>②>③的順序執行的,但實則不然。

**JMM實際上遵循的是順序一致性的基本原則,只要執行結果不變,隨你怎麼優化都行。**這樣一來,既給了編譯器和處理器最大的自由,又通過happens-before規則給了程序員最清晰簡單的保證。

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

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

除了happens-before規則,JMM還提供了volatile、synchronize、final、鎖這些機制來同步線程,保證程序在多線程環境下的正確執行,這部分內容繁多,在此不再贅述。

結語

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

參考資料

  1. 《Java併發編程的藝術》
  2. 全面理解Java內存模型(JMM)及volatile關鍵字——zejian_
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章