使用 Synchronized 關鍵字來解決併發問題是最簡單的一種方式,我們只需要使用它修飾需要被併發處理的程式碼塊、方法或欄位屬性,虛擬機器自動為它加鎖和釋放鎖,並將不能獲得鎖的執行緒阻塞在相應的阻塞佇列上。
基本使用
我們在上篇文章介紹執行緒的基本概念時,提到了多執行緒的好處,能夠最大化 CPU 使用效率、更友好互動等等,但是也提出了它帶來的問題,比如競態條件、記憶體可見性問題。
我們引用上篇文章中的一個案例:
一百個執行緒隨機地為 count 加一,由於自增操作非原子性,多執行緒之間不正常的訪問導致 count 最終的值不確定,始終得不到預期的結果。
使用 synchronized 即刻就能解決,看程式碼:
程式碼稍作修改,現在的程式無論你執行多少次,或者你增大併發量,最後 count 的值總是正確的 100 。
大概什麼意思呢?
我們的 JAVA 中,對於每個物件都有一把『內建鎖』,而 synchronized 中的程式碼在被執行緒執行之前,會去嘗試獲取一個物件的鎖,如果成功,就進入並順利執行程式碼,否則將會被阻塞在該物件上。
除此之外,synchronized 除了可以修飾程式碼塊,還可以直接修飾在方法上,例如:
public synchronized void addCount(){......}
複製程式碼
public static synchronized void addCount(){......}
複製程式碼
這是兩種不同的使用方式,前一種是使用 synchronized 修飾的例項方法,那麼 synchronized 使用的就是當前方法呼叫時所屬的那個例項的『內建鎖』。也就是說,addCount 方法呼叫前會去嘗試獲取呼叫例項物件的鎖。
而後一種 addCount 方法是一個靜態方法,所以 synchronized 使用的就是 addCount 所屬的類物件的鎖。
synchronized 的使用方式還是很簡單的,什麼時候加鎖,什麼時候釋放鎖都不需要我們操心,被 JVM 封裝好了,下面我們就來簡單看看 JVM 是如何實現這種間接鎖機制的。
基本實現原理
我們先看一段簡單的程式碼:
public class TestAxiom {
private int count;
@Test
public void test() throws InterruptedException {
synchronized (this){
count++;
}
}
}
複製程式碼
這是一段非常簡單的程式碼,使用 synchronized 修飾程式碼塊,保護 count++ 操作。現在我們反編譯一下:
可以看到,在執行 count++ 指令之前,編譯器加了一條 monitorenter 指令,count++ 指令執行結束時又加了一條 monitorexit 指令。準確意義上來說,這就是兩條加鎖的釋放鎖的指令,具體細節我們稍後再看。
除此之外,我們的 synchronized 方法在反編譯後並沒有這兩條指令,但是編譯器卻在方法表的 flags 屬性中設定了一個標誌位 ACC_SYNCHRONIZED。
這樣,每個執行緒在呼叫該方法之前都會檢查這個狀態位是否為 1,如果狀態為 1 說明這是一個同步方法,需要首先執行 monitorenter 指令去嘗試獲取當前例項物件的內建鎖,並在方法執行結束執行 monitorexit 指令去釋放鎖。
其實本質上是一樣的,只是 synchronized 方法是一種隱式的實現。下面我們來看一看這個內建鎖的具體細節。
Java 中一個物件主要由以下三種型別資料組成:
- 物件頭:也稱 Mark Word,主要儲存的物件的 hash 值以及相關鎖資訊。
- 例項資料:儲存的當前物件的資料,包括父類屬性資訊等。
- 填充資料:這部分是應 JVM 要求,每個物件的起始地址必須是 8 的倍數,所以如果當前物件不足 8 的倍數字節時用於位元組填充。
我們的『內建鎖』在物件頭裡面,而 Mark Word 的一個基本結構是這樣的:
先不去管什麼是,輕量鎖,重量鎖,偏向鎖,自旋鎖,這是虛擬機器一種鎖優化機制,通過鎖膨脹來優化效能,這一點的細節我們以後再介紹,你先把它們統一理解為一把鎖。
其中,每把鎖會有一個標誌位用於區分鎖型別,和一個指向鎖記錄的指標,也就是說鎖指標會關聯另一種結構,Monitor Record。
Owner 欄位儲存的是擁有當前鎖的執行緒唯一標識號,當某個執行緒擁有了該鎖之後就會把自己的執行緒號寫入這個欄位中。如果某個執行緒發現這裡的 Owner 欄位不是 null 也不是自己的執行緒號,那麼它將會被阻塞在 Monitor 的阻塞佇列上直至某個執行緒走出同步程式碼塊併發起喚醒操作。
總結一下,被 synchronized 修飾的程式碼塊或者方法在編譯器會被額外插入兩條指令,monitorenter 會去檢查物件頭鎖資訊,對應到一個 Monitor 結構,如果該結構的 Owner 欄位已經被佔用了,那麼當前執行緒將會被阻塞在 Monitor 的一個阻塞佇列上,直到佔有鎖的執行緒釋放了鎖並喚起一波新的鎖競爭。
synchronized 的幾個特性
1、可重入性
一個物件往往有多個方法,這些方法有的是同步的,有的是非同步的,那麼如果一個執行緒已經獲得了某個物件的鎖並進入了其某個同步方法,而這個同步方法中還需要呼叫同一例項的另一個同步方法,是否需要重新競爭鎖?
這對於某些鎖來說,是需要重新競爭鎖的,但是我們的 synchronized 是「可重入的」,也就是說,如果當前執行緒獲得了某個物件的鎖,那麼該物件的所有方法都是可以無需競爭鎖式呼叫的。
原因也很簡單,monitorenter 指令找到 Monitor,檢視了 Owner 欄位的值等於當前執行緒的執行緒號,於是將 Nest 欄位增加一,表示當前執行緒多次持有該物件的鎖,每呼叫一次 monitorexit 都會減一 Nest 的值。
2、記憶體可見性
引用上篇文章的一個例子:
執行緒 ThreadTwo 不停的監聽 flag 的值,而我們主執行緒對 flag 進行了修改,由於記憶體可見性,ThreadTwo 看不見,於是程式一直死迴圈。
某種意義上,synchronized 是可以解決這類記憶體可見性問題的,修改程式碼如下:
主執行緒先獲得 obj 的內建鎖,然後啟動 ThreadTwo 執行緒,該執行緒由於獲取不到 obj 的鎖而被阻塞,也就是它知道已經有其他執行緒在操作共享變數,所以等到自己獲得鎖的時候一定要從記憶體重新讀一下共享變數。
而我們的主執行緒會在釋放鎖的時候將私有工作記憶體中所有的全域性變數的值重新整理到記憶體空間,這樣其實就實現了多執行緒之間的記憶體可見性。
當然有一點大家要注意,synchronized 修飾的程式碼塊會在釋放鎖的時候重新整理自己更改過的全域性變數,但是另一個執行緒要想看見,必須也從記憶體中重新讀才行。而一般情況下,不是你加了 synchronized 執行緒就會從記憶體中讀資料的,而只有它在競爭某把鎖失敗後,得知有其他執行緒正在修改共享變數,這樣的前提下等到自己擁有鎖之後才會重新去刷記憶體資料。
你也可以試試,讓 ThreadTwo 執行緒不去競爭 obj 這把鎖,而隨便給它一個物件,結果依然會是死迴圈,flag 的值只會是 ThreadTwo 剛啟動時從記憶體讀入的初始資料的快取版。
但是說實話,解決記憶體可見性而使用 synchronized 代價太高,需要加鎖和釋放鎖,甚至還需要阻塞和喚醒執行緒,我們一般使用關鍵字 volatile 直接修飾在變數上就可以了,這樣對於該變數的讀取和修改都是直接對映記憶體的,不經過執行緒本地私有工作記憶體的。
關於 synchronized 關鍵字我們暫時先介紹到這,後續還會涉及到它的,我們還要介紹近幾個 JDK 版本對於 synchronized 的優化細節,包括自旋鎖,偏向鎖,重量級鎖之間的鎖膨脹機制,也是這種優化使得現在的 synchronized 效能不輸於 Lock。
文章中的所有程式碼、圖片、檔案都雲端儲存在我的 GitHub 上:
歡迎關注微信公眾號:OneJavaCoder,所有文章都將同步在公眾號上。