《深入理解 Java 虛擬機器》讀書筆記:Java 記憶體模型與執行緒
阿新 • • 發佈:2020-04-02
# 正文
由於計算機的處理器運算速度與它的儲存和通訊子系統速度的差距太大了,大量的時間都花費在磁碟 I/O、網路通訊或者資料庫訪問上,導致處理器在大部分時間裡都處於等待其他資源的狀態。因此,為了充分利用計算機的處理器運算能力,現代計算機作業系統採用了多工處理的方式,即讓計算機併發處理多個任務。
對於計算量相同的任務,程式執行緒併發協調得越有條不紊,效率自然就會越高;反之,執行緒之間頻繁阻塞甚至死鎖,將會大大降低程式的併發能力。
## 一、硬體的效率與一致性
### 1、快取記憶體
由於計算機的儲存裝置與處理器的運算速度有幾個數量級的差距,所以現代計算機系統加入了一層讀寫速度儘可能接近處理器運算速度的快取記憶體來作為記憶體與處理器之間的緩衝:將運算需要使用的資料複製到快取中,讓運算能快速進行,當運算結束後再從快取同步回記憶體中,這樣處理器就無須等待緩慢的記憶體讀寫了。
### 2、快取一致性
基於快取記憶體的儲存互動解決了處理器與記憶體的速度矛盾,但也引入了一個新的問題:快取一致性。
在多處理器系統中,每個處理器都有自己的快取記憶體,而它們又共享同一主記憶體。當多個處理器的運算任務都涉及同一塊主記憶體區域時,將可能導致各自的快取資料不一致。為了解決一致性的問題,需要各個處理器訪問快取時遵循一些協議,在讀寫時根據協議來進行操作,比如 MSI、MESI 等協議。
**處理器、快取記憶體、主記憶體間的互動關係:**
![](https://img2020.cnblogs.com/blog/1613877/202004/1613877-20200401231931259-346601500.jpg)
### 3、亂序執行
除了增加快取記憶體之外,為了使處理器內部的運算單元能儘量被充分利用,處理器可能會對輸入程式碼進行亂序執行優化。處理器會在計算之後將亂序執行的結果重組,保證該結果與順序執行的結果一致。
## 二、Java 記憶體模型
Java 記憶體模型的主要目標是定義程式中各個變數的訪問規則,即在虛擬機器中將變數儲存到記憶體和從記憶體中取出變數這樣的底層細節。此處的變數包括例項欄位、靜態欄位和構成陣列物件的元素,但不包括區域性變數與方法引數,因為後者是執行緒私有的,不會被共享,不存在競爭問題。
### 1、主記憶體與工作記憶體
Java 記憶體模型規定了所有的變數都儲存在主記憶體中。每條執行緒還有自己的工作記憶體,執行緒的工作記憶體中儲存了被該執行緒使用到的變數的主記憶體副本拷貝。
執行緒對變數的所有操作(讀取、賦值等)都必須在工作記憶體中進行,而不能直接讀寫主記憶體中的變數。不同的執行緒之間也無法直接訪問對方工作記憶體中的變數,執行緒間變數值的傳遞均需要通過主記憶體來完成。
從定義上來看,主記憶體主要對應於 Java 堆中的物件例項資料部分,而工作記憶體則對應於虛擬機器棧中的部分割槽域。
從更低層次上說,主記憶體直接對應於物理硬體的記憶體,而為了獲取更好的執行速度,虛擬機器(甚至是硬體系統本身的優化措施)可能會讓工作記憶體優先儲存於暫存器和快取記憶體中,因為程式執行時主要訪問讀寫的是工作記憶體。
### 2、記憶體間互動操作
關於主記憶體與工作記憶體之間的互動協議,即一個變數如何從主記憶體拷貝到工作記憶體、如何從工作記憶體同步回主記憶體之類的實現細節,Java 記憶體模型中定義了 8 種操作來完成,虛擬機器必須保證每一種操作都是原子的、不可再分的。
* **lock(鎖定)**:作用於主記憶體的變數,它把一個變數標識為一條執行緒獨佔的狀態。
* **unlock(解鎖)**:作用於主記憶體的變數,它把一個處於鎖定狀態的變數釋放出來,釋放後的變數才可以被其他執行緒鎖定。
* **read(讀取)**:作用於主記憶體的變數,它把一個變數的值從主記憶體傳輸到執行緒的工作記憶體中,以便隨後的 load 動作使用。
* **load(載入)**:作用於工作記憶體的變數,它把 read 操作從主記憶體中得到的變數值放入工作記憶體的變數副本中。
* **use(使用)**:作用於工作記憶體的變數,它把工作記憶體中一個變數的值傳遞給執行引擎,每當虛擬機器遇到一個需要使用到變數的值的位元組碼指令時將會執行這個操作。
* **assign(賦值)**:作用於工作記憶體的變數,它把一個從執行引擎接收到的值賦給工作記憶體的變數,每當虛擬機器遇到一個給變數賦值的位元組碼指令時執行這個操作。
* **store(儲存)**:作用於工作記憶體的變數,它把工作記憶體中一個變數的值傳送到主記憶體中,以便隨後的 write 操作使用。
* **write(寫入)**:作用於主記憶體的變數,它把 store 操作從工作記憶體中得到的變數的值放入主記憶體的變數中。
### 3、對於 volatile 型變數的特殊規則
**volatile 的作用:**
* 保證變數對所有執行緒的可見性。即當一條執行緒修改了某個變數的值,新值對於其他執行緒來說是可以立即得知的。
* 禁止指令重排序優化。
volatile 變數只能保證可見性,不能保證原子性。在以下運算場景中,仍然要通過加鎖(使用 synchronized 或 java.util.concurrent 中的原子類)來保證原子性:
* 運算結果依賴於變數的當前值,並且其他執行緒可能會修改變數的值。
* 變數需要與其他的狀態變數共同參與不變約束。
**對 volatile 變數的特殊規則:**
* 某個執行緒對 volatile 變數的 read、load、use 操作必須連續一起出現。這條規則要求在工作記憶體中,每次使用變數前都必須先從主記憶體重新整理最新的值,用於保證能看見其他執行緒對變數所做的修改後的值。
* 某個執行緒對 volatile 變數的 assign、store、write 操作必須連續一起出現。這條規則要求在工作記憶體中,每次修改變數後都必須立刻同步回主記憶體中,用於保證其他執行緒可以看到自己對變數所做的修改。
* 如果執行緒 A 的 use、assign 操作先於執行緒 B,那麼執行緒 A 的 read、write 也必須先於 執行緒 B。這條規則要求 volatile 變數不會被指令重排序優化,保證程式碼的執行順序與程式的順序相同。
### 4、對於 long 和 double 型變數的特殊規則
Java 記憶體模型允許虛擬機器將沒有被 volatile 修飾的 64 位資料(long 和 double)的讀寫操作劃分為兩次 32 位的操作來進行,即允許虛擬機器實現可以不保證 64 位資料型別的 load、store、read 和 write 這 4 個操作的原子性,這就是所謂的 long 和 double 的非原子性協定。
目前各平臺下的商用虛擬機器幾乎都把 64 位資料的讀寫操作實現為具有原子性的操作,因此在編寫程式碼時一般不需要把 long 和 double 變數專門宣告為 volatile。
### 5、原子性、可見性與有序性
Java 記憶體模型是圍繞著在併發過程中如何處理原子性、可見性和有序性這 3 個特徵來建立的。
#### (1)原子性
* 基本資料型別的訪問讀寫具備原子性(不考慮 long、double 的非原子性協定): Java 記憶體模型直接保證了 read、load、assign、use、store 和 write 操作的原子性。
* synchronized 程式碼塊之間的操作具備原子性:底層通過 lock 和 unlock 操作實現。
#### (2)可見性
可見性是指當一個執行緒修改了共享變數的值,其他執行緒能夠立即得知這個修改。
Java 記憶體模型是通過在變數修改後將新值同步回主記憶體,在變數讀取前從主記憶體重新整理變數值這種依賴主記憶體作為傳遞媒介的方式來實現可見性的。
volatile、synchronized、final 關鍵字都能實現可見性。
#### (3)有序性
Java 程式中天然的有序性可以總結為一句話:如果在本執行緒內觀察,所有的操作都是有序的;如果在一個執行緒中觀察另一個執行緒,所有的操作都是無序的。前半句是指“執行緒內表現為序列的語義”,後半句是指“指令重排序”現象和“工作記憶體與主記憶體同步延遲”現象。
Java 語言提供了 volatile、synchronized 關鍵字來保證執行緒之間操作的有序性。
### 6、先行發生原則
先行發生是 Java 記憶體模型中定義的兩項操作之間的偏序關係,如果說操作 A 先行發生於操作 B,其實就是說在發生操作 B 之前,操作 A 產生的影響能被操作 B 觀察到。“影響”包括修改了記憶體中共享變數的值、傳送了訊息、呼叫了方法等。它是判斷資料是否存在競爭、執行緒是否安全的主要依據。
**Java記憶體模型的先行發生關係:**
* **程式次序規則**:在一個執行緒內,按照程式程式碼順序,書寫在前的操作先行發生於書寫在後的操作。準確地說,是控制流順序而不是程式程式碼順序,因為要考慮分支、迴圈等結構。
* **管程鎖定規則**:一個 unlock 操作先行發生於後面(時間上的先後順序)對同一個鎖的 lock 操作。
* **volatile 變數規則**:對一個 volatile 變數的寫操作先行發生於後面(時間上的先後順序)對這個變數的讀操作。
* **執行緒啟動規則**:Thread 物件的 start() 方法先行發生於此執行緒的每一個動作。
* **執行緒終止規則**:執行緒中的所有操作都先行發生於對此執行緒的終止檢測,可以通過 Thread.isAlive() 方法檢測到執行緒是否已經終止執行。
* **執行緒中斷規則**:對執行緒 interrupt() 方法的呼叫先行發生於被中斷執行緒檢測到中斷事件的發生,可以通過 Thread.interrupted() 方法檢測到是否有中斷髮生。
* **物件終結規則**:一個物件的初始化完成(建構函式執行結束)先行發生於它的 finalize() 方法的開始。
* **傳遞性**:如果操作 A 先行發生於操作 B,操作 B 先行發生於操作 C,那麼可以得出操作 A 先行發生於操作 C。
如果兩個操作之間的關係不滿足以上規則,並且無法從以上規則推匯出來,那麼它們就沒有順序性保障,虛擬機器可以對它們隨意地進行重排序。
## 三、Java 與執行緒
### 1、執行緒的實現
主流的作業系統都提供了執行緒實現,Java 語言則提供了在不同硬體和作業系統平臺下對執行緒操作的統一處理,每個已經執行 start() 且還未結束的 java.lang.Thread 類的例項就代表了一個執行緒。
#### (1)使用核心執行緒實現
核心執行緒(Kernel-Level Thread,KLT)就是直接由作業系統核心(Kernel)支援的執行緒,這種執行緒由核心來完成執行緒切換,核心通過操縱排程器對執行緒進行排程,並負責將執行緒的任務對映到各個處理器上。
每個核心執行緒可以視為核心的一個分身,這樣作業系統就有能力同時處理多件事情,支援多執行緒的核心就叫做多執行緒核心。
程式一般不會直接使用核心執行緒,而是使用核心執行緒的一種高階介面——輕量級程序(Light Weight Process,LWP),輕量級程序就是通常意義上所講的執行緒,每個輕量級程序都由一個核心執行緒支援。這種輕量級程序與核心執行緒之間 1:1 的關係稱為**一對一的執行緒模型**。
![](https://img2020.cnblogs.com/blog/1613877/202004/1613877-20200401231950325-152150685.jpg)
**輕量級程序的侷限性:**
* 由於是基於核心執行緒實現的,所以各種執行緒操作,如建立、析構及同步,都需要進行系統呼叫。而系統呼叫的代價相對較高,需要在使用者態和核心態中來回切換。
* 每個輕量級程序都需要有一個核心執行緒的支援,會消耗一定的核心資源(如核心執行緒的棧空間),因此一個系統支援輕量級程序的數量是有限的。
#### (2)使用使用者執行緒實現
使用者執行緒(User Thread,UT)完全建立在使用者空間的執行緒庫上,系統核心不能感知執行緒的存在。使用者執行緒的建立、同步、銷燬和排程完全在使用者態中完成,不需要核心的幫助。因此操作快速且低消耗,也可以支援規模更大的執行緒數量。這種程序與使用者執行緒之間 1:N 的關係稱為**一對多的執行緒模型**。
![](https://img2020.cnblogs.com/blog/1613877/202004/1613877-20200401232000414-1134880198.jpg)
使用使用者執行緒的優勢在於不需要系統核心支援,劣勢也在於沒有系統核心的支援,所有的執行緒操作都需要使用者程式自己處理。因此使用使用者執行緒實現的程式一般都比較複雜。
#### (3)使用使用者執行緒加輕量級程序混合實現
混合實現時,使用者執行緒還是完全建立在使用者空間中,而作業系統提供支援的輕量級程序則作為使用者執行緒和核心執行緒之間的橋樑。在這種混合模式中,使用者執行緒與輕量級程序的數量比是不定的,即為 N:M 的關係,這種就是**多對多的執行緒模型**。
![](https://img2020.cnblogs.com/blog/1613877/202004/1613877-20200401232011174-1191894978.jpg)
**混合實現的好處:**
* 使用者執行緒的操作依然廉價,並且可以支援大規模的使用者執行緒併發。
* 可以使用核心提供的執行緒排程功能及處理器對映。
* 由於使用者執行緒的系統呼叫要通過輕量級程序來完成,因此大大降低了整個程序被完全阻塞的風險。
### 2、Java 執行緒排程
執行緒排程是指系統為執行緒分配處理器使用權的過程,主要排程方式有兩種:協同式執行緒排程和搶佔式執行緒排程。
#### (1)協同式執行緒排程
執行緒的執行時間由執行緒本身來控制,執行緒執行完之後,主動通知系統切換到另外一個執行緒上。
協同式執行緒排程最大的好處是實現簡單,而且切換執行緒的操作對執行緒自己是可知的,所以沒有什麼執行緒同步的問題。它的壞處就是執行緒執行時間不可控,如果一個執行緒編寫有問題,一直不告知系統進行執行緒切換,那麼程式就會一直阻塞在那裡。
#### (2)搶佔式執行緒排程
每個執行緒由系統來分配執行時間,執行緒的切換不由執行緒本身來決定。
使用搶佔式執行緒排程時,執行緒的執行時間是系統可控的,不會有一個執行緒導致整個程序阻塞的問題。
Java 使用的執行緒排程方式就是搶佔式排程。
### 3、執行緒狀態
#### (1)6 種執行緒狀態
* **新建(New)**:建立後尚未啟動的執行緒處於這種狀態。
* **執行(Runable)**:包括了作業系統執行緒狀態中的 Running 和 Ready,處於此狀態的執行緒有可能正在執行,也有可能正在等待著 CPU 為它分配執行時間。
* **無限期等待(Waiting)**:不會被分配 CPU 執行時間,等待著被其他執行緒顯式地喚醒。
* **限期等待(Timed Waiting)**:不會被分配 CPU 執行時間,無須等待被其他執行緒顯式地喚醒,在一定時間之後會由系統自動喚醒。
* **阻塞(Blocked)**:執行緒被阻塞了,在等待著獲取到一個排他鎖。在程式等待進入同步區域的時候,執行緒將進入這種狀態。
* **結束(Terminated)**:已終止執行緒的執行緒狀態,執行緒已經結束執行。
#### (2)執行緒狀態轉換
![](https://img2020.cnblogs.com/blog/1613877/202004/1613877-20200401232025077-12775052