JSR - 133 都解決了哪些問題?

程式設計師cxuan發表於2021-07-13

究竟什麼是記憶體模型?

在多處理系統中,每個 CPU 通常都包含一層或者多層記憶體快取,這樣設計的原因是為了加快資料訪問速度(因為資料會更靠近處理器) 並且能夠減少共享記憶體匯流排上的流量(因為可以滿足許多記憶體操作)來提高效能。記憶體快取能夠極大的提高效能。

但是同時,這種設計方式也帶來了許多挑戰。

比如,當兩個 CPU 同時對同一記憶體位置進行操作時會發生什麼?在什麼情況下這兩個 CPU 會看到同一個記憶體值?

現在,記憶體模型登場了!!!在處理器層面,記憶體模型明確定義了其他處理器的寫入是如何對當前處理器保持可見的,以及當前處理器寫入記憶體的值是如何使其他處理器可見的,這種特性被稱為可見性,這是官方定義的一種說法。

然而,可見性也分為強可見性弱可見性,強可見性說的是任何 CPU 都能夠看到指定記憶體位置具有相同的值;弱可見性說的是需要一種被稱為記憶體屏障的特殊指令來重新整理快取或者使本地處理器快取無效,才能看到其他 CPU 對指定記憶體位置寫入的值,寫入後的值就是記憶體值。這些特殊的記憶體屏障是被封裝之後的,我們不研究原始碼的話是不知道記憶體屏障這個概念的。

記憶體模型還規定了另外一種特性,這種特效能夠使編譯器對程式碼進行重新排序(其實重新排序不只是編譯器所具有的特性),這種特性被稱為有序性。如果兩行程式碼彼此沒有相關性,那麼編譯器是能夠改變這兩行程式碼的編譯順序的,只要程式碼不會改變程式的語義,那麼編譯器就會這樣做。

我們上面剛提到了,重新排序不只是編譯器所特有的功能,編譯器的這種重排序只是一種靜態重排序,其實在執行時或者硬體執行指令的過程中也會發生重排序,重排序是一種提高程式執行效率的一種方式。

比如下面這段程式碼

Class Reordering {
  int x = 0, y = 0;
  public void writer() {
    x = 1;
    y = 2;
  }

  public void reader() {
    int r1 = y;
    int r2 = x;
  }
}

當兩個執行緒並行執行上面這段程式碼時,可能會發生重排序現象,因為 x 、 y 是兩個互不相關的變數,所以當執行緒一執行到 writer 中時,發生重排序,y = 2 先被編譯,然後執行緒切換,執行 r1 的寫入,緊接著執行 r2 的寫入,注意此時 x 的值是 0 ,因為 x = 1 沒有編譯。這時候執行緒切換到 writer ,編譯 x = 1,所以最後的值為 r1 = 2,r2 = 0,這就是重排序可能導致的後果。

所以 Java 記憶體模型為我們帶來了什麼?

Java 記憶體模型描述了多執行緒中哪些行為是合法的,以及執行緒之間是如何通過記憶體進行互動的。Java 記憶體模型提供了兩種特性,即變數之間的可見性和有序性,這些特性是需要我們在日常開發中所注意到的點。Java 中也提供了一些關鍵字比如 volatile、final 和 synchronized 來幫助我們應對 Java 記憶體模型帶來的問題,同時 Java 記憶體模型也定義了 volatile 和 synchronized 的行為。

其他語言,比如 C++ 會有記憶體模型嗎?

其他語言比如 C 和 C++ 在設計時並未直接支援多執行緒,這些語言針對編譯器和硬體發生的重排序是依靠執行緒庫(比如 pthread )、所使用的編譯器以及執行程式碼的平臺提供的保證。

JSR - 133 是關於啥的?

在 1997 年,在此時 Java 版本中的記憶體模型中發現了幾個嚴重的缺陷,這個缺陷經常會出現詭異的問題,比如欄位的值經常會發生改變,並且非常容易削弱編譯器的優化能力。

所以,Java 提出了一項雄心勃勃的暢想:合併記憶體模型,這是程式語言規範第一次嘗試合併一個記憶體模型,這個模型能夠為跨各種架構的併發性提供一致的語義,但是實際操作起來要比暢想困難很多。

最終,JSR-133 為 Java 語言定義了一個新的記憶體模型,它修復了早期記憶體模型的缺陷。

所以,我們說的 JSR - 133 是關於記憶體模型的一種規範和定義。

JSR - 133 的設計目標主要包括:

  • 保留 Java 現有的安全性保證,比如型別安全,並加強其他安全性保證,比如執行緒觀察到的每個變數的值都必須是某個執行緒對變數進行修改之後的。
  • 程式的同步語義應該儘可能簡單和直觀。
  • 將多執行緒如何互動的細節交給程式設計師進行處理。
  • 在廣泛、流行的硬體架構上設計正確、高效能的 JVM 實現。
  • 應提供初始化安全的保證,如果一個物件被正確構造後,那麼所有看到物件構造的執行緒都能夠看到建構函式中設定其最終欄位的值,而不用進行任何的同步操作。
  • 對現有的程式碼影響要儘可能的小。

重排序是什麼?

在很多情況下,訪問程式變數,比如物件例項欄位、類靜態欄位和陣列元素的執行順序與程式設計師編寫的程式指定的執行順序不同。編譯器可以以優化的名義任意調整指令的執行順序。在這種情況下,資料可以按照不同於程式指定的順序在暫存器、處理器快取和記憶體之間移動。

有許多潛在的重新排序來源,例如編譯器、JIT(即時編譯)和快取。

重排序是硬體、編譯器一起製造出來的一種錯覺,在單執行緒程式中不會發生重排序的現象,重排序往往發生在未正確同步的多執行緒程式中。

舊的記憶體模型有什麼錯誤?

新記憶體模型的提出是為了彌補舊記憶體模型的不足,所以舊記憶體模型有哪些不足,我相信讀者也能大致猜到了。

首先,舊的記憶體模型不允許發生重排序。再一點,舊的記憶體模型沒有保證 final 的真正 不可變性,這是一個非常令人大跌眼睛的結論,舊的記憶體模型沒有把 final 和其他不用 final 修飾的欄位區別對待,這也就意味著,String 並非是真正不可變,這確實是一個非常嚴重的問題。

其次,舊的記憶體模型允許 volatile 寫入與非 volatile 讀取和寫入重新排序,這與大多數開發人員對 volatile 的直覺不一致,因此引起了混亂。

什麼是不正確同步?

當我們討論不正確同步的時候,我們指的是任何程式碼

  • 一個執行緒對一個變數執行寫操作,
  • 另一個執行緒讀取了相同的變數,
  • 並且讀寫之間並沒有正確的同步

當違反這些規則時,我們說在這個變數上發生了資料競爭現象。 具有資料競爭現象的程式是不正確同步的程式。

同步(synchronization)都做了哪些事情?

同步有幾個方面,最容易理解的是互斥,也就是說一次只有一個執行緒可以持有一個監視器(monitor),所以在 monitor 上的同步意味著一旦一個執行緒進入一個受 monitor 保護的同步程式碼塊,其他執行緒就不能進入受該 monitor 保護的塊直到第一個執行緒退出同步程式碼塊。

但是同步不僅僅只有互斥,它還有可見,同步能夠確保執行緒在進入同步程式碼塊之前和同步程式碼塊執行期間,執行緒寫入記憶體的值對在同一 monitor 上同步的其他執行緒可見。

在進入同步塊之前,會獲取 monitor ,它具有使本地處理器快取失效的效果,以便變數將從主記憶體中重新讀取。 在退出一個同步程式碼塊後,會釋放 monitor ,它具有將快取重新整理到主存的功能,以便其他執行緒可以看到該執行緒所寫入的值

新的記憶體模型語義在記憶體操作上面制定了一些特定的順序,這些記憶體操作包含(read、write、lock、unlock)和一些執行緒操作(start 、join),這些特定的順序保證了第一個動作在執行之前對第二個動作可見,這就是 happens-before 原則,這些特定的順序有

  • 執行緒中的每個操作都 happens - before 按照程式定義的執行緒操作之前。
  • Monitor 中的每個 unlock 操作都 happens-before 相同 monitor 的後續 lock 操作之前。
  • 對 volatile 欄位的寫入都 happens-before 在每次後續讀取同一 volatile 變數之前。
  • 對執行緒的 start() 呼叫都 happens-before 在已啟動執行緒的任何操作之前。
  • 執行緒中的所有操作都 happens-before 在任何其他執行緒從該執行緒上的 join() 成功返回之前。

需要注意非常重要的一點:兩個執行緒在同一個 monitor 之間的同步非常重要。並不是執行緒 A 在物件 X 上同步時可見的所有內容在物件 Y 上同步後對執行緒 B 可見。釋放和獲取必須進行匹配(即,在同一個 monitor 上執行)才能有正確的記憶體語義,否則就會發生資料競爭現象。

另外,關於 synchronized 在 Java 中的用法,你可以參考這篇文章 synchronized 的超多幹貨!

final 在新的 JMM 下是如何工作的?

通過上面的講述,你現在已經知道,final 在舊的 JMM 下是無法正常工作的,在舊的 JMM 下,final 的語義就和普通的欄位一樣,沒什麼其他區別,但是在新的 JMM 下,final 的這種記憶體語義發生了質的改變,下面我們就來探討一下 final 在新的 JMM 下是如何工作的。

物件的 final 欄位在建構函式中設定,一旦物件被正確的構造出來,那麼在建構函式中的 final 的值將對其他所有執行緒可見,無需進行同步操作。

什麼是正確的構造呢?

正確的構造意味著在構造的過程中不允許對正在構造的物件的引用發生 逃逸,也就是說,不要將正在構造的物件的引用放在另外一個執行緒能夠看到它的地方。下面是一個正確構造的示例:

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;
    }
  }
}

執行讀取器的執行緒一定會看到 f.x 的值 3,因為它是 final 的。 不能保證看到 y 的值 4,因為它不是 final 的。 如果 FinalFieldExample 的建構函式如下所示:

public FinalFieldExample() { 
  x = 3;
  y = 4;
  // 錯誤的構造,可能會發生逃逸
  global.obj = this;
}

這樣就不會保證讀取 x 的值一定是 3 了。

這也就說是,如果在一個執行緒構造了一個不可變物件(即一個只包含 final 欄位的物件)之後,你想要確保它被所有其他執行緒正確地看到,通常仍然需要正確的使用同步。

volatile 做了哪些事情?

我寫過一篇 volatile 的詳細用法和其原理的文章,你可以閱讀這篇文章 volatile 的用法和實現原理

新的記憶體模型修復了雙重檢查鎖的問題嗎?

也許我們大家都見過多執行緒單例模式雙重檢查鎖的寫法,這是一種支援延遲初始化同時避免同步開銷的技巧。

class DoubleCheckSync{
 	private static DoubleCheckSync instance = null;
  public DoubleCheckSync getInstance() {
    if (instance == null) {
      synchronized (this) {
        if (instance == null)
          instance = new DoubleCheckSync();
      }
    }
    return instance;
  } 
}

這樣的程式碼看起來在程式定義的順序上看起來很聰明,但是這段程式碼卻有一個致命的問題:它不起作用

??????

雙重檢查鎖不起作用?

是的!

為毛?

原因就是初始化例項的寫入和對例項欄位的寫入可以由編譯器或快取重新排序,看起來我們可能讀取了初始化了 instance 物件,但其實你可能只是讀取了一個未初始化的 instance 物件。

有很多小夥伴認為使用 volatile 能夠解決這個問題,但是在 1.5 之前的 JVM 中,volatile 不能保證。在新的記憶體模型下,使用 volatile 會修復雙重檢查鎖定的問題,因為這樣在構造執行緒初始化 DoubleCheckSync 和返回其值之間將存在 happens-before 關係讀取它的執行緒。

另外,我自己肝了六本 PDF,全網傳播超過10w+ ,微信搜尋「程式設計師cxuan」關注公眾號後,在後臺回覆 cxuan ,領取全部 PDF,這些 PDF 如下

免費領取六本 PDF

相關文章