Java記憶體模型常見問題

hebtu666發表於2019-02-03

1.什麼是記憶體模型?

  • 在多核系統中,處理器一般有一層或者多層的快取,這些的快取通過加速資料訪問(因為資料距離處理器更近)和降低共享記憶體在匯流排上的通訊(因為本地快取能夠滿足許多記憶體操作)來提高CPU效能。快取能夠大大提升效能,但是它們也帶來了許多挑戰。例如,當兩個CPU同時檢查相同的記憶體地址時會發生什麼?在什麼樣的條件下它們會看到相同的值?

  • 在處理器層面上,記憶體模型定義了一個充要條件,“讓當前的處理器可以看到其他處理器寫入到記憶體的資料”以及“其他處理器可以看到當前處理器寫入到記憶體的資料”。有些處理器有很強的記憶體模型(strong memory model),能夠讓所有的處理器在任何時候任何指定的記憶體地址上都可以看到完全相同的值。而另外一些處理器則有較弱的記憶體模型(weaker memory model),在這種處理器中,必須使用記憶體屏障(一種特殊的指令)來重新整理本地處理器快取並使本地處理器快取無效,目的是為了讓當前處理器能夠看到其他處理器的寫操作或者讓其他處理器能看到當前處理器的寫操作。這些記憶體屏障通常在lock和unlock操作的時候完成。記憶體屏障在高階語言中對程式設計師是不可見的。

  • 在強記憶體模型下,有時候編寫程式可能會更容易,因為減少了對記憶體屏障的依賴。但是即使在一些最強的記憶體模型下,記憶體屏障仍然是必須的。設定記憶體屏障往往與我們的直覺並不一致。近來處理器設計的趨勢更傾向於弱的記憶體模型,因為弱記憶體模型削弱了快取一致性,所以在多處理器平臺和更大容量的記憶體下可以實現更好的可伸縮性

  • “一個執行緒的寫操作對其他執行緒可見”這個問題是因為編譯器對程式碼進行重排序導致的。例如,只要程式碼移動不會改變程式的語義,當編譯器認為程式中移動一個寫操作到後面會更有效的時候,編譯器就會對程式碼進行移動。如果編譯器推遲執行一個操作,其他執行緒可能在這個操作執行完之前都不會看到該操作的結果,這反映了快取的影響。

  • 此外,寫入記憶體的操作能夠被移動到程式裡更前的時候。在這種情況下,其他的執行緒在程式中可能看到一個比它實際發生更早的寫操作。所有的這些靈活性的設計是為了通過給編譯器,執行時或硬體靈活性使其能在最佳順序的情況下來執行操作。在記憶體模型的限定之內,我們能夠獲取到更高的效能。

  • Java記憶體模型描述了在多執行緒程式碼中哪些行為是合法的,以及執行緒如何通過記憶體進行互動。它描述了“程式中的變數“ 和 ”從記憶體或者暫存器獲取或儲存它們的底層細節”之間的關係。Java記憶體模型通過使用各種各樣的硬體和編譯器的優化來正確實現以上事情。

  • Java包含了幾個語言級別的關鍵字,包括:volatile, final以及synchronized,目的是為了幫助程式設計師向編譯器描述一個程式的併發需求。Java記憶體模型定義了volatile和synchronized的行為,更重要的是保證了同步的java程式在所有的處理器架構下面都能正確的執行。

2.其他語言,像C++,也有記憶體模型嗎?

  • 大部分其他的語言,像C和C++,都沒有被設計成直接支援多執行緒。這些語言對於發生在編譯器和處理器平臺架構的重排序行為的保護機制會嚴重的依賴於程式中所使用的執行緒庫(例如pthreads),編譯器,以及程式碼所執行的平臺所提供的保障。

3.Java記憶體模型FAQ(三)JSR133是什麼?

  • 從1997年以來,人們不斷髮現Java語言規範的17章定義的Java記憶體模型中的一些嚴重的缺陷。這些缺陷會導致一些使人迷惑的行為(例如final欄位會被觀察到值的改變)和破壞編譯器常見的優化能力。
  • Java記憶體模型是一個雄心勃勃的計劃,它是程式語言規範第一次嘗試合併一個能夠在各種處理器架構中為併發提供一致語義的記憶體模型。不過,定義一個既一致又直觀的記憶體模型遠比想象要更難。JSR133為Java語言定義了一個新的記憶體模型,它修復了早期記憶體模型中的缺陷。為了實現JSR133,final和volatile的語義需要重新定義。
  • 完整的語義見: Java記憶體模型
  • 但是正式的語義不是小心翼翼的,它是令人驚訝和清醒的,目的是讓人意識到一些看似簡單的概念(如同步)其實有多複雜。幸運的是,你不需要懂得這些正式語義的細節——JSR133的目的是建立一組正式語義,這些正式語義提供了volatile、synchronzied和final如何工作的直觀框架。

JSR 133的目標包含了

  1. 保留已經存在的安全保證(像型別安全)以及強化其他的安全保證。例如,變數值不能憑空建立:執行緒觀察到的每個變數的值必須是被其他執行緒合理的設定的。
  2. 正確同步的程式的語義應該儘量簡單和直觀。
  3. 應該定義未完成或者未正確同步的程式的語義,主要是為了把潛在的安全危害降到最低。
  4. 程式設計師應該能夠自信的推斷多執行緒程式如何同記憶體進行互動的。
  5. 能夠在現在許多流行的硬體架構中設計正確以及高效能的JVM實現。
  6. 應該能提供安全地初始化的保證。如果一個物件正確的構建了 (意思是它的引用沒有在構建的時候逸出,那麼所有能夠看到這個物件的引用的執行緒,在不進行同步的情況下,也將能看到在構造方法中中設定的final欄位的值。
  7. 應該儘量不影響現有的程式碼。

4.重排序意味著什麼?

  • 在很多情況下,訪問一個程式變數(物件例項欄位,類靜態欄位和陣列元素)可能會使用不同的順序執行,而不是程式語義所指定的順序執行。編譯器能夠自由的以優化的名義去改變指令順序。在特定的環境下,處理器可能會次序顛倒的執行指令。資料可能在暫存器,處理器緩衝區和主記憶體中以不同的次序移動,而不是按照程式指定的順序。這裡主要是因為CPU有快取且CPU命令是以流水線形式執行。
  • 例如,如果一個執行緒寫入值到欄位a,然後寫入值到欄位b,而且b的值不依賴於a的值,那麼,處理器就能夠自由的調整它們的執行順序,而且緩衝區能夠在a之前重新整理b的值到主記憶體。有許多潛在的重排序的來源,例如編譯器,JIT以及緩衝區。
  • 編譯器,執行時和硬體被期望一起協力建立好像是順序執行的語義的假象,這意味著在單執行緒的程式中,程式應該是不能夠觀察到重排序的影響的。但是,重排序在沒有正確同步了的多執行緒程式中開始起作用,在這些多執行緒程式中,一個執行緒能夠觀察到其他執行緒的影響,也可能檢測到其他執行緒將會以一種不同於程式語義所規定的執行順序來訪問變數。
  • 大部分情況下,一個執行緒不會關注其他執行緒正在做什麼,但是當它需要關注的時候,這時候就需要同步了。

5.舊的記憶體模型有什麼問題?

  • 舊的記憶體模型中有幾個嚴重的問題。這些問題很難理解,因此被廣泛的違背。例如,舊的儲存模型在許多情況下,不允許JVM發生各種重排序行為。舊的記憶體模型中讓人產生困惑的因素造就了JSR-133規範的誕生。

  • 例如,一個被廣泛認可的概念就是,如果使用final欄位,那麼就沒有必要在多個執行緒中使用同步來保證其他執行緒能夠看到這個欄位的值。儘管這是一個合理的假設和明顯的行為,也是我們所期待的結果。實際上,在舊的記憶體模型中,我們想讓程式正確執行起來卻是不行的。在舊的記憶體模型中,final欄位並沒有同其他欄位進行區別對待——這意味著同步是保證所有執行緒看到一個在構造方法中初始化的final欄位的唯一方法。結果——如果沒有正確同步的話,對一個執行緒來說,它可能看到一個欄位的預設值,然後在稍後的時間裡,又能夠看到構造方法中設定的值。這意味著,一些不可變的物件,例如String,能夠改變它們值——這實在很讓人鬱悶。這裡是因為String型別預設null,而初始化是我們自定義的值,也就是兩種不同的值,彷彿String是可變的一樣

  • 舊的記憶體模型允許volatile變數的寫操作和非volaitle變數的讀寫操作一起進行重排序,這和大多數的開發人員對於volatile變數的直觀感受是不一致的,因此會造成迷惑。

  • 最後,我們將看到的是,程式設計師對於程式沒有被正確同步的情況下將會發生什麼的直觀感受通常是錯誤的。JSR-133的目的之一就是要引起這方面的注意。

6.沒有正確同步的含義是什麼?

  • 沒有正確同步的程式碼對於不同的人來說可能會有不同的理解。在Java記憶體模型這個語義環境下,我們談到“沒有正確同步”,我們的意思是:

    1. 一個執行緒中有一個對變數的寫操作,
    2. 另外一個執行緒對同一個變數有讀操作,
    3. 而且寫操作和讀操作沒有通過同步來保證順序。
  • 當這些規則被違反的時候,我們就說在這個變數上有一個“資料競爭”(data race)。一個有資料競爭的程式就是一個沒有正確同步的程式。

7.同步會幹些什麼呢?

  • 同步有幾個方面的作用。最廣為人知的就是互斥 ——一次只有一個執行緒能夠獲得一個監視器,因此,在一個監視器上面同步意味著一旦一個執行緒進入到監視器保護的同步塊中,其他的執行緒都不能進入到同一個監視器保護的塊中間,除非第一個執行緒退出了同步塊。

  • 但是同步的含義比互斥更廣。同步保證了一個執行緒在同步塊之前或者在同步塊中的一個記憶體寫入操作以可預知的方式對其他有相同監視器的執行緒可見。當我們退出了同步塊,我們就釋放了這個監視器,這個監視器有重新整理緩衝區到主記憶體的效果,因此該執行緒的寫入操作能夠為其他執行緒所見。在我們進入一個同步塊之前,我們需要獲取監視器,監視器有使本地處理器快取失效的功能,因此變數會從主存重新載入,於是其它執行緒對共享變數的修改對當前執行緒來說就變得可見了。

  • 依據快取來討論同步,可能聽起來這些觀點僅僅會影響到多處理器的系統。但是,重排序效果能夠在單一處理器上面很容易見到。對編譯器來說,在獲取之前或者釋放之後移動你的程式碼是不可能的。當我們談到在緩衝區上面進行的獲取和釋放操作,我們使用了簡述的方式來描述大量可能的影響。

  • 新的記憶體模型語義在記憶體操作(讀取欄位,寫入欄位,鎖,解鎖)以及其他執行緒的操作(start 和 join)中建立了一個部分排序,在這些操作中,一些操作被稱為happen before其他操作。當一個操作在另外一個操作之前發生,第一個操作保證能夠排到前面並且對第二個操作可見。這些排序的規則如下:

    1. 執行緒中的每個操作happens before該執行緒中在程式順序上後續的每個操作。
    2. 解鎖一個監視器的操作happens before隨後對相同監視器進行鎖的操作。
    3. 對volatile欄位的寫操作happens before後續對相同volatile欄位的讀取操作。
    4. 執行緒上呼叫start()方法happens before這個執行緒啟動後的任何操作。
    5. 一個執行緒中所有的操作都happens before從這個執行緒join()方法成功返回的任何其他執行緒。
      (注意思是其他執行緒等待一個執行緒的join()方法完成,那麼,這個執行緒中的所有操作happens before其他執行緒中的所有操作)

補充:happens-before 偏序關係(離散數學)

偏序又分非嚴格偏序(自反偏序)與嚴格偏序(反自反偏序)

自反偏序 給定集合S,“≤”是S上的二元關係,若“≤”滿足:

  • 自反性:∀a∈S,有a≤a
  • 反對稱性:∀a,b∈S,a≤b且b≤a,則a=b
  • 傳遞性:∀a,b,c∈S,a≤b且b≤c,則a≤c

反自反偏序 給定集合S,“<”是S上的二元關係,若“<”滿足:

  • 反自反性:∀a∈S,有a≮a
  • 非對稱性:∀a,b∈S,a<b ⇒ b≮a
  • 傳遞性:∀a,b,c∈S,a<b且b<c,則a<c

注意:這裡的符號不是簡單的表示大小,與JMM中的happens-before不是表示時間的前後是一樣,理解偏序是關鍵的一步。

延申:

嚴格偏序與有向無環圖(dag)有直接的對應關係。一個集合上的嚴格偏序的關係圖就是一個有向無環圖。其傳遞閉包是它自己。

有向無環圖的判斷:

  • 深度優先遍歷
  • 拓撲排序
  • 求關鍵路徑的前提是無環,能不能判斷嚴格來說也可以

更加通俗具體的happens-before講解

注:《深入理解Java虛擬機器》中曾指出,happens-before規則誕生之前是使用八大具體的原子操作定義發生的順序

這意味著:任何記憶體操作,這個記憶體操作在退出一個同步塊前對一個執行緒是可見的,對任何執行緒在它進入一個被相同的監視器保護的同步塊後都是可見的,因為所有記憶體操作happens before釋放監視器以及釋放監視器happens before獲取監視器。 其他如下模式的實現被一些人用來強迫實現一個記憶體屏障的,不會生效:

synchronized (new Object()) {}

這段程式碼其實不會執行任何操作,你的編譯器會把它完全移除掉,因為編譯器知道沒有其他的執行緒會使用相同的監視器進行同步。要看到其他執行緒的結果,你必須為一個執行緒建立happens before關係。

  • 重點注意:對兩個執行緒來說,為了正確建立happens before關係而在相同監視器上面進行同步是非常重要的。以下觀點是錯誤的:當執行緒A在物件X上面同步的時候,所有東西對執行緒A可見,執行緒B在物件Y上面進行同步的時候,所有東西對執行緒B也是可見的。釋放監視器和獲取監視器必須匹配(也就是說要在相同的監視器上面完成這兩個操作),否則,程式碼就會存在“資料競爭”。

8.final欄位如何改變它們的值

  • 我們可以通過分析String類的實現具體細節來展示一個final變數是如何可以改變的。

  • String物件包含了三個欄位:一個character陣列,一個陣列的offset和一個length。實現String類的基本原理為:它不僅僅擁有character陣列,而且為了避免多餘的物件分配和拷貝,多個String和StringBuffer物件都會共享相同的character陣列。因此,String.substring()方法能夠通過改變length和offset,而共享原始的character陣列來建立一個新的String。對一個String來說,這些欄位都是final型的欄位。

    String s1 = "/usr/tmp"; String s2 = s1.substring(4); 字串s2的offset的值為4,length的值為4。但是,在舊的記憶體模型下,對其他執行緒來說,看到offset擁有預設的值0是可能的,而且 稍後一點時間會看到正確的值4,好像字串的值從“/usr”變成了“/tmp”一樣。

舊的Java記憶體模型允許這些行為,部分JVM已經展現出這樣的行為了。在新的Java記憶體模型裡面,這些是非法的。

9.在新的Java記憶體模型中,final欄位是如何工作的?

  • 一個物件的final欄位值是在它的構造方法裡面設定的。假設物件被正確的構造了,一旦物件被構造,在構造方法裡面設定給final欄位的的值在沒有同步的情況下對所有其他的執行緒都會可見。另外,引用這些final欄位的物件或陣列都將會看到final欄位的最新值。

  • 對一個物件來說,被正確的構造是什麼意思呢?簡單來說,它意味著這個正在構造的物件的引用在構造期間沒有被允許逸出。(參見安全構造技術)。換句話說,不要讓其他執行緒在其他地方能夠看見一個構造期間的物件引用。不要指派給一個靜態欄位,不要作為一個listener註冊給其他物件等等。這些操作應該在構造方法之後完成,而不是構造方法中來完成。

class FinalFieldExample {
  final int x;
  int y;
  static FinalFieldExample f;
  public FinalFieldExample() {
    x = 3;
y = 4;
  }

  static void writer() {
    f = new FinalFieldExample();
  }

  static void reader() {
    if (f != null) {
      int i = f.x;
      int j = f.y;
    }
  }
}

上面的類展示了final欄位應該如何使用。一個正在執行reader方法的執行緒保證看到f.x的值為3,因為它是final欄位。它不保證看到f.y的值為4,因為f.y不是final欄位。如果FinalFieldExample的構造方法像這樣:

public FinalFieldExample() { // bad!
  x = 3;
  y = 4;
  // bad construction - allowing this to escape
  global.obj = this;
}

那麼,從global.obj中讀取this的引用執行緒不會保證讀取到的x的值為3。

  • 能夠看到欄位的正確的構造值固然不錯,但是,如果欄位本身就是一個引用,那麼,你還是希望你的程式碼能夠看到引用所指向的這個物件(或者陣列)的最新值。如果你的欄位是final欄位,那麼這是能夠保證的。因此,當一個final指標指向一個陣列,你不需要擔心執行緒能夠看到引用的最新值卻看不到引用所指向的陣列的最新值。重複一下,這兒的“正確的”的意思是“物件構造方法結尾的最新的值”而不是“最新可用的值”。
  • 現在,在講了如上的這段之後,如果在一個執行緒構造了一個不可變物件之後(物件僅包含final欄位),你希望保證這個物件被其他執行緒正確的檢視,你仍然需要使用同步才行。例如,沒有其他的方式可以保證不可變物件的引用將被第二個執行緒看到。使用final欄位的程式應該仔細的除錯,這需要深入而且仔細的理解併發在你的程式碼中是如何被管理的。
  • 如果你使用JNI來改變你的final欄位,這方面的行為是沒有定義的。

10.volatile是幹什麼用的?

  • Volatile欄位是用於執行緒間通訊的特殊欄位。每次讀volatile欄位都會看到其它執行緒寫入該欄位的最新值;實際上,程式設計師之所以要定義volatile欄位是因為在某些情況下由於快取和重排序所看到的陳舊的變數值是不可接受的。編譯器和執行時禁止在暫存器裡面分配它們。它們還必須保證,在它們寫好之後,它們被從緩衝區重新整理到主存中,因此,它們立即能夠對其他執行緒可見。相同地,在讀取一個volatile欄位之前,緩衝區必須失效,因為值是存在於主存中而不是本地處理器緩衝區。在重排序訪問volatile變數的時候還有其他的限制。
  • 在舊的記憶體模型下,訪問volatile變數不能被重排序,但是,它們可能和訪問非volatile變數一起被重排序。這破壞了volatile欄位從一個執行緒到另外一個執行緒作為一個訊號條件的手段。
  • 在新的記憶體模型下,volatile變數仍然不能彼此重排序。和舊模型不同的時候,volatile周圍的普通欄位的也不再能夠隨便的重排序了。寫入一個volatile欄位和釋放監視器有相同的記憶體影響,而且讀取volatile欄位和獲取監視器也有相同的記憶體影響。事實上,因為新的記憶體模型在重排序volatile欄位訪問上面和其他欄位(volatile或者非volatile)訪問上面有了更嚴格的約束。當執行緒A寫入一個volatile欄位f的時候,如果執行緒B讀取f的話 ,那麼對執行緒A可見的任何東西都變得對執行緒B可見了。 如下例子展示了volatile欄位應該如何使用:
class VolatileExample {
  int x = 0;
  volatile boolean v = false;
  public void writer() {
    x = 42;
    v = true;
  }

  public void reader() {
    if (v == true) {
      //uses x - guaranteed to see 42.
    }
  }
}

假設一個執行緒叫做“writer”,另外一個執行緒叫做“reader”。對變數v的寫操作會等到變數x寫入到記憶體之後,然後讀執行緒就可以看見v的值。因此,如果reader執行緒看到了v的值為true,那麼,它也保證能夠看到在之前發生的寫入42這個操作。而這在舊的記憶體模型中卻未必是這樣的。如果v不是volatile變數,那麼,編譯器可以在writer執行緒中重排序寫入操作,那麼reader執行緒中的讀取x變數的操作可能會看到0。
  • 實際上,volatile的語義已經被加強了,已經快達到同步的級別了。為了可見性的原因,每次讀取和寫入一個volatile欄位已經像一個半同步操作了
  • 重點注意:對兩個執行緒來說,為了正確的設定happens-before關係,訪問相同的volatile變數是很重要的。以下的結論是不正確的:當執行緒A寫volatile欄位f的時候,執行緒A可見的所有東西,線上程B讀取volatile的欄位g之後,變得對執行緒B可見了。釋放操作和獲取操作必須匹配(也就是在同一個volatile欄位上面完成)。

11.新的記憶體模型是否修復了雙重鎖檢查問題?

  • 臭名昭著的雙重鎖檢查(也叫多執行緒單例模式)是一個騙人的把戲,它用來支援lazy初始化,同時避免過度使用同步。 在非常早的JVM中,同步非常慢,開發人員非常希望刪掉它。雙重鎖檢查程式碼如下:
// double-checked-locking - don't do this!
private static Something instance = null;

public Something getInstance() {
  if (instance == null) {
    synchronized (this) {
      if (instance == null)
        instance = new Something();
    }
  }
  return instance;
}
  • 這看起來好像非常聰明——在公用程式碼中避免了同步。這段程式碼只有一個問題 —— 它不能正常工作。為什麼呢?最明顯的原因是,初始化例項的寫入操作和例項欄位的寫入操作能夠被編譯器或者緩衝區重排序,重排序可能會導致返回部分構造的一些東西。就是我們讀取到了一個沒有初始化的物件。這段程式碼還有很多其他的錯誤,以及為什麼對這段程式碼的演算法修正是錯誤的。在舊的java記憶體模型下沒有辦法修復它。更多深入的資訊可參見:Double-checkedlocking: Clever but broken and The “DoubleChecked Locking is broken” declaration
  • 許多人認為使用volatile關鍵字能夠消除雙重鎖檢查模式的問題。在1.5的JVM之前,volatile並不能保證這段程式碼能夠正常工作(因環境而定)。在新的記憶體模型下,例項欄位使用volatile可以解決雙重鎖檢查的問題,因為在構造執行緒來初始化一些東西和讀取執行緒返回它的值之間有happens-before關係。
  • 然後,對於喜歡使用雙重鎖檢查的人來說(我們真的希望沒有人這樣做),仍然不是好訊息。雙重鎖檢查的重點是為了避免過度使用同步導致效能問題。從java1.0開始,不僅同步會有昂貴的效能開銷,而且在新的記憶體模型下,使用volatile的效能開銷也有所上升,幾乎達到了和同步一樣的效能開銷。因此,使用雙重鎖檢查來實現單例模式仍然不是一個好的選擇。(修訂—在大多數平臺下,volatile效能開銷還是比較低的)。 使用IODH來實現多執行緒模式下的單例會更易讀:
// 還可以使用列舉,靜態內部類實現
private static class LazySomethingHolder {
  public static Something something = new Something();
}

public static Something getInstance() {
  return LazySomethingHolder.something;
}

 

這段程式碼是正確的,因為初始化是由static欄位來保證的。如果一個欄位設定在static初始化中,對其他訪問這個類的執行緒來說是能正確的保證它的可見性的。

12.為什麼我需要關注Java記憶體模型?

  • 為什麼你需要關注java記憶體模型?併發程式的bug非常難找。它們經常不會在測試中發生,而是直到你的程式執行在高負荷的情況下才發生,非常難於重現和跟蹤。你需要花費更多的努力提前保證你的程式是正確同步的。這不容易,但是它比除錯一個沒有正確同步的程式要容易的多。

 

 

相關文章