16、synchronized底層如何實現?什麼是鎖的升級、降級? (高併發編程----2)

目錄

syncronized 底層如何實現?什麼是鎖的升級、降級?

典型回答

鎖的升級、降級

考點分析

知識擴展

展開一些 synchronized 的底層實現

順着鎖升降級的過程分析下去,偏斜鎖到輕量級鎖的過程是如何實現的呢?

理解併發包中 java.util.concurrent.lock 提供的其他鎖實現

爲什麼我們需要讀寫鎖(ReadWriteLock)等其他鎖呢?

一課一練


我在上一講對比和分析了synchronized ReentrantLock,算是專欄進入併發編程階段的熱身,相信你已經對線程安全,以及如何使用基本的同步機制有了基礎,今天我們將深入瞭解synchronize 底層機制,分析其他鎖實現和應用場景。

 

syncronized 底層如何實現?什麼是鎖的升級、降級?

典型回答

在回答這個問題前,先簡單複習一下上一講的知識點。synchronized 代碼塊是由一對兒 monitorenter/monitorexit 指令實現的,Monitor 對象是同步的基本實現單元

在 Java 6 之前,Monitor 的實現完全是依靠操作系統內部的互斥鎖,因爲需要進行用戶態到內核態的切換,所以同步操作是一個無差別的重量級操作。

現代的(Oracle)JDK 中,JVM 對此進行了大刀闊斧地改進,提供了三種不同的Monitor 實現,也就是常說的三種不同的鎖:偏斜鎖(Biased Locking)、輕量級鎖和重量級鎖,大大改進了其性能。

 

鎖的升級、降級

所謂鎖的升級、降級,就是 JVM 優化 synchronized 運行的機制,當 JVM 檢測到不同的競爭狀況時,會自動切換到適合的鎖實現,這種切換就是鎖的升級、降級。

鎖的升級:偏斜鎖==>輕量級鎖==>重量級鎖
鎖的降級:重量級鎖==>輕量級鎖==>偏斜鎖

當沒有競爭出現時,默認會使用偏斜鎖。JVM 會利用 CAS 操作(compare and swap),在對象頭上的Mark Word 部分設置線程 ID,以表示這個對象偏向於當前線程,所以並不涉及真正的互斥鎖。這樣做的假設是基於在很多應用場景中,大部分對象生命週期中最多會被一個線程鎖定,使用偏斜鎖可以降低無競爭開銷

如果有另外的線程試圖鎖定某個已經被偏斜過的對象,JVM 就需要撤銷(revoke)偏斜鎖,並切換到輕量級鎖實現輕量級鎖依賴 CAS 操作 Mark Word 來試圖獲取鎖,如果重試成功,就使用普通的輕量級鎖;否則,進一步升級爲重量級鎖

我注意到有的觀點認爲 Java 不會進行鎖降級。實際上據我所知,鎖降級確實是會發生的,當 JVM 進入安全點(SafePoint)的時候,會檢查是否有閒置的 Monitor,然後試圖進行降級

 

考點分析

我個人認爲,能夠基礎性地理解這些概念和機制,其實對於大多數併發編程已經足夠了,畢竟大部分工程師未必會進行更底層、更基礎的研發,很多時候解決的是知道與否,真正的提高還要靠實踐踩坑。

後面我會進一步分析:

  •     從源碼層面,稍微展開一些 synchronized 的底層實現,並補充一些上面答案中欠缺的細節,有同學反饋這部分容易被問到。如果你對 Java 底層源碼有興趣,但還沒有找到入手點,這裏可以成爲一個切入點。
  •     理解併發包中 java.util.concurrent.lock 提供的其他鎖實現,畢竟 Java可不是隻有 ReentrantLock 一種顯式的鎖類型,我會結合代碼分析其使用。


知識擴展

展開一些 synchronized 的底層實現

我在上一講提到過 synchronized 是 JVM 內部的 Intrinsic Lock(內在鎖),所以偏斜鎖、輕量級鎖、重量級鎖的代碼實現,並不在核心類庫部分,而是在 JVM 的代碼中

Java 代碼運行可能是解釋模式也可能是編譯模式(如果不記得,請複習專欄第1講),所以對應的同步邏輯
實現,也會分散在不同模塊下,比如,解釋器版本就是:

src/hotspot/share/interpreter/interpreterRuntime.cpp
<http://hg.openjdk.java.net/jdk/jdk/file/6659a8f57d78/src/hotspot/share/interpreter/interpreterRuntime.cpp>

爲了簡化便於理解,我這裏會專注於通用的基類實現:

src/hotspot/share/runtime/
<http://hg.openjdk.java.net/jdk/jdk/file/6659a8f57d78/src/hotspot/share/runtime/>

另外請注意,鏈接指向的是最新 JDK 代碼庫,所以可能某些實現與歷史版本有所不同。

 

首先,synchronized 的行爲是 JVM runtime 的一部分,所以我們需要先找到Runtime 相關的功能實現。通過在代碼中查詢類似“monitor_enter”或“Monitor Enter”,很直觀的就可以定位到:

  •     sharedRuntime.cpp

    <http://hg.openjdk.java.net/jdk/jdk/file/6659a8f57d78/src/hotspot/share/runtime/sharedRuntime.cpp>/hpp,它是解釋器和編譯器運行時的基類。

  •     synchronizer.cpp

    <https://java.se.oracle.com/source/xref/jdk-jdk/open/src/hotspot/share/runtime/synchronizer.cpp>/hpp,JVM 同步相關的各種基礎邏輯。

 

在 sharedRuntime.cpp 中,下面代碼體現了 synchronized 的主要邏輯。

Handle h_obj(THREAD, obj);
  if (UseBiasedLocking) {
    // Retry fast entry if bias is revoked to avoid unnecessary inflation
    ObjectSynchronizer::fast_enter(h_obj, lock, true, CHECK);
  } else {
    ObjectSynchronizer::slow_enter(h_obj, lock, CHECK);
  }

其實現可以簡單進行分解:

  •    UseBiasedLocking 是一個檢查,因爲,在 JVM 啓動時,我們可以指定是否開啓偏斜鎖。

偏斜鎖並不適合所有應用場景,撤銷操作(revoke)是比較重的行爲,只有當存在較多不會真正競爭的 synchronized 塊兒時,才能體現出明顯改善。實踐中對於偏斜鎖的一直是有爭議的,有人甚至認爲,當你需要大量使用併發類庫時,往往意味着你不需要偏斜鎖。從具體選擇來看,我還是建議需要在實踐中進行測試,根據結果再決定是否使用。

還有一方面是,偏斜鎖會延緩 JIT 預熱的進程,所以很多性能測試中會顯式地關閉偏斜鎖,命令如下:

-XX:-UseBiasedLocking
  •   fast_enter 是我們熟悉的完整鎖獲取路徑,slow_enter 則是繞過偏斜鎖,直接進入輕量級鎖獲取邏輯。

那麼 fast_enter 是如何實現的呢?同樣是通過在代碼庫搜索,我們可以定位到synchronizer.cpp
<https://java.se.oracle.com/source/xref/jdk-jdk/open/src/hotspot/share/runtime/synchronizer.cpp>。 類似 fast_enter 這種實現,解釋器或者動態編譯器,都是拷貝這段基礎邏輯,所以如果我們修改這部分邏輯,要保證一致性。這部分代碼是非常敏感的,微小的問題都可能導致死鎖或者正確性問題。

void ObjectSynchronizer::fast_enter(Handle obj, BasicLock* lock,
                                    bool attempt_rebias, TRAPS) {
  if (UseBiasedLocking) {
    if (!SafepointSynchronize::is_at_safepoint()) {
      BiasedLocking::Condition cond = BiasedLocking::revoke_and_rebias(obj, attempt_rebias, THREAD);
      if (cond == BiasedLocking::BIAS_REVOKED_AND_REBIASED) {
        return;
      }
    } else {
      assert(!attempt_rebias, "can not rebias toward VM thread");
      BiasedLocking::revoke_at_safepoint(obj);
    }
    assert(!obj->mark()->has_bias_pattern(), "biases should be revoked by now");
  }

  slow_enter(obj, lock, THREAD);
}

我來分析下這段邏輯實現:

  •     biasedLocking

    <http://hg.openjdk.java.net/jdk/jdk/file/6659a8f57d78/src/hotspot/share/runtime/biasedLocking.cpp>定義了偏斜鎖相關操作,revoke_and_rebias 是獲取偏斜鎖的入口方法,revoke_at_safepoint 則定義了當檢測到安全點時的處理邏輯。

  •     如果獲取偏斜鎖失敗,則進入 slow_enter。
  •     這個方法裏面同樣檢查是否開啓了偏斜鎖,但是從代碼路徑來看,其實如果關閉了偏斜鎖,是不會進入這個方法的,所以算是個額外的保障性檢查吧。

另外,如果你仔細查看synchronizer.cpp
<https://java.se.oracle.com/source/xref/jdk-jdk/open/src/hotspot/share/runtime/synchronizer.cpp>裏,會發現不僅僅是 synchronized 的邏輯,包括從本地代碼,也就是 JNI,觸發的 Monitor 動作,全都可以在裏面找到(jni_enter/jni_exit)。

關於biasedLocking
<http://hg.openjdk.java.net/jdk/jdk/file/6659a8f57d78/src/hotspot/share/runtime/biasedLocking.cpp>的更多細節我就不展開了,明白它是通過 CAS 設置Mark Word 就完全夠用了,對象頭中 Mark Word 的結構,可以參考下圖:



順着鎖升降級的過程分析下去,偏斜鎖到輕量級鎖的過程是如何實現的呢?

我們來看看 slow_enter 到底做了什麼。

void ObjectSynchronizer::slow_enter(Handle obj, BasicLock* lock, TRAPS) {
  markOop mark = obj->mark();
 if (mark->is_neutral()) {
       // 將目前的 Mark Word 複製到 Displaced Header 上
    lock->set_displaced_header(mark);
    // 利用 CAS 設置對象的 Mark Word
    if (mark == obj()->cas_set_mark((markOop) lock, mark)) {
      TEVENT(slow_enter: release stacklock);
      return;
    }
    // 檢查存在競爭
  } else if (mark->has_locker() &&
             THREAD->is_lock_owned((address)mark->locker())) {
    // 清除
    lock->set_displaced_header(NULL);
    return;
  }

  // 重置 Displaced Header
  lock->set_displaced_header(markOopDesc::unused_mark());
  ObjectSynchronizer::inflate(THREAD,
                              obj(),
                              inflate_cause_monitor_enter)->enter(THREAD);
}

請結合我在代碼中添加的註釋,來理解如何從試圖獲取輕量級鎖,逐步進入鎖膨脹的過程。你可以發現這個處理邏輯,和我在這一講最初介紹的過程是十分吻合的。

  •     設置 Displaced Header,然後利用 cas_set_mark 設置對象 Mark Word,如果成功就成功獲取輕量級鎖。
  •     否則 Displaced Header,然後進入鎖膨脹階段,具體實現在 inflate 方法中。

 

今天就不介紹膨脹的細節了,我這裏提供了源代碼分析的思路和樣例,考慮到應用實踐,再進一步增加源代碼解讀意義不大,有興趣的同學可以參考我提供的synchronizer.cpp
<https://java.se.oracle.com/source/xref/jdk-jdk/open/src/hotspot/share/runtime/synchronizer.cpp>鏈接,例如:

  •     deflate_idle_monitors是分析鎖降級邏輯的入口,這部分行爲還在進行持續改進,因爲其邏輯是在安全點內運行,處理不當可能拖長 JVM 停頓(STW,stop-the-world)的時間。
  •     fast_exit 或者 slow_exit 是對應的鎖釋放邏輯。

 

理解併發包中 java.util.concurrent.lock 提供的其他鎖實現

前面分析了 synchronized 的底層實現,理解起來有一定難度,下面我們來看一些相對輕鬆的內容。 我在上一講對比了 synchronized 和 ReentrantLock,Java 核心類庫中還有其他一些特別的鎖類型,具體請參考下面的圖。

你可能注意到了,這些鎖竟然不都是實現了 Lock 接口,ReadWriteLock 是一個單獨的接口,它通常是代表了一對兒鎖,分別對應只讀和寫操作,標準類庫中提供了再入版本的讀寫鎖實現(ReentrantReadWriteLock),對應的語義和ReentrantLock 比較相似。

StampedLock 竟然也是個單獨的類型,從類圖結構可以看出它是不支持再入性的語義的,也就是它不是以持有鎖的線程爲單位。

 

爲什麼我們需要讀寫鎖(ReadWriteLock)等其他鎖呢?

這是因爲,雖然 ReentrantLock 和 synchronized 簡單實用,但是行爲上有一定侷限性,通俗點說就是“太霸道”,要麼不佔,要麼獨佔。實際應用場景中,有的時候不需要大量競爭的寫操作,而是以併發讀取爲主,如何進一步優化併發操作的粒度呢?

Java 併發包提供的讀寫鎖等擴展了鎖的能力,它所基於的原理是多個讀操作是不需要互斥的,因爲讀操作並不會更改數據,所以不存在互相干擾。而寫操作則會導致併發一致性的問題,所以寫線程之間、讀寫線程之間,需要精心設計的互斥邏輯。

 

下面是一個基於讀寫鎖實現的數據結構,當數據量較大,併發讀多、併發寫少的時候,能夠比純同步版本凸顯出優勢。

public class RWSample {
    private final Map<String, String> m = new TreeMap<>();
    private final ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
    private final Lock r = rwl.readLock();
    private final Lock w = rwl.writeLock();
    public String get(String key) {
        r.lock();
        System.out.println(" 讀鎖鎖定!");
        try {
            return m.get(key);
        } finally {
            r.unlock();
        }
    }

    public String put(String key, String entry) {
        w.lock();
        System.out.println(" 寫鎖鎖定!");
        try {
            return m.put(key, entry);
        } finally {
            w.unlock();
        }
    }
    // …
}

在運行過程中,如果讀鎖試圖鎖定時,寫鎖是被某個線程持有,讀鎖將無法獲得,而只好等待對方操作結束,這樣就可以自動保證不會讀取到有爭議的數據。

讀寫鎖看起來比 synchronized 的粒度似乎細一些,但在實際應用中,其表現也並不盡如人意,主要還是因爲相對比較大的開銷。

所以,JDK 在後期引入了 StampedLock,在提供類似讀寫鎖的同時,還支持優化讀模式。優化讀基於假設,大多數情況下讀操作並不會和寫操作衝突,其邏輯是先試着修改,然後通過 validate 方法確認是否進入了寫模式,如果沒有進入,就成功避免了開銷;如果進入,則嘗試獲取讀鎖。請參考我下面的樣例代碼。

public class StampedSample {
    private final StampedLock sl = new StampedLock();

    void mutate() {
        long stamp = sl.writeLock();
        try {
            write();
        } finally {
            sl.unlockWrite(stamp);
        }
    }

    Data access() {
        long stamp = sl.tryOptimisticRead();
        Data data = read();
        if (!sl.validate(stamp)) {
            stamp = sl.readLock();
            try {
                data = read();
            } finally {
                sl.unlockRead(stamp);
            }
        }
        return data;
    }
    // …
}

注意,這裏的 writeLock 和 unLockWrite 一定要保證成對調用。

你可能很好奇這些顯式鎖的實現機制,Java 併發包內的各種同步工具,不僅僅是各種 Lock,其他的如Semaphore
<https://docs.oracle.com/javase/10/docs/api/java/util/concurrent/Semaphore.html>、

CountDownLatch
<https://docs.oracle.com/javase/10/docs/api/java/util/concurrent/CountDownLatch.html>,

甚至是早期的FutureTask
<https://docs.oracle.com/javase/10/docs/api/java/util/concurrent/FutureTask.html>等,

都是基於一種AQS
<https://docs.oracle.com/javase/10/docs/api/java/util/concurrent/locks/AbstractQueuedSynchronizer.html>框架。

今天,我全面分析了 synchronized 相關實現和內部運行機制,簡單介紹了併發包中提供的其他顯式鎖,並結合樣例代碼介紹了其使用方法,希望對你有所幫助。

 

一課一練

關於今天我們討論的你做到心中有數了嗎?思考一個問題,你知道“自旋鎖”是做什麼的嗎?它的使用場景是什麼?

自旋鎖: 競爭鎖的失敗的線程,並不會真實的在操作系統層面掛起等待,而是JVM會讓線程做幾個空循環(基於預測在不久的將來就能獲得),在經過若干次循環後,如果可以獲得鎖,那麼進入臨界區,如果還不能獲得鎖,纔會真實的將線程在操作系統層面進行掛起

適用場景: 自旋鎖可以減少線程的阻塞,這對於鎖競爭不激烈,且佔用鎖時間非常短的代碼塊來說,有較大的性能提升,因爲自旋的消耗會小於線程阻塞掛起操作的消耗。
如果鎖的競爭激烈,或者持有鎖的線程需要長時間佔用鎖執行同步塊,就不適合使用自旋鎖了,因爲自旋鎖在獲取鎖前一直都是佔用cpu做無用功,線程自旋的消耗大於線程阻塞掛起操作的消耗,造成cpu的浪費。

優缺點: 好處是減少了上下文切換,缺點是消耗cpu。

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