一夜搞懂 | Java 記憶體模型與執行緒
前言
本文已經收錄到我的 Github 個人部落格,歡迎大佬們光臨寒舍:
我的 GIthub 部落格
學習導圖
一.為什麼要學習記憶體模型與執行緒?
併發處理的廣泛應用是
Amdah1
定律代替摩爾定律成為計算機效能發展源動力的根本原因,也是人類壓制計算機運算能力的最有力武器
執行緒通訊是指執行緒之間以何種機制來交換資訊。在指令式程式設計中,執行緒之間的通訊機制有兩種:共享記憶體和訊息傳遞。
執行緒同步是指程式用於控制不同執行緒之間操作發生相對順序的機制。
Java
的併發採用的是共享記憶體模型,Java
執行緒之間的通訊總是隱式進行,整個通訊過程對程式設計師完全透明。如果你想設計表現良好的併發程式,理解 Java
Java
記憶體模型規定了如何和何時可以看到由其他執行緒修改過後的共享變數的值,以及在必須時如何同步的訪問共享變數。
二.核心知識點歸納
2.1 概述
Q1:多工處理的必要性
- 充分利用計算機處理器的能力,避免處理器在磁碟
I/O
、網路通訊或資料庫訪問時總是處於等待其他資源的狀態 - 便於一個服務端同時對多個客戶端提供服務
通過指標
TPS
(Transactions Per Second
)可衡量一個服務效能的高低好壞,它表示每秒服務端平均能響應的請求總數,進而體現出程式的併發能力
Q2:硬體的效率與一致性
為了更好的理解
Java
記憶體模型,先理解物理計算機中的併發問題,兩者有很高的可比性
為了平衡記憶體互動速度與處理器的運算速度之間幾個數量級的差距,引入一層快取記憶體(Cache
)來作為記憶體與處理器之間的緩衝:
- 將運算需要使用到的資料複製到快取中,讓運算能快速進行
- 當運算結束後再從快取同步回記憶體之中,而無須讓處理器等待緩慢的記憶體讀寫
- 出現問題:引入快取記憶體雖解決了處理器與記憶體速度之間的矛盾,但是其引入了一個新的問題——快取一致性
- 解決辦法:需要各個處理器訪問快取時都遵循一些協議,在讀寫時要根據協議來進行操作
記憶體模型可以理解為:在特定的操作協議下,對特定的記憶體或快取記憶體進行讀寫訪問的過程抽象
2.2 Java
記憶體模型
之前筆者在 進階之路 | 奇妙的Thread之旅中簡要介紹過
Java
記憶體模型,相信看過的讀者都有一些印象
2.2.1 設計目的
遮蔽掉各種硬體和作業系統的記憶體訪問差異,實現 Java
程式在各種平臺下都能達到一致的記憶體訪問效果
2.2.2 設計方法
通過定義程式中各個變數的訪問規則,即在虛擬機器中將變數儲存到記憶體和從記憶體中取出變數這樣的底層細節
注意:這裡的變數與
Java
中說的變數不同,而指的是例項欄位、靜態欄位和構成陣列物件的元素,但不包括區域性變數與方法引數(其存放於區域性變量表中,而區域性變量表在JVM
棧中),因為後者是執行緒私有的,不會被共享,自然就不會存在競爭問題。
2.2.3 模型結構
- 主記憶體:所有變數的儲存位置。直接對應於物理硬體的記憶體
注意:這裡的主記憶體、工作記憶體與 一文洞悉JVM記憶體管理機制 說的
Java
記憶體區域中的Java
堆、棧、方法區等並不是同一個層次的記憶體劃分
- 工作記憶體:每條執行緒還有自己的工作記憶體,用於儲存被該執行緒使用到的變數的主記憶體副本拷貝。為了獲取更好的執行速度,虛擬機器可能會讓工作記憶體優先儲存於暫存器和快取記憶體中
注意:
- 執行緒對變數的所有操作都必須在工作記憶體中進行,而不能直接讀寫主記憶體中的變數
- 不同的執行緒之間也無法直接訪問對方工作記憶體中的變數,執行緒間變數值的傳遞必須通過主記憶體來完成
-
互動協議:用於規定一個變數如何從主記憶體拷貝到工作記憶體、如何從工作記憶體同步回主記憶體之類的實現細節。
共有
8
種操作:
- 作用於主記憶體變數:
- 鎖定
lock
:把變數標識為一條執行緒獨佔的狀態- 解鎖
unlock
:把處於鎖定狀態的變數釋放出來- 寫入
write
:把store
操作從工作記憶體中得到的變數的值放入主記憶體的變數中- 讀取
read
:把變數的值從主記憶體傳輸到執行緒的工作記憶體中,以便隨後的load
動作使用
2.用於工作記憶體變數:
- 賦值
assign
:把從執行引擎接收到的值賦給工作記憶體的變數- 使用
use
:把工作記憶體中一個變數的值傳遞給執行引擎- 儲存
store
:把工作記憶體中變數的值傳送到主記憶體中,以便隨後的write
操作使用- 寫入
write
:把store
操作從工作記憶體中得到的變數的值放入主記憶體的變數中
結論:注意是順序非連續
- 如果要把變數從主記憶體複製到工作記憶體,那就要順序地執行
read
和load
- 如果要把變數從工作記憶體同步回主記憶體,就要順序地執行
store
和write
2.2.4 確保併發操作安全的原則
A1:執行八種基本操作的時候,必須滿足如下規則:
- 不允許
read
和load
、store
和write
操作之一單獨出現,即不允許一個變數從主記憶體讀取了但工作記憶體不接受,或者從工作記憶體發起回寫了但主記憶體不接受的情況出現
可以簡單理解為不能拒絕別人給的東西
- 不允許一個執行緒丟棄它的最近的
assign
操作,即變數在工作記憶體中改變了之後必須把該變化同步回主記憶體 - 不允許一個執行緒無原因地,即沒有發生過任何
assign
操作,就把資料從執行緒的工作記憶體同步回主記憶體中 - 一個新的變數只能在主記憶體中 “誕生”,不允許在工作記憶體中直接使用一個未被初始化(
load
或assign
)的變數,即對一個變數實施use
、store
操作之前必須先執行過了assign
和load
操作 - 如果對一個變數執行
lock
操作,那將會清空工作記憶體中此變數的值,在執行引擎使用這個變數前,需要重新執行load
或assign
操作初始化變數的值
下文的
volatile
底層就是用到了lock
來實現可見性
- 如果一個變數事先沒有被
lock
操作鎖定,那就不允許對它執行unlock
操作,也不允許去unlock
一個被其他執行緒鎖定住的變數 - 對一個變數執行
unlock
操作之前,必須先把此變數同步回主記憶體中
可見這麼多規則非常繁瑣,實踐也麻煩,下面再介紹一個等效判斷原則 -- 先行發生原則
A2:先行發生原則:
是 Java
記憶體模型中定義的兩項操作之間的偏序關係。
下面例舉一些 “天然的” 先行發生關係,無須任何同步器協助就已經存在,可以在編碼中直接使用
- 程式次序規則:在一個執行緒內,按照控制流順序,書寫在前面的操作先行發生於書寫在後面的操作
- 管程鎖定規則:一個
unlock
操作先行發生於後面對同一個鎖的lock
操作volatile
變數規則:對一個volatile
變數的寫操作先行發生於後面對這個變數的讀操作- 執行緒啟動規則:
Thread
的start()
先行發生於此執行緒的每一個動作- 執行緒終止規則:執行緒中的所有操作都先行發生於對此執行緒的終止檢測。可通過
Thread.join()
結束、Thread.isAlive()
的返回值等手段檢測到執行緒已經終止執行- 執行緒中斷規則:對執行緒
interrupt()
的呼叫先行發生於被中斷執行緒的程式碼檢測到中斷事件的發生。可通過Thread.isInterrupted()
檢測到是否有中斷髮生- 物件終結規則:一個物件的初始化完成先行發生於它的
finalize()
的開始- 傳遞性:如果操作 A 先行發生於操作 B,操作 B 先行發生於操作 C,那麼操作 A 一定先行發生於操作 C
2.2.5 保證原子性、可見性和有序性的措施
- 原子性:一個操作要麼都執行要麼都不執行
可直接保證的原子性變數操作有:
read
、load
、assign
、use
、store
和write
,因此可認為基本資料型別的訪問讀寫是具備原子性的若需要保證更大範圍的原子性,可通過更高層次的位元組碼指令
monitorenter
和monitorexit
來隱式地使用lock
和unlock
這兩個操作,反映到Java
程式碼中就是同步程式碼塊synchronized
關鍵字
- 可見性:當一個執行緒修改了共享變數的值,其他執行緒能夠立即得知這個修改
- 通過在變數修改後將新值同步回主記憶體,在變數讀取前從主記憶體重新整理變數值這種依賴主記憶體作為傳遞媒介的方式來實現
- 提供三個關鍵字保證可見性:
volatile
能保證新值能立即同步到主記憶體,且每次使用前立即從主記憶體重新整理synchronized
對一個變數執行unlock
操作之前可以先把此變數同步回主記憶體中- 被
final
修飾的欄位在構造器中一旦初始化完成且構造器沒有把this
的引用傳遞出去,就可以在其他執行緒中就能看見final
欄位的值
- 有序性:程式程式碼按照指令順序執行
- 如果在本執行緒內觀察,所有的操作都是有序的,指 “執行緒內表現為序列的語義”;
- 如果在一個執行緒中觀察另一個執行緒,所有的操作都是無序的,指 “指令重排序” 現象和 “工作記憶體與主記憶體同步延遲” 現象
- 提供兩個關鍵字保證有序性:
volatile
本身就包含了禁止指令重排序的語義synchronized
保證一個變數在同一個時刻只允許一條執行緒對其進行lock
操作,使得持有同一個鎖的兩個同步塊只能序列地進入
想詳細瞭解 volatile
的讀者,可以看下筆者之前寫的文章:進階之路 | 奇妙的 Thread 之旅
2.3 Java
與執行緒
2.3.1 執行緒實現的三種方式
1.使用核心執行緒
英文:
Kernel-Level Thread
,簡稱:KLT
- 定義:由作業系統核心支援的執行緒
- 原理:由核心來完成執行緒切換,核心通過操縱排程器(
Scheduler
)對執行緒進行排程,並負責將執行緒的任務對映到各個處理器上。每個核心執行緒可以視為核心的一個分身, 這樣作業系統就有能力同時處理多件事情 - 多執行緒核心:支援多執行緒的核心
- 輕量級程序(
Light Weight Process
,簡稱:LWP
):核心執行緒的一種高階介面
- 優點:每個輕量級程序都由一個核心執行緒支援,因此每個都成為一個獨立的排程單元,即使有一個輕量級程序在系統呼叫中阻塞,也不會影響整個程序繼續工作
- 缺點:
- 由於基於核心執行緒實現,所以各種執行緒操作(建立、析構及同步)都需要進行系統呼叫,代價相對較高,需要在使用者態和核心態中來回切換
- 一個系統支援輕量級程序的數量是有限的
- 一對一執行緒模型:輕量級程序與核心執行緒之間
1:1
的關係,如圖所示
2.使用使用者執行緒
英文:
User Thread
,簡稱:UT
- 定義:
- 廣義上認為一個執行緒不是核心執行緒就是使用者執行緒
- 狹義上認為使用者執行緒指的是完全建立在使用者空間的執行緒庫上,而系統核心不能感知執行緒存在的實現
- 優點:由於使用者執行緒的建立、同步、銷燬和排程完全在使用者態中完成,不需要核心的幫助,甚至可以不需要切換到核心態,所以操作非常快速且低消耗的,且可以支援規模更大的執行緒數量
- 缺點:由於沒有系統核心的支援,所有的執行緒操作都需要使用者程式自己處理,執行緒的建立、切換和排程都是需要考慮的問題,實現較複雜
- 一對多的執行緒模型程序:程序與使用者執行緒之間
1:N
的關係,如圖所示
3.混合
-
定義:既存在使用者執行緒,也存在輕量級程序
-
優點:
- 使用者執行緒完全建立在使用者空間中,因此使用者執行緒的建立、切換、析構等操作依然廉價,並且可以支援大規模的使用者執行緒併發
- 作業系統提供支援的輕量級程序作為使用者執行緒和核心執行緒之間的橋樑,可以使用核心提供的執行緒排程功能及處理器對映,且使用者執行緒的系統呼叫要通過輕量級執行緒來完成,大大降低了整個程序被完全阻塞的風險
- 多對多的執行緒模型:使用者執行緒與輕量級程序的數量比不定,即使用者執行緒與輕量級程序之間
N:M
的關係,如圖所示
Q:
Java
執行緒的實現是選擇哪一種呢?A:答案是不確定的。作業系統支援怎樣的執行緒模型,在很大程度上決定了
JVM
的執行緒是怎樣對映的。執行緒模型只對執行緒的併發規模和操作成本產生影響,而對Java
程式的編碼和執行過程來說,這些差異都是透明的。
2.3.2 執行緒排程的兩種方式
執行緒排程:指系統為執行緒分配處理器使用權的過程
1.協同式執行緒排程
- 由執行緒本身來控制執行緒的執行時間。執行緒把自己的工作執行完後,要主動通知系統切換到另外一個執行緒上
- 好處:
- 實現簡單
- 切換操作自己可知,不存線上程同步的問題
- 壞處:執行緒執行時間不可控,假如一個執行緒編寫有問題一直不告知系統進行執行緒切換,那麼程式就會一直被阻塞
2.搶佔式執行緒排程
- 由系統來分配每個執行緒的執行時間
- 好處:執行緒執行時間是系統可控的,不存在一個執行緒導致整個程序阻塞的問題
- 可以通過設定執行緒優先順序,優先順序越高的執行緒越容易被系統選擇執行
但是執行緒優先順序並不是太靠譜,一方面因為
Java
的執行緒是通過對映到系統的原生執行緒上來實現的,所以執行緒排程最終還是取決於作業系統,在一些平臺上不同的優先順序實際會變得相同;另一方面優先順序可能會被系統自行改變。
2.3.3 執行緒的六種狀態
在任意一個時間點,一個執行緒只能有且只有其中的一種狀態:
-
新建
New
:執行緒建立後尚未啟動 -
執行
Runable
:包括正在執行(Running
)和等待著 CPU 為它分配執行時間(Ready
)兩種 -
無限期等待
Waiting
:該執行緒不會被分配CPU
執行時間,要等待被其他執行緒顯式地喚醒。以下方法會讓執行緒陷入無限期等待狀態:
沒有設定
Timeout
引數的Object.wait()
沒有設定
Timeout
引數的Thread.join()
LockSupport.park()
(PS:想詳細瞭解它的可以看下這篇文章:Java 多執行緒學習(7)聊聊 LockSupport.park () 和 LockSupport.unpark ())
- 限期等待
Timed Waiting
:該執行緒不會被分配CPU
執行時間,但在一定時間後會被系統自動喚醒。以下方法會讓執行緒進入限期等待狀態:
Thread.sleep()
- 設定了
Timeout
引數的Object.wait()
- 設定了
Timeout
引數的Thread.join()
LockSupport.parkNanos()
LockSupport.parkUntil()
- 阻塞
Blocked
:執行緒被阻塞
注意區別阻塞和等待:
- 阻塞狀態:在等待獲取到一個排他鎖,在另外一個執行緒放棄這個鎖的時候發生;
- 等待狀態:在等待一段時間或者喚醒動作的發生,在程式等待進入同步區域的時候發生。
- 結束
Terminated
:執行緒已經結束執行
三.碎碎念
恭喜你!已經看完了前面的文章,相信你對
Java
記憶體模型與執行緒已經有一定深度的瞭解!你可以稍微放鬆獎勵自己一下,可以睡一個美美的覺,明天起來繼續沖沖衝!!!PS:原本《深入理解Java虛擬機器》第3版中還提及了協程,但是我還沒學過協程的基本用法,這時候給大家講解感覺有點打腫臉充胖子的感覺 hhh,明天《第一行程式碼-第三版》也要到了,待我看完《第一行程式碼》再補充協程的內容吧 hhhh
如果文章對您有一點幫助的話,希望您能點一下贊,您的點贊,是我前進的動力
本文參考連結:
- 《深入理解Java虛擬機器》第3版
- 理解Java記憶體模型
- Java 多執行緒學習(7)聊聊 LockSupport.park () 和 LockSupport.unpark ()
- 要點提煉 | 理解 JVM 之記憶體模型 & 執行緒
- 啃碎併發(九):記憶體模型之基礎概述