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

究竟什麼是內存模型?

在多處理系統中,每個 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

Tags: