探索 Java 記憶體模型與同步機制的奧秘 | Synchronized 鎖的 3 種狀態

探索 Java 記憶體模型與同步機制的奧秘 | Synchronized 鎖的 3 種狀態

Overview of Content

在現代計算機系統中,效能和安全性是至關重要的兩大指標。而在 Java 開發中,記憶體模型(Java Memory Model,JMM)和同步機制(synchronized)是實現這兩大指標的關鍵所在

這篇文章將帶你深入了解 Java 記憶體模型的規劃、CPU 高速緩存的運作原理,以及同步機制的底層實現與比較。無論您是初學者還是資深開發者,都能從中獲得有價值的知識,提升您的 Java 編程能力

寫文章分享不易,如有引用參考請詳註出處,如有指導、意見歡迎留言(如果覺得寫得好也請給我一些支持),感謝 😀

個人程式分享時比較注重「縮排」,所以可能不適合手機的排版閱讀,建議切換至「電腦版」、「平板版」視窗看


JMM 計算機原理

JMM 的全名是 Java Memory Model,其定義了 Java 虛擬機(JVM) 在計算機內存(PC Memory)中的工作方式,所以 JMM 是屬於 JVM 底下的,Java 1.5 版本對它進行了重構,現在的 Java 仍繼續使用,在 JMM 中遇到的問題跟現在的 PC 是差不多的

由 Jeff Dean 在 Google 全體工程大會的報告,我們可以大致認識一下每個裝置處理數據的時間… 如下表所示

OperationUse Time
打開一個站點幾s
查看數據厙(有引索)10幾ms
1.6G CPU 執行一條指令0.6ns
++機械式硬碟++ 讀取 1M 數據2~10ms
++SSD 固態硬碟++ 讀取 1M 數據0.3ms
++內存記憶體++ 讀取 1M 數據250us
CPU 讀取一次內存100ns
1G 網卡傳輸 2KB 數據20ms

● 接著,我們假設 CPU 讀取內存 1M 的數據,那它會耗費多少時間呢?這裡我們試算看看…


/**
 * 假設有 1M 數據
 */ 
1024 * 1024 = 1048576 (1MByte 多)

/**
* CPU 讀取 1M 數據,並且一次讀 4 Byte 
*/
1048576 / 4 = 262144 次的讀取動作

/**
 * 1.6G CPU 一條指令 0.6ns
 */ 
 262144 * 0.6ns = 0.157286ms (執行 1M 讀取)

 /**
  * 由於我們上面是計算一次讀取 4 Byte,那假設 CPU 處理 4Byte 需要 100ns
  */ 
 262144 * 100ns = 26214400 = 26.2144ms (執行 & 讀取)

 /**
  * CPU 讀取完 1M 所耗費的時間
  */ 
 26.2144ms + 0.157286ms = 26.37ms

CPU 高速緩存:多種不同的記憶體

● 從上面可以看出來 CPU 處理指令的時間是相當快的,但是多工處理器是分開機算,必須要內存互交,近期 cpu 處理速度越來越快,已經是好幾倍的超越了(0.3m/0.6n),所以中間一定會插入一個接近 cpu 處理速度的緩存

● 快速緩存 cache 是比數據複製到 cpu 內存中,減少從內存讀取的次數,提高處理效率


// linux 指令
watch -n 1 "lscpu"

Window 從工作管理員中就可以看到

● CPU 快速緩存模型(以下是 4 核 CPU),主要有分為 L1 L2 L3 快速緩存,L1、L2唯獨有緩存 L3 為共享緩存

越接近 CPU 的緩存所需要耗費的時間越少,但相對的設備成本就高

理解 Java 記憶體模型 JMM 的規劃

● 先大致了解 Java 記憶體模型,WorkThread 在操作主記憶體變量時都擁有一個變量複本,Thread 在改變這個複本後在寫回主記憶體

● JMM 定義了執行序 & 主記憶體之間的抽象關係,執行序之間的共享變量儲存在主記憶體中(Main Memory),而每個執行序都有一個 「私有」的本地記憶體(Local Memory),私有記憶體了該執行序已讀寫變量的副本

而執行序自身的 本地(私有)記憶體只是一個抽象 並不存在,它包含了緩存、暫存器、編譯優化... 等等訊息,概念如下


探索 synchronized 實現原理

● Synchronized 是 Java 內置同步鎖的機制,我們現在使用 Java 指令對其反編譯,以下是使用 Java 反編譯指令,可以觀察到使用 synchronized 時會使用到關鍵字 monitorentermonitorexit


# Java 指令
javac MySync

javap -c MySyncTask.class    # -c is 反組譯

A. monitorenter 為同步開始的位子,monitorexit 為同步結束的位子,兩者是成對出現的

B. 當成是執行到 monitorenter 會嘗試獲取到 Monitor 的所有權 & 嘗試獲取 synchronized 對象的鎖,monitorexit 則是在方法結束 or 異常處


// 以下都使用該程式
public class TestSynch {
    private int a = 0;

    public synchronized void _SyncMethod() {
        a = a + 1; //a++;
    }

    public void _SyncThis() {
        synchronized (this) {
            a = a + 1; //a++;
        }
    }

    public void _SyncClass() {
        synchronized (TestSynch.class) {
            a = a + 1; //a++;
        }
    }

}

反編譯:同步方法

同步方法在反組譯時是看不出有 monitor 關鍵字的,但是可以發現 ACC_SYNCHRONIZED 關鍵字,而它會呼叫 monitor


public synchronized void _SyncMethod() {
    a = a + 1; //a++;
}

--反編譯內容--

反編譯:同步類物件

● 重點在於同步的是物件 this,可以觀察到其 反編譯內容有可以看到 monitorenter(3)、monitorexit(21) 的範圍


public void _SyncThis() {
    synchronized (this) {
        a = a + 1; //a++;
    }
}

--反編譯內容--

反編譯:同步類

● 重點在於同步的是 class 類,也可以看出 monitorenter(4)、monitorexit(22)的範圍


public void _SyncClass() {
    synchronized (TestSynch.class) {
        a = a + 1; //a++;
    }
}

--反編譯內容--

.

物件頭:Synchronized 鎖的狀態

鎖的狀態都是 synchronized 內部對於物件頭中的鎖 的狀態鎖更改,它是 synchronized 關鍵字的一種優化(它會依照不同的狀況改變鎖的狀態,不同狀態又有不同的消能)

鎖的狀態:無鎖 -> 偏向鎖 -> 輕量級 -> 重量級可以升級鎖的狀態,但是不能降級

無鎖就先不介紹 (它就是沒有鎖,所以物件頭也不會改變)

graph LR 無鎖 --> 偏向鎖 --> 輕量級 --> 重量級

Java 物件、synchronized 物件鎖

Java 物件在虛擬機中的內存,而 synchronized 物件鎖存在物件頭(Header)中的 MarkWord,以下畫出的為有鎖狀態的 MarkWord (紅箭頭),以及正常狀態無鎖狀態 (如果使用 synchronized 關鍵字,物件頭就會改變)

而有鎖狀態的差異如下,主要有分為「偏向鎖」、「輕量級鎖」、「重量級鎖」

認識 JVM 自旋鎖

自旋鎖是一種 機制、行為

我們知道執行序的休眠、喚醒是十分花費時間、性能的 (大概要花費 10000 ~ 20000 個指令時間,一個指令時間又花 0.6ns 再加上兩次就是兩倍的時間),如果小小的等後一下就可以進入鎖的話,那就自旋旋判斷一下即可

自旋鎖的實現之一就是 CAS 機制

自旋不斷判斷也是消費性能的,所以必須設定一個自旋上限,超過上限時就進入堵塞狀態(CPU 不再循環判斷),堵塞不會占用 CPU 時間,但是自旋則會 (這也是要注意的地方)

自旋時間過長則會非常消耗 CPU 性能,比起休眠更加消耗,所以要衡量 CAS 比較次數的上限

A. JDK 1.5 默認為 10 次

B. JDK 1.6 引入了適應性自旋鎖,使用 -XX+UseSpining 開啟自旋鎖

C. JDK 1.7 去除自旋鎖的設定參數,改由 JVM 自控制

偏向鎖、輕量級鎖

偏向鎖的定義

大多數情況下不需要競爭,基本上都是由 A 執行序獲得鎖的所有權,減少不必要的 CAS 操作,因而引入偏向鎖

● 通常使用在第一個獲取鎖的執行序,當有其他的執行序進爭鎖 JVM 則會將該鎖升級成「輕量鎖」,而該 鎖升級過程是會觸發 stop the world

A. Thread-A 訪問對象,並確認對象頭的狀態,鎖的標誌是否為 1,Flag 是否為 01

B. 都確認完後就設置,並進入偏向鎖狀態,並執行步驟 5

C. Thread-B 確認,如果已經有 Thread ID,則使用 CAS 方式競爭鎖,競爭成功執行步驟 5,失敗執行步驟 4

D. Thread-B CAS 競爭失敗後,當達到了 safepoint 時獲得偏向鎖的執行序被掛起,並使用 stop the world 將鎖升級為輕量級鎖,之後被掛起的 Thread-B 執行同步區塊

E. 執行同步的區塊程式

JVM 關閉/開啟 偏向鎖

開啟 : -XX:+UseBiasedLocking-XX:BiasedLockingStartupDelay=0

關閉 : -XX:-UseBiasedLocking

輕量級、重量級鎖

輕量級定義 : 當一執行序對象頭已經是 偏向鎖 時,還有一個執行序來競爭鎖這時就會升級為輕量級鎖

● 一個無狀態也可以直接經由 Lock record 跳過偏向鎖 (被 JVM 設定為不可偏向鎖),升級為輕量級鎖

A. 將無鎖狀態的 MarkWord 複製到當前執行序 Thread-A 的棧楨中 (標明為 Lock Record) 用於儲存對象目前的 MarkWord 拷貝,

B. JVM 使用 CAS 競爭中,成功的指向 Lock Record 區塊中的 object mark word,Thread-A 修改 MarkWord 到輕量級鎖 00,並執行同步區塊

C. Thread-B 競爭失敗,使用 CAS 機制自旋鎖

D. 當 Thread-B 使用自旋鎖嘗試次數高過限制時會將對象的鎖升級為重量級鎖 10,接著 Thread-B 進入執行序休眠狀態

E. Thread-A 執行完畢任務後釋放鎖,換成 Thread-B 執行

Synchronized 鎖的比較

● 針對 Synchronized 中鎖的比較,這些鎖是 Synchronized 的一種優化

Synchronized

鎖的差異
優點缺點使用場景
偏向鎖加鎖 & 解鎖不需要進入等待週期(上下文切換),也不必如 CAS 比較如果多執行序會有鎖的撤銷(一些性能消耗)只有一個執行序訪問該同步區塊時
輕量級鎖鎖的競爭不用上下文切換,提高響應速度(使用自旋功能)如果過度自旋反而會拉低 CPU 響應速度(CAS 要不斷判斷)追求響應時間,同步區塊程式也相對少時(不需等待其他響應時)
重量級鎖使用上下文切換,CPU 會直接跳過它的執行 (不消耗 CPU 效能)響應速度較慢,畢竟要喚醒 & 休眠同步缺塊程式較多時,或是要等待其他響應

更多的 Java 語言相關文章

Java 語言深入

● 在這個系列中,我們全方位地探討了 Java 語言的各個核心主題,旨在幫助你徹底掌握這門強大的編程語言。無論你是想深入理解 Java 的基礎類型與變數作用域,還是探索異常處理與運算子的細節,這些文章都將為您提供寶貴的知識

深入 Java 物件導向

● 探索 Java 物件導向的奧妙,掌握介面、抽象類、繼承等重要概念!幫助你針對物件導向設計有更深入的了解!


Leave a Comment

Comments

No comments yet. Why don’t you start the discussion?

發表迴響