Java併發編程的藝術筆記
- 併發編程的挑戰
- Java併發機制的底層實現原理
- Java內存模型
- Java併發編程基礎
- Java中的鎖的使用和實現介紹
- Java併發容器和框架
- Java中的12個原子操作類介紹
- Java中的併發工具類
- Java中的線程池 Executor框架
目錄
-
內存模型基礎
-
volatile的內存語義
-
鎖的內存語義
-
final域的內存語義
-
happens-before
-
雙重檢查鎖定與延遲初始化
-
Java內存模型綜述
-
小結
內存模型基礎
1、併發編程的兩個關鍵問題
-
線程之間如何通信?
通信是指 以何種機制來交換信息。
命令式編程中線程的通信機制主要是以下兩種:-
共享內存 的併發模型:通過 讀寫內存中的公共狀態 來進行隱式通信。
-
消息傳遞 的併發模型:沒有公共狀態,只能 通過發送消息來顯示的進行通信。
-
-
線程之間如何同步?
同步是指 程序中用於控制不同線程間操作發生相對順序 的機制。
-
共享內存 的併發模型:同步時顯示進行的。我們必須顯示指定某段代碼需要在線程直線互斥執行。
-
消息傳遞 的併發模型:由於消息發送必須在消息接收之前,因此同步時隱式的。
-
Java併發 採用的是 共享內存模型,Java線程之前的通信總是隱式進行的。
2、Java內存模型的抽象結構
在Java中,所有 實例域、靜態域 和 數組元素 都儲存在堆內存中,堆內存在線程之前共享。
本文用 共享變量 統一描述 實例域、靜態域 和 數組元素 。
局部變量 、方法定義參數、異常處理器參數 不會在內存之間共享,他們不會有內存可見性問題,也不受內存模型影響。
Java線程通信由Java內存模型(簡稱 JMM
)控制,JMM 決定一個線程對共享變量的寫入何時對另一個線程可見。
從抽象角度看,JMM定義了 線程 和 主內存 之間的抽象關係:線程之間的共享變量儲存在主內存中,每個線程都有一個私有的本地內存,本地內存儲存了 該線程 以讀寫共享變量的副本。
從上圖來看,線程A和線程B需要通信的話,需要經歷以下步驟:
1、線程A 把 本地內存A 中的 共享變量副本 刷新到 主內存 中。
2、線程B 去讀取 主內存 中 線程A 刷新過的 共享變量。
從整體來看,這兩個步驟實質上是線程A向線程B發送消息,而通信必須經過主內存。
JMM 通過控制主內存與每個線程的本地內存之間的交互,來提供內存可見性的保證。
3、從源代碼到指令序列的重排序
執行程序的時候,爲了提高性能,編譯器 和 處理器 常常會對指令做 重排序。
主要有以下三類:
- 編譯器優化的重排序 :編譯器在 不改變單線程程序語義 的前提下,可以重新安排語句的執行順序。
- 指令級並行的重排序 : 現代處理器採用 並行技術 來將**多條指令重疊執行,如果不存在數據依賴性,處理器可以改變對應機器指令的執行順序。
- 內存系統的重排序 : 由於處理使用緩存和讀寫緩衝區,這使得加載和存儲操作看上去可能亂序執行。
以下描述了源代碼到最終執行的指令序列的示意圖:
上圖中的 1 屬於 編譯器重排序,2 和 3 屬於 處理器重排序。這些重排序可能會導致多線程程序出現內存可見性問題。
對於編譯器重排序, JMM的編譯器重排序規則 會禁止特定類型的編譯器重排序。
對於處理器重排序,JMM的處理器重排序規則 會要求編譯器在生成指令序列時,插入特定類型的內存屏障指令,通過內存屏障指令來禁止特定類型的處理器重排序。
4、happens-before簡介
從 JDK5 開始,Java使用新的 JSR-133 內存模型。 JSR-133 使用 happens-before 的概念來闡述操作之間的內存可見性。在 JMM 中,如果一個操作執行的結果需要對另一個操作可見,則這兩個操作必須要存在happen-before關係 。
happen-before 規則如下:
- 程序順序規則:一個線程中的每個操作,happen-before與該線程中的任意後續操作
- 監視器鎖規則:對一個鎖的解鎖,happen-before與隨後這個鎖的加鎖
- volatile變量規則:對於一個volatile域的寫,happen-before與任意後續對這個volatile域的讀
- 傳遞性: A happen-before B,B happen-before C,則A happen-before C
volatile的內存語義
理解volatile
特性的一個好方法是把對volatile
變量的單個讀/寫,看成是 使用同一個鎖 對這些單個讀/寫操作做了同步。
volatile
變量具有下列特性:
- 可見性:總是能看到(任意線程)對這個volatile變量最後的寫入。
- 原子性:對任意單個volatile變量的讀/寫具有原子性,但類似於volatile++這種複合操作不具有原子性。
volatile
寫的內存語義:當寫一個volatile
變量時,JMM
會把該線程對應的本地內存中的共享變量值刷新到主內存。
volatile
讀的內存語義:當讀一個volatile
變量時,JMM
會把該線程對應的本地內存置爲無效。線程接下來將從主內存中讀取共享變量。
volatile內存語義的實現:
爲了實現volatile
內存語義,JMM 會分別限制這兩種類型的重排序類型。
以下是 JMM 針對 編譯器 制定的 volatile
重排序規則表:
第三行最後一個單元格(1)的意思是:在程序中,當第一個操作爲普通變量的讀或寫時,如果第二個操作爲volatile寫,則編譯器不能重排序這兩個操作。
在上表中,我們可以知道:
- 當第二個操作是
volatile 寫
時,不管第一個操作是什麼,都不能重排序。這個規則確保volatile 寫
之前的操作不會被編譯器重排序到volatile 寫
之後。 - 當第一個操作是
volatile 讀
時,不管第二個操作是什麼,都不能重排序。這個規則確保volatile 讀
之後的操作不會被編譯器重排序到volatile 讀
之前。 - 當第一個操作是
volatile 寫
,第二個操作是volatile 讀
時,不能重排序。
爲了實現volatile
的內存語義,編譯器在生成字節碼時,會在指令序列中插入 內存屏障 來禁止特定類型的 處理器重排序。
對於編譯器來說,發現一個最優佈置來最小化插入屏障的總數幾乎不可能。
爲此,JMM採取保守策略。
下面是基於保守策略的JMM內存屏障插入策略。
- 在每個
volatile 寫
操作的前面插入一個StoreStore
屏障,後面插入一個StoreLoad
屏障。 - 在每個
volatile 讀
操作的後面插入一個LoadLoad
屏障,後面插入一個LoadStore
屏障。
當讀線程的數量大大超過寫線程時,選擇在
volatile寫
之後插入StoreLoad
屏障將帶來可觀的執行效率的提升。
鎖的內存語義
- 鎖是Java併發編程中最重要的同步機制。鎖除了讓臨界區互斥執行外,還可以讓釋放鎖的線程向獲取同一個鎖的線程發送消息。
- 當線程釋放鎖時,JMM會把該線程對應的本地內存中的共享變量刷新到主內存中。和 volatile 寫 類似。
- 當線程獲取鎖時,JMM會把該線程對應的本地內存置爲無效。和 volatile 讀 類似。
鎖釋放和鎖獲取的內存語義總結:
- 線程A釋放一個鎖,實質上是線程A向接下來將要獲取這個鎖的某個線程發出了(線程A對共享變量所做修改的)消息。
- 線程B獲取一個鎖,實質上是線程B接收了之前某個線程發出的(在釋放這個鎖之前對共享變量所做修改的)消息。
- 線程A釋放鎖,隨後線程B獲取這個鎖,這個過程實質上是線程A通過主內存向線程B發送消息。
類似 Java併發編程基礎 介紹的 等待/通知 機制。
final域的內存語義
與前面介紹的鎖和volatile
相比,對final
域的讀和寫更像是普通的變量訪問。
對於final域,編譯器 和 處理器 要遵守兩個 重排序規則。
- 在構造函數內對一個
final
域的寫入,與隨後把這個被構造對象的引用賦值給一個引用變量,這兩個操作之間不能重排序。 - 初次讀一個包含
final
域的對象的引用,與隨後初次讀這個final
域,這兩個操作之間不能重排序。
寫final
域 的重排序規則禁止把final 域的寫重排序到構造函數之外。
讀 final
域 的重排序規則是,在一個線程中,初次讀對象引用與初次讀該對象包含的 final
域,JMM
禁止處理器重排序這兩個操作(注意,這個規則僅僅針對處理器)。
happens-before
happens-before
是 JMM 最核心的概念。
重排序規則:只要不改變程序的執行結果(指的是單線程程序和正確同步的多線程程序),編譯器和處理器怎麼優化都行。
happens-before
關係的定義如下:
- 如果一個操作
happens-before
另一個操作,那麼第一個操作的執行結果將對第二個操作可見,而且第一個操作的執行順序排在第二個操作之前。 - 兩個操作之間存在
happens-before
關係,並不意味着Java平臺的具體實現必須要按照happens-before
關係指定的順序來執行。如果重排序之後的執行結果,與按happens-before
關係來執行的結果一致,那麼這種重排序並不非法(也就是說,JMM允許這種重排序)。
《JSR-133:Java Memory Model and Thread Specification》 定義瞭如下happens-before
規則:
- 程序順序規則:一個線程中的每個操作,
happens-before
於該線程中的任意後續操作。 - 監視器鎖規則:對一個鎖的解鎖,
happens-before
於隨後對這個鎖的加鎖。 - volatile變量規則:對一個
volatile
域的寫,happens-before
於任意後續對這個volatile 域的讀。 - 傳遞性:如果A
happens-before
B,且Bhappens-befor
e C,那麼Ahappens-before
C。 - start()規則:如果
線程A
執行操作ThreadB.start()
(啓動線程B
),那麼A線程的ThreadB.start()
操作happens-before
於線程B中的任意操作。 - join()規則:如果線程A執行操作
ThreadB.join()
併成功返回,那麼線程B中的任意操作happens-before
於線程A從ThreadB.join()
操作成功返回。
雙重檢查鎖定與延遲初始化
雙重檢查鎖定 示例代碼:
private static Instance instance; //1
public static Instance getInstance() { //2
if (instance == null) { //3
synchronized (Instance.class) { //4
if (instance == null) { //5
instance = new Instance() //6
}
}
}
return instance;
}
存在的問題:
在線程執行到第3
行if (instance == null)
,代碼讀取到instance
不爲null
時,instance
引用的對象有可能還沒有完成初始化。
問題的根源
前面的雙重檢查鎖定示例代碼的第6
行instance=new Singleton();
創建了一個對象。
這一行可以分解爲:
1 memory = allocate(); // 1:分配對象的內存空間
2 ctorInstance(memory); // 2:初始化對象
3 instance = memory; // 3:設置instance指向剛分配的內存地址
代碼中的2
和3
之間,可能會被重排序爲:
1 memory = allocate(); // 1:分配對象的內存空間
2 instance = memory; // 3:設置instance指向剛分配的內存地址
// 注意,此時對象還沒有被初始化!
3 ctorInstance(memory); // 2:初始化對象
如下圖所示,只要保證2
排在4
的前面,即使2和3之間重排序了,也不會違反intra-thread semantics
。
如果發生重排序,另一個併發執行的線程B就有可能在示例代碼第 3
行if (instance == null)
判斷instance
不爲null
。
解決方法:
- 不允許圖中 2 和 3 重排序。
- 允許圖中 2 和 3 重排序,但不允許其他線程“看到”這個重排序。
基於volatile的解決方案
只需要給變量 instance
添加 volatile
修飾符。
private volatile static Instance instance;
public static Instance getInstance() {
if (instance == null) {
synchronized (Instance.class) {
if (instance == null) {
instance = new Instance();
}
}
}
return instance;
}
基於類初始化的解決方案
public static class InstanceFactory {
public static Instance getInstance() {
// 這裏將導致InstanceHolder類被初始化
return InstanceHolder.instance;
}
private static class InstanceHolder {
public static Instance instance = new Instance();
}
}
初始化一個類,包括執行這個類的靜態初始化和初始化在這個類中聲明的靜態字段。根據Java語言規範,在首次發生下列任意一種情況時,一個類或接口類型T將被立即初始化。
- T是一個類,而且一個T類型的實例被創建。
- T是一個類,且T中聲明的一個靜態方法被調用。
- T中聲明的一個靜態字段被賦值。
- T中聲明的一個靜態字段被使用,而且這個字段不是一個常量字段。
- T是一個頂級類(Top Level Class,見Java語言規範的§7.6),而且一個斷言語句嵌套在T內部被執行。
在InstanceFactory
示例代碼中,首次執行getInstance
()方法的線程將導致InstanceHolder
類被初始化(符合第4
條)。
由於Java語言是多線程的,多個線程可能在同一時間嘗試去初始化同一個類或接口。
因此,在Java中初始化一個類或者接口時,需要做細緻的同步處理。
Java語言規範規定,對於每一個類或接口C,都有一個唯一的初始化鎖LC與之對應。從C到LC的映射,由JVM的具體實現去自由實現。JVM在類初始化期間會獲取這個初始化鎖,並且每個線程至少獲取一次鎖來確保這個類已經被初始化過了。
Java內存模型綜述
處理器的內存模型
順序一致性內存模型 是一個 理論參考模型,JMM和處理器內存模型在設計時通常會以順序一致性內存模型爲參照。
在設計時,JMM和處理器內存模型會 對順序一致性模型做一些放鬆,因爲如果完全按照順序一致性模型來實現處理器和JMM,那麼很多的處理器和編譯器優化都要被禁止,這對執行性能將會有很大的影響。
根據對不同類型的讀/寫操作組合的執行順序的放鬆,可以把常見處理器的內存模型劃分爲如下幾種類型:
- 放鬆程序中寫-讀操作的順序,由此產生了
Total Store Ordering
內存模型(簡稱爲 TSO)。 - 在上面的基礎上,繼續放鬆程序中寫-寫操作的順序,由此產生了
Partial Store Order
內存模型(簡稱爲 PSO)。 - 在前面兩條的基礎上,繼續放鬆程序中讀-寫和讀-讀操作的順序,由此產生了
Relaxed Memory Order
內存模型(簡稱爲 RMO)和 PowerPC 內存模型。
從上圖中可知:
- 所有處理器內存模型都允許
寫-讀
重排序,因爲都使用了寫緩存區
。
由於寫緩存區僅對當前處理器可見,這個特性導致當前處理器可以比其他處理器先看到臨時保存在自己寫緩存區中的寫。 - 從上到下,模型由強變弱。越是追求性能的處理器,內存模型設計得會越弱。
由於常見的處理器內存模型比JMM要弱,Java編譯器在生成字節碼時,會在執行指令序列的適當位置插入 內存屏障 來限制處理器的重排序。
各種內存模型之間的關係
JMM 是一個語言級的內存模型。
處理器內存模型 是硬件級的內存模型。
順序一致性內存模型 是一個理論參考模型。
從下圖可以看出:
常見的4
種 處理器內存模型 比常用的3
中 語言內存模型 要 弱,
處理器內存模型 和 語言內存模型 都比 順序一致性內存模型 要 弱。
同處理器內存模型一樣,越是追求執行性能的語言,內存模型設計得會越弱。
JMM的內存可見性保證
按程序類型,Java程序的內存可見性保證可以分爲下列3類:
- 單線程程序:不會出現內存可見性問題。JMM爲它們提供了最小安全性保障:線程執行時讀取到的值,要麼是之前某個線程寫入的值,要麼是默認值(0、null、false)。
- 正確同步的多線程程序:程序的執行將具有順序一致性。這是JMM關注的重點,JMM通過限制編譯器和處理器的重排序來爲程序員提供內存可見性保證。
- 未同步/未正確同步的多線程程序:JMM爲它們提供了最小安全性保障:線程執行時讀取到的值,要麼是之前某個線程寫入的值,要麼是默認值(0、null、false)。
JSR-133對舊內存模型的修補
JSR-133 對 JDK 5 之前的舊內存模型的修補主要有兩個:
- 增強
volatile
的內存語義:限制volatile
變量與普通變量的重排序,使volatile的寫-讀和鎖的釋放-獲取具有相同的內存語義。 - 增強
final
的內存語義:保證final引用不會從構造函數內逸出的情況下,final
具有了初始化安全性。
小結
本文我們介紹了:
-
線程之後如何通信以及同步?
-
命令式編程的兩種通信機制:共享內存 和 消息傳遞。
Java併發採用的是 共享內存,通信時隱式進行的。
-
Java內存模型的抽象結構。 存儲在堆內存的
實例域
、靜態域
和數組元素
等才能在線程間共享。 -
三種類型的重排序。
-
happens-before
規則 -
volatile
的特性、內存語義以及實現。 -
鎖的內存語義
-
final
域的內存語義 -
基於
volatile
和 類初始化 兩種方式來處理 單例模式 雙重檢查鎖定的優化,以及出現問題的根源介紹。 -
對各內存模型的介紹和對比。
如果覺得不錯的話,請幫忙點個讚唄。