併發編程學習(2)synchronized與鎖的喚醒

synchronized 的基本認識

在多線程併發編程中 synchronized 一直是元老級角色,很多人都會稱呼它爲重量級鎖。但是,隨着 Java SE 1.6 對synchronized 進行了各種優化之後,有些情況下它就並不那麼重,Java SE 1.6 中爲了減少獲得鎖和釋放鎖帶來的性能消耗而引入的偏向鎖和輕量級鎖。

synchronized 的基本語法

synchronized 有三種方式來加鎖,分別是

  1. 修飾實例方法,作用於當前實例加鎖,進入同步代碼前要獲得當前實例的鎖
  2. 靜態方法,作用於當前類對象加鎖,進入同步代碼前要獲得當前類對象的鎖
  3. 修飾代碼塊,指定加鎖對象,對給定對象加鎖,進入同步代碼庫前要獲得給定對象的鎖。
public class SyncDemo {
    Object lock = new Object();

    public synchronized void demo1() {

    }

    public void demo2() {
        // TODO
        synchronized (this) {

        }
        // TODO
    }

    public synchronized static void demo3() {

    }

    public void demo4() {
        synchronized (SyncDemo.class) {

        }
    }

    public void demo5() {
        synchronized (lock) {

        }
    }
}

不同的修飾類型,代表鎖的控制粒度

鎖如何存儲

對象在內存中的佈局

在 Hotspot 虛擬機中,對象在內存中的存儲佈局,可以分爲三個區域:對象頭(Header)、實例數據(Instance Data)、對齊填充(Padding)

MarkWord

普通對象的對象頭由兩部分組成,分別是markOop以及類元信息,markOop官方稱爲Mark Word,在Hotspot中,markOop的定義在 markOop.hpp文件中,代碼如下

class markOopDesc: public oopDesc {
 private:
  // Conversion
  uintptr_t value() const { return (uintptr_t) this; }

 public:
  // Constants
  enum { age_bits                 = 4, // 分代年齡
         lock_bits                = 2, // 所表示
         biased_lock_bits         = 1, // 是否爲偏向鎖
         max_hash_bits            = BitsPerWord - age_bits - lock_bits - biased_lock_bits,
         hash_bits                = max_hash_bits > 31 ? 31 : max_hash_bits,
         cms_bits                 = LP64_ONLY(1) NOT_LP64(0),
         epoch_bits               = 2 // 偏向鎖的時間戳
  };

Mark word記錄了對象和鎖有關的信息,當某個對象被synchronized關鍵字當成同步鎖時,那麼圍繞這個鎖的一系列操作都和Mark word有關係。Mark Word在32位虛擬機的長度是32bit、在64位虛擬機的長度是64bit。 Mark Word裏面存儲的數據會隨着鎖標誌位的變化而變化,Mark Word可能變化爲存儲以下5中情況
32位

64位的變化

鎖標誌位的表示意義

  • 鎖標識 lock=00 表示輕量級鎖
  • 鎖標識 lock=10 表示重量級鎖
  • 偏向鎖標識 biased_lock=1表示偏向鎖
  • 偏向鎖標識 biased_lock=0且鎖標識=01表示無鎖狀態

總結一下前面的內容,synchronized(lock)中的lock可以用Java中任何一個對象來表示,而鎖標識的存儲實際上就是在lock這個對象中的對象頭內

爲什麼任何對象都可以實現鎖

首先,Java中的每個對象都派生自Object類,而每個Java Object在JVM內部都有一個native的C++對象 oop/oopDesc進行對應。 其次,線程在獲取鎖的時候,實際上就是獲得一個監視器對象(monitor) ,monitor可以認爲是一個同步對象,所有的Java對象是天生攜帶monitor. 在hotspot源碼的 markOop.hpp文件中;多個線程訪問同步代碼塊時,相當於去爭搶對象監視器修改對象中的鎖標識。

synchronized 鎖的升級

前面提到了鎖的幾個概念,偏向鎖、輕量級鎖、重量級鎖。在JDK1.6之前,synchronized是一個重量級鎖,性能比較差。從JDK1.6開始,爲了減少獲得鎖和釋放鎖帶來的性能消耗,synchronized進行了優化,引入了 偏向鎖和 輕量級鎖的概念。所以從JDK1.6開始,鎖一共會有四種狀態,鎖的狀態根據競爭激烈程度從低到高分別是:無鎖狀態->偏向鎖狀態->輕量級鎖狀態->重量級鎖狀態。這幾個狀態會隨着鎖競爭的情況逐步升級。爲了提高獲得鎖和釋放鎖的效率,鎖可以升級但是不能降級。 下面就詳細講解synchronized的三種鎖的狀態及升級原理

偏向鎖的基本原理

在大多數的情況下,鎖不僅不存在多線程的競爭,而且總是由同一個線程獲得。因此爲了讓線程獲得鎖的代價更低引入了偏向鎖的概念。偏向鎖的意思是如果一個線程獲得了一個偏向鎖,如果在接下來的一段時間中沒有其他線程來競爭鎖,那麼持有偏向鎖的線程再次進入或者退出同一個同步代碼塊,不需要再次進行搶佔鎖和釋放鎖的操作。

當一個線程訪問加了同步鎖的代碼塊時,會在對象頭中存儲當前線程的 ID,後續這個線程進入和退出這段加了同步鎖的代碼塊時,不需要再次加鎖和釋放鎖。而是直接比較對象頭裏面是否存儲了指向當前線程的偏向鎖。如果相等表示偏向鎖是偏向於當前線程的,就不需要再嘗試獲得鎖了

偏向鎖的獲取
  1. 首先獲取鎖 對象的 Markword,判斷是否處於可偏向狀態。(biased_lock=1、且 ThreadId 爲空)
  2. 如果是可偏向狀態,則通過 CAS 操作,把當前線程的 ID寫入到 MarkWord
    – 如果 cas 成功,那麼 markword 就會變成這樣。表示已經獲得了鎖對象的偏向鎖,接着執行同步代碼塊
    – 如果 cas 失敗,說明有其他線程已經獲得了偏向鎖,這種情況說明當前鎖存在競爭,需要撤銷已獲得偏向鎖的線程,並且把它持有的鎖升級爲輕量級鎖(這個操作需要等到全局安全點,也就是沒有線程在執行字節碼)才能執行
  3. 如果是已偏向狀態,需要檢查 markword 中存儲的ThreadID 是否等於當前線程的 ThreadID
    – 如果相等,不需要再次獲得鎖,可直接執行同步代碼塊
    – 如果不相等,說明當前鎖偏向於其他線程,需要撤銷偏向鎖並升級到輕量級鎖

CAS:表示自旋鎖,由於線程的阻塞和喚醒需要CPU從用戶態轉爲核心態,頻繁的阻塞和喚醒對CPU來說性能開銷很大。同時,很多對象鎖的鎖定狀態指會持續很短的時間,因此引入了自旋鎖,所謂自旋就是一個無意義的死循環,在循環體內不斷的重行競爭鎖。當然,自旋的次數會有限制,超出指定的限制會升級到阻塞鎖。

偏向鎖的撤銷

偏向鎖的撤銷並不是把對象恢復到無鎖可偏向狀態(因爲偏向鎖並不存在鎖釋放的概念),而是在獲取偏向鎖的過程中,發現 cas 失敗也就是存在線程競爭時,直接把被偏向的鎖對象升級到被加了輕量級鎖的狀態。

  1. 原獲得偏向鎖的線程如果已經退出了臨界區,也就是同步代碼塊執行完了,那麼這個時候會把對象頭設置成無鎖狀態並且爭搶鎖的線程可以基於 CAS 重新偏向當前線程
  2. 如果原獲得偏向鎖的線程的同步代碼塊還沒執行完,處於臨界區之內,這個時候會把原獲得偏向鎖的線程升級爲輕量級鎖後繼續執行同步代碼塊

在我們的應用開發中,絕大部分情況下一定會存在 2 個以上的線程競爭,那麼如果開啓偏向鎖,反而會提升獲取鎖的資源消耗。所以可以通過 jvm 參數UseBiasedLocking 來設置開啓或關閉偏向鎖** -XX:+UseBiasedLocking開啓或者關閉**

輕量級鎖的基本原理

當存在超過一個線程在競爭同一個同步代碼塊時,會發生偏向鎖的撤銷。偏向鎖撤銷以後對象會可能會處於兩種狀態

輕量級鎖加鎖
  • JVM會先在當前線程的棧幀中創建用於存儲鎖記錄的空間(LockRecord)
  • 將對象頭中的Mark Word複製到鎖記錄中,稱爲Displaced Mark Word.
  • 線程嘗試使用CAS將對象頭中的Mark Word替換爲指向鎖記錄的指針
  • 如果替換成功,表示當前線程獲得輕量級鎖,如果失敗,表示存在其他線程競爭鎖,那麼當前線程會嘗試使用CAS來獲取鎖,當自旋超過指定次數(可以自定義)時仍然無法獲得鎖,此時鎖會膨脹升級爲重量級鎖
自旋鎖

輕量級鎖在加鎖過程中,用到了自旋鎖,所謂自旋,就是指當有另外一個線程來競爭鎖時,這個線程會在原地循環等待,而不是把該線程給阻塞,直到那個獲得鎖的線程釋放鎖之後,這個線程就可以馬上獲得鎖的。注意,鎖在原地循環的時候,是會消耗 cpu 的,就相當於在執行一個啥也沒有的 for 循環。所以,輕量級鎖適用於那些同步代碼塊執行的很快的場景,這樣,線程原地等待很短的時間就能夠獲得鎖了。自旋鎖的使用,其實也是有一定的概率背景,在大部分同步代碼塊執行的時間都是很短的。所以通過看似無異議的循環反而能提升鎖的性能。但是自旋必須要有一定的條件控制,否則如果一個線程執行同步代碼塊的時間很長,那麼這個線程不斷的循環反而會消耗 CPU 資源。默認情況下自旋的次數是 10 次,可以通過 preBlockSpin 來修改

在 JDK1.6 之後,引入了自適應自旋鎖,自適應意味着自旋的次數不是固定不變的,而是根據前一次在同一個鎖上自旋的時間以及鎖的擁有者的狀態來決定。如果在同一個鎖對象上,自旋等待剛剛成功獲得過鎖,並且持有鎖的線程正在運行中,那麼虛擬機就會認爲這次自旋也是很有可能再次成功,進而它將允許自旋等待持續相對更長的時間。如果對於某個鎖,自旋很少成功獲得過,那在以後嘗試獲取這個鎖時將可能省略掉自旋過程,直接阻塞線程,避免浪費處理器資源

輕量級鎖的解鎖
  • JVM會先在當前線程的棧幀中創建用於存儲鎖記錄的空間(LockRecord)
  • 將對象頭中的Mark Word複製到鎖記錄中,稱爲Displaced Mark Word.
  • 線程嘗試使用CAS將對象頭中的Mark Word替換爲指向鎖記錄的指針
  • 如果替換成功,表示當前線程獲得輕量級鎖,如果失敗,表示存在其他線程競爭鎖,那麼當前線程會嘗試使用CAS來獲取鎖,當自旋超過指定次數(可以自定義)時仍然無法獲得鎖,此時鎖會膨脹升級爲重量級鎖

重量級鎖的基本原理

  private static Object Object;
    public static void main(String[] args) {
        Object=new Object();
        synchronized (Object){
        }
    }

我們通過javap -v 類 來查看信息

加了同步代碼塊以後,在字節碼中會看到一個monitorenter 和 monitorexit。每一個 JAVA 對象都會與一個監視器 monitor 關聯,我們可以把它理解成爲一把鎖,當一個線程想要執行一段被synchronized 修飾的同步方法或者代碼塊時,該線程得先獲取到 synchronized 修飾的對象對應的 monitor。monitorenter 表示去獲得一個對象監視器。monitorexit 表示釋放 monitor 監視器的所有權,使得其他被阻塞的線程可以嘗試去獲得這個監視器

爲什麼重量級鎖的開銷比較大呢?

原因是monitor 依賴操作系統的 MutexLock(互斥鎖)來實現的,當系統檢查到是重量級鎖之後,會把等待想要獲取鎖的線程阻塞,被阻塞的線程不會消耗CPU,但是阻塞或者喚醒一個線程,都需要通過操作系統來實現,也就是相當於從用戶態轉化到內核態,而轉化狀態是需要消耗時間的

回顧線程的競爭機制

synchronized (lock) {
 // do something java 封裝的東西越簡單底層封裝就越複雜
}
  1. 只有 Thread#1 會進入臨界區;
  2. Thread#1 和 Thread#2 交替進入臨界區,競爭不激烈;
  3. Thread#1/Thread#2/Thread3… 同時進入臨界區,競爭激烈

偏向鎖

此時當 Thread#1 進入臨界區時,JVM 會將 lockObject 的對象頭 Mark Word 的鎖標誌位設爲“01”,同時會用 CAS 操作把 Thread#1 的線程 ID 記錄到 Mark Word 中,此時進入偏向模式。所謂“偏向”,指的是這個鎖會偏向於 Thread#1,若接下來沒有其他線程進入臨界區,則 Thread#1 再出入臨界區無需再執行任何同步操作。也就是說,若只有Thread#1 會進入臨界區,實際上只有 Thread#1 初次進入
臨界區時需要執行 CAS 操作,以後再出入臨界區都不會有同步操作帶來的開銷。

輕量級鎖

偏向鎖的場景太過於理想化,更多的時候是 Thread#2 也會嘗試進入臨界區, 如果 Thread#2 也進入臨界區但是Thread#1 還沒有執行完同步代碼塊時,會暫停 Thread#1並且升級到輕量級鎖。Thread#2 通過自旋再次嘗試以輕量級鎖的方式來獲取鎖

重量級鎖

如果 Thread#1 和 Thread#2 正常交替執行,那麼輕量級鎖基本能夠滿足鎖的需求。但是如果 Thread#1 和 Thread#2同時進入臨界區,那麼輕量級鎖就會膨脹爲重量級鎖,意味着 Thread#1 線程獲得了重量級鎖的情況下,Thread#2就會被阻塞

Synchronized 結合 Java Object 對象中的wait,notify,notifyAll

前面看synchronized的時候,發現被阻塞的線程什麼時候被喚醒,取決於獲得鎖的線程什麼時候執行完同步代碼塊並且釋放鎖。那怎麼做到顯示控制呢?我們就需要藉助一個信號機制:在 Object 對 象 中,提供了wait/notify/notifyall,可以用於控制線程的狀態
先看代碼

public class ThreadA extends Thread{
    Object lock;

    public ThreadA(Object lock) {
        this.lock = lock;
    }

    @Override
    public void run() {
        synchronized (lock){
            try {
                System.out.println("start threadA");
                lock.wait();
                System.out.println("end threadA");
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}
public class ThreadB extends Thread{
    Object lock;

    public ThreadB(Object lock) {
        this.lock = lock;
    }

    @Override
    public void run() {
        synchronized (lock){
            System.out.println("start threadB");
            lock.notify();
            System.out.println("end threadB");
        }
    }
}
    public static void main(String[] args) {
        Object lock=new Object();
        Thread threadA=new ThreadA(lock);
        threadA.start();
        Thread threadB=new ThreadB(lock);
        threadB.start();
    }
輸出----------------------
start threadA
start threadB
end threadB
end threadA

執行流程

wait/notify/notifyall 基本概念

wait:表示持有對象鎖的線程 A 準備釋放對象鎖權限,釋放 cpu 資源並進入等待狀態。
notify:表示持有對象鎖的線程 A 準備釋放對象鎖權限,通知 jvm 喚 醒 某 個 競 爭 該 對 象 鎖 的 線 程 X,線 程 A
synchronized 代碼執行結束並且釋放了鎖之後,線程 X 直接獲得對象鎖權限,其他競爭線程繼續等待(即使線程 X 同步完畢,釋放對象鎖,其他競爭線程仍然等待,直至有新的 notify ,notifyAll 被調用)。
notifyAll:notifyall 和 notify 的區別在於,notifyAll 會喚醒所有競爭同一個對象鎖的所有線程,當已經獲得鎖的線程A 釋放鎖之後,所有被喚醒的線程都有可能獲得對象鎖權限
注意:三個方法都必須在synchronized同步關鍵字所限定的作用域中調用(一定要理解同步的原因),否則會報錯java.lang.IllegalMonitorStateException,意思是因爲沒有同步,所以線程對對象鎖的狀態是不確定的,不能調用這些方法。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章