【Java】【并發編程】詳解Java內存模型
一、什么是JMM內存模型
Java內存模型即 Java Menory Model,簡稱JMM。JMM定義了Java虛擬機(JVM)在計算機內存(RAM)中的工作方法。JVM是整個計算機虛擬模型,所以JMM隸屬于JVM的。
Java內存模型定義了多線程之間共享變量的可見性以及如何在需要的時候對共享變量進行同步。原始的Java內存模型效率并不是很理想,因此在Java1.5版本對其進行了重構,現在的Java8仍沿用了1.5的版本。 模型如下:
二、Java內存模型與并發編程的關系
如果想要深入了解Java并發編程,就要先理解好Java內存模型。
并發編程的模型分類
總共分成兩類:
共享內存并發模型
消息傳遞并發模型
在并發編程中的關鍵問題
線程之間如何通信
線程之間如何同步
通信是指線程之間以何種機制來交換信息,在命令式編程中(編程主要分類:允許有副作用的命令式編程,不允許有副作用的函數式編程和不描述操作執行順序的聲明式編程),線程之間的通信機制有兩種:
共享內存:在共享內存的并發模型里,線程之間共享程序的公共狀態(共享變量),線程之間通過寫-讀內存中的公共狀態來隱式進行通信。
消息傳遞:在消息傳遞的并發模型里,線程之間沒有公共狀態,線程之間必須通過明確的發送消息來顯式進行通信
同步是指程序用于控制不同線程之間操作發生相對順序的機制,有兩種方式:
共享內存:同步是顯式進行的。程序員必須顯式指定某個方法或某段代碼需要在線程之間互斥執行。
消息傳遞:由于消息的發送必須在消息的接收之前,因此同步是隱式進行的。
Java里面的并發就是采用共享內存的并發模型,Java線程之間的通信總是隱式進行,整個通信過程對程序員是完全透明的(即你是看不見就發生了并發過程)。
Java的并發模型采用的是共享內存模型
Java線程之前的通信總是隱式進行的,整個通信過程對程序員完全透明。如果編寫多線程程序不理解隱式進行的線程之間通信的工作機制,很可能會遇到各種奇怪的內存可見性問題。
三、Java內存模型的抽象
在Java中的共享變量有:所有實例域和數組元素存儲在堆內存中,堆內存在線程之間共享(方法區也是線程共享,方法區保存類信息【類名稱,方法,字段屬性】,常量和靜態變量 )。局部變量、方法定義參數和異常處理器參數不會再線程之間共享,他們不會有內存可見性的問題,也不受內存模型的影響。
Java線程之間的通信由Java內存模型(JMM)控制。JMM決定了一個線程對共享變量的寫入時對另一個線程可見。從抽象的角度來看,JMM定義了線程與主內存之間的抽象關系,線程之間的共享變量存儲在主內存中,每一個線程都有一個自己私有的本地內存,本地內存中存儲了該變量以讀/寫共享變量的副本。本地內存是JMM的一個抽象概念,并不是真實存在。
JMM抽象模型圖:
從圖上看,如果線程A要和線程B通信的話,所經歷的步驟是:
線程A需要將本地內存A中的共享變量副本刷新到主內存中去
線程B去主內存中讀取線程A之前已經更新過的共享內存
步驟圖:
整體看,這兩個步驟實質上是線程A在向線程B發送消息,而這個通信過程必須經過主內存。JMM通過控制主內存與每個線程的本地內存之間的交互,來為我們提供內存的可見性保證。
重排序(帶來的并發問題)
在執行程序時為了提高性能,編譯器和處理器常常會對指令做重排序。重排序分三類:
編譯器優化的重排序(編譯器重排序)。編譯器在不改變單線程程序語義的前提下,可以重新安排語句的執行順序
指令級并行的重排序(處理器重排序)。現代處理器 采用了指令級并行技術將多條指令重疊執行。如果不存在數據依賴性,處理器可以改變語句對應及其指令的執行順序。
內存系統的重排序(處理器重排序)。由于處理器使用緩存和讀寫緩存,這使得加載和存儲操作看上去可能是在亂序執行。
上面的重排序可能會導致多線程程序出現內存可見性問題,對于編譯器,JMM的編譯器重排序規則則會禁止特定類型的編譯器重排序(并不是所有的編譯器重排序都要禁止),對于處理器重排序,JMM的處理器重排序規則會要求Java編譯器在生成指令序列時,插入特定類型的內存屏障指令,通過內存屏障來禁止特定類型的處理器重排序(不是所有的處理器重排序都要禁止)。
JMM屬于語言級的內存模型,它確保在不同的編譯器和不同處理器平臺上,通過禁止特定類型的編譯器重排序和處理器重排序,為我們提供一致的內存可見性保證。還有就是重排序(包括編譯器和處理器重排序)必須遵守as-if-serial規則(解決編譯器重排序問題),該語義也就是說不管怎么排序,單線程程序執行的結果都不能改變;讓人感覺單線程程序是按程序的順序執行的,如果多個線程操作之間沒有數據的依賴性則是允許重排序的,但是如果存在數據的依賴,則不會重排序的,這一點的問題是有保證的,所以現在的主要問題是,處理器使用緩存來讀寫數據,會導致數據讀取不一,帶來一種代碼指令被重排序的感覺,對于共享變量,很容易出現問題。
編譯器重排序
編譯器重排序的定義為:如果兩個操作它們之間沒有任何的依賴關系,也就是說A操作的結果和B操作的結果相互間沒有任何的影響,此時編譯器就可以對這兩個操作進行重排序,如果兩個操作共同操作一個共享變量,其中有一個操作為寫,那么它們兩是有數據依賴性的,從重排序會對最終執行結果產生影響,所以編譯器重排序(也包括處理器重排序)都會遵循數據依賴性,編譯器和處理器不會改變存在依賴關系的兩個操作的執行順序
處理器重排序
現在的處理器使用寫緩沖區來臨時保存向內存寫入的數據。寫緩存區可有保證指令流水想般持續運行,它可以避免由于處理器停頓下來等待向內存寫入數據而產生的延遲,同時通過批處理方式刷新寫緩存區,以及合并寫緩存區中對同一內存地址的多次寫,可以減少對內存總線的占用,。雖然寫緩存區有這么多的好處,但是每個處理器上的寫緩存區,僅僅對它所在的處理器可見,這個特性會對內存操作的執行順序產生重要的影響:處理器對內存的讀寫操作的執行順序,不一定與內存實際發生的讀寫操作順序是一致的。
舉個例子:a,b變量為共享變量,兩個處理器項目訪問共享變量。
處理器A
處理器B
執行的代碼:
a=1; //A1
x=b;//A2
執行的代碼:
b=2;//B1
y=a;//B2
初始狀態:a=b=0;
處理器允許執行后可能得到的結果:x=y=0;
具體原因如下圖:
出現的一個執行順序:
處理器A和處理器B同時把共享變量寫入在寫緩沖區中(A1,B1步驟),然后再從主內存中讀取另一個共享變量的值(A2,B2步驟),最后才把自己寫緩沖區中保存的臟數據刷新到主內存中(A3,B3步驟)。但最后執行下來就會得到一個結果:x=y=0。
從實際理想的發生的順序來看,正常執行順序是這樣的:
直到處理器A執行了A3步驟之后已經刷新自己的寫內存,寫操作A1才算真正被執行,然后接著讀A2。即發生的順序:A1->A2.
但內存操作實際發生這種可能:A2-->A1。此時處理器A的內存操作順序被重排序了。由于寫緩存區僅對自己的處理器可見,它會導致處理器執行內存操作的順序可能會與內存實際的操作執行順序不一樣。由于現在的處理器都會使用寫緩存區,因此都會允許對讀寫操作指令進行重排序。
內存屏障指令(解決處理器重排序問題)
為了解決上面說的重排序問題,需要保證內存的可見性,可以使用內存屏障來達到這個效果,通過在適當位置插入內存屏障指令來禁止特定類型的處理器重排序。JMM把內存屏障指令分為以下四類:
LoadLoad屏障
適用場景:Load1;LoadLoad;Load2
Load1和Load2代表兩條讀指令。在Load2讀取指令裝載之前,確保Load1讀指令已經裝載完畢。
StoreStore屏障
適用場景:Store1;StoreStore;Store2
Store1和Store2代表兩條寫指令。在Store2寫指令的存儲之前(刷新到內存中),確保Store1寫指令的數據對其他處理器可見(刷新到內存中)
LoadStore屏障
適用場景:Load1;LoadStore;Store2
在Store2寫指令的存儲之前(刷新到內存中),確保Load1讀指令已經裝載讀取完畢。
StoreLoad屏障
使用場景:Store1;StoreLoad;Load2
在Load2讀指令裝載之前,確保Store1寫指令的數據對其他處理器可見(刷新到內存中),開銷最大;該屏障會使之前的所有內存訪問指令(存儲和狀態指令)完成之后,才執行該屏障之后的內存訪問指令。
happens-before規則(定義兩個操作之間的執行順序,利用內存屏障指令提供內存可見性的保障)
JSR-133內存模型使用happens-before的概念來闡述操作之間的內存可見性。在JMM中,如果一個操作執行的結果需要對另外一個操作可見,那么這兩個操作之間必須要存在happens-before關系。這里提到的兩個操作可以是一個線程內的,也可以是不同線程之內的。
與程序員密切相關的happens-before規則(共有八大規則)如下:
注意,兩個操作之間具有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.
start()規則:如果線程A執行操作ThreadB.start()(啟動線程B),那么A線程的ThreadB.start()操作happens-before于線程B中的任意操作。
join()規則:如果線程A執行操作ThreadB.join()并成功返回,那么線程B中的任意操作happens-before于線程A從ThreadB.join()操作成功返回。
程序中斷規則:對線程interrupted()方法的調用先行于被中斷線程的代碼檢測到中斷時間的發生。
對象finalize規則:一個對象的初始化完成(構造函數執行結束)先行于發生它的finalize()方法的開始。
總結一下
解決重排序問題主要兩種規則:as-if-serial(解決編譯器重排序)和happens-before(解決處理器重排序)
As-if-serial規則保證單線程程序的執行結果不被改變,happens-before規則保證正確同步的多線程程序的執行結果不會被改變
as-if-serial規則給我們一種感覺:單線程程序是按照程序順序來執行的,而happens-before規則是正確同步的多線程程序是按照happens-before指定的順序來執行的,
as-if-serial和happens-before規則都是為了在不改變程序執行結果的前提下,盡可能的提高程序的執行并行度。
四、JMM在實際開發中遇到的問題及解決方法
當對象和變量存儲到計算機的各個內存區域時,必然會面臨一些問題,其中最主要有兩個問題:
共享對象對各個線程的可見性(使用volatile關鍵字解決)
共享對象的競爭現象(使用同步鎖解決)
我們在實際的多線程開發中需要從原子性、可見性、有序性這三方面進行考慮,有序性的話JMM已經幫我們基本優化了,重點看一下原子性和可見性
共享對象的可見性(鎖以synchronized為例)
當多個線程同時操作一個共享對象時,如果沒有合理的使用volatile和synchronized關鍵字,一個線程對共享對象的更新有可能導致其他線程不可見。 我們的共享對象存儲在主內存中,一個CPU的線程去讀取主內存的數據到CPU緩存中,然后對共享內存做了更改,但CPU緩存中的更改后的對象還沒有刷新到主內存中,此時其他線程對共享對象的更改是不可見的,最終每個線程都會拷貝共享變量位于不同的CPU緩存中。要解決這個可見性問題,我們可以使用Java volatile關鍵字,volatile關鍵字可以保證共享變量會直接從主內存中讀取,而對共享變量也會直接寫到主內存中去。volatile原理是基于CPU內存屏障實現的。
競爭現象(鎖以sybchronized為例)
如果多個線程共享一個對象,它們同時修改這個共享對象,這就產生了競爭關系。例如線程A和線程B共享一個對象,線程A從主內存中讀取共享對象到CPU緩存中,同時,線程B也同時讀取共享對象到它的CPU緩存中,線程A和B同時對該共享變量做相同的操作(如同時進行+1操作,對象初始值為1),不管線程A或B有沒有刷新到主內存中,并行執行,結果都會出錯(相加了兩次,結果卻為2)。要解決這種競爭關系問題,我們可以使用java中的synchronized代碼塊,synchronized代碼塊可以保證同一時刻只有一個線程進入到代碼競爭區,synchronized代碼塊也能保證代碼塊中所有變量都是從主內存中讀,當線程退出的時候,對所有變量的更新都將會flush到之內存中,不管這些變量是不是volatile類型的。
volatile和鎖(synchronized,Lock)
對于一個volatile變量的單個讀/寫操作,與對一個普通變量的讀寫操作使用同一個鎖來同步,它們之間的執行效果時相同的(因為它們都是從主內存中讀寫變量的)。
相同點:
可見性。鎖的happens-before規則是保證釋放鎖和獲取鎖的兩個線程之間的內存可見性,這跟volatile的可見性是一樣的:對于一個volatile變量的讀,總能看到(任意線程)對這個volatile變量最后的寫入。
原子性。鎖的語義(同步)就決定了臨界區代碼的執行具有原子性,跟volatile一樣,對于volatile變量的讀取也具有原子性,但是對于多個volatile操作(類似于volatile++這種復合操作)就不具備原子性。
(原子性就是在執行的過程中不會被中斷,一次執行的,不會被其他線程干擾)
不同點:
volatile:讀寫內存定義
當讀一個volatile變量的時候,JMM會把線程對應的本地內存置為無效,線程將從主內存中讀取共享變量。
當寫一個volatile變量的時候,JMM會把該線程對應的本地內存中的共享變量刷新到主內存中。
volatile語義的實現:
為了實現volatile的內存語義,編譯器在生成字節碼時,會在指令序列中插入內存屏障來禁止特定類型的處理器重排序。
基于保守策略的JMM內存屏障插入策略:
在每個volatile寫操作的前面插入一個StoreStore屏障
在每個volatile寫操作的后面插入一個StoreLoad屏障
在每個volatile讀操作的后面插入一個LoadLoad屏障
在每個volatile讀操作的后面插入一個LoadStore屏障
因為確保寫操作內存可見,所以前面的寫和后面的讀寫都不能重排序(按照原來的順序來執行)
因為確保別的線程讀正確,所以后面的讀寫指令都不能重排序(必須確保volatile讀完后才操作,為了可見性)
具體插入內存屏障后生成的指令示意圖如下:
這種volatile讀寫操作的內存屏障是非常保守的,在實際執行過程中,只要不改變volatile讀寫的定義,編譯器可以根據具體情況省略不必要的屏障。
鎖:鎖釋放和鎖獲取的內存定義
當線程釋放鎖時,JMM會把該線程對應的本地內存中的共享變量刷新到主內存中。
當線程獲取鎖時,JMM會把該線程對應的本地內存置為無效,從而使得被監視器保護的臨界區代碼必須從主內存中讀取共享變量。
鎖內存定義的實現:
鎖有很多,包括ReentrantLock,Synchronized,公平鎖,非公平鎖,AQS等等,現在借助ReentrantLock來說明一下鎖內存定義的實現。
首先是concurrent包的實現:
如果我們仔細分析concurrent包的源代碼實現,會發現一個通用化的實現模式:
首先聲明共享變量為volatile
然后使用CAS的原子條件更新來實現線程之同步
同時配合以volatile的讀寫和CAS所具有的volatile讀和寫的內存語義來實現線程之間的通信
AQS
非阻塞數據結構和原子變量類(java.util.concurrent.atomic包中的類)
這些concurrent包中的基礎類都是使用這種模式來實現的,而concurrent包中的高層類又是依賴這些基礎類來實現的。從整體來看,concurrent包的實現示意圖如下:
五、總結
JMM的設計示意圖
JMM向程序員提供的happens-before規則能滿足程序員的需求。JMM的happens-before規則不但簡單易懂,而且也向程序員提供了足夠強的內存可見性保證(有些內存可見性保證其實并不一定真實存在,比如上面的A happens-before B)。
JMM對編譯器和處理器的束縛已經盡可能少。從上面的分析可以看出,JMM其實是在遵循一個基本原則:只要不改變程序的執行結果(指的是單線程程序和正確同步的多線程程序),編譯器和處理器怎么優化都行。例如,如果編譯器經過細致的分析后,認定一個鎖只會被單個線程訪問,那么這個鎖可以被消除。再如,如果編譯器經過細致的分析后,認定一個volatile變量只會被單個線程訪問,那么編譯器可以把這個volatile變量當作一個普通變量來對待。這些優化既不會改變程序的執行結果,又能提高程序的執行效率。
Java程序的內存可見性保證
單線程程序。單線程程序不會出現內存可見性問題。編譯器、runtime和處理器會共同確保單線程程序的執行結果與該程序在順序一致性模型中的執行結果相同
正確同步多線程程序。正確的同步多線程與該程序在順序一致性內存模型中執行的結果相同。JMM通過限制編譯器和處理器的重排序來為我們提供內存可見性保證。
未同步/未正確同步的多線程程序。JMM為它們提供了最小的安全保證:線程執行時讀取到的值,要么是之前某個線程寫入的值,要么是默認值(0,null,false)。
最后
從上面內存抽象結構來說,可能出在數據“臟讀”的現象,這就是數據可見性的問題,另外,重排序在多線程中不注意的話也容易存在一些問題,比如一個很經典的問題就是DCL(雙重檢驗鎖),這就是需要禁止重排序,另外,在多線程下原子操作例如i++不加以注意的也容易出現線程安全的問題。但總的來說,在多線程開發時需要從原子性,有序性,可見性三個方面進行考慮。J.U.C包下的并發工具類和并發容器也是需要花時間去掌握的。
Java 任務調度
版權聲明:本文內容由網絡用戶投稿,版權歸原作者所有,本站不擁有其著作權,亦不承擔相應法律責任。如果您發現本站中有涉嫌抄襲或描述失實的內容,請聯系我們jiasou666@gmail.com 處理,核實后本網站將在24小時內刪除侵權內容。