JSR 133 (Java Memory Model) FAQ

目錄

原文地址:
http://www.cs.umd.edu/~pugh/java/memoryModel/jsr-133-faq.html#whatismm
Jeremy Manson and Brian Goetz, February 2004

到底什麼是內存模型?

在一個多處理器系統中,處理器通常擁有多層緩存。這些多層緩存能夠高速獲取數據(因爲緩存中的數據與處理器更近,cpu—>寄存器—>緩存—>主存)和減少主存的訪問頻率以降低總線的繁忙程度(因爲很多操作可以通過緩存的數據自滿足,不需要在主存交互)來提高cpu整體的運行速度。

雖然緩存可以明顯的優化性能,但是緩存技術的引入也帶來了很多調整。比如:當兩個處理器同時訪問同一個內存地址時會發生什麼?在什麼情況下這兩個處理器會看到相同的值?緩存的引入會造成數據不一致問題?

在處理器層面,一個內存模型中需要定義必要的、充足的條件限制,這些條件保證了一個處理器寫入主存的數據對其他處理器的可見性,並且其他處理器的寫入操作也可以對當前處理器可見。

一些處理器設計所展現出的是一個強內存模型:所有處理器讀到的同一內存地址的數據都是一致的。還有些處理器設計展現出一個較弱的內存模型:需要一些內存柵欄去將當前處理器緩存的數據刷入主存,或則將當前處理器緩存置爲無效。通過這些內存柵欄去保證處理器讀寫的數據一致性。這些內存柵欄通常在lock\unlock指令發生時被被執行;另外,在語言層面這些內存柵欄是不會被程序員感知到的。

在強內存模型中,通常寫程序會比較簡單,因爲強內存模型中對內存屏障的使用需求較少。然而,有時在一些非常強一致內存模型中,使用內存柵欄也是非常必要的;通常這些內存柵欄的插入位置也是違反直覺的,令程序員摸不着頭腦。

最近處理器設計的趨勢是鼓勵弱內存模型的,因爲這種緩存一致性 的"弱化"在多處理器、大內存容量場景下帶來了更強的擴展性。

編譯器的重排序使得線程間可見性問題更加複雜。比如:編譯器可能爲了提高效率會延後執行一個程序中的寫操作,當然這種重排序只要不影響程序的整體語義就是完全允許的。那麼一旦編譯器將一個操作重排序,使得這個操作較後執行,那麼這個被重排序的操作執行之前, 其他線程是無法看到的。同樣的情況也會出現在緩存中。

此外,寫入主存操作也可以向前重排序,提前執行。那麼對於其他線程來說,會看見一個被提前執行了的寫入操作。所有這些允許編譯器、運行時、硬件重排序的靈活性,可以使機器代碼在一個最優的順序裏執行,以獲取最優性能。當然這些重排序優化,都是在當前內存模型的邊界範圍內。

以下代碼可以展示一個重排序的簡單示例:

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

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

當以上代碼被兩個線程併發執行,對y變量的讀取會看到y的值爲2,因爲"y=2"這行代碼在“x=1”之後,所以程序員就會認爲 讀取y的值發現是2,那麼x的值肯定是1。然而,並不一定。writer()中的代碼也許已經被重排序後執行了。實際上可能會是這樣的執行情況:線程A執行writer()方法,先執行了y=2(被排序到首行執行),接着另一個線程B開始執行reader()方法,讀取到y的值爲2,x的值爲1後,線程A纔開始執行x=1的代碼指令。最終線程B看到的就是 r12;r20。

JMM描述的是多線程代碼中什麼樣的行爲是合法的,線程間如何通過主存相互通信。JMM描述的是一個程序中變量之間的相互影響;以及一個真實的計算機中,變量在主存中或者寄存器中的讀寫訪問細節。並且JMM的正確實現可以不受各種各樣的硬件限制、可以不受 各種各樣的編譯器優化策略的限制,(跨平臺),實現統一的內存模型。

java中的volatile、synchronized、final關鍵字是爲了幫助程序員在java語言層面向編譯器提出併發性的需求。JMM規定了 volatile、synchronized的行爲,更重要的是確保一個被正確同步的java程序可以暢通無阻的在各種各樣的處理器上正確執行

這個問題的總結(非原文):
1.緩存提升cpu整體效率,它的兩個優勢:

  • 緩存比主存的使用速度快效率高;
  • 緩存通過部門替代主存的使用,減少總線阻塞;
    2.緩存雖然能明顯提高cpu整體新能,但會存在緩存一致性問題。
    3.cpu層面,JMM通常必須定義一些約束,以保證一個cpu對主存的寫操作,能夠被其他cpu可見。JMM有強內存模型、弱內存模型,這裏的強指的是在這個模型中,一個cpu(或一個線程)寫入內存的數據,對其他cpu(或其他線程)的可見性更強。強、弱內存模型都會使用內存屏障技術保障cache與主存的及時同步,但是強內存模型中使用的內存屏障比較少。弱內存模型會更易於接受、有更好的擴展性
    4.緩存、編譯器爲了提高效率,會對指令重排序。
    5.JMM描述了多線程代碼中什麼樣的行爲是合法的,線程之間如何通過主存相互通信;程序中變量之間的相互關係(happen-before),以及這些變量在主存或寄存器的存取底層細節。並且不因平臺的改變而受到影響

其他語言(例如C++ )有內存模型嗎?

除java外的其他大多數語言(C、C++)的設計都不是直接支持併發的。對於限制處理器、編譯器的重排序的這種必要保護,主要是依賴於第三方線程處理類庫(比如 pthreads)完成。

JSR133是什麼?

1997年之後,當時的JMM被發現了多個嚴重的缺陷。這些缺陷會產生一些令人困惑的行爲,比如final 字段可以改變自己的值,另外,這些缺陷還包括JMM會不知不覺的破壞編譯器通用的優化能力。

JMM在當時是一個很有雄心的嘗試,是計算機史上第一次出現的一個編程語言的定義中包含一個JMM的定義,並且這個JMM是一個能夠爲併發提供一致性語義的內存模型(跨平臺的)。不幸的是,定義一個兼備一致性保證和符合直覺(就是能夠被理解的符合描述的)的內存模型非常困難,困難程度超出了想象。JSR133中修復了早起JMM中的缺陷,定以了一個新的JMM。爲了修復這些缺陷,新的JMM中修改了原有final和volatile的語義。

完整的語義可以在這個鏈接中查看 http://www.cs.umd.edu/users/pugh/java/memoryModel ,但是需要提醒的是膽小者勿入。這些語義驚人的、清醒的展示了一些看似簡單的概念卻是十分複雜的,比如 synchronization。幸運的是,你不需要理解這些正式語義的細節描述。

JSR133的目標是創建一系列正式的語義,這些語義提供一個易於理解的框架,你可以清楚的明白 volatile, synchronized,final是如何生效的。

JSR133的目標包括:

  • 保留現存的安全保障(例如類型安全檢查),同時加強其他的部分。例如,保證 變量的值不能夠憑空出現;保證 每個可以被其他線程可見的變量以及值,必須是可以被其他線程合理替換的值;
  • "正確同步 "的語義應該儘可能簡單易懂、儘可能符合直接
  • "不正確同步"或者“未完整同步”這些術語的語義應該被明確定義出來,這樣可以儘可能的減少安全性被破壞的潛在風險。
  • 程序員應該能夠自信的解釋多線程程序中線程間如何通過內存交互;
  • 應該儘可能的去正確設計一個高性能、跨平臺的JVM實現。
  • 應該提供一個安全初始化的新保證(規定)。一旦一個對象正確的構建完成(正確構建完成,就是指當前對象的引用沒有在構造未完成時,被其他線程看到),那麼其他可以看到這個對象引用的線程,都可以看到這個對象的final 變量值(一般final變量在構造器中初始化),並且不需要額外的同步動作。
  • 應該儘可能減少對現有JDK代碼的使用影響。

什麼是重排序?

對程序中變量 的訪問也許會出現實際指令執行順序與程序員在代碼中編寫的順序不一致的情況。

編譯器爲了優化程序的執行效率,可以自由的改變指令的實際執行順序。
處理器也會在某些情況下出現重排序執行指令的現象。

數據在寄存器、處理器緩存、主存中的移動順序有時會與程序代碼中實際編寫順序不一致的情況。

例如,一個線程對變量 a 執行寫入操作,然後對變量 b執行寫入操作,並且 a ,b 這兩個變量的值沒有依賴關係,那麼編譯器可以自由的重排序這些指令,並且緩存可以不受約束的先把 b的值刷入主存、然後再把a的值刷入主存。
編譯器、JIT、緩存都可以重排序指令。

似乎應該在編譯器、runtime和硬件的共同協作下,展現出as-if-serial語義。這個as-if-serial語義是指:在單線程程序中,程序應該是按照程序員的代碼編寫順序執行,觀察不到重排序的影響。
然而,重排序會在沒有正確同步的多線程程序中展現。在這種爲正確同步的多線程程序中,一個線程可能會看到其他線程對程序的影響,可能會看到對變量的訪問順序發生了變化,比如讀操作會提前讀到它隨後的一個寫操作的數據。

大多數情況下,一個線程不會在意其他線程的工作**。但是一旦需要關注多個線程的活動時(併發編程),這個時候就需要考慮“正確同步”問題了**!

舊內存模型的缺陷是什麼?

舊JMM有很多缺陷。舊JMM很難讓人理解,所以人們總是不經意間違反JMM的要求。比如,在大多數情況下,舊JMM不允許重排序在JVM中發生。
正是因爲舊JMM的這些缺陷,促使JSR-133中定義了新的JMM.

有一個這樣的共識,如果使用了final修飾字段,那麼就不需要在線程間額外去做同步工作了,final可以保證被修飾字段的可見性。
但是在舊JMM中,這個合理的假設、合理的行爲並不是按照我們的想法工作的。在舊JMM中,final字段與普通字段並無區別,這意味着final字段與普通字段一樣,在多線程中必須考慮正確的同步。
總之,在舊JMM中,我們聲明一個final字段,並在當前對象的構造器中設置初始值的代碼中,在多線程下另外一個線程可能看到這個變量的未初始化的默認值,也可能看到這個變量在構造器中初始化的值。
這就以爲着,不可變對象比如String對象,可以出現值被改變的情況,這是多麼令人擔憂呀!

舊JMM允許 volatile變量的寫操作可以與 普通變量的讀操作、寫操作 不受約束任意重排序。這是與大多數程序員對volatile變量的理解大相徑庭的,因此很是讓人困惑

最終,舊JMM中程序員在總是無法正確判斷未正確同步的程序會出現什麼樣的情況。
JSR-133的一個目標就是讓人們注意這個事實。

“沒有正確同步”是什麼意思?

"沒有正確同步的代碼” 含義因人而異。當我們再JMM上下文中談到“未正確同步”的代碼,我們是指這樣的一些代碼:

1.線程A執行一個變量的寫操作;
2.線程B執行這個變量的讀操作;
3.這兩個操作沒有被同步處理,也就是說他們執行的先後順序是不確定的。

當出現了以上類似情況我們通常稱在這個變量上存在“數據競爭”一個存在數據競爭的程序都是沒有正確同步過的程序。

(synchronization)同步具體會做什麼?

同步有幾個方面,最爲人熟知的就是“互斥訪問"。同一時刻只有一個線程可以獲得一個monitor(可以理解爲對象鎖)**,所以在一個monitor上的同步塊只允許獲得這個monitor的線程進入同步塊,其他線程都無法獲得這個monitor,當然也無法進入同步塊,必須等到當前同步塊中的線程退出同步塊,並釋放這個monitor後纔可嘗試進入。

但是同步並不僅僅只是互斥執行,同步確保了一個線程在進入同步塊中(或進入同步塊之前)的寫操作會以一種可預期的方式對同樣在這個monitor上同步(也就是說多個線程在一個對象鎖上存在同步)的其他線程可見。
當一個線程在退出synchronized同步塊時,同時釋放對象鎖(monitor),同步機制會保證當前線程的緩存數據被刷入主內存,所以這個線程在退出同步塊之前的寫操作對其他線程可見。

在一個線程進入synchronized 塊之前,首先要嘗試獲取對象鎖(monitor)。這個線程獲取對象鎖成功,同時也會使得當前cpu緩存數據失效,那麼就會重新從系統主存中填充本地緩存。可以看到monitor對象鎖的釋放和獲取都會導致緩存數據刷入主存、緩存數據被重新從主存更新,那麼緩存數據都會被即使更新並同步主存,很明顯消除了可見性問題。

從緩存的角度來討論這個問題,似乎這些問題只會影響到多處理器的機器。然而,重排序的影響在單處理器機器中也很容易被發現。
It is not possible, for example, for the compiler to move your code before an acquire or after a release. When we say that acquires and releases act on caches, we are using shorthand for a number of possible effects.

這個新內存模型的語義實際上展現出來的是一系列內存操作(read 、write、lock、unlock)和其他多線程操作(start、join)的部分重排序。
這種“部分的重排序”也被稱爲 happen-before規則。

如果說A happen before B,那麼就保證A會在B之前執行,並且A操作對B可見。具體的happen-before規則如下:

* 同一個線程中的操作,都是按照代碼編寫的順序執行。
* 一個對象鎖的釋放 一定會發生在 這個鎖接下來被獲取的操作 之前
* 對一個volatile變量的寫操作 一定會發生在 隨後的這個volatile變量的讀操作 之前
* 對一個線程的start()方法的調用一定會發生在 這個線程被啓動後執行的任何動作 之前
* 一個線程中的所有操作 一定會發生在 其他線程成功的從這個線程的join方法返回 之前

倘若所有的內存操作都在monitor對象鎖釋放之前發生,並且monitor對象鎖的釋放都發生在對象鎖的獲取之前,那麼這就是說所有在退出阻塞塊之前(釋放鎖之前)的內存操作,都是對其他同樣獲取了這個monitor鎖並進入了阻塞塊的線程可見。

以下的代碼模式嘗試插入內存柵欄,但是毫無用處:
synchronized (new Object()) {}

這個實際是一個空操作,並且你的編譯器將會完全移除上邊的代碼。因爲編譯器知道不會有第二個線程在同一個對象鎖上阻塞(同步)。所以,這個同步毫無用處。

值得注意的是:
正確的使多個線程在某些代碼上同步,需要讓這線程都阻塞(同步)在一個相同的對象鎖上這樣纔可以正確的建立happen-before規則
對線程a(假設線程A在object X 上同步)可見的操作將會 對線程B可見(假設線程B在objectY上同步)?然後並不是!
對象鎖的獲取與對象鎖的釋放必須要匹配,才能保證正確的語義(比如,獲取、釋放 的是同一個對象鎖),否則就是“沒有正確同步”(或者稱爲存在data race)。

final字段在新的JMM中如何工作?

現在有這麼一個對象類,它的final字段在這個對象的構造器中完成初始化。假設這個對象被正確地構建(構造器正確完成執行),一旦這個對象被構造完成,不需要額外的任何同步手段,這個對象的所有final字段(假設這些final字段在構造器中初始化設值)將會對其他線程可見(也就是說其他對象會見到這些final字段在構造器中設置的初始值)。

另外,引用這些final字段的對象或數組都將會看到final字段的最新值。

什麼是“對象被正確地構建”?就是被構建對象的引用在構造器執行期間不會被其他線程訪問到,就是說構造器執行期間,其他線程無法拿到"this"!
換句話說,不要在一個對象正在被構建時,把這個對象的this引用暴露給其他線程;不要把this賦給一個static 變量;不要把this註冊爲一個listener等等。
總之,所有可能暴露this的動作都需要在構造器完成之後進行!

(See Safe Construction Techniques for examples.)

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,因爲f.x被final修飾過了。但是無法保證f.y的值一定是4,也可能是0.因爲它不是final的。

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

上邊這段代碼是一個this逃逸的示例,錯誤的初始化final字段。在當前線程構造對象期間,其他線程可以訪問this(通過global.obj),因此無法保證x的final語義(也就是說其他線程可能看到x值爲0)

通過正確初始化含final字段的對象,可以正確的看到final變量的值(這裏指的變量時基本類型)是非常好的。但是如果這個final變量是一個引用類型,並且你希望這個引用指向的最新的對象(或數組)及時可見怎麼辦?

你還是希望你的代碼能夠看到引用所指向的這個對象(或者數組)的最新值。如果你的字段是final字段,那麼這是能夠保證的。因此,當一個final指針指向一個數組,你不需要擔心線程能夠看到引用的最新值卻看不到引用所指向的數組的最新值。重複一下,這兒的“正確的”的意思是“對象構造方法結尾的最新的值”而不是“最新可用的值”。

現在,在講了如上的這段之後,如果在一個線程構造了一個不可變對象之後(對象僅包含final字段),你希望保證這個對象被其他線程正確的查看,你仍然需要使用同步才行。例如,沒有其他的方式可以保證不可變對象的引用將被第二個線程看到。使用final字段的程序應該仔細的調試,這需要深入而且仔細的理解併發在你的代碼中是如何被管理的。

如果你使用JNI來改變你的final字段,這方面的行爲是沒有定義的

volatile做了什麼?

volatile字段是被用來在線程間交流(通信)狀態的字段每個volatile的讀操作都可以看到任何其他線程對這個變量的上一次(最新的)寫操作的結果(1.可見性)

實際上,volatile字段的用處是杜絕讀取到緩存值或者發生關於這個字段的重排序操作。(2.禁止重排序,happen-before)
JMM禁止編譯器以及運行時環境將volatile變量分配在寄存器中(通信順序:cpu—>register—>cache—>main memroy)。volatile字段會在被寫入後,立即將緩存同步到主存中去,那麼這些變量因此會立即對其他線程可見。
與此類似,在一個volatile變量被讀取之前,本地處理器緩存會被置爲失效,那麼會直接從主存中讀數據。

對於對volatile變量的訪問,還有些其他的約束。

在舊JMM中, 對於volatile變量的各種訪問操作不能夠相互重排序。但是,volatile變量的訪問卻可以與非volatile變量的訪問重排序
(也就是說,…; volatile_a=n;volatile_b=x; …;可以被重排序爲:volatile_a=n;nonvolatile_c=f;y=nonvolatile_d; volatile_b=x; )。
這也就是說,volatile變量最爲線程間狀態通知的作用被破壞了。

在新的JMM中,對於volatile變量的各種訪問操作依舊不能夠相互重排序。然而與舊JMM不同之處在於,對於普通變量的訪問操作被重排序到volatile變量訪問操作之前的這種重排序被更嚴格的限制了。
對volatile變量的寫操作 類似於monitor對象鎖的釋放效果,對volatile變量的讀操作與monitor對象鎖的獲取有同樣的效果。
實際上,這些都是因爲新JMM在volatile訪問 與非volatile訪問的重排序問題上加入了更嚴格的限制。
這裏的示例展示了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.
    }
  }
}

假設線程A正在執行writer方法,線程B正在執行reader方法。在writer中對v的寫操作會使得對 x 的寫操作被刷入主存中,並且 v 在reader中的讀操作會直接從主存中訪問最新的數據。
那麼,如果reader執行時“v == true ”成立,那麼肯定能保證的是在 v 被賦值爲 true 的操作之前,“x=42”操作肯定被執行了。當然在舊JMM中就未必如此了。

如果 v 不是volatile變量,那麼編譯器就可以在 writer方法中進行重排序,那麼 reader方法中對x 變量的讀取就可能看到是0。

實際上,volatile的語義在JSR133中被充分地增強了,幾乎達到了synchronization的程度。對volatile變量的讀\寫等價於充當了一“半”同步的作用——可見性。

值得注意的是:爲了使用volatile的語義建立happen-before關係,要注意兩個線程必須是獲取同一個volatile變量。

並不是當threadA對一個volatile變量 f 執行了寫操作,緊接着threadB對volatile變量 g 執行了讀操作,
那麼 f 就對threadB可見, g.就都對threadA可見。讀寫必須要匹配以便保證正確的語義(也就是說讀寫必須要在同一個volatile 變量上才行)。

新JMM是否修復了 “雙重檢查鎖”問題?

聲名狼藉的 double-check locking 模式(也是一種多線程的單利發佈模式)是個取巧的設計,這個設計爲了支持懶加載同時想避免使用synchronized造成的開銷。
在 早期的JVM,synchronization 是非常慢的,因此程序員都排斥它。所以會有double-check locking 模式中避免使用synchronized的做法:
// 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;
}

看起來似乎非常聰明,儘可能的在公共代碼上縮小同步塊。但是,它是錯誤的、無效的。爲什麼?
instance = new Something();這行代碼是會被編譯器或者緩存重排序的,因此會導致它最終返回一個被部分構造的 Something對象(沒有完全初始化的)。結果就是我們獲取了一個未初始化成功的對象。

當然這個模式還存在其他問題,並且這個模式 得算法修正版本也是錯誤的。使用舊JMM,是無法修復這個問題的!
更多細節可參考 Double-checked locking: Clever, but broken;The “Double Checked Locking is broken” declaration。

很多人以爲使用volatile關鍵字將會修復這個問題。在JVM1.5版本以前,volatile關鍵字並無法保證修復這個問題。但是在新的JMM中,用volatile 修飾 instance 變量就會修復這個問題,因爲一個線程A初始化 Something 與另一個讀取並返回這個對象引用的線程B 之間建立的 happen-before關係。

然而使用需求持有者( Demand Holder)模式更好,它不僅線程安全而且非常容易理解。
(通過調用靜態工廠方法,去觸發一個static內部類的static變量初始化。static變量默認的JVM阻塞式初始化,並且static變量有且僅有一例)

private static class LazySomethingHolder {
  public static Something something = new Something();
}

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

這些跟程序員有什麼關係?

與你肯定有關係,併發bug是非常難於調試解決的。 這些併發bug通常不會再測試階段暴露,往往在系統高壓下暴露出來, 並且這些bug也難於重新並定位.
你最好花些額外的精力去確保你的程序是正確同步的;然而這些並不容易,但總是要比問題出現再去定位這個未正確同步的程序容易些。也就說預防總是要比定位解決容易些。

還有少量內容,本人認爲對於JMM理解並不重要,所以略去。

發佈了126 篇原創文章 · 獲贊 196 · 訪問量 26萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章