相關文章
Java并發編程(一)線程定義、狀態和屬性
Java并發編程(二)同步
Java并發編程(三)volatile域
前言
此前我們講到了線程、同步以及volatile關鍵字,對于Java的并發編程我們有必要了解下Java的內存模型,因為Java線程之間的通信對于工程師來言是完全透明的,內存可見性問題很容易使工程師們覺得困惑,這篇文章我們來主要的講下Java內存模型的相關概念。
1.共享內存和消息傳遞
線程之間的通信機制有兩種:共享內存和消息傳遞;在共享內存的并發模型里,線程之間共享程序的公共狀態,線程之間通過寫-讀內存中的公共狀態來隱式進行通信。在消息傳遞的并發模型里,線程之間沒有公共狀態,線程之間必須通過明確的發送消息來顯式進行通信。
同步是指程序用于控制不同線程之間操作發生相對順序的機制。在共享內存并發模型里,同步是顯式進行的。工程師必須顯式指定某個方法或某段代碼需要在線程之間互斥執行。在消息傳遞的并發模型里,由于消息的發送必須在消息的接收之前,因此同步是隱式進行的。
Java的并發采用的是共享內存模型,Java線程之間的通信總是隱式進行,整個通信過程對工程師完全透明。
2.Java內存模型的抽象
在java中,所有實例域、靜態域和數組元素存儲在堆內存中,堆內存在線程之間共享(本文使用“共享變量”這個術語代指實例域,靜態域和數組元素)。局部變量,方法定義參數和異常處理器參數不會在線程之間共享,它們不會有內存可見性問題,也不受內存模型的影響。
Java線程之間的通信由Java內存模型(本文簡稱為JMM)控制,JMM決定一個線程對共享變量的寫入何時對另一個線程可見。從抽象的角度來看,JMM定義了線程和主內存之間的抽象關系:線程之間的共享變量存儲在主內存中,每個線程都有一個私有的本地內存,本地內存中存儲了該線程以讀/寫共享變量的副本。本地內存是JMM的一個抽象概念,并不真實存在。它涵蓋了緩存,寫緩沖區,寄存器以及其他的硬件和編譯器優化。Java內存模型的抽象示意圖如下:
從上圖來看,線程A與線程B之間如要通信的話,必須要經歷下面2個步驟:
- 線程A把本地內存A中更新過的共享變量刷新到主內存中去。
- 線程B到主內存中去讀取線程A之前已更新過的共享變量。
3.從源代碼到指令序列的重排序
在執行程序時為了提高性能,編譯器和處理器常常會對指令做重排序。重排序分三種類型:
- 編譯器優化的重排序。編譯器在不改變單線程程序語義的前提下,可以重新安排語句的執行順序。
- 指令級并行的重排序。現代處理器采用了指令級并行技術來將多條指令重疊執行。如果不存在數據依賴性,處理器可以改變語句對應機器指令的執行順序。
- 內存系統的重排序。由于處理器使用緩存和讀/寫緩沖區,這使得加載和存儲操作看上去可能是在亂序執行。
從java源代碼到最終實際執行的指令序列,會分別經歷下面三種重排序:
上述的1屬于編譯器重排序,2和3屬于處理器重排序。這些重排序都可能會導致多線程程序出現內存可見性問題。對于編譯器,JMM的編譯器重排序規則會禁止特定類型的編譯器重排序(不是所有的編譯器重排序都要禁止)。對于處理器重排序,JMM的處理器重排序規則會要求java編譯器在生成指令序列時,插入特定類型的內存屏障指令,通過內存屏障指令來禁止特定類型的處理器重排序(不是所有的處理器重排序都要禁止)。
JMM屬于語言級的內存模型,它確保在不同的編譯器和不同的處理器平臺之上,通過禁止特定類型的編譯器重排序和處理器重排序,為程序員提供一致的內存可見性保證。
4.happens-before簡介
happens-before是JMM最核心的概念,對于Java工程師來說,理解happens-before是理解JMM的關鍵。
JMM的設計意圖
在設計JMM需要考慮兩個關鍵因素:
- 工程師對內存模型的使用,希望內存模型易于理解和編程,工程師希望基于一個強內存模型來編寫代碼。
- 編譯器和處理器對內存的實現,希望內存模型對他們的束縛越少越好,編譯器和處理器希望實現一個弱內存模型。
這兩個因素是互相矛盾的,所以JSR-133專家組設計時需要考慮到一個好的平衡點:一方面為工程師提供足夠強的內存可見性,另一方面要對編譯器和處理器的限制要盡量松些。
我們來舉了例子:
int a=10; //A
int b=20; //B
int c=a*b; //C
上面是一個簡單的乘法運算,并存在3個happens-before關系:
- A happens-before B
- B happens-before C
- A happens-before C
這三個happens-before關系中,2和3是必須的,但1是不必要的。因此,JMM把happens-before要求禁止的重排序分為兩類:
- 會改變程序執行結果的重排序。
- 不會改變程序執行結果的重排序。
JMM對這兩種不同性質的重排序,采取了不同的策略:
- 對于會改變程序執行結果的重排序,JMM要求編譯器和處理器必須禁止這種重排序。
- 對于不會改變程序執行結果的重排序,JMM要求編譯器和處理器不做要求,可以允許這種重排序。
happens-before的定義與規則
JSR-133使用happens-before的概念來指定兩個操作之間的執行順序,由于這兩個操作可以在一個線程內,也可以在不同的線程之間。因此,JMM可以通過happens-before關系向工程師提供跨線程的內存可見性保證。
happens-before規則如下:
- 程序順序規則:一個線程中的每個操作,happens- before 于該線程中的任意后續操作。
- 監視器鎖規則:對一個監視器鎖的解鎖,happens- before 于隨后對這個監視器鎖的加鎖。
- volatile變量規則:對一個volatile域的寫,happens- before 于任意后續對這個volatile域的讀。
- 傳遞性:如果A happens- before B,且B happens- before C,那么A happens- before
C。
5.順序一致性
順序一致性內存模型是一個理論參考模型,在設計的時候,處理器的內存模型和編程語言的內存模型都會以順序一致性內存模型為參考。
數據競爭與順序一致性
當程序未正確同步時,就會存在數據競爭。數據競爭指的是:在一個線程中寫一個變量,在另一個線程讀同一個變量,而且寫和讀沒有通過同步來排序。
當代碼中包含數據競爭時,程序的執行往往產生違反直覺的結果。如果一個多線程程序能正確同步,這個程序將是一個沒有數據競爭的程序。
JMM對正確同步的多線程程序的內存一致性做了如下保證:
如果程序是正確同步的,程序的執行將具有順序一致性(sequentially consistent),即程序的執行結果與該程序在順序一致性內存模型中的執行結果相同。這里的同步是指廣義上的同步,包括對常用同步原語(synchronized,volatile和final)的正確使用。
順序一致性模型
順序一致性內存模型是一個被計算機科學家理想化了的理論參考模型,它為程序員提供了極強的內存可見性保證。順序一致性內存模型有兩大特性:
- 一個線程中的所有操作必須按照程序的順序來執行。
- (不管程序是否同步)所有線程都只能看到一個單一的操作執行順序。在順序一致性內存模型中,每個操作都必須原子執行且立刻對所有線程可見。
順序一致性內存模型為程序員提供的視圖如下:
在概念上,順序一致性模型有一個單一的全局內存,這個內存通過一個左右擺動的開關可以連接到任意一個線程。同時,每一個線程必須按程序的順序來執行內存讀/寫操作。從上圖我們可以看出,在任意時間點最多只能有一個線程可以連接到內存。當多個線程并發執行時,圖中的開關裝置能把所有線程的所有內存讀/寫操作串行化。
順序一致性內存模型中的每個操作必須立即對任意線程可見,但是在JMM中就沒有這個保證。未同步程序在JMM中不但整體的執行順序是無序的,而且所有線程看到的操作執行順序也可能不一致。比如,在當前線程把寫過的數據緩存在本地內存中,且還沒有刷新到主內存之前,這個寫操作僅對當前線程可見;從其他線程的角度來觀察,會認為這個寫操作根本還沒有被當前線程執行。只有當前線程把本地內存中寫過的數據刷新到主內存之后,這個寫操作才能對其他線程可見。在這種情況下,當前線程和其它線程看到的操作執行順序將不一致。
同步程序的順序一致性
我們接下來看看正確同步的程序如何具有順序一致性。
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;
……
}
}
}
上面示例代碼中,假設A線程執行writer()方法后,B線程執行reader()方法。這是一個正確同步的多線程程序。根據JMM規范,該程序的執行結果將與該程序在順序一致性模型中的執行結果相同。下面是該程序在兩個內存模型中的執行時序對比圖:
在順序一致性模型中,所有操作完全按程序的順序串行執行。而在JMM中,臨界區內的代碼可以重排序(但JMM不允許臨界區內的代碼“逸出”到臨界區之外,那樣會破壞監視器的語義)。JMM會在退出監視器和進入監視器這兩個關鍵時間點做一些特別處理,使得線程在這兩個時間點具有與順序一致性模型相同的內存視圖。雖然線程A在臨界區內做了重排序,但由于監視器的互斥執行的特性,這里的線程B根本無法“觀察”到線程A在臨界區內的重排序。這種重排序既提高了執行效率,又沒有改變程序的執行結果。
從這里我們可以看到JMM在具體實現上的基本方針:在不改變(正確同步的)程序執行結果的前提下,盡可能的為編譯器和處理器的優化打開方便之門。
未同步程序的順序一致性
JMM不保證未同步程序的執行結果與該程序在順序一致性模型中的執行結果一致。因為未同步程序在順序一致性模型中執行時,整體上是無序的,其執行結果無法預知。保證未同步程序在兩個模型中的執行結果一致毫無意義。
和順序一致性模型一樣,未同步程序在JMM中的執行時,整體上也是無序的,其執行結果也無法預知。
同時,未同步程序在這兩個模型中的執行特性有下面幾個差異:
- 順序一致性模型保證單線程內的操作會按程序的順序執行,而JMM不保證單線程內的操作會按程序的順序執行(比如上面正確同步的多線程程序在臨界區內的重排序)。
- 順序一致性模型保證所有線程只能看到一致的操作執行順序,而JMM不保證所有線程能看到一致的操作執行順序。
- JMM不保證對64位的long型和double型變量的讀/寫操作具有原子性,而順序一致性模型保證對所有的內存讀/寫操作都具有原子性。
對于第三個差異:在一些32位的處理器上,如果要求對64位數據的讀/寫操作具有原子性,會有比較大的開銷。為了照顧這種處理器,java語言規范鼓勵但不強求JVM對64位的long型變量和double型變量的讀/寫具有原子性。當JVM在這種處理器上運行時,會把一個64位long/ double型變量的讀/寫操作拆分為兩個32位的讀/寫操作來執行。這兩個32位的讀/寫操作可能會被分配到不同的總線事務中執行,此時對這個64位變量的讀/寫將不具有原子性。
當單個內存操作不具有原子性,將可能會產生意想不到后果。請看下面示意圖:
如上圖所示,假設處理器A寫一個long型變量,同時處理器B要讀這個long型變量。處理器A中64位的寫操作被拆分為兩個32位的寫操作,且這兩個32位的寫操作被分配到不同的寫事務中執行。同時處理器B中64位的讀操作被拆分為兩個32位的讀操作,且這兩個32位的讀操作被分配到同一個的讀事務中執行。當處理器A和B按上圖的時序來執行時,處理器B將看到僅僅被處理器A“寫了一半“的無效值。
參考資料:
《Java并發編程的藝術》
深入理解Java內存模型(一)——基礎