使用 Synchronized 關鍵字


使用 Synchronized 關鍵字來解決並發問題是最簡單的一種方式,我們只需要使用它修飾需要被並發處理的代碼塊、方法或字段屬性,虛擬機自動為它加鎖和釋放鎖,並將不能獲得鎖的線程阻塞在相應的阻塞隊列上。

基本使用

我們在上篇文章介紹線程的基本概念時,提到了多線程的好處,能夠最大化 CPU 使用效率、更友好交互等等,但是也提出了它帶來的問題,比如競態條件、內存可見性問題。

我們引用上篇文章中的一個案例:

image

一百個線程隨機地為 count 加一,由於自增操作非原子性,多線程之間不正常的訪問導致 count 最終的值不確定,始終得不到預期的結果。

使用 synchronized 即刻就能解決,看代碼:

image

代碼稍作修改,現在的程序無論你運行多少次,或者你增大並發量,最后 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++ 操作。現在我們反編譯一下:

image

可以看到,在執行 count++ 指令之前,編譯器加了一條 monitorenter 指令,count++ 指令執行結束時又加了一條 monitorexit 指令。准確意義上來說,這就是兩條加鎖的釋放鎖的指令,具體細節我們稍后再看。

除此之外,我們的 synchronized 方法在反編譯后並沒有這兩條指令,但是編譯器卻在方法表的 flags 屬性中設置了一個標志位 ACC_SYNCHRONIZED。

這樣,每個線程在調用該方法之前都會檢查這個狀態位是否為 1,如果狀態為 1 說明這是一個同步方法,需要首先執行 monitorenter 指令去嘗試獲取當前實例對象的內置鎖,並在方法執行結束執行 monitorexit 指令去釋放鎖。

其實本質上是一樣的,只是 synchronized 方法是一種隱式的實現。下面我們來看一看這個內置鎖的具體細節。

Java 中一個對象主要由以下三種類型數據組成:

  • 對象頭:也稱 Mark Word,主要存儲的對象的 hash 值以及相關鎖信息。
  • 實例數據:保存的當前對象的數據,包括父類屬性信息等。
  • 填充數據:這部分是應 JVM 要求,每個對象的起始地址必須是 8 的倍數,所以如果當前對象不足 8 的倍數字節時用於字節填充。

我們的『內置鎖』在對象頭里面,而 Mark Word 的一個基本結構是這樣的:

image

先不去管什么是,輕量鎖,重量鎖,偏向鎖,自旋鎖,這是虛擬機一種鎖優化機制,通過鎖膨脹來優化性能,這一點的細節我們以后再介紹,你先把它們統一理解為一把鎖。

其中,每把鎖會有一個標志位用於區分鎖類型,和一個指向鎖記錄的指針,也就是說鎖指針會關聯另一種結構,Monitor Record。

image

Owner 字段存儲的是擁有當前鎖的線程唯一標識號,當某個線程擁有了該鎖之后就會把自己的線程號寫入這個字段中。如果某個線程發現這里的 Owner 字段不是 null 也不是自己的線程號,那么它將會被阻塞在 Monitor 的阻塞隊列上直至某個線程走出同步代碼塊並發起喚醒操作。

總結一下,被 synchronized 修飾的代碼塊或者方法在編譯器會被額外插入兩條指令,monitorenter 會去檢查對象頭鎖信息,對應到一個 Monitor 結構,如果該結構的 Owner 字段已經被占用了,那么當前線程將會被阻塞在 Monitor 的一個阻塞隊列上,直到占有鎖的線程釋放了鎖並喚起一波新的鎖競爭。

synchronized 的幾個特性

1、可重入性

一個對象往往有多個方法,這些方法有的是同步的,有的是非同步的,那么如果一個線程已經獲得了某個對象的鎖並進入了其某個同步方法,而這個同步方法中還需要調用同一實例的另一個同步方法,是否需要重新競爭鎖?

這對於某些鎖來說,是需要重新競爭鎖的,但是我們的 synchronized 是「可重入的」,也就是說,如果當前線程獲得了某個對象的鎖,那么該對象的所有方法都是可以無需競爭鎖式調用的。

原因也很簡單,monitorenter 指令找到 Monitor,查看了 Owner 字段的值等於當前線程的線程號,於是將 Nest 字段增加一,表示當前線程多次持有該對象的鎖,每調用一次 monitorexit 都會減一 Nest 的值。

2、內存可見性

引用上篇文章的一個例子:

image

線程 ThreadTwo 不停的監聽 flag 的值,而我們主線程對 flag 進行了修改,由於內存可見性,ThreadTwo 看不見,於是程序一直死循環。

某種意義上,synchronized 是可以解決這類內存可見性問題的,修改代碼如下:

image

主線程先獲得 obj 的內置鎖,然后啟動 ThreadTwo 線程,該線程由於獲取不到 obj 的鎖而被阻塞,也就是它知道已經有其他線程在操作共享變量,所以等到自己獲得鎖的時候一定要從內存重新讀一下共享變量。

而我們的主線程會在釋放鎖的時候將私有工作內存中所有的全局變量的值刷新到內存空間,這樣其實就實現了多線程之間的內存可見性。

當然有一點大家要注意,synchronized 修飾的代碼塊會在釋放鎖的時候刷新自己更改過的全局變量,但是另一個線程要想看見,必須也從內存中重新讀才行。而一般情況下,不是你加了 synchronized 線程就會從內存中讀數據的,而只有它在競爭某把鎖失敗后,得知有其他線程正在修改共享變量,這樣的前提下等到自己擁有鎖之后才會重新去刷內存數據。

你也可以試試,讓 ThreadTwo 線程不去競爭 obj 這把鎖,而隨便給它一個對象,結果依然會是死循環,flag 的值只會是 ThreadTwo 剛啟動時從內存讀入的初始數據的緩存版。

但是說實話,解決內存可見性而使用 synchronized 代價太高,需要加鎖和釋放鎖,甚至還需要阻塞和喚醒線程,我們一般使用關鍵字 volatile 直接修飾在變量上就可以了,這樣對於該變量的讀取和修改都是直接映射內存的,不經過線程本地私有工作內存的。

關於 synchronized 關鍵字我們暫時先介紹到這,后續還會涉及到它的,我們還要介紹近幾個 JDK 版本對於 synchronized 的優化細節,包括自旋鎖,偏向鎖,重量級鎖之間的鎖膨脹機制,也是這種優化使得現在的 synchronized 性能不輸於 Lock。


文章中的所有代碼、圖片、文件都雲存儲在我的 GitHub 上:

(https://github.com/SingleYam/overview_java)

歡迎關注微信公眾號:OneJavaCoder,所有文章都將同步在公眾號上。

image


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM