Java并發(fā)編程(四)Java內(nèi)存模型

相關(guān)文章
Java并發(fā)編程(一)線程定義、狀態(tài)和屬性
Java并發(fā)編程(二)同步
Java并發(fā)編程(三)volatile域

前言

此前我們講到了線程、同步以及volatile關(guān)鍵字,對于Java的并發(fā)編程我們有必要了解下Java的內(nèi)存模型,因為Java線程之間的通信對于工程師來言是完全透明的,內(nèi)存可見性問題很容易使工程師們覺得困惑,這篇文章我們來主要的講下Java內(nèi)存模型的相關(guān)概念。

1.共享內(nèi)存和消息傳遞

線程之間的通信機(jī)制有兩種:共享內(nèi)存和消息傳遞;在共享內(nèi)存的并發(fā)模型里,線程之間共享程序的公共狀態(tài),線程之間通過寫-讀內(nèi)存中的公共狀態(tài)來隱式進(jìn)行通信。在消息傳遞的并發(fā)模型里,線程之間沒有公共狀態(tài),線程之間必須通過明確的發(fā)送消息來顯式進(jìn)行通信。
同步是指程序用于控制不同線程之間操作發(fā)生相對順序的機(jī)制。在共享內(nèi)存并發(fā)模型里,同步是顯式進(jìn)行的。工程師必須顯式指定某個方法或某段代碼需要在線程之間互斥執(zhí)行。在消息傳遞的并發(fā)模型里,由于消息的發(fā)送必須在消息的接收之前,因此同步是隱式進(jìn)行的。
Java的并發(fā)采用的是共享內(nèi)存模型,Java線程之間的通信總是隱式進(jìn)行,整個通信過程對工程師完全透明。

2.Java內(nèi)存模型的抽象

在java中,所有實例域、靜態(tài)域和數(shù)組元素存儲在堆內(nèi)存中,堆內(nèi)存在線程之間共享(本文使用“共享變量”這個術(shù)語代指實例域,靜態(tài)域和數(shù)組元素)。局部變量,方法定義參數(shù)和異常處理器參數(shù)不會在線程之間共享,它們不會有內(nèi)存可見性問題,也不受內(nèi)存模型的影響。
Java線程之間的通信由Java內(nèi)存模型(本文簡稱為JMM)控制,JMM決定一個線程對共享變量的寫入何時對另一個線程可見。從抽象的角度來看,JMM定義了線程和主內(nèi)存之間的抽象關(guān)系:線程之間的共享變量存儲在主內(nèi)存中,每個線程都有一個私有的本地內(nèi)存,本地內(nèi)存中存儲了該線程以讀/寫共享變量的副本。本地內(nèi)存是JMM的一個抽象概念,并不真實存在。它涵蓋了緩存,寫緩沖區(qū),寄存器以及其他的硬件和編譯器優(yōu)化。Java內(nèi)存模型的抽象示意圖如下:

這里寫圖片描述

從上圖來看,線程A與線程B之間如要通信的話,必須要經(jīng)歷下面2個步驟:

  1. 線程A把本地內(nèi)存A中更新過的共享變量刷新到主內(nèi)存中去。
  2. 線程B到主內(nèi)存中去讀取線程A之前已更新過的共享變量。

3.從源代碼到指令序列的重排序

在執(zhí)行程序時為了提高性能,編譯器和處理器常常會對指令做重排序。重排序分三種類型:

  1. 編譯器優(yōu)化的重排序。編譯器在不改變單線程程序語義的前提下,可以重新安排語句的執(zhí)行順序。
  2. 指令級并行的重排序?,F(xiàn)代處理器采用了指令級并行技術(shù)來將多條指令重疊執(zhí)行。如果不存在數(shù)據(jù)依賴性,處理器可以改變語句對應(yīng)機(jī)器指令的執(zhí)行順序。
  3. 內(nèi)存系統(tǒng)的重排序。由于處理器使用緩存和讀/寫緩沖區(qū),這使得加載和存儲操作看上去可能是在亂序執(zhí)行。
    從java源代碼到最終實際執(zhí)行的指令序列,會分別經(jīng)歷下面三種重排序:
這里寫圖片描述

上述的1屬于編譯器重排序,2和3屬于處理器重排序。這些重排序都可能會導(dǎo)致多線程程序出現(xiàn)內(nèi)存可見性問題。對于編譯器,JMM的編譯器重排序規(guī)則會禁止特定類型的編譯器重排序(不是所有的編譯器重排序都要禁止)。對于處理器重排序,JMM的處理器重排序規(guī)則會要求java編譯器在生成指令序列時,插入特定類型的內(nèi)存屏障指令,通過內(nèi)存屏障指令來禁止特定類型的處理器重排序(不是所有的處理器重排序都要禁止)。
JMM屬于語言級的內(nèi)存模型,它確保在不同的編譯器和不同的處理器平臺之上,通過禁止特定類型的編譯器重排序和處理器重排序,為程序員提供一致的內(nèi)存可見性保證。

4.happens-before簡介

happens-before是JMM最核心的概念,對于Java工程師來說,理解happens-before是理解JMM的關(guān)鍵。

JMM的設(shè)計意圖

在設(shè)計JMM需要考慮兩個關(guān)鍵因素:

  1. 工程師對內(nèi)存模型的使用,希望內(nèi)存模型易于理解和編程,工程師希望基于一個強(qiáng)內(nèi)存模型來編寫代碼。
  2. 編譯器和處理器對內(nèi)存的實現(xiàn),希望內(nèi)存模型對他們的束縛越少越好,編譯器和處理器希望實現(xiàn)一個弱內(nèi)存模型。

這兩個因素是互相矛盾的,所以JSR-133專家組設(shè)計時需要考慮到一個好的平衡點:一方面為工程師提供足夠強(qiáng)的內(nèi)存可見性,另一方面要對編譯器和處理器的限制要盡量松些。

我們來舉了例子:

int a=10;   //A
int b=20;   //B
int c=a*b;  //C

上面是一個簡單的乘法運算,并存在3個happens-before關(guān)系:

  1. A happens-before B
  2. B happens-before C
  3. A happens-before C

這三個happens-before關(guān)系中,2和3是必須的,但1是不必要的。因此,JMM把happens-before要求禁止的重排序分為兩類:

  1. 會改變程序執(zhí)行結(jié)果的重排序。
  2. 不會改變程序執(zhí)行結(jié)果的重排序。

JMM對這兩種不同性質(zhì)的重排序,采取了不同的策略:

  1. 對于會改變程序執(zhí)行結(jié)果的重排序,JMM要求編譯器和處理器必須禁止這種重排序。
  2. 對于不會改變程序執(zhí)行結(jié)果的重排序,JMM要求編譯器和處理器不做要求,可以允許這種重排序。

happens-before的定義與規(guī)則

JSR-133使用happens-before的概念來指定兩個操作之間的執(zhí)行順序,由于這兩個操作可以在一個線程內(nèi),也可以在不同的線程之間。因此,JMM可以通過happens-before關(guān)系向工程師提供跨線程的內(nèi)存可見性保證。

happens-before規(guī)則如下:

  1. 程序順序規(guī)則:一個線程中的每個操作,happens- before 于該線程中的任意后續(xù)操作。
  2. 監(jiān)視器鎖規(guī)則:對一個監(jiān)視器鎖的解鎖,happens- before 于隨后對這個監(jiān)視器鎖的加鎖。
  3. volatile變量規(guī)則:對一個volatile域的寫,happens- before 于任意后續(xù)對這個volatile域的讀。
  4. 傳遞性:如果A happens- before B,且B happens- before C,那么A happens- before
    C。

5.順序一致性

順序一致性內(nèi)存模型是一個理論參考模型,在設(shè)計的時候,處理器的內(nèi)存模型和編程語言的內(nèi)存模型都會以順序一致性內(nèi)存模型為參考。

數(shù)據(jù)競爭與順序一致性

當(dāng)程序未正確同步時,就會存在數(shù)據(jù)競爭。數(shù)據(jù)競爭指的是:在一個線程中寫一個變量,在另一個線程讀同一個變量,而且寫和讀沒有通過同步來排序。
當(dāng)代碼中包含數(shù)據(jù)競爭時,程序的執(zhí)行往往產(chǎn)生違反直覺的結(jié)果。如果一個多線程程序能正確同步,這個程序?qū)⑹且粋€沒有數(shù)據(jù)競爭的程序。
JMM對正確同步的多線程程序的內(nèi)存一致性做了如下保證:
如果程序是正確同步的,程序的執(zhí)行將具有順序一致性(sequentially consistent),即程序的執(zhí)行結(jié)果與該程序在順序一致性內(nèi)存模型中的執(zhí)行結(jié)果相同。這里的同步是指廣義上的同步,包括對常用同步原語(synchronized,volatile和final)的正確使用。

順序一致性模型

順序一致性內(nèi)存模型是一個被計算機(jī)科學(xué)家理想化了的理論參考模型,它為程序員提供了極強(qiáng)的內(nèi)存可見性保證。順序一致性內(nèi)存模型有兩大特性:

  1. 一個線程中的所有操作必須按照程序的順序來執(zhí)行。
  2. (不管程序是否同步)所有線程都只能看到一個單一的操作執(zhí)行順序。在順序一致性內(nèi)存模型中,每個操作都必須原子執(zhí)行且立刻對所有線程可見。

順序一致性內(nèi)存模型為程序員提供的視圖如下:


這里寫圖片描述

在概念上,順序一致性模型有一個單一的全局內(nèi)存,這個內(nèi)存通過一個左右擺動的開關(guān)可以連接到任意一個線程。同時,每一個線程必須按程序的順序來執(zhí)行內(nèi)存讀/寫操作。從上圖我們可以看出,在任意時間點最多只能有一個線程可以連接到內(nèi)存。當(dāng)多個線程并發(fā)執(zhí)行時,圖中的開關(guān)裝置能把所有線程的所有內(nèi)存讀/寫操作串行化。

順序一致性內(nèi)存模型中的每個操作必須立即對任意線程可見,但是在JMM中就沒有這個保證。未同步程序在JMM中不但整體的執(zhí)行順序是無序的,而且所有線程看到的操作執(zhí)行順序也可能不一致。比如,在當(dāng)前線程把寫過的數(shù)據(jù)緩存在本地內(nèi)存中,且還沒有刷新到主內(nèi)存之前,這個寫操作僅對當(dāng)前線程可見;從其他線程的角度來觀察,會認(rèn)為這個寫操作根本還沒有被當(dāng)前線程執(zhí)行。只有當(dāng)前線程把本地內(nèi)存中寫過的數(shù)據(jù)刷新到主內(nèi)存之后,這個寫操作才能對其他線程可見。在這種情況下,當(dāng)前線程和其它線程看到的操作執(zhí)行順序?qū)⒉灰恢隆?/p>

同步程序的順序一致性

我們接下來看看正確同步的程序如何具有順序一致性。

class SynchronizedExample {
int a = 0;
boolean flag = false;

public synchronized void writer() {
    a = 1;
    flag = true;
}

public synchronized void reader() {
    if (flag) {
        int i = a;
        ……
    }
}
}

上面示例代碼中,假設(shè)A線程執(zhí)行writer()方法后,B線程執(zhí)行reader()方法。這是一個正確同步的多線程程序。根據(jù)JMM規(guī)范,該程序的執(zhí)行結(jié)果將與該程序在順序一致性模型中的執(zhí)行結(jié)果相同。下面是該程序在兩個內(nèi)存模型中的執(zhí)行時序?qū)Ρ葓D:

這里寫圖片描述

在順序一致性模型中,所有操作完全按程序的順序串行執(zhí)行。而在JMM中,臨界區(qū)內(nèi)的代碼可以重排序(但JMM不允許臨界區(qū)內(nèi)的代碼“逸出”到臨界區(qū)之外,那樣會破壞監(jiān)視器的語義)。JMM會在退出監(jiān)視器和進(jìn)入監(jiān)視器這兩個關(guān)鍵時間點做一些特別處理,使得線程在這兩個時間點具有與順序一致性模型相同的內(nèi)存視圖。雖然線程A在臨界區(qū)內(nèi)做了重排序,但由于監(jiān)視器的互斥執(zhí)行的特性,這里的線程B根本無法“觀察”到線程A在臨界區(qū)內(nèi)的重排序。這種重排序既提高了執(zhí)行效率,又沒有改變程序的執(zhí)行結(jié)果。
從這里我們可以看到JMM在具體實現(xiàn)上的基本方針:在不改變(正確同步的)程序執(zhí)行結(jié)果的前提下,盡可能的為編譯器和處理器的優(yōu)化打開方便之門。

未同步程序的順序一致性

JMM不保證未同步程序的執(zhí)行結(jié)果與該程序在順序一致性模型中的執(zhí)行結(jié)果一致。因為未同步程序在順序一致性模型中執(zhí)行時,整體上是無序的,其執(zhí)行結(jié)果無法預(yù)知。保證未同步程序在兩個模型中的執(zhí)行結(jié)果一致毫無意義。
和順序一致性模型一樣,未同步程序在JMM中的執(zhí)行時,整體上也是無序的,其執(zhí)行結(jié)果也無法預(yù)知。
同時,未同步程序在這兩個模型中的執(zhí)行特性有下面幾個差異:

  1. 順序一致性模型保證單線程內(nèi)的操作會按程序的順序執(zhí)行,而JMM不保證單線程內(nèi)的操作會按程序的順序執(zhí)行(比如上面正確同步的多線程程序在臨界區(qū)內(nèi)的重排序)。
  2. 順序一致性模型保證所有線程只能看到一致的操作執(zhí)行順序,而JMM不保證所有線程能看到一致的操作執(zhí)行順序。
  3. JMM不保證對64位的long型和double型變量的讀/寫操作具有原子性,而順序一致性模型保證對所有的內(nèi)存讀/寫操作都具有原子性。

對于第三個差異:在一些32位的處理器上,如果要求對64位數(shù)據(jù)的讀/寫操作具有原子性,會有比較大的開銷。為了照顧這種處理器,java語言規(guī)范鼓勵但不強(qiáng)求JVM對64位的long型變量和double型變量的讀/寫具有原子性。當(dāng)JVM在這種處理器上運行時,會把一個64位long/ double型變量的讀/寫操作拆分為兩個32位的讀/寫操作來執(zhí)行。這兩個32位的讀/寫操作可能會被分配到不同的總線事務(wù)中執(zhí)行,此時對這個64位變量的讀/寫將不具有原子性。
當(dāng)單個內(nèi)存操作不具有原子性,將可能會產(chǎn)生意想不到后果。請看下面示意圖:


這里寫圖片描述

如上圖所示,假設(shè)處理器A寫一個long型變量,同時處理器B要讀這個long型變量。處理器A中64位的寫操作被拆分為兩個32位的寫操作,且這兩個32位的寫操作被分配到不同的寫事務(wù)中執(zhí)行。同時處理器B中64位的讀操作被拆分為兩個32位的讀操作,且這兩個32位的讀操作被分配到同一個的讀事務(wù)中執(zhí)行。當(dāng)處理器A和B按上圖的時序來執(zhí)行時,處理器B將看到僅僅被處理器A“寫了一半“的無效值。

參考資料:
《Java并發(fā)編程的藝術(shù)》
深入理解Java內(nèi)存模型(一)——基礎(chǔ)

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容