目錄
- 多線程需要解決的問題
- 線程之間的通信
- 線程之間的同步
- Java內存模型
- 內存間的交互操作
- 指令屏障
- happens-before規則
- 指令重排序
- 從源程序到字節指令的重排序
- as-if-serial語義
- 程序順序規則
- 順序一致性模型
- 順序一致性模型特性
- 順序一致性模型特性
- 當程序未正確同步會發生什么
- 參考資料
多線程需要解決的問題
在多線程編程中,線程之間如何通信和同步是一個必須解決的問題:
線程之間的通信:
線程之間有兩種通信的方式:消息傳遞和共享內存
- 共享內存:線程之間共享程序的公共狀態,通過讀——寫修改公共狀態進行隱式通信。如上面代碼中的
num
和Lock
可以被理解為公共狀態 - 消息傳遞:線程之間沒有公共狀態,必須通過發送消息來進行顯示通信
在java中,線程是通過共享內存來完成線程之間的通信
線程之間的同步:
同步指程序中永固空值不同線程間的操作發生的相對順序的機制
- 共享內存:同步是顯示進行的,程序員需要指定某個方法或者某段代碼需要在線程之間互斥執行。如上面代碼中的
Lock
加鎖和解鎖之間的代碼塊,或者被synchronized
包圍的代碼塊 - 消息傳遞:同步是隱式執行的,因為消息的發送必然發生在消息的接收之前,例如使用
Objetc#notify()
,喚醒的線程接收信號一定在發送喚醒信號的發送之后。
Java內存模型
在java中,所有的實例域,靜態域、數組都被存儲在堆空間當中,堆內存在線程之間共享。
所有的局部變量,方法定義參數和異常處理器參數不會被線程共享,在每個線程棧中獨享,他們不會存在可見性和線程安全問題。
從Java線程模型(JMM)的角度來看,線程之間的共享變量存儲在主內存當中,每個線程擁有一個私有的本地內存(工作內存)本地內存存儲了該線程讀——寫共享的變量的副本。
JMM只是一個抽象的概念,在現實中並不存在,其中所有的存儲區域都在堆內存當中。JMM的模型圖如下圖所示:
而java線程對於共享變量的操作都是對於本地內存(工作內存)中的副本的操作,並沒有對共享內存中原始的共享變量進行操作;
以線程1和線程2為例,假設線程1修改了共享變量,那么他們之間需要通信就需要兩個步驟:
- 線程1本地內存中修改過的共享變量的副本同步到共享內存中去
- 線程2從共享內存中讀取被線程1更新過的共享變量
這樣才能完成線程1的修改對線程2的可見。
內存間的交互操作
為了完成這一線程之間的通信,JMM為內存間的交互操作定義了8個原子操作,如下表:
操作 | 作用域 | 說明 |
---|---|---|
lock(鎖定) | 共享內存中的變量 | 把一個變量標識為一條線程獨占的狀態 |
unlock(解鎖) | 共享內存中的變量 | 把一個處於鎖定的變量釋放出來,釋放后其他線程可以進行訪問 |
read(讀取) | 共享內存中的變量 | 把一個變量的值從共享內存傳輸到線程的工作內存。供隨后的load操作使用 |
load(載入) | 工作內存 | 把read操作從共享內存中得到的變量值放入工作內存的變量副本當中 |
use(使用) | 工作內存 | 把工作內存中的一個變量值傳遞給執行引擎 |
assign(賦值) | 工作內存 | 把一個從執行引擎接受到的值賦值給工作內存的變量 |
store(存儲) | 作用於工作內存 | 把一個工作內存中的變量傳遞給共享內存,供后續的write使用 |
write(寫入) | 共享內存中的變量 | 把store操作從工作內存中得到的變量的值放入主內存 |
JMM規定JVM四線時必須保證上述8個原子操作是不可再分割的,同時必須滿足以下的規則:
- 不允許
read
和load
、store
和write
操作之一單獨出現,即不允許只從共享內存讀取但工作內存不接受,或者工作捏村發起回寫但是共享內存不接收 - 不允許一個線程舍棄
assign
操作,即當一個線程修改了變量后必須寫回工作內存和共享內存 - 不允許一個線程將未修改的變量值寫回共享內存
- 變量只能從共享內存中誕生,不允許線程直接使用未初始化的變量
- 一個變量同一時刻只能由一個線程對其執行
lock
操作,但是一個變量可以被同一個線程重復執行多次lock
,但是需要相同次數的unlock
- 如果對一個變量執行
lock
操作,那么會清空工作內存中此變量的值,在執行引擎使用這個變量之前需要重新執行load和assign - 不允許
unlock
一個沒有被鎖定的變量,也不允許unlock
一個其他線程lock
的變量 - 對一個變量
unlock
之前必須把此變量同步回主存當中。
對
long
和double
的特殊操作
在一些32位的處理器上,如果要求對64位的long
和double
的寫具有原子性,會有較大的開銷,為了照固這種情況,
java語言規范鼓勵但不要求虛擬機對64位的long
和double
型變量的寫操作具有原子性,當JVM在這種處理器上運行時,
可能會把64位的long和double拆分成兩次32位的寫
指令屏障
為了保證內存的可見性,JMM的編譯器會禁止特定類型的編譯器重新排序;對於處理器的重新排序,
JMM會要求編譯器在生成指令序列時插入特定類型的的內存屏障指令,通過內存屏障指令巾紙特定類型的處理器重新排序
JMM規定了四種內存屏障,具體如下:
屏障類型 | 指令示例 | 說明 |
---|---|---|
LoadLoad Barriers | Load1;LoadLoad;Load2 | 確保Load1的數據先於Load2以及所有后續裝在指令的裝載 |
StoreStore Barries | Store1;StoreStore;Store2 | 確保Store1數據對於其他處理器可見(刷新到內存)先於Store2及后續存儲指令的存儲 |
LoadStore Barriers | Load1;LoadStore;Store2 | 確保Load1的裝載先於Store2及后續所有的存儲指令 |
StoreLoad Barrier | Store1;StoreLoad;Load2 | 確保Store1的存儲指令先於Load1以及后續所所有的加載指令 |
StoreLoad
是一個“萬能”的內存屏障,他同時具有其他三個內存屏障的效果,現代的處理器大都支持該屏障(其他的內存屏障不一定支持),
但是執行這個內存屏障的開銷很昂貴,因為需要將處理器緩沖區所有的數據刷回內存中。
happens-before規則
在JSR-133種內存模型種引入了happens-before
規則來闡述操作之間的內存可見性。在JVM種如果一個操作的結果過需要對另一個操作可見,
那么兩個操作之間必然要存在happens-bsfore關系:
- 程序順序規則:一個線程中的個每個操作,happens-before於該線程的后續所有操作
- 監視器鎖規則:對於一個鎖的解鎖,happens-before於隨后對於這個鎖的加鎖
- volatitle變量規則:對於一個volatile的寫,happens-before於認意后續對這個volatile域的讀
- 線程啟動原則:對線程的start()操作先行發生於線程內的任何操作
- 線程終止原則:線程中的所有操作先行發生於檢測到線程終止,可以通過Thread.join()、Thread.isAlive()的返回值檢測線程是否已經終止
- 線程終端原則:對線程的interrupt()的調用先行發生於線程的代碼中檢測到中斷事件的發生,可以通過Thread.interrupted()方法檢測是否發生中斷
- 對象終結原則:一個對象的初始化完成(構造方法執行結束)先行發生於它的finalize()方法的開始。
- 傳遞性:如果A happens-before B B happends-beforeC,那么A happends-before C
指令重排序
從源程序到字節指令的重排序
眾所周知,JVM執行的是字節碼,Java源代碼需要先編譯成字節碼程序才能在Java虛擬機中運行,但是考慮下面的程序;
int a = 1;
int b = 1;
在這段代碼中,a
和b
沒有任何的相互依賴關系,因此完全可以先對b
初始化賦值,再對a
變量初始化賦值;
事實上,為了提高性能,編譯器和處理器通常會對指令做重新排序。重排序分為3種:
- 編譯器優化的重排序。編譯器在不改變單線程的程序語義的前提下,可以安排字語句的執行順序。編譯器的對象是語句,不是字節碼,
但是反應的結果就是編譯后的字節碼和寫的語句順序不一致。 - 執行級並行的重排序。現代處理器采用了並行技術,來將多條指令重疊執行。如果不存在數據依賴性,處理器可以改變語句對應機器指令的執行順序。
- 內存系統的重排序,由於處理器使用了緩存和讀/寫緩沖區,這使得加載和存儲操作看上去可能是在亂序執行。
數據依賴性:如果兩個操作訪問同一個變量,且兩個操作有一個是寫操作,則這兩個操作存在數據依賴性,改變這兩個操作的執行順序,就會改變執行結果。
盡管指令重排序會提高代碼的執行效率,但是卻為多線程編程帶來了問題,多線程操作共享變量需要一定程度上遵循代碼的編寫順序,
也需要將修改的共享數據存儲到共享內存中,不按照代碼順序執行可能會導致多線程程序出現內存可見性的問題,那又如何實現呢?
as-if-serial語義
as-if-serial
語義:不論程序怎樣進行重排序,(單線程)程序的執行結果不能被改變。編譯器、runtime
和處理器都必須支持as-if-serial
語義。
程序順序規則
假設存在以下happens-before
程序規則:
1) A happens-before B
2) B happens-before C
3) A happens-before C
盡管這里存在A happens-before B
這一關系,但是JMM並不要求A
一定要在B
之前執行,僅僅要求A
的執行結果對B
可見。
即JMM僅要求前一個操作的結果對於后一個操作可見,並且前一個操作按照順序排在后一個操作之前。
但是若前一個操作放在后一個操作之后執行並不影響執行結果,則JMM認為這並不違法,JMM允許這種重排序。
順序一致性模型
在一個線程中寫一個變量,在另一個線程中同時讀取這個變量,讀和寫沒有通過排序來同步來排序,就會引發數據競爭。
數據競爭的核心原因是程序未正確同步。如果一個多線程程序是正確同步的,這個程序將是一個沒有數據競爭的程序。
順序一致性模型只是一個參考模型。
順序一致性模型特性
- 一個線程中所有的操作必須按照程序的順序來執行。
- 不管線程是否同步,所有的線程都只能看到一個單一的執行順序。
在順序一致性模型中每個曹祖都必須原子執行且立刻對所有線程可見。
當程序未正確同步會發生什么
當線程未正確同步時,JMM只提供最小的安全性,當讀取到一個值時,這個值要么是之前寫入的值,要么是默認值。
JMM保證線程的操作不會無中生有。為了保證這一特點,JMM在分配對象時,首先會對內存空間清0,然后才在上面分配對象。
未同步的程序在JMM種執行時,整體上是無序的,執行結果也無法預知。位同步程序子兩個模型中執行特點有如下幾個差異:
- 順序一致性模型保證單線程內的操作會按照程序的順序執行,而JMM不保證單線程內的操作會按照程序的順序執行
- 順序一致性模型保證所有線程只能看到一致的操作執行順序,而JMM不保證所有線程能看到一致的操作執行順序
- JMM不保證對64位的
long
和double
型變量具有寫操作的原子性,而順序一致性模型保證對所有的內存的讀/寫操作都具有原子性
參考資料
java並發編程的藝術-方騰飛,魏鵬,程曉明著