徹底理解synchronized

喝水會長肉發表於2021-12-16

徹底理解synchronized

1. synchronized簡介

在學習知識前,我們先來看一個現象:

public class SynchronizedDemo implements Runnable {
    private static int count = 0;

    public static void main(String[] args) {
        for (int i = 0; i < 10; i++) {
            Thread thread = new Thread(new SynchronizedDemo());
            thread.start();
        }
        try {
            Thread.sleep(500);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("result: " + count);
    }

    @Override
    public void run() {
        for (int i = 0; i < 1000000; i++)
            count++;
    }
}
複製程式碼

開啟了10個執行緒,每個執行緒都累加了1000000次,如果結果正確的話自然而然總數就應該是10 * 1000000 = 10000000。可就執行多次結果都不是這個數,而且每次執行結果都不一樣。這是為什麼了?有什麼解決方案了?這就是我們今天要聊的事情。

在上一篇博文中我們已經瞭解了java記憶體模型的一些知識,並且已經知道出現執行緒安全的主要來源於JMM的設計,主要集中在主記憶體和執行緒的工作記憶體而導致的記憶體可見性問題,以及重排序導致的問題,進一步知道了happens-before規則。執行緒執行時擁有自己的棧空間,會在自己的棧空間執行,如果多執行緒間沒有共享的資料也就是說多執行緒間並沒有協作完成一件事情,那麼,多執行緒就不能發揮優勢,不能帶來巨大的價值。那麼共享資料的執行緒安全問題怎樣處理?很自然而然的想法就是每一個執行緒依次去讀寫這個共享變數,這樣就不會有任何資料安全的問題,因為每個執行緒所操作的都是當前最新的版本資料。那麼,在java關鍵字synchronized就具有使每個執行緒依次排隊操作共享變數的功能。很顯然,這種同步機制效率很低,但synchronized是其他併發容器實現的基礎,對它的理解也會大大提升對併發程式設計的感覺,從功利的角度來說,這也是面試高頻的考點。好了,下面,就來具體說說這個關鍵字。

2. synchronized實現原理

在java程式碼中使用synchronized可是使用在程式碼塊和方法中,根據Synchronized用的位置可以有這些使用場景:

Synchronized的使用場景

如圖,synchronized可以用在方法上也可以使用在程式碼塊中,其中方法是例項方法和靜態方法分別鎖的是該類的例項物件和該類的物件。而使用在程式碼塊中也可以分為三種,具體的可以看上面的表格。這裡的需要注意的是:如果鎖的是類物件的話,儘管new多個例項物件,但他們仍然是屬於同一個類依然會被鎖住,即執行緒之間保證同步關係

現在我們已經知道了怎樣synchronized了,看起來很簡單,擁有了這個關鍵字就真的可以在併發程式設計中得心應手了嗎?愛學的你,就真的不想知道synchronized底層是怎樣實現了嗎?

2.1 物件鎖(monitor)機制

現在我們來看看synchronized的具體底層實現。先寫一個簡單的demo:

public class SynchronizedDemo {
    public static void main(String[] args) {
        synchronized (SynchronizedDemo.class) {
        }
        method();
    }

    private static void method() {
    }
}
複製程式碼

上面的程式碼中有一個同步程式碼塊,鎖住的是類物件,並且還有一個同步靜態方法,鎖住的依然是該類的類物件。編譯之後,切換到SynchronizedDemo.class的同級目錄之後,然後用javap -v SynchronizedDemo.class檢視位元組碼檔案:

SynchronizedDemo.class

如圖,上面用黃色高亮的部分就是需要注意的部分了,這也是添Synchronized關鍵字之後獨有的。執行同步程式碼塊後首先要先執行monitorenter指令,退出的時候monitorexit指令。通過分析之後可以看出,使用Synchronized進行同步,其關鍵就是必須要對物件的監視器monitor進行獲取,當執行緒獲取monitor後才能繼續往下執行,否則就只能等待。而這個獲取的過程是互斥的,即同一時刻只有一個執行緒能夠獲取到monitor。上面的demo中在執行完同步程式碼塊之後緊接著再會去執行一個靜態同步方法,而這個方法鎖的物件依然就這個類物件,那麼這個正在執行的執行緒還需要獲取該鎖嗎?答案是不必的,從上圖中就可以看出來,執行靜態同步方法的時候就只有一條monitorexit指令,並沒有monitorenter獲取鎖的指令。這就是鎖的重入性,即在同一鎖程中,執行緒不需要再次獲取同一把鎖。Synchronized先天具有重入性。每個物件擁有一個計數器,當執行緒獲取該物件鎖後,計數器就會加一,釋放鎖後就會將計數器減一

任意一個物件都擁有自己的監視器,當這個物件由同步塊或者這個物件的同步方法呼叫時,執行方法的執行緒必須先獲取該物件的監視器才能進入同步塊和同步方法,如果沒有獲取到監視器的執行緒將會被阻塞在同步塊和同步方法的入口處,進入到BLOCKED狀態(關於執行緒的狀態可以看這篇文章

下圖表現了物件,物件監視器,同步佇列以及執行執行緒狀態之間的關係:

物件,物件監視器,同步佇列和執行緒狀態的關係

該圖可以看出,任意執行緒對Object的訪問,首先要獲得Object的監視器,如果獲取失敗,該執行緒就進入同步狀態,執行緒狀態變為BLOCKED,當Object的監視器佔有者釋放後,在同步佇列中得執行緒就會有機會重新獲取該監視器。

2.2 synchronized的happens-before關係

在上一篇文章中討論過happens-before規則,抱著學以致用的原則我們現在來看一看Synchronized的happens-before規則,即監視器鎖規則:對同一個監視器的解鎖,happens-before於對該監視器的加鎖。繼續來看程式碼:

public class MonitorDemo {
    private int a = 0;

    public synchronized void writer() {     // 1
        a++;                                // 2
    }                                       // 3

    public synchronized void reader() {    // 4
        int i = a;                         // 5
    }                                      // 6
}
複製程式碼

該程式碼的happens-before關係如圖所示:

synchronized的happens-before關係

在圖中每一個箭頭連線的兩個節點就代表之間的happens-before關係,黑色的是通過程式順序規則推匯出來,紅色的為監視器鎖規則推導而出:執行緒A釋放鎖happens-before執行緒B加鎖,藍色的則是通過程式順序規則和監視器鎖規則推測出來happens-befor關係,通過傳遞性規則進一步推導的happens-before關係。現在我們來重點關注2 happens-before 5,通過這個關係我們可以得出什麼?

根據happens-before的定義中的一條:如果A happens-before B,則A的執行結果對B可見,並且A的執行順序先於B。執行緒A先對共享變數A進行加一,由2 happens-before 5關係可知執行緒A的執行結果對執行緒B可見即執行緒B所讀取到的a的值為1。

2.3 鎖獲取和鎖釋放的記憶體語義

在上一篇文章提到過JMM核心為兩個部分:happens-before規則以及記憶體抽象模型。我們分析完Synchronized的happens-before關係後,還是不太完整的,我們接下來看看基於java記憶體抽象模型的Synchronized的記憶體語義。

廢話不多說依舊先上圖。

執行緒A寫共享變數

從上圖可以看出,執行緒A會首先先從主記憶體中讀取共享變數a=0的值然後將該變數拷貝到自己的本地記憶體,進行加一操作後,再將該值重新整理到主記憶體,整個過程即為執行緒A 加鎖-->執行臨界區程式碼-->釋放鎖相對應的記憶體語義。

執行緒B讀共享變數

執行緒B獲取鎖的時候同樣會從主記憶體中共享變數a的值,這個時候就是最新的值1,然後將該值拷貝到執行緒B的工作記憶體中去,釋放鎖的時候同樣會重寫到主記憶體中。

從整體上來看,執行緒A的執行結果(a=1)對執行緒B是可見的,實現原理為:釋放鎖的時候會將值重新整理到主記憶體中,其他執行緒獲取鎖時會強制從主記憶體中獲取最新的值。另外也驗證了2 happens-before 5,2的執行結果對5是可見的。

從橫向來看,這就像執行緒A通過主記憶體中的共享變數和執行緒B進行通訊,A 告訴 B 我們倆的共享資料現在為1啦,這種執行緒間的通訊機制正好吻合java的記憶體模型正好是共享記憶體的併發模型結構。

3. synchronized優化

通過上面的討論現在我們對Synchronized應該有所印象了,它最大的特徵就是在同一時刻只有一個執行緒能夠獲得物件的監視器(monitor),從而進入到同步程式碼塊或者同步方法之中,即表現為互斥性(排它性)。這種方式肯定效率低下,每次只能通過一個執行緒,既然每次只能通過一個,這種形式不能改變的話,那麼我們能不能讓每次通過的速度變快一點了。打個比方,去收銀臺付款,之前的方式是,大家都去排隊,然後去紙幣付款收銀員找零,有的時候付款的時候在包裡拿出錢包再去拿出錢,這個過程是比較耗時的,然後,支付寶解放了大家去錢包找錢的過程,現在只需要掃描下就可以完成付款了,也省去了收銀員跟你找零的時間的了。同樣是需要排隊,但整個付款的時間大大縮短,是不是整體的效率變高速率變快了?這種優化方式同樣可以引申到鎖優化上,縮短獲取鎖的時間,偉大的科學家們也是這樣做的,令人欽佩,畢竟java是這麼優秀的語言(微笑臉)。

在聊到鎖的優化也就是鎖的幾種狀態前,有兩個知識點需要先關注:(1)CAS操作 (2)Java物件頭,這是理解下面知識的前提條件。

3.1 CAS操作

3.1.1 什麼是CAS?

使用鎖時,執行緒獲取鎖是一種悲觀鎖策略,即假設每一次執行臨界區程式碼都會產生衝突,所以當前執行緒獲取到鎖的時候同時也會阻塞其他執行緒獲取該鎖。而CAS操作(又稱為無鎖操作)是一種樂觀鎖策略,它假設所有執行緒訪問共享資源的時候不會出現衝突,既然不會出現衝突自然而然就不會阻塞其他執行緒的操作。因此,執行緒就不會出現阻塞停頓的狀態。那麼,如果出現衝突了怎麼辦?無鎖操作是使用**CAS(compare and swap)**又叫做比較交換來鑑別執行緒是否出現衝突,出現衝突就重試當前操作直到沒有衝突為止。

3.1.2 CAS的操作過程

CAS比較交換的過程可以通俗的理解為CAS(V,O,N),包含三個值分別為:V 記憶體地址存放的實際值;O 預期的值(舊值);N 更新的新值。當V和O相同時,也就是說舊值和記憶體中實際的值相同表明該值沒有被其他執行緒更改過,即該舊值O就是目前來說最新的值了,自然而然可以將新值N賦值給V。反之,V和O不相同,表明該值已經被其他執行緒改過了則該舊值O不是最新版本的值了,所以不能將新值N賦給V,返回V即可。當多個執行緒使用CAS操作一個變數是,只有一個執行緒會成功,併成功更新,其餘會失敗。失敗的執行緒會重新嘗試,當然也可以選擇掛起執行緒

CAS的實現需要硬體指令集的支撐,在JDK1.5後虛擬機器才可以使用處理器提供的CMPXCHG指令實現。

Synchronized VS CAS

元老級的Synchronized(未優化前)最主要的問題是:在存線上程競爭的情況下會出現執行緒阻塞和喚醒鎖帶來的效能問題,因為這是一種互斥同步(阻塞同步)。而CAS並不是武斷的間執行緒掛起,當CAS操作失敗後會進行一定的嘗試,而非進行耗時的掛起喚醒的操作,因此也叫做非阻塞同步。這是兩者主要的區別。

3.1.3 CAS的應用場景

在J.U.C包中利用CAS實現類有很多,可以說是支撐起整個concurrency包的實現,在Lock實現中會有CAS改變state變數,在atomic包中的實現類也幾乎都是用CAS實現,關於這些具體的實現場景在之後會詳細聊聊,現在有個印象就好了(微笑臉)。

3.1.4 CAS的問題

1. ABA問題 因為CAS會檢查舊值有沒有變化,這裡存在這樣一個有意思的問題。比如一箇舊值A變為了成B,然後再變成A,剛好在做CAS時檢查發現舊值並沒有變化依然為A,但是實際上的確發生了變化。解決方案可以沿襲資料庫中常用的樂觀鎖方式,新增一個版本號可以解決。原來的變化路徑A->B->A就變成了1A->2B->3C。java這麼優秀的語言,當然在java 1.5後的atomic包中提供了AtomicStampedReference來解決ABA問題,解決思路就是這樣的。

2. 自旋時間過長

使用CAS時非阻塞同步,也就是說不會將執行緒掛起,會自旋(無非就是一個死迴圈)進行下一次嘗試,如果這裡自旋時間過長對效能是很大的消耗。如果JVM能支援處理器提供的pause指令,那麼在效率上會有一定的提升。

3. 只能保證一個共享變數的原子操作

當對一個共享變數執行操作時CAS能保證其原子性,如果對多個共享變數進行操作,CAS就不能保證其原子性。有一個解決方案是利用物件整合多個共享變數,即一個類中的成員變數就是這幾個共享變數。然後將這個物件做CAS操作就可以保證其原子性。atomic中提供了AtomicReference來保證引用物件之間的原子性。

3.2 Java物件頭

在同步的時候是獲取物件的monitor,即獲取到物件的鎖。那麼物件的鎖怎麼理解?無非就是類似對物件的一個標誌,那麼這個標誌就是存放在Java物件的物件頭。Java物件頭裡的Mark Word裡預設的存放的物件的Hashcode,分代年齡和鎖標記位。32為JVM Mark Word預設儲存結構為(注:java物件頭以及下面的鎖狀態變化摘自《java併發程式設計的藝術》一書,該書我認為寫的足夠好,就沒在自己組織語言班門弄斧了):

Mark Word儲存結構

如圖在Mark Word會預設存放hasdcode,年齡值以及鎖標誌位等資訊。

Java SE 1.6中,鎖一共有4種狀態,級別從低到高依次是:無鎖狀態、偏向鎖狀態、輕量級鎖狀態和重量級鎖狀態,這幾個狀態會隨著競爭情況逐漸升級。鎖可以升級但不能降級,意味著偏向鎖升級成輕量級鎖後不能降級成偏向鎖。這種鎖升級卻不能降級的策略,目的是為了提高獲得鎖和釋放鎖的效率。物件的MarkWord變化為下圖:

Mark Word狀態變化

3.2 偏向鎖

HotSpot的作者經過研究發現,大多數情況下,鎖不僅不存在多執行緒競爭,而且總是由同一執行緒多次獲得,為了讓執行緒獲得鎖的代價更低而引入了偏向鎖。

偏向鎖的獲取

當一個執行緒訪問同步塊並獲取鎖時,會在物件頭棧幀中的鎖記錄裡儲存鎖偏向的執行緒ID,以後該執行緒在進入和退出同步塊時不需要進行CAS操作來加鎖和解鎖,只需簡單地測試一下物件頭的Mark Word裡是否儲存著指向當前執行緒的偏向鎖。如果測試成功,表示執行緒已經獲得了鎖。如果測試失敗,則需要再測試一下Mark Word中偏向鎖的標識是否設定成1(表示當前是偏向鎖):如果沒有設定,則使用CAS競爭鎖;如果設定了,則嘗試使用CAS將物件頭的偏向鎖指向當前執行緒

偏向鎖的撤銷

偏向鎖使用了一種等到競爭出現才釋放鎖的機制,所以當其他執行緒嘗試競爭偏向鎖時,持有偏向鎖的執行緒才會釋放鎖。

偏向鎖撤銷流程

如圖,偏向鎖的撤銷,需要等待全域性安全點(在這個時間點上沒有正在執行的位元組碼)。它會首先暫停擁有偏向鎖的執行緒,然後檢查持有偏向鎖的執行緒是否活著,如果執行緒不處於活動狀態,則將物件頭設定成無鎖狀態;如果執行緒仍然活著,擁有偏向鎖的棧會被執行,遍歷偏向物件的鎖記錄,棧中的鎖記錄和物件頭的Mark Word要麼重新偏向於其他執行緒,要麼恢復到無鎖或者標記物件不適合作為偏向鎖,最後喚醒暫停的執行緒。

下圖執行緒1展示了偏向鎖獲取的過程,執行緒2展示了偏向鎖撤銷的過程。

偏向鎖獲取和撤銷流程

如何關閉偏向鎖

偏向鎖在Java 6和Java 7裡是預設啟用的,但是它在應用程式啟動幾秒鐘之後才啟用,如有必要可以使用JVM引數來關閉延遲:-XX:BiasedLockingStartupDelay=0。如果你確定應用程式裡所有的鎖通常情況下處於競爭狀態,可以通過JVM引數關閉偏向鎖:-XX:-UseBiasedLocking=false,那麼程式預設會進入輕量級鎖狀態

3.3 輕量級鎖

加鎖

執行緒在執行同步塊之前,JVM會先在當前執行緒的棧楨中建立用於儲存鎖記錄的空間,並將物件頭中的Mark Word複製到鎖記錄中,官方稱為Displaced Mark Word。然後執行緒嘗試使用CAS將物件頭中的Mark Word替換為指向鎖記錄的指標。如果成功,當前執行緒獲得鎖,如果失敗,表示其他執行緒競爭鎖,當前執行緒便嘗試使用自旋來獲取鎖。

解鎖

輕量級解鎖時,會使用原子的CAS操作將Displaced Mark Word替換回到物件頭,如果成功,則表示沒有競爭發生。如果失敗,表示當前鎖存在競爭,鎖就會膨脹成重量級鎖。下圖是兩個執行緒同時爭奪鎖,導致鎖膨脹的流程圖。

輕量級鎖加鎖解鎖以及鎖膨脹

因為自旋會消耗CPU,為了避免無用的自旋(比如獲得鎖的執行緒被阻塞住了),一旦鎖升級成重量級鎖,就不會再恢復到輕量級鎖狀態。當鎖處於這個狀態下,其他執行緒試圖獲取鎖時,都會被阻塞住,當持有鎖的執行緒釋放鎖之後會喚醒這些執行緒,被喚醒的執行緒就會進行新一輪的奪鎖之爭。

3.5 各種鎖的比較

各種鎖的對比

4. 一個例子

經過上面的理解,我們現在應該知道了該怎樣解決了。更正後的程式碼為:

public class SynchronizedDemo implements Runnable {
    private static int count = 0;

    public static void main(String[] args) {
        for (int i = 0; i < 10; i++) {
            Thread thread = new Thread(new SynchronizedDemo());
            thread.start();
        }
        try {
            Thread.sleep(500);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("result: " + count);
    }

    @Override
    public void run() {
        synchronized (SynchronizedDemo.class) {
            for (int i = 0; i < 1000000; i++)
                count++;
        }
    }
}
複製程式碼

開啟十個執行緒,每個執行緒在原值上累加1000000次,最終正確的結果為10X1000000=10000000,這裡能夠計算出正確的結果是因為在做累加操作時使用了同步程式碼塊,這樣就能保證每個執行緒所獲得共享變數的值都是當前最新的值,如果不使用同步的話,就可能會出現A執行緒累加後,而B執行緒做累加操作有可能是使用原來的就值,即“髒值”。這樣,就導致最終的計算結果不是正確的。而使用Syncnized就可能保證記憶體可見性,保證每個執行緒都是操作的最新值。這裡只是一個示例性的demo,聰明的你,還有其他辦法嗎?

參考文獻

《java併發程式設計的藝術》

徹底理解synchronized

相關文章