最新详细的JMM内存模型(三天熬夜血肝)
知識圖譜
前言
網上并發以及JMM部分的內容大部分都特別的亂,也不好整理。花了三四天時間才整理了一篇,有些概念的東西,是需要了解的,也標注出來了。
標注:在學習中需要修改的內容以及筆記全在這里 www.javanode.cn,謝謝!有任何不妥的地方望糾正
并發編程的優缺點
1. 為什么要用到并發
多核的CPU的背景下,催生了并發編程的趨勢,通過并發編程的形式可以將多核CPU的計算能力發揮到極致,性能得到提升
面對復雜業務模型,并行程序會比串行程序更適應業務需求,而并發編程更能吻合這種業務拆分
2. 并發編程有哪些缺點
2.1 頻繁的上下文切換
時間片是CPU分配給各個線程的時間,因為時間非常短,所以CPU不斷通過切換線程,讓我們覺得多個線程是同時執行的,時間片一般是幾十毫秒。而每次切換時,需要保存當前的狀態起來,以便能夠進行恢復先前狀態,而這個切換時非常損耗性能,過于頻繁反而無法發揮出多線程編程的優勢。通常減少上下文切換可以采用無鎖并發編程,CAS算法,使用最少的線程和使用協程。
2.2 線程安全
多線程編程中最難以把握的就是臨界區線程安全問題,稍微不注意就會出現死鎖的情況,一旦產生死鎖就會造成系統功能不可用。
public class DeadLockDemo {private static String resource_a = "A";private static String resource_b = "B";public static void main(String[] args) {deadLock();}public static void deadLock() {Thread threadA = new Thread(new Runnable() {@Overridepublic void run() {synchronized (resource_a) {System.out.println("get resource a");try {Thread.sleep(3000);synchronized (resource_b) {System.out.println("get resource b");}} catch (InterruptedException e) {e.printStackTrace();}}}});Thread threadB = new Thread(new Runnable() {@Overridepublic void run() {synchronized (resource_b) {System.out.println("get resource b");synchronized (resource_a) {System.out.println("get resource a");}}}});threadA.start();threadB.start();} }通常可以用如下方式避免死鎖的情況
并發三要素(了解)
可見性: CPU緩存引起
可見性:當多個線程訪問同一個變量時,如果其中一個線程對其作了修改,其他線程能立即獲取到最新的值。
原子性: 分時復用引起
原子性:即一個操作或者多個操作 要么全部執行并且執行的過程不會被任何因素打斷,要么就都不執行
有序性: 重排序引起
程序執行的順序按照代碼的先后順序執行。(處理器可能會對指令進行重排序)
在執行程序時為了提高性能,編譯器和處理器常常會對指令做重排序。重排序分三種類型:
- 編譯器優化的重排序。編譯器在不改變單線程程序語義的前提下,可以重新安排語句的執行順序。
- 指令級并行的重排序。現代處理器采用了指令級并行技術(Instruction-Level Parallelism, ILP)來將多條指令重疊執行。如果不存在數據依賴性,處理器可以改變語句對應機器指令的執行順序。
- 內存系統的重排序。由于處理器使用緩存和讀 / 寫緩沖區,這使得加載和存儲操作看上去可能是在亂序執行。
并發核心概念(了解)
并發與并行(重要)
-
第一種
-
在單CPU系統中,系統調度在某一時刻只能讓一個線程運行,雖然這種調試機制有多種形式(大多數是時間片輪巡為主),但無論如何,要通過不斷切換需要運行的線程讓其運行的方式就叫并發(concurrent)。
-
而在多CPU系統中,可以讓兩個以上的線程同時運行,這種可以同時讓兩個以上線程同時運行的方式叫做并行
-
-
第二種
你吃飯吃到一半,電話來了,你一直到吃完了以后才去接,這就說明你不支持并發也不支持并行。
你吃飯吃到一半,電話來了,你停了下來接了電話,接完后繼續吃飯,這說明你支持并發。
你吃飯吃到一半,電話來了,你一邊打電話一邊吃飯,這說明你支持并行。
并發的關鍵是你有處理多個任務的能力,不一定要同時。
并行的關鍵是你有同時處理多個任務的能力。
關鍵的點就是:是否是『同時』。
同步(重要)
在并發中,我們可以將同步定義為一種協調兩個或更多任務以獲得預期結果的機制。同步的方式有兩種:
-
控制同步:例如,當一個任務的開始依賴于另一個任務的結束時,第二個任務不能在第一個任務完成之前開始。
-
數據訪問同步:當兩個或更多任務訪問共享變量時,在任意時間里,只有一個任務可以訪問該變量。
與同步密切相關的一個概念是臨界段。臨界段是一段代碼,由于它可以訪問共享資源,因此在任何給定時間內,只能被一個任務執行。互斥是用來保證這一要求的機制,而且可以采用不同的方式來實現。
并發系統中有不同的同步機制。從理論角度看,最流行的機制如下:
-
信號量(semaphore):一種用于控制對一個或多個單位資源進行訪問的機制。它有一個用于存放可用資源數量的變量,而且可以采用兩種原子操作來管理該變量。互斥(mutex,mutual exclusion的簡寫形式)是一種特殊類型的信號量,它只能取兩個值(即資源空閑和資源忙),而且只有將互斥設置為忙的那個進程才可以釋放它。互斥可以通過保護臨界段來幫助你避免出現競爭條件。
-
監視器:一種在共享資源上實現互斥的機制。它有一個互斥、一個條件變量、兩種操作(等待條件和通報條件)。一旦你通報了該條件,在等待它的任務中只有一個會繼續執行。如果共享數據的所有用戶都受到同步機制的保護,那么代碼(或方法、對象)就是線程安全的。數據的非阻塞的CAS(compare-and-swap,比較和交換)原語是不可變的,這樣就可以在并發應用程序中使用該代碼而不會出任何問題。
不可變對象
不可變對象是一種非常特殊的對象。在其初始化后,不能修改其可視狀態(其屬性值)。如果想修改一個不可變對象,那么你就必須創建一個新的對象。
不可變對象的主要優點在于它是線程安全的。你可以在并發應用程序中使用它而不會出現任何問題。
不可變對象的一個例子就是java中的String類。當你給一個String對象賦新值時,會創建一個新的String對象。
原子操作和原子變量
與應用程序的其他任務相比,原子操作是一種發生在瞬間的操作。在并發應用程序中,可以通過一個臨界段來實現原子操作,以便對整個操作采用同步機制。
原子變量是一種通過原子操作來設置和獲取其值的變量。可以使用某種同步機制來實現一個原子變量,或者也可以使用CAS以無鎖方式來實現一個原子變量,而這種方式并不需要任何同步機制。
共享內存與消息傳遞(重要)
任務可以通過兩種不同的方式來相互通信。
-
共享內存,通常用于在同一臺計算機上運行多任務的情況。任務在讀取和寫入值的時候使用相同的內存區域。為了避免出現問題,對該共享內存的訪問必須在一個由同步機制保護的臨界段內完成。
-
消息傳遞,通常用于在不同計算機上運行多任務的情形。當一個任務需要與另一個任務通信時,它會發送一個遵循預定義協議的消息。如果發送方保持阻塞并等待響應,那么該通信就是同步的;如果發送方在發送消息后繼續執行自己的流程,那么該通信就是異步的。
并發的問題(了解)
數據競爭
如果有兩個或者多個任務在臨界段之外對一個共享變量進行寫入操作,也就是說沒有使用任何同步機制,那么應用程序可能存在數據競爭(也叫做競爭條件)。
在這些情況下,應用程序的最終結果可能取決于任務的執行順序。
public class ConcurrentDemo { private float myFloat; public void modify(float difference) { float value = this.myFloat; this.myFloat = value + difference;}public static void main(String[] args) {} }死鎖
當兩個(或多個)任務正在等待必須由另一線程釋放的某個共享資源,而該線程又正在等待必須由前述任務之一釋放的另一共享資源時,并發應用程序就出現了死鎖。當系統中同時出現如下四種條件時,就會導致這種情形。我們將其稱為Coffman 條件。
- 互斥: 死鎖中涉及的資源、必須是不可共享的。一次只有一個任務可以使用該資源。
- 占有并等待條件: 一個任務在占有某一互斥的資源時又請求另一互斥的資源。當它在等待時,不會釋放任何資源。
- 不可剝奪:資源只能被那些持有它們的任務釋放。
- 循環等待:任務1正等待任務2 所占有的資源, 而任務2 又正在等待任務3 所占有的資源,以此類推,最終任務n又在等待由任務1所占有的資源,這樣就出現了循環等待。
有一些機制可以用來避免死鎖。
-
忽略它們:這是最常用的機制。你可以假設自己的系統絕不會出現死鎖,而如果發生死鎖,結果就是你可以停止應用程序并且重新執行它。
-
檢測:系統中有一項專門分析系統狀態的任務,可以檢測是否發生了死鎖。如果它檢測到了死鎖,可以采取一些措施來修復該問題,例如,結束某個任務或者強制釋放某一資源。
-
預防:如果你想防止系統出現死鎖,就必須預防Coffman 條件中的一條或多條出現
-
規避:如果你可以在某一任務執行之前得到該任務所使用資源的相關信息,那么死鎖是可以規避的。當一個任務要開始執行時,你可以對系統中空閑的資源和任務所需的資源進行分析,這樣就可以判斷任務是否能夠開始執行。
活鎖
如果系統中有兩個任務,它們總是因對方的行為而改變自己的狀態, 那么就出現了活鎖。最終結果是它們陷入了狀態變更的循環而無法繼續向下執行。
例如,有兩個任務:任務1和任務2 ,它們都需要用到兩個資源:資源1和資源2 。假設任務1對資源1加了一個鎖,而任務2 對資源2 加了一個鎖。當它們無法訪問所需的資源時,就會釋放自己的資源并且重新開始循環。這種情況可以無限地持續下去,所以這兩個任務都不會結束自己的執行過程。
資源不足
當某個任務在系統中無法獲取維持其繼續執行所需的資源時,就會出現資源不足。當有多個任務在等待某一資源且該資源被釋放時,系統需要選擇下一個可以使用該資源的任務。如果你的系統中沒有設計良好的算法,那么系統中有些線程很可能要為獲取該資源而等待很長時間。
要解決這一問題就要確保公平原則。所有等待某一資源的任務必須在某一給定時間之內占有該資源。可選方案之一就是實現一個算法,在選擇下一個將占有某一資源的任務時,對任務已等待該資源的時間因素加以考慮。然而,實現鎖的公平需要增加額外的開銷,這可能會降低程序的吞吐量。
優先權反轉
當一個低優先權的任務持有了一個高優先級任務所需的資源時,就會發生優先權反轉。這樣的話,低優先權的任務就會在高優先權的任務之前執行。
java內存模型(JMM) 重要
JMM概述
出現線程安全的問題一般是因為主內存和工作內存數據不一致性和重排序導致的,而解決線程安全的問題最重要的就是理解這兩種問題是怎么來的,那么,理解它們的核心在于理解java內存模型(JMM)。
Java 的并發采用的是共享內存模型,Java 線程之間的通信總是隱式進行,整個通信過程對程序員完全透明。如果編寫多線程程序的 Java 程序員不理解隱式進行的線程之間通信的工作機制,很可能會遇到各種奇怪的內存可見性問題。我們需要處理兩個關鍵問題:線程之間如何通信及線程之間如何同步(這里的線程是指并發執行的活動實體)。通信是指線程之間以何種機制來交換信息。緊接著我們需要知道java中那些是共享內存
共享變量與局部變量
-
共享變量:在 java 中,所有實例域、靜態域和數組元素存儲在堆內存中,堆內存在線程之間共享。
-
局部變量(Local variables), 方法定義參數(java 語言規范稱之為 formal method parameters)和異常處理器參數(exception handler parameters)不會在線程之間共享,它們不會有內存可見性問題,也不受內存模型的影響。
JMM內存模型抽象
Java 線程之間的通信由 Java 內存模型(JMM java method model)控制,JMM 決定一個線程對共享變量的寫入何時對另一個線程可見。從抽象的角度來看,JMM 定義了線程和主內存之間的抽象關系:線程之間的共享變量存儲在主內存(main memory)中,每個線程都有一個私有的本地內存(local memory),本地內存中存儲了該線程以讀 / 寫共享變量的副本。本地內存是 JMM 的一個抽象概念,并不真實存在。它涵蓋了緩存,寫緩沖區,寄存器以及其他的硬件和編譯器優化。
Java 內存模型的抽象示意圖如下:
從上圖來看,線程 A 與線程 B 之間如要通信的話,必須要經歷下面 2 個步驟:
- 首先,線程 A 把本地內存 A 中更新過的共享變量刷新到主內存中去。
- 然后,線程 B 到主內存中去讀取線程 A 之前已更新過的共享變量。
線程A和線程B通過共享變量在進行隱式通信。如果線程A更新后數據并沒有及時寫回到主存,而此時線程B讀到的是過期的數據,這就出現了“臟讀”現象。可以通過同步機制(控制不同線程間操作發生的相對順序)來解決或者通過volatile關鍵字使得每次volatile變量都能夠強制刷新到主存,從而對每個線程都是可見的。
重排序(重要)
一個好的內存模型實際上會放松對處理器和編譯器規則的束縛,也就是說軟件技術和硬件技術都為同一個目標而進行奮斗:在不改變程序執行結果的前提下,盡可能提高并行度。JMM對底層盡量減少約束,使其能夠發揮自身優勢。因此,在執行程序時,為了提高性能,編譯器和處理器常常會對指令進行重排序。
Store Buffer的延遲寫入是重排序的一種,稱為內存重排序(Memory Ordering)。除此之外,還有編譯器和CPU的指令重排序。
編譯器重排序。
對于沒有先后依賴關系的語句,編譯器可以重新調整語句的執行順序。
CPU指令重排序。
在指令級別,讓沒有依賴關系的多條指令并行。
CPU內存重排序。
CPU有自己的緩存,指令的執行順序和寫入主內存的順序不完全一致。
**1屬于編譯器重排序,而2和3統稱為CPU處理器重排序。**這些重排序會導致線程安全的問題,一個很經典的例子就是DCL問題.
假設:X、Y是兩個全局變量,初始的時候,X,Y是全局變量并 X=0,Y=0。 線程A,B 分別執行各自的值。線程1和線程2的執行先后順序是不確定的,可能順序執行,也可能交叉執行,這就造成內存可見性問題。可能會出現結果可能是:
對于編譯器,JMM 的編譯器重排序規則會禁止特定類型的編譯器重排序(不是所有的編譯器重排序都要禁止)。對于CPU處理器重排序,JMM 的處理器重排序規則會要求 java 編譯器在生成指令序列時,插入特定類型的內存屏障指令,通過內存屏障指令來禁止特定類型的處理器重排序`(不是所有的處理器重排序都要禁止)。
內存屏障(了解)
為了禁止編譯器重排序和 CPU 重排序,在編譯器和 CPU 層面都有對應的指令,也就是內存屏障(Memory Barrier)。這也正是JMM和happen-before規則的底層實現原理。
編譯器的內存屏障,只是為了告訴編譯器不要對指令進行重排序。當編譯完成之后,這種內存屏障就消失了,CPU并不會感知到編譯器中內存屏障的存在。
而CPU的內存屏障是CPU提供的指令,可以由開發者顯示調用。內存屏障是很底層的概念,對于 Java 開發者來說,一般用 volatile 關鍵字就足夠了。但從JDK 8開始,Java在Unsafe類中提供了三個內存屏障函數,如下所示。
public final class Unsafe { // ... public native void loadFence(); public native void storeFence(); public native void fullFence();// ... }在理論層面,可以把基本的CPU內存屏障分成四種:
LoadLoad:禁止讀和讀的重排序。
StoreStore:禁止寫和寫的重排序。
LoadStore:禁止讀和寫的重排序。
StoreLoad:禁止寫和讀的重排序。
Unsafe中的方法:
loadFence=LoadLoad+LoadStore
storeFence=StoreStore+LoadStore
fullFence=loadFence+storeFence+StoreLoad
as-if-serial語義(了解)
as-if-serial語義的意思是:不管怎么重排序(編譯器和處理器為了提供并行度),(單線程)程序的執行結果不能被改變。
重排序的原則是什么?什么場景下可以重排序,什么場景下不能重排序呢?
無論什么語言,站在編譯器和CPU的角度來說,不管怎么重排序,單線程程序的執行結果不能改變,這就是單線程程序的重排序規則。
即只要操作之間沒有數據依賴性,編譯器和CPU都可以任意重排序,因為執行結果不會改變,代碼看起來就像是完全串行地一行行從頭執行到尾,這也就是as-if-serial語義。
對于單線程程序來說,編譯器和CPU可能做了重排序,但開發者感知不到,也不存在內存可見性問題。
編譯器和CPU的這一行為對于單線程程序沒有影響,但對多線程程序卻有影響。
對于多線程程序來說,線程之間的數據依賴性太復雜,編譯器和CPU沒有辦法完全理解這種依賴性并據此做出最合理的優化。
編譯器和CPU只能保證每個線程的as-if-serial語義。
線程之間的數據依賴和相互影響,需要編譯器和CPU的上層來確定。
上層要告知編譯器和CPU在多線程場景下什么時候可以重排序,什么時候不能重排序。
happens-before定義
JMM可以通過happens-before關系向程序員提供跨線程的內存可見性保證這兩個操作既可以是在一個線程之內,也可以是在不同線程之間。
-
如果一個操作happens-before另一個操作,那么第一個操作的執行結果將對第二個操作可見,而且第一個操作的執行順序排在第二個操作之前。
-
兩個操作之間存在happens-before關系,并不意味著Java平臺的具體實現必須要按照happens-before關系指定的順序來執行。如果重排序之后的執行結果,與按happens-before關系來執行的結果一致,那么這種重排序并不非法(也就是說,JMM允許這種重排序)。
上面的1)是JMM對程序員的承諾。從程序員的角度來說,可以這樣理解happens-before關系:如果A happens-before B,那么Java內存模型將向程序員保證——A操作的結果將對B可見,且A的執行順序排在B之前。注意,這只是Java內存模型向程序員做出的保證!
上面的2)是JMM對編譯器和處理器重排序的約束原則。正如前面所言,JMM其實是在遵循一個基本原則:只要不改變程序的執行結果(指的是單線程程序和正確同步的多線程程序),編譯器和處理器怎么優化都行。JMM這么做的原因是:程序員對于這兩個操作是否真的被重排序并不關心,程序員關心的是程序執行時的語義不能被改變(即執行結果不能被改變)。因此,happens-before關系本質上和as-if-serial語義是一回事。
as-if-serial和happens-before的區別
happens-before規則(了解)
happens-before值傳遞(了解)
這些基本的happen-before規則,happen-before還具有傳遞性,即若A happen-before B,Bhappen-before C,則A happen-before C。
舉例:
- volatile
如果一個變量不是volatile變量,當一個線程讀取、一個線程寫入時可能有問題。那豈不是說,在多線程程序中,我們要么加鎖,要么必須把所有變量都聲明為volatile變量?這顯然不可能,而這就得歸功于happen-before的傳遞性。
class A { private int a = 0; private volatile int c = 0; public void set() { a = 5; // 操作1 c = 1; // 操作2 }public int get() { int d = c; // 操作3 return a; // 操作4 } }? 操作1和操作2是在同一個線程內存中執行的,操作1 happen-before 操作2,同理,操作3 happen,before操作4。又因為c是volatile變量,對c的寫入happen-before對c的讀取,所以操作2 happen,before操作3。利用happen-before的傳遞性,就得到:
? 操作1 happen-before 操作2 happen-before 操作3 happen-before操作4。
- synchronized
因為與volatile一樣,synchronized同樣具有happen-before語義。展開上面的代碼可得到類似于下面的偽代碼:
class A { private int a = 0; private int c = 0; public synchronized void set() {a = 5; // 操作1 c = 1; // 操作2 }public synchronized int get() { return a; } }JMM的設計(重要)
上面已經聊了關于JMM的兩個方面:1. JMM的抽象結構(主內存和線程工作內存);2. 重排序以及happens-before規則。
- 上層會有基于JMM的關鍵字和J.U.C包下的一些具體類用來方便程序員能夠迅速高效率的進行并發編程。
- JMM處于中間層,包含了兩個方面:1. 內存模型;2.重排序以及happens-before規則。為了禁止特定類型的重排序會對編譯器和處理器指令序列加以控制。
在設計JMM時需要考慮兩個關鍵因素:
- 程序員對內存模型的使用 程序員希望內存模型易于理解、易于編程。程序員希望基于一個強內存模型來編寫代碼
- 編譯器和處理器對內存模型的實現 編譯器和處理器希望內存模型對它們的束縛越少越好,這樣它們就可以做盡可能多的優化來提高性能。編譯器和處理器希望實現一個弱內存模型。
JMM 把 happens- before 要求禁止的重排序分為了下面兩類:
- 會改變程序執行結果的重排序。
- 不會改變程序執行結果的重排序。
JMM 對這兩種不同性質的重排序,采取了不同的策略:
- 對于會改變程序執行結果的重排序,JMM 要求編譯器和處理器必須禁止這種重排序。
- 對于不會改變程序執行結果的重排序,JMM 對編譯器和處理器不作要求(JMM 允許這種重排序)
從上圖可以看出兩點:
- JMM 向程序員提供的 happens- before 規則能滿足程序員的需求。JMM 的 happens- before 規則不但簡單易懂,而且也向程序員提供了足夠強的內存可見性保證(有些內存可見性保證其實并不一定真實存在,比如上面的 A happens- before B)。
- JMM 對編譯器和處理器的束縛已經盡可能的少。從上面的分析我們可以看出,JMM 其實是在遵循一個基本原則:只要不改變程序的執行結果(指的是單線程程序和正確同步的多線程程序),編譯器和處理器怎么優化都行。比如,如果編譯器經過細致的分析后,認定一個鎖只會被單個線程訪問,那么這個鎖可以被消除。再比如,如果編譯器經過細致的分析后,認定一個 volatile 變量僅僅只會被單個線程訪問,那么編譯器可以把這個 volatile 變量當作一個普通變量來對待。這些優化既不會改變程序的執行結果,又能提高程序的執行效率。
JMM 的內存可見性保證(重要)
Java 程序的內存可見性保證按程序類型可以分為下列三類:
- 單線程程序。單線程程序不會出現內存可見性問題。編譯器,runtime 和處理器會共同確保單線程程序的執行結果與該程序在順序一致性模型中的執行結果相同。
- 正確同步的多線程程序。正確同步的多線程程序的執行將具有順序一致性(程序的執行結果與該程序在順序一致性內存模型中的執行結果相同)。這是 JMM 關注的重點,JMM 通過限制編譯器和處理器的重排序來為程序員提供內存可見性保證。
- 未同步 / 未正確同步的多線程程序。JMM 為它們提供了最小安全性保障:線程執行時讀取到的值,要么是之前某個線程寫入的值,要么是默認值(0,null,false)。
下圖展示了這三類程序在 JMM 中與在順序一致性內存模型中的執行結果的異同:
標注:在學習中需要修改的內容以及筆記全在這里 www.javanode.cn,謝謝!有任何不妥的地方望糾正
總結
以上是生活随笔為你收集整理的最新详细的JMM内存模型(三天熬夜血肝)的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 脱发篇-多线程基础(下)来看看你知道多少
- 下一篇: 1202年最新最详细最全的synchro