Java內存模型的基礎
并發(fā)編程模型的兩個關鍵問題
在并發(fā)編程種,需要處理兩個關鍵問題:線程之間如何通信及線程之間如何同步(這里的線程是指并發(fā)執(zhí)行的活動實體)蕾久。通信是指線程之間以何種機制來交換信息靴寂。在命令式編程種蛾魄,線程之間的通信機制有兩種:共享內存和消息傳遞征峦。
在共享內存的并發(fā)模型里情屹,線程之間共享程序的公共狀態(tài)柏锄,通過寫-讀內存中的公共狀態(tài)進行隱式通信酿箭。在消息傳遞的并發(fā)模型里,線程之間沒有公共狀態(tài)趾娃,線程之間必須通過發(fā)送消息來顯式進行通信缭嫡。
同步是指程序中用于控制不同線程間操作發(fā)生相對順序的機制。在共享內存并發(fā)模型里抬闷,同步是閑時間進行的妇蛀。程序員必須顯式指定某個方法或某段代碼需要在線程之間互斥執(zhí)行。在消息傳遞的并發(fā)模型里笤成,由于消息的發(fā)送必須在消息的接受之前评架,因此同步是隱式進行的。
Java的并發(fā)采用的是共享內存模型炕泳,Java線程之間的通信總是隱式進行纵诞,整個通信過程對程序員完全透明。如果編寫多線程程序的Java程序員不理解隱式進行的線程之間通信的工作機制培遵,很可能會遇到各種奇怪的內存可見性問題挣磨。
Java內存模型的抽象結構
Java線程之間的通信由Java內存模型(簡稱為JMM)控制,JMM決定一個線程對共享變量的寫入何時對另一個線程可見荤懂。
Java內存模型的抽象示意圖如圖所示:
如果線程A與線程B之間要通信的話茁裙,必須要經歷下面2個步驟:
1)線程A把本地內存A中更新過的共享變量刷新到主內存中去。
2)線程B到主內存中去讀取線程A之前已更新過的共享變量节仿。
下面通過示意圖來說明這兩個步驟:
如圖所示晤锥,本地內存A和本地內存B由主內存中共享變量x的副本。假設初始時廊宪,這3個內存中的x值都為0矾瘾。線程A在執(zhí)行時,把更新后的x值(假設值為1)臨時存放在自己的本地內存A中箭启。當線程A和線程B需要通信時壕翩,線程A首先會把自己本地內存中修改后的x值刷新到主內存中,此時主內存中的x值變?yōu)榱?.隨后傅寡,線程B到主內存中讀取線程A更新后的x值放妈,此時線程B的本地內存的x值也變?yōu)榱?北救。
從整體來看,這兩個步驟實質上是線程A在向線程B發(fā)送消息芜抒,而且這個通信過程必須要經過主內存珍策。JMM通過控制主內存與每個線程的本地內存之間的交互,來為Java程序提供內存可見性保證宅倒。
從源代碼到指令序列的重排序
在執(zhí)行程序時攘宙,為了提高性能,編譯器和處理器常常會對指令做重排序拐迁。重排序分3種類型蹭劈。
編譯器優(yōu)化的重排序。編譯器在不改變單線程程序語義的前提下线召,可以重新安排語句的執(zhí)行順序链方。
指令級并行的重排序。現(xiàn)代處理器采用了指令級并行技術(Instruction-Level Parallelism灶搜,ILP)來講多條指令重疊執(zhí)行。如果不存在數據依賴性工窍,處理器可以改變語句對應機器指令的執(zhí)行順序割卖。
內存系統(tǒng)的重排序。由于處理器使用緩存和讀/寫緩沖區(qū)患雏,這使得加載和存儲操作看上去可能是在亂序執(zhí)行鹏溯。
從Java源代碼到最終實際執(zhí)行的指令序列,會分別經歷下面3種重排序淹仑,如圖所示:
上述的1屬于編譯器重排序丙挽,2和3屬于處理器重排序。
并發(fā)編程模型的分類
現(xiàn)在的處理器使用寫緩沖區(qū)臨時保存向內存寫入的數據匀借。寫緩沖區(qū)可以保證指令流水線持續(xù)運行颜阐,它可以避免由于處理器停頓下來等待向內存寫入數據而產生的延遲。同時吓肋,通過以批處理的方式刷新寫緩沖區(qū)凳怨,以及合并寫緩沖區(qū)中對同一內存地址的多次寫,減少對內存總線的占用是鬼。雖然寫緩存區(qū)有這么多好處肤舞,但每個處理器上的寫緩沖區(qū),僅僅對它所在的處理器可見均蜜。這個特性會對內存操作的執(zhí)行順序產生重要的影響:處理器對內存的讀/寫操作的執(zhí)行順序李剖,不一定與內存實際發(fā)生的讀/寫操作順序一致!為了具體說明囤耳,請看下面的圖:
假設處理器A和處理器B按程序的順序并執(zhí)行內存訪問篙顺,最終可能得到x=y=0的結果偶芍。
這里處理器A和處理器B可以同時把共享變量寫入自己的寫緩沖區(qū)(A1,B1)慰安,然后從內存中讀取另一個共享變量(A2腋寨,B2),最后才把自己寫緩存區(qū)中保存的臟數據刷新到內存中(A3化焕,B3)萄窜。當以這種時序執(zhí)行時,程序就可以得到x=y=0的結果撒桨。
從內存操作實際發(fā)生的順序來看查刻,直到處理器A執(zhí)行A3來刷新自己的寫緩存區(qū),寫操作A1才算真正執(zhí)行了凤类。雖然處理器A執(zhí)行內存操作的順序為A1->A2穗泵,但內存操作實際發(fā)生的順序卻是A2->A1。此時谜疤,處理器A的內存操作順序被重排序了(處理器B的情況和處理器A一樣這里就不贅述了)佃延。
注意
- sparc_TSO是指以TSO(Total Store Order)內存模型運行時sparc處理器的特性。
- 表中的X86包含X64及AMD64夷磕。
- 由于ARM處理器的內存模型與PowerPC處理器的內存模型非常類似履肃,本文將忽略它。
為了保證內存可見性坐桩,Java編譯器在生成指令序列的適當位置會插入內存屏障指令來禁止特定類型的處理器重排序尺棋。JMM把內存屏障指令來禁止指令分為四類,如表所示:
屏障類型 | 指令實例 | 說明 |
---|---|---|
LoadLoad Barriers | Load1;LoadLoad;Load2 | 確保Load1數據的裝載先于Load2及所有后續(xù)裝載指令的裝載 |
StoreStore Barriers | Store1;StoreStore;Store2 | 確保Store1數據對其他處理器可見(刷新到內存)先于Store2及所有后續(xù)存儲指令的存儲 |
LoadStore Barriers | Load1;LoadStroe;Store2 | 確保Load1數據裝載先于Store2及所有后續(xù)的存儲指令刷新到內存 |
StoreLoad Barriers | Store1;StoreLoad;Load2 | 確保Store1數據對其他處理器變得可見(指刷新到內存)先于Load2及所有后續(xù)裝載指令的裝載绵跷。StoreLoad Barriers會使該屏障之前的所有內存訪問指令(存儲和裝載指令)完成之后膘螟,才執(zhí)行該屏障之后的內存訪問指令。 |
StoreLoad Barriers是一個“全能型”的屏障,它同時具有其他3個屏障的效果。現(xiàn)代的多處理器大多支持該屏障(其他類型的屏障不一定被所有處理器支持)峭沦。執(zhí)行該屏障開銷會很昂貴忠藤,因為當前處理器通常要把寫緩沖區(qū)中的數據全部刷新到內存中(Buffer Fuller Flush)。
happens-before簡介
從JDK5開始,Java使用新的JSR-133內存模型(除非特別說明,本文針對的都是JSR-133內存模型)。JSR-133使用happens-before的概念來闡述操作之間的內存可見性嘿期。在JMM中,如果一個操作執(zhí)行的結果需要對另一個操作可見埋合,那么這兩個操作之間必須要存在happens-before關系备徐。這里提到的兩個操作既可以是在一個線程之內,也可以是在不同線程之間甚颂。
與程序員密切相關的happens-before規(guī)則如下蜜猾。
- 程序順序規(guī)則:一個線程中的每個操作秀菱,happens-before于該線程中的任意后續(xù)操作。
- 監(jiān)視器鎖規(guī)則:對一個鎖的解鎖蹭睡,happens-before于隨后對這個鎖的加鎖衍菱。
- volatile變量規(guī)則:對一個volatile域的寫,happens-before于任意后續(xù)對這個volatile域的讀肩豁。
- 傳遞性:如果A happens-before B脊串,且B happens-before C,那么A happens-before C清钥。
注意 兩個操作之間具有happens-before關系琼锋,并不意味著前一個操作必須要在后一個操作之前執(zhí)行!happens-before僅僅要求前一個操作(執(zhí)行的結果)對后一個操作可見祟昭,且前一個操作按順序排在第二個操作之前(this first is visible to and ordered before the second)缕坎。
happens-before與JMM的關系如圖所示:
如圖所示,一個happens-before規(guī)則對應于一個或多個編譯器和處理器重排序規(guī)則篡悟。對于Java程序員來說谜叹,happens-before規(guī)則簡單易懂,它避免Java程序員為了理解JMM提供的內存可見性保證而去學習復雜的重排序規(guī)則以及這些規(guī)則的具體實現(xiàn)方法搬葬。
重排序
重排序是指編譯器和處理器為了優(yōu)化程序性能而對指令序列進行重新排序的一種手段荷腊。
數據依賴性
如果兩個操作訪問同一個變量,且這兩個操作中有一個為寫操作踩萎,此時這兩個操作之間就存在數據依賴性。數據以來分為下列3種類型很钓。
名稱 | 代碼示例 | 說明 |
---|---|---|
寫后讀 | a=1; b=a; | 寫一個變量之后香府,再讀這個位置 |
寫后寫 | a=1; a=2; | 寫一個變量之后,再寫這個變量 |
讀后寫 | a=b; b=1; | 讀一個變量之后码倦,再寫這個變量 |
as-if-serial語義
as-if-serial語義的意思是:不管怎么重排序(編譯器和處理器為了提高并行度)企孩,(單線程)程序的執(zhí)行結果不能被改變。編譯器袁稽、runtime和處理器都必須遵守as-if-serial語義勿璃。
為了遵守as-if-serial語義,編譯器和處理器不會對存在數據依賴關系的操作做重排序推汽,因為這種重排序會改變執(zhí)行結果补疑。
順序一致性
數據競爭與順序一致性
Java內存模型規(guī)范對數據競爭的定義如下。
在一個線程中寫一個變量歹撒,
在另一個線程讀同一個變量莲组,
而且寫和讀沒有通過同步來排序。
如果一個多線程程序能夠正確同步暖夭,這個程序將是一個沒有數據競爭的程序锹杈。
如果程序是正確同步的撵孤,程序的執(zhí)行講具有順序一致性(Sequentially Consistent)——即程序的執(zhí)行結果與該程序在順序一致性內存模型中的執(zhí)行結果相同。這里的同步是指廣義上的同步竭望,包括對常用同步原語(synchronized邪码、volatile和final)的正確使用。
順序一致性內存模型
順序一致性內存模型有兩大特性咬清。
1)一個線程中的所有操作必須按照程序的順序來執(zhí)行闭专。
2)(不管程序是否同步)所有現(xiàn)場都只能看到一個單一的操作執(zhí)行順序。在順序一致性內存模型中枫振,每個操作都必須源自執(zhí)行且立刻對所有線程可見喻圃。
順序一致性模型中,所有操作完全按程序的順序串行執(zhí)行粪滤。
未同步程序的執(zhí)行特性
未同步程序在兩個模型中的執(zhí)行特性有如下幾個差異斧拍。
1)順序一致性模型保證單線程內的操作會按程序的順序執(zhí)行,而JMM不保證單線程內的操作會按程序的順序執(zhí)行
2)順序一致性模型保證所有線程只能看到一致的操作執(zhí)行順序杖小,而JMM不保證所有線程能看到一致的操作執(zhí)行順序肆汹。
3)JMM不保證對64位的long型和double型變量的寫操作具有原子性,而順序一致性模型保證對所有的內存讀/寫操作都具有原子操作
在計算機中予权,數據通過總線在處理器和內存之間傳遞昂勉。每次處理器和內存之間的數據傳遞都是通過一系列步驟來完成的,這一系列步驟稱之為總線事務( Bus Transaction)扫腺「谡眨總線事務包括讀事務(Read Transaction)和寫事務(Write Transaction)。讀事務從內存?zhèn)魉蛿祿教幚砥靼驶罚瑢懯聞諒奶幚砥鱾魉蛿祿絻却嬖苤粒總€事務會讀/寫內存中一個或多個物理上來連續(xù)的字。這里的關鍵是躁劣,總線會同步視圖并發(fā)使用總線的事務迫吐。在一個處理器執(zhí)行總線事務期間,總線會禁止其他的處理器和I/O設備執(zhí)行內存的讀/寫账忘。
在任意時間點志膀,最多只能有一個處理器可以訪問內存。這個特性確保了單個總線事務之中的內存讀/寫操作具有原子性鳖擒。
在一些32位的處理器上溉浙,如果要求對64位數據的寫操作具有原子性,會有比較大的開銷蒋荚。當JMM在這種處理器上運行時放航,可能會把一個64位long/double型變量的寫操作拆分為兩個32位的寫操作來執(zhí)行。這兩個32位的寫操作可能會被分配到不同的總線事務中執(zhí)行圆裕,此時對這個64位變量的寫操作將不具有原子性广鳍。