JMM和底层实现原理
1.并發編程領域的關鍵問題
1.1 線程之間的通信
線程的通信是指線程之間以何種機制來交換信息。在編程中,線程之間的通信機制有兩種,共享內存和消息傳遞。
在共享內存的并發模型里,線程之間共享程序的公共狀態,線程之間通過寫-讀內存中的公共狀態來隱式進行通信,典型的共享內存通信方式就是通過共享對象進行通信。
在消息傳遞的并發模型里,線程之間沒有公共狀態,線程之間必須通過明確的發送消息來顯式進行通信,在java中典型的消息傳遞方式就是wait()和notify()。
1.2 線程間的同步
同步是指程序用于控制不同線程之間操作發生相對順序的機制。
在共享內存并發模型里,同步是顯式進行的。程序員必須顯式指定某個方法或某段代碼需要在線程之間互斥執行。
在消息傳遞的并發模型里,由于消息的發送必須在消息的接收之前,因此同步是隱式進行的。
2.Java內存模型——JMM
- Java的并發采用的是共享內存模型
2.1 現代計算機的內存模型
物理計算機中的并發問題,物理機遇到的并發問題與虛擬機中的情況有不少相似之處,物理機對并發的處理方案對于虛擬機的實現也有相當大的參考意義。
其中一個重要的復雜性來源是絕大多數的運算任務都不可能只靠處理器“計算”就能完成,處理器至少要與內存交互,如讀取運算數據、存儲運算結果等,這個I/O操作是很難消除的(無法僅靠寄存器來完成所有運算任務)。早期計算機中cpu和內存的速度是差不多的,但在現代計算機中,cpu的指令速度遠超內存的存取速度,由于計算機的存儲設備與處理器的運算速度有幾個數量級的差距,所以現代計算機系統都不得不加入一層讀寫速度盡可能接近處理器運算速度的高速緩存(Cache)來作為內存與處理器之間的緩沖:將運算需要使用到的數據復制到緩存中,讓運算能快速進行,當運算結束后再從緩存同步回內存之中,這樣處理器就無須等待緩慢的內存讀寫了。
基于高速緩存的存儲交互很好地解決了處理器與內存的速度矛盾,但是也為計算機系統帶來更高的復雜度,因為它引入了一個新的問題:緩存一致性(Cache Coherence)。在多處理器系統中,每個處理器都有自己的高速緩存,而它們又共享同一主內存(MainMemory)。當多個處理器的運算任務都涉及同一塊主內存區域時,將可能導致各自的緩存數據不一致,舉例說明變量在多個CPU之間的共享。如果真的發生這種情況,那同步回到主內存時以誰的緩存數據為準呢?為了解決一致性的問題,需要各個處理器訪問緩存時都遵循一些協議,在讀寫時要根據協議來進行操作,這類協議有MSI、MESI(Illinois Protocol)、MOSI、Synapse、Firefly及Dragon Protocol等。
?
- 該內存模型帶來的問題
現代的處理器使用寫緩沖區臨時保存向內存寫入的數據。寫緩沖區可以保證指令流水線持續運行,它可以避免由于處理器停頓下來等待向內存寫入數據而產生的延遲。同時,通過以批處理的方式刷新寫緩沖區,以及合并寫緩沖區中對同一內存地址的多次寫,減少對內存總線的占用。雖然寫緩沖區有這么多好處,但每個處理器上的寫緩沖區,僅僅對它所在的處理器可見。這個特性會對內存操作的執行順序產生重要的影響:處理器對內存的讀/寫操作的執行順序,不一定與內存實際發生的讀/寫操作順序一致!
處理器A和處理器B按程序的順序并行執行內存訪問,最終可能得到x=y=0的結果。
處理器A和處理器B可以同時把共享變量寫入自己的寫緩沖區(A1,B1),然后從內存中讀取另一個共享變量(A2,B2),最后才把自己寫緩存區中保存的臟數據刷新到內存中(A3,B3)。當以這種時序執行時,程序就可以得到x=y=0的結果。
從內存操作實際發生的順序來看,直到處理器A執行A3來刷新自己的寫緩存區,寫操作A1才算真正執行了。雖然處理器A執行內存操作的順序為:A1→A2,但內存操作實際發生的順序卻是A2→A1。
?
2.2 Java內存模型(JMM)
JMM定義了Java 虛擬機(JVM)在計算機內存(RAM)中的工作方式。JVM是整個計算機虛擬模型,所以JMM是隸屬于JVM的。從抽象的角度來看,JMM定義了線程和主內存之間的抽象關系:線程之間的共享變量存儲在主內存(Main Memory)中,每個線程都有一個私有的本地內存(Local Memory),本地內存中存儲了該線程以讀/寫共享變量的副本。本地內存是JMM的一個抽象概念,并不真實存在。它涵蓋了緩存、寫緩沖區、寄存器以及其他的硬件和編譯器優化。
?
2.2.1 JVM對Java內存模型的實現
-
在JVM內部,Java內存模型把內存分成了兩部分:線程棧區和堆區
?
JVM中運行的每個線程都擁有自己的線程棧,線程棧包含了當前線程執行的方法調用相關信息,我們也把它稱作調用棧。隨著代碼的不斷執行,調用棧會不斷變化。image.png
所有原始類型(boolean,byte,short,char,int,long,float,double)的局部變量都直接保存在線程棧當中,對于它們的值各個線程之間都是獨立的。對于原始類型的局部變量,一個線程可以傳遞一個副本給另一個線程,當它們之間是無法共享的。
堆區包含了Java應用創建的所有對象信息,不管對象是哪個線程創建的,其中的對象包括原始類型的封裝類(如Byte、Integer、Long等等)。不管對象是屬于一個成員變量還是方法中的局部變量,它都會被存儲在堆區。
一個局部變量如果是原始類型,那么它會被完全存儲到棧區。 一個局部變量也有可能是一個對象的引用,這種情況下,這個本地引用會被存儲到棧中,但是對象本身仍然存儲在堆區。
對于一個對象的成員方法,這些方法中包含局部變量,仍需要存儲在棧區,即使它們所屬的對象在堆區。 對于一個對象的成員變量,不管它是原始類型還是包裝類型,都會被存儲到堆區。Static類型的變量以及類本身相關信息都會隨著類本身存儲在堆區。
?
2.3 Java內存模型帶來的問題
2.3.1 可見性問題
CPU中運行的線程從主存中拷貝共享對象obj到它的CPU緩存,把對象obj的count變量改為2。但這個變更對運行在右邊CPU中的線程不可見,因為這個更改還沒有flush到主存中:要解決共享對象可見性這個問題,我們可以使用java volatile關鍵字或者是加鎖
?
2.3.2 競爭現象
線程A和線程B共享一個對象obj。假設線程A從主存讀取Obj.count變量到自己的CPU緩存,同時,線程B也讀取了Obj.count變量到它的CPU緩存,并且這兩個線程都對Obj.count做了加1操作。此時,Obj.count加1操作被執行了兩次,不過都在不同的CPU緩存中。如果這兩個加1操作是串行執行的,那么Obj.count變量便會在原始值上加2,最終主存中的Obj.count的值會是3。然而下圖中兩個加1操作是并行的,不管是線程A還是線程B先flush計算結果到主存,最終主存中的Obj.count只會增加1次變成2,盡管一共有兩次加1操作。 要解決上面的問題我們可以使用java synchronized代碼塊。
?
2.4 Java內存模型中的重排序
- 在執行程序時,為了提高性能,編譯器和處理器常常會對指令做重排序。
2.4.1 重排序類型
- 1)編譯器優化的重排序。編譯器在不改變單線程程序語義的前提下,可以重新安排語句的執行順序。
- 2)指令級并行的重排序。現代處理器采用了指令級并行技術(Instruction-LevelParallelism,ILP)來將多條指令重疊執行。如果不存在數據依賴性,處理器可以改變語句對應機器指令的執行順序。
- 3)內存系統的重排序。由于處理器使用緩存和讀/寫緩沖區,這使得加載和存儲操作看上去可能是在亂序執行。
2.4.2 重排序與依賴性
-
數據依賴性
?
如果兩個操作訪問同一個變量,且這兩個操作中有一個為寫操作,此時這兩個操作之間就存在數據依賴性。數據依賴分為下列3種類型,這3種情況,只要重排序兩個操作的執行順序,程序的執行結果就會被改變。 -
控制依賴性
?
flag變量是個標記,用來標識變量a是否已被寫入,在use方法中比變量i依賴if (flag)的判斷,這里就叫控制依賴,如果發生了重排序,結果就不對了。 -
as-if-serial
? ?
不管如何重排序,都必須保證代碼在單線程下的運行正確,連單線程下都無法正確,更不用討論多線程并發的情況,所以就提出了一個as-if-serial的概念。
as-if-serial語義的意思是:不管怎么重排序(編譯器和處理器為了提高并行度),(單線程)程序的執行結果不能被改變。編譯器、runtime和處理器都必須遵守as-if-serial語義。為了遵守as-if-serial語義,編譯器和處理器不會對存在數據依賴關系的操作做重排序,因為這種重排序會改變執行結果。(強調一下,這里所說的數據依賴性僅針對單個處理器中執行的指令序列和單個線程中執行的操作,不同處理器之間和不同線程之間的數據依賴性不被編譯器和處理器考慮。)但是,如果操作之間不存在數據依賴關系,這些操作依然可能被編譯器和處理器重排序。1和3之間存在數據依賴關系,同時2和3之間也存在數據依賴關系。因此在最終執行的指令序列中,3不能被重排序到1和2的前面(3排到1和2的前面,程序的結果將會被改變)。但1和2之間沒有數據依賴關系,編譯器和處理器可以重排序1和2之間的執行順序。
asif-serial語義使單線程下無需擔心重排序的干擾,也無需擔心內存可見性問題。
2.4.3 并發下重排序帶來的問題
?
這里假設有兩個線程A和B,A首先執行init ()方法,隨后B線程接著執行use ()方法。線程B在執行操作4時,能否看到線程A在操作1對共享變量a的寫入呢?答案是:不一定能看到。
由于操作1和操作2沒有數據依賴關系,編譯器和處理器可以對這兩個操作重排序;同樣,操作3和操作4沒有數據依賴關系,編譯器和處理器也可以對這兩個操作重排序。讓我們先來看看,當操作1和操作2重排序時,可能會產生什么效果?操作1和操作2做了重排序。程序執行時,線程A首先寫標記變量flag,隨后線程B讀這個變量。由于條件判斷為真,線程B將讀取變量a。此時,變量a還沒有被線程A寫入,這時就會發生錯誤!
當操作3和操作4重排序時會產生什么效果?
在程序中,操作3和操作4存在控制依賴關系。當代碼中存在控制依賴性時,會影響指令序列執行的并行度。為此,編譯器和處理器會采用猜測(Speculation)執行來克服控制相關性對并行度的影響。以處理器的猜測執行為例,執行線程B的處理器可以提前讀取并計算a*a,然后把計算結果臨時保存到一個名為重排序緩沖(Reorder Buffer,ROB)的硬件緩存中。當操作3的條件判斷為真時,就把該計算結果寫入變量i中。猜測執行實質上對操作3和4做了重排序,問題在于這時候,a的值還沒被線程A賦值。在單線程程序中,對存在控制依賴的操作重排序,不會改變執行結果(這也是as-if-serial語義允許對存在控制依賴的操作做重排序的原因);但在多線程程序中,對存在控制依賴的操作重排序,可能會改變程序的執行結果。
2.4.4 解決在并發下的問題
1)內存屏障——禁止重排序
?
Java編譯器在生成指令序列的適當位置會插入內存屏障指令來禁止特定類型的處理器重排序,從而讓程序按我們預想的流程去執行。
1、保證特定操作的執行順序。
2、影響某些數據(或則是某條指令的執行結果)的內存可見性。
編譯器和CPU能夠重排序指令,保證最終相同的結果,嘗試優化性能。插入一條Memory Barrier會告訴編譯器和CPU:不管什么指令都不能和這條Memory Barrier指令重排序。
Memory Barrier所做的另外一件事是強制刷出各種CPU cache,如一個Write-Barrier(寫入屏障)將刷出所有在Barrier之前寫入 cache 的數據,因此,任何CPU上的線程都能讀取到這些數據的最新版本。
JMM把內存屏障指令分為4類,解釋表格,StoreLoad Barriers是一個“全能型”的屏障,它同時具有其他3個屏障的效果。現代的多處理器大多支持該屏障(其他類型的屏障不一定被所有處理器支持)。
2)臨界區(synchronized?)
?
臨界區內的代碼可以重排序(但JMM不允許臨界區內的代碼“逸出”到臨界區之外,那樣會破壞監視器的語義)。JMM會在退出臨界區和進入臨界區這兩個關鍵時間點做一些特別處理,雖然線程A在臨界區內做了重排序,但由于監視器互斥執行的特性,這里的線程B根本無法“觀察”到線程A在臨界區內的重排序。這種重排序既提高了執行效率,又沒有改變程序的執行結果。
2.5 Happens-Before
用happens-before的概念來闡述操作之間的內存可見性。在JMM中,如果一個操作執行的結果需要對另一個操作可見,那么這兩個操作之間必須要存在happens-before關系 。
兩個操作之間具有happens-before關系,并不意味著前一個操作必須要在后一個操作之前執行!happens-before僅僅要求前一個操作(執行的結果)對后一個操作可見,且前一個操作按順序排在第二個操作之前(the first is visible to and ordered before the second) 。
1)如果一個操作happens-before另一個操作,那么第一個操作的執行結果將對第二個操作可見,而且第一個操作的執行順序排在第二個操作之前。(對程序員來說)
2)兩個操作之間存在happens-before關系,并不意味著Java平臺的具體實現必須要按照happens-before關系指定的順序來執行。如果重排序之后的執行結果,與按happens-before關系來執行的結果一致,那么這種重排序是允許的(對編譯器和處理器 來說)
在Java 規范提案中為讓大家理解內存可見性的這個概念,提出了happens-before的概念來闡述操作之間的內存可見性。對應Java程序員來說,理解happens-before是理解JMM的關鍵。JMM這么做的原因是:程序員對于這兩個操作是否真的被重排序并不關心,程序員關心的是程序執行時的語義不能被改變(即執行結果不能被改變)。因此,happens-before關系本質上和as-if-serial語義是一回事。as-if-serial語義保證單線程內程序的執行結果不被改變,happens-before關系保證正確同步的多線程程序的執行結果不被改變。
- Happens-Before規則-無需任何同步手段就可以保證的
1)程序順序規則:一個線程中的每個操作,happens-before于該線程中的任意后續操作。
2)監視器鎖規則:對一個鎖的解鎖,happens-before于隨后對這個鎖的加鎖。
3)volatile變量規則:對一個volatile域的寫,happens-before于任意后續對這個volatile域的讀。
4)傳遞性:如果A happens-before B,且B happens-before C,那么A happens-before C。
5)start()規則:如果線程A執行操作ThreadB.start()(啟動線程B),那么A線程的ThreadB.start()操作happens-before于線程B中的任意操作。
6)join()規則:如果線程A執行操作ThreadB.join()并成功返回,那么線程B中的任意操作happens-before于線程A從ThreadB.join()操作成功返回。
7 )線程中斷規則:對線程interrupt方法的調用happens-before于被中斷線程的代碼檢測到中斷事件的發生。
3.實現原理
- 內存語義:可以簡單理解為 volatile,synchronize,atomic,lock 之類的在 JVM 中的內存方面實現原則
3.1 volatile的內存語義
volatile變量自身具有下列特性:
- 可見性。對一個volatile變量的讀,總是能看到(任意線程)對這個volatile變量最后的寫入。
- 原子性:對任意單個volatile變量的讀/寫具有原子性,但類似于volatile++這種復合操作不具有原子性。
volatile寫的內存語義如下:當寫一個volatile變量時,JMM會把該線程對應的本地內存中的共享變量值刷新到主內存。
?
volatile讀的內存語義如下:當讀一個volatile變量時,JMM會把該線程對應的本地內存置為無效。線程接下來將從主內存中讀取共享變量。
?
volatile重排序規則:
?
volatile內存語義的實現——JMM對volatile的內存屏障插入策略:
在每個volatile寫操作的前面插入一個StoreStore屏障。在每個volatile寫操作的后面插入一個StoreLoad屏障。
在每個volatile讀操作的后面插入一個LoadLoad屏障。在每個volatile讀操作的后面插入一個LoadStore屏障。
?
?
3.1.1 volatile的實現原理
有volatile變量修飾的共享變量進行寫操作的時候會使用CPU提供的Lock前綴指令:
- 將當前處理器緩存行的數據寫回到系統內存
- 這個寫回內存的操作會使在其他CPU里緩存了該內存地址的數據無效。
3.2 鎖的內存語義
當線程釋放鎖時,JMM會把該線程對應的本地內存中的共享變量刷新到主內存中。。
當線程獲取鎖時,JMM會把該線程對應的本地內存置為無效。從而使得被監視器保護的臨界區代碼必須從主內存中讀取共享變量。
?
?
3.2.1 synchronized的實現原理
使用monitorenter和monitorexit指令實現的:
- monitorenter指令是在編譯后插入到同步代碼塊的開始位置,而monitorexit是插入到方法結束處和異常處
- 每個monitorenter必須有對應的monitorexit與之配對
- 任何對象都有一個monitor與之關聯,當且一個monitor被持有后,它將處于鎖定狀態
鎖的存放位置:
?
3.2.2 了解各種鎖
鎖一共有4種狀態,級別從低到高依次是:無鎖狀態、偏向鎖狀態、輕量級鎖狀態和重量級鎖狀態。
偏向鎖:大多數情況下,鎖不僅不存在多線程競爭,而且總是由同一線程多次獲得,為了讓線程獲得鎖的代價更低而引入了偏向鎖。無競爭時不需要進行CAS操作來加鎖和解鎖。
輕量級鎖:無競爭時通過CAS操作來加鎖和解鎖。(自旋鎖——是一種鎖的機制,不是狀態)
重量級鎖:真正的加鎖操作
3.3 final的內存語義
編譯器和處理器要遵守兩個重排序規則:
- 在構造函數內對一個final域的寫入,與隨后把這個被構造對象的引用賦值給一個引用變量,這兩個操作之間不能重排序。
- 初次讀一個包含final域的對象的引用,與隨后初次讀這個final域,這兩個操作之間不能重排序。
final域為引用類型:
- 增加了如下規則:在構造函數內對一個final引用的對象的成員域的寫入,與隨后在構造函數外把這個被構造對象的引用賦值給一個引用變量,這兩個操作之間不能重排序。
final語義在處理器中的實現:
- 會要求編譯器在final域的寫之后,構造函數return之前插入一個StoreStore障屏。
- 讀final域的重排序規則要求編譯器在讀final域的操作前面插入一個LoadLoad屏障
----------------------------------------------------------------------------------------------------------------------------------------------------------
java內存模型JMM理解整理
?
什么是JMM
JMM即為JAVA 內存模型(java memory model)。因為在不同的硬件生產商和不同的操作系統下,內存的訪問邏輯有一定的差異,結果就是當你的代碼在某個系統環境下運行良好,并且線程安全,但是換了個系統就出現各種問題。Java內存模型,就是為了屏蔽系統和硬件的差異,讓一套代碼在不同平臺下能到達相同的訪問結果。JMM從java 5開始的JSR-133發布后,已經成熟和完善起來。
內存劃分
JMM規定了內存主要劃分為主內存和工作內存兩種。此處的主內存和工作內存跟JVM內存劃分(堆、棧、方法區)是在不同的層次上進行的,如果非要對應起來,主內存對應的是Java堆中的對象實例部分,工作內存對應的是棧中的部分區域,從更底層的來說,主內存對應的是硬件的物理內存,工作內存對應的是寄存器和高速緩存。
JVM在設計時候考慮到,如果JAVA線程每次讀取和寫入變量都直接操作主內存,對性能影響比較大,所以每條線程擁有各自的工作內存,工作內存中的變量是主內存中的一份拷貝,線程對變量的讀取和寫入,直接在工作內存中操作,而不能直接去操作主內存中的變量。但是這樣就會出現一個問題,當一個線程修改了自己工作內存中變量,對其他線程是不可見的,會導致線程不安全的問題。因為JMM制定了一套標準來保證開發者在編寫多線程程序的時候,能夠控制什么時候內存會被同步給其他線程。
內存交互操作
? 內存交互操作有8種,虛擬機實現必須保證每一個操作都是原子的,不可在分的(對于double和long類型的變量來說,load、store、read和write操作在某些平臺上允許例外)
-
- lock? ? ?(鎖定):作用于主內存的變量,把一個變量標識為線程獨占狀態
- unlock (解鎖):作用于主內存的變量,它把一個處于鎖定狀態的變量釋放出來,釋放后的變量才可以被其他線程鎖定
- read? ? (讀取):作用于主內存變量,它把一個變量的值從主內存傳輸到線程的工作內存中,以便隨后的load動作使用
- load? ? ?(載入):作用于工作內存的變量,它把read操作從主存中變量放入工作內存中
- use? ? ? (使用):作用于工作內存中的變量,它把工作內存中的變量傳輸給執行引擎,每當虛擬機遇到一個需要使用到變量的值,就會使用到這個指令
- assign? (賦值):作用于工作內存中的變量,它把一個從執行引擎中接受到的值放入工作內存的變量副本中
- store? ? (存儲):作用于主內存中的變量,它把一個從工作內存中一個變量的值傳送到主內存中,以便后續的write使用
- write (寫入):作用于主內存中的變量,它把store操作從工作內存中得到的變量的值放入主內存的變量中
JMM對這八種指令的使用,制定了如下規則:
-
- 不允許read和load、store和write操作之一單獨出現。即使用了read必須load,使用了store必須write
- 不允許線程丟棄他最近的assign操作,即工作變量的數據改變了之后,必須告知主存
- 不允許一個線程將沒有assign的數據從工作內存同步回主內存
- 一個新的變量必須在主內存中誕生,不允許工作內存直接使用一個未被初始化的變量。就是懟變量實施use、store操作之前,必須經過assign和load操作
- 一個變量同一時間只有一個線程能對其進行lock。多次lock后,必須執行相同次數的unlock才能解鎖
- 如果對一個變量進行lock操作,會清空所有工作內存中此變量的值,在執行引擎使用這個變量前,必須重新load或assign操作初始化變量的值
- 如果一個變量沒有被lock,就不能對其進行unlock操作。也不能unlock一個被其他線程鎖住的變量
- 對一個變量進行unlock操作之前,必須把此變量同步回主內存
JMM對這八種操作規則和對volatile的一些特殊規則就能確定哪里操作是線程安全,哪些操作是線程不安全的了。但是這些規則實在復雜,很難在實踐中直接分析。所以一般我們也不會通過上述規則進行分析。更多的時候,使用java的happen-before規則來進行分析。
模型特征
原子性:例如上面八項操作,在操作系統里面是不可分割的單元。被synchronized關鍵字或其他鎖包裹起來的操作也可以認為是原子的。從一個線程觀察另外一個線程的時候,看到的都是一個個原子性的操作。
1 synchronized (this) { 2 a=1; 3 b=2; 4 }例如一個線程觀察另外一個線程執行上面的代碼,只能看到a、b都被賦值成功結果,或者a、b都尚未被賦值的結果。
可見性:每個工作線程都有自己的工作內存,所以當某個線程修改完某個變量之后,在其他的線程中,未必能觀察到該變量已經被修改。volatile關鍵字要求被修改之后的變量要求立即更新到主內存,每次使用前從主內存處進行讀取。因此volatile可以保證可見性。除了volatile以外,synchronized和final也能實現可見性。synchronized保證unlock之前必須先把變量刷新回主內存。final修飾的字段在構造器中一旦完成初始化,并且構造器沒有this逸出,那么其他線程就能看到final字段的值。
有序性:java的有序性跟線程相關。如果在線程內部觀察,會發現當前線程的一切操作都是有序的。如果在線程的外部來觀察的話,會發現線程的所有操作都是無序的。因為JMM的工作內存和主內存之間存在延遲,而且java會對一些指令進行重新排序。volatile和synchronized可以保證程序的有序性,很多程序員只理解這兩個關鍵字的執行互斥,而沒有很好的理解到volatile和synchronized也能保證指令不進行重排序。
Volatile內存語義
volatile的一些特殊規則
Final域的內存語義
被final修飾的變量,相比普通變量,內存語義有一些不同。具體如下:
-
- JMM禁止把Final域的寫重排序到構造器的外部。
- 在一個線程中,初次讀該對象和讀該對象下的Final域,JMM禁止處理器重新排序這兩個操作。
假設現在有線程A執行FinalConstructor.write()方法,線程B執行FinalConstructor.read()方法。
對應上述的Final的第一條規則,因為JMM禁止把Final域的寫重排序到構造器的外部,而對普通變量沒有這種限制,所以變量A=1,而變量B可能會等于2(構造完成),也有可能等于0(第11行代碼被重排序到構造器的外部)。
? 對應上述的Final的第二條規則,如果constructor的引用不為null,A必然為1,要么constructor為null,拋出空指針異常。保證讀final域之前,一定會先讀該對象的引用。但是普通對象就沒有這種規則。
(上述的Final規則反復測試,遺憾的是我并沒有能模擬出來普通變量不能正常構造的結果)
Happen-Before(先行發生規則)
在常規的開發中,如果我們通過上述規則來分析一個并發程序是否安全,估計腦殼會很疼。因為更多時候,我們是分析一個并發程序是否安全,其實都依賴Happen-Before原則進行分析。Happen-Before被翻譯成先行發生原則,意思就是當A操作先行發生于B操作,則在發生B操作的時候,操作A產生的影響能被B觀察到,“影響”包括修改了內存中的共享變量的值、發送了消息、調用了方法等。
Happen-Before的規則有以下幾條
-
- 程序次序規則(Program Order Rule):在一個線程內,程序的執行規則跟程序的書寫規則是一致的,從上往下執行。
- 管程鎖定規則(Monitor Lock Rule):一個Unlock的操作肯定先于下一次Lock的操作。這里必須是同一個鎖。同理我們可以認為在synchronized同步同一個鎖的時候,鎖內先行執行的代碼,對后續同步該鎖的線程來說是完全可見的。
- volatile變量規則(volatile Variable Rule):對同一個volatile的變量,先行發生的寫操作,肯定早于后續發生的讀操作
- 線程啟動規則(Thread Start Rule):Thread對象的start()方法先行發生于此線程的沒一個動作
- 線程中止規則(Thread Termination Rule):Thread對象的中止檢測(如:Thread.join(),Thread.isAlive()等)操作,必行晚于線程中所有操作
- 線程中斷規則(Thread Interruption Rule):對線程的interruption()調用,先于被調用的線程檢測中斷事件(Thread.interrupted())的發生
- 對象中止規則(Finalizer Rule):一個對象的初始化方法先于一個方法執行Finalizer()方法
- 傳遞性(Transitivity):如果操作A先于操作B、操作B先于操作C,則操作A先于操作C
以上就是Happen-Before中的規則。通過這些條件的判定,仍然很難判斷一個線程是否能安全執行,畢竟在我們的時候線程安全多數依賴于工具類的安全性來保證。想提高自己對線程是否安全的判斷能力,必然需要理解所使用的框架或者工具的實現,并積累線程安全的經驗。
?
-----------------------------------------------------------------------------------------------------------------------------------------------------------------------
深入理解Java內存模型(一)——基礎
本文屬于作者原創,原文發表于InfoQ:http://www.infoq.com/cn/articles/java-memory-model-1
并發編程模型的分類
在并發編程中,我們需要處理兩個關鍵問題:線程之間如何通信及線程之間如何同步(這里的線程是指并發執行的活動實體)。通信是指線程之間以何種機制來交換信息。在命令式編程中,線程之間的通信機制有兩種:共享內存和消息傳遞。
在共享內存的并發模型里,線程之間共享程序的公共狀態,線程之間通過寫-讀內存中的公共狀態來隱式進行通信。在消息傳遞的并發模型里,線程之間沒有公共狀態,線程之間必須通過明確的發送消息來顯式進行通信。
同步是指程序用于控制不同線程之間操作發生相對順序的機制。在共享內存并發模型里,同步是顯式進行的。程序員必須顯式指定某個方法或某段代碼需要在線程之間互斥執行。在消息傳遞的并發模型里,由于消息的發送必須在消息的接收之前,因此同步是隱式進行的。
Java的并發采用的是共享內存模型,Java線程之間的通信總是隱式進行,整個通信過程對程序員完全透明。如果編寫多線程程序的Java程序員不理解隱式進行的線程之間通信的工作機制,很可能會遇到各種奇怪的內存可見性問題。
Java內存模型的抽象
在java中,所有實例域、靜態域和數組元素存儲在堆內存中,堆內存在線程之間共享(本文使用“共享變量”這個術語代指實例域,靜態域和數組元素)。局部變量(Local variables),方法定義參數(java語言規范稱之為formal method parameters)和異常處理器參數(exception handler parameters)不會在線程之間共享,它們不會有內存可見性問題,也不受內存模型的影響。
Java線程之間的通信由Java內存模型(本文簡稱為JMM)控制,JMM決定一個線程對共享變量的寫入何時對另一個線程可見。從抽象的角度來看,JMM定義了線程和主內存之間的抽象關系:線程之間的共享變量存儲在主內存(main memory)中,每個線程都有一個私有的本地內存(local memory),本地內存中存儲了該線程以讀/寫共享變量的副本。本地內存是JMM的一個抽象概念,并不真實存在。它涵蓋了緩存,寫緩沖區,寄存器以及其他的硬件和編譯器優化。Java內存模型的抽象示意圖如下:
從上圖來看,線程A與線程B之間如要通信的話,必須要經歷下面2個步驟:
1. 首先,線程A把本地內存A中更新過的共享變量刷新到主內存中去。
2. 然后,線程B到主內存中去讀取線程A之前已更新過的共享變量。
下面通過示意圖來說明這兩個步驟:
如上圖所示,本地內存A和B有主內存中共享變量x的副本。假設初始時,這三個內存中的x值都為0。線程A在執行時,把更新后的x值(假設值為1)臨時存放在自己的本地內存A中。當線程A和線程B需要通信時,線程A首先會把自己本地內存中修改后的x值刷新到主內存中,此時主內存中的x值變為了1。隨后,線程B到主內存中去讀取線程A更新后的x值,此時線程B的本地內存的x值也變為了1。
從整體來看,這兩個步驟實質上是線程A在向線程B發送消息,而且這個通信過程必須要經過主內存。JMM通過控制主內存與每個線程的本地內存之間的交互,來為java程序員提供內存可見性保證。
重排序
在執行程序時為了提高性能,編譯器和處理器常常會對指令做重排序。重排序分三種類型:
1. 編譯器優化的重排序。編譯器在不改變單線程程序語義的前提下,可以重新安排語句的執行順序。
2. 指令級并行的重排序。現代處理器采用了指令級并行技術(Instruction-Level Parallelism, ILP)來將多條指令重疊執行。如果不存在數據依賴性,處理器可以改變語句對應機器指令的執行順序。
3. 內存系統的重排序。由于處理器使用緩存和讀/寫緩沖區,這使得加載和存儲操作看上去可能是在亂序執行。
從java源代碼到最終實際執行的指令序列,會分別經歷下面三種重排序:
上述的1屬于編譯器重排序,2和3屬于處理器重排序。這些重排序都可能會導致多線程程序出現內存可見性問題。對于編譯器,JMM的編譯器重排序規則會禁止特定類型的編譯器重排序(不是所有的編譯器重排序都要禁止)。對于處理器重排序,JMM的處理器重排序規則會要求java編譯器在生成指令序列時,插入特定類型的內存屏障(memory barriers,intel稱之為memory fence)指令,通過內存屏障指令來禁止特定類型的處理器重排序(不是所有的處理器重排序都要禁止)。
JMM屬于語言級的內存模型,它確保在不同的編譯器和不同的處理器平臺之上,通過禁止特定類型的編譯器重排序和處理器重排序,為程序員提供一致的內存可見性保證。
處理器重排序與內存屏障指令
現代的處理器使用寫緩沖區來臨時保存向內存寫入的數據。寫緩沖區可以保證指令流水線持續運行,它可以避免由于處理器停頓下來等待向內存寫入數據而產生的延遲。同時,通過以批處理的方式刷新寫緩沖區,以及合并寫緩沖區中對同一內存地址的多次寫,可以減少對內存總線的占用。雖然寫緩沖區有這么多好處,但每個處理器上的寫緩沖區,僅僅對它所在的處理器可見。這個特性會對內存操作的執行順序產生重要的影響:處理器對內存的讀/寫操作的執行順序,不一定與內存實際發生的讀/寫操作順序一致!為了具體說明,請看下面示例:
| a = 1; //A1 x = b; //A2 | b = 2; //B1 y = a; //B2 |
| 初始狀態:a = b = 0 處理器允許執行后得到結果:x = y = 0 | |
假設處理器A和處理器B按程序的順序并行執行內存訪問,最終卻可能得到x = y = 0的結果。具體的原因如下圖所示:
這里處理器A和處理器B可以同時把共享變量寫入自己的寫緩沖區(A1,B1),然后從內存中讀取另一個共享變量(A2,B2),最后才把自己寫緩存區中保存的臟數據刷新到內存中(A3,B3)。當以這種時序執行時,程序就可以得到x = y = 0的結果。
從內存操作實際發生的順序來看,直到處理器A執行A3來刷新自己的寫緩存區,寫操作A1才算真正執行了。雖然處理器A執行內存操作的順序為:A1->A2,但內存操作實際發生的順序卻是:A2->A1。此時,處理器A的內存操作順序被重排序了(處理器B的情況和處理器A一樣,這里就不贅述了)。
這里的關鍵是,由于寫緩沖區僅對自己的處理器可見,它會導致處理器執行內存操作的順序可能會與內存實際的操作執行順序不一致。由于現代的處理器都會使用寫緩沖區,因此現代的處理器都會允許對寫-讀操作重排序。
下面是常見處理器允許的重排序類型的列表:
| ? | Load-Load | Load-Store | Store-Store | Store-Load | 數據依賴 |
| sparc-TSO | N | N | N | Y | N |
| x86 | N | N | N | Y | N |
| ia64 | Y | Y | Y | Y | N |
| PowerPC | Y | Y | Y | Y | N |
上表單元格中的“N”表示處理器不允許兩個操作重排序,“Y”表示允許重排序。
從上表我們可以看出:常見的處理器都允許Store-Load重排序;常見的處理器都不允許對存在數據依賴的操作做重排序。sparc-TSO和x86擁有相對較強的處理器內存模型,它們僅允許對寫-讀操作做重排序(因為它們都使用了寫緩沖區)。
※注1:sparc-TSO是指以TSO(Total Store Order)內存模型運行時,sparc處理器的特性。
※注2:上表中的x86包括x64及AMD64。
※注3:由于ARM處理器的內存模型與PowerPC處理器的內存模型非常類似,本文將忽略它。
※注4:數據依賴性后文會專門說明。
為了保證內存可見性,java編譯器在生成指令序列的適當位置會插入內存屏障指令來禁止特定類型的處理器重排序。JMM把內存屏障指令分為下列四類:
| 屏障類型 | 指令示例 | 說明 |
| LoadLoad Barriers | Load1; LoadLoad; Load2 | 確保Load1數據的裝載,之前于Load2及所有后續裝載指令的裝載。 |
| StoreStore Barriers | Store1; StoreStore; Store2 | 確保Store1數據對其他處理器可見(刷新到內存),之前于Store2及所有后續存儲指令的存儲。 |
| LoadStore Barriers | Load1; LoadStore; Store2 | 確保Load1數據裝載,之前于Store2及所有后續的存儲指令刷新到內存。 |
| StoreLoad Barriers | Store1; StoreLoad; Load2 | 確保Store1數據對其他處理器變得可見(指刷新到內存),之前于Load2及所有后續裝載指令的裝載。StoreLoad Barriers會使該屏障之前的所有內存訪問指令(存儲和裝載指令)完成之后,才執行該屏障之后的內存訪問指令。 |
StoreLoad Barriers是一個“全能型”的屏障,它同時具有其他三個屏障的效果。現代的多處理器大都支持該屏障(其他類型的屏障不一定被所有處理器支持)。執行該屏障開銷會很昂貴,因為當前處理器通常要把寫緩沖區中的數據全部刷新到內存中(buffer fully flush)。
happens-before
從JDK5開始,java使用新的JSR -133內存模型(本文除非特別說明,針對的都是JSR- 133內存模型)。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。
注意,兩個操作之間具有happens-before關系,并不意味著前一個操作必須要在后一個操作之前執行!happens-before僅僅要求前一個操作(執行的結果)對后一個操作可見,且前一個操作按順序排在第二個操作之前(the first is visible to and ordered before the second)。happens- before的定義很微妙,后文會具體說明happens-before為什么要這么定義。
happens-before與JMM的關系如下圖所示:
?
如上圖所示,一個happens-before規則通常對應于多個編譯器和處理器重排序規則。對于java程序員來說,happens-before規則簡單易懂,它避免java程序員為了理解JMM提供的內存可見性保證而去學習復雜的重排序規則以及這些規則的具體實現。
轉載自?http://ifeve.com/java-memory-model-1/
總結
以上是生活随笔為你收集整理的JMM和底层实现原理的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 图解Tomcat类加载机制(阿里面试题)
- 下一篇: redis的五种数据结构及其使用场景