08.從原始碼揭祕偏向鎖的升級

2023-01-07 12:00:27

大家好,我是王有志。關注王有志,一起聊技術,聊遊戲,從北漂生活談到國際風雲。

最近搞了個抽獎送書的活動,歡迎點選連結參與。

今天開始,我會和大家一起深入學習synchronized的原理,原理部分會涉及到兩篇:

  • 偏向鎖升級到輕量級鎖的過程
  • 輕量級鎖升級到重量級鎖的過程

今天我們先來學習偏向鎖升級到輕量級鎖的過程。因為涉及到大量HotSpot原始碼,會有單獨的一篇註釋版原始碼的文章。

通過本篇文章,你們解答synchronized都問啥?中統計到的如下問題:

  • 詳細描述下synchronized的實現原理(67%)
  • 為什麼說synchronized是可重入鎖?(67%)
  • 詳細描述下synchronized的鎖升級(膨脹)過程(67%)
  • 偏向鎖是什麼?synchronized是怎樣實現偏向鎖的?(100%)
  • Java 8之後,synchronized做了哪些優化?(50%)

準備工作

正式開始分析synchronized原始碼前,我們先做一些準備:

  • HotSpot原始碼準備:Open JDK 11
  • 位元組碼工具,推薦jclasslib外掛
  • 用於跟蹤物件狀態的jol-core包。

Tips

  • 可以使用javap命令和IDEA自帶的位元組碼工具;
  • jclasslib的優勢在於可以直接跳轉到相關命令的官方站點。

範例程式碼

準備一個簡單的範例程式碼:

public class SynchronizedPrinciple {
    private int count = 0;

    private void add() {
        synchronized (this) {
            count++;
        }
    }
}

通過工具,我們可以得到如下位元組碼:

aload_0
dup
astore_1
monitorenter // 1
aload_0
dup
getfield #2 <com/wyz/keyword/synckeyword/SynchronizedPrinciple.count : I>
iconst_1
iadd
putfield #2 <com/wyz/keyword/synckeyword/SynchronizedPrinciple.count : I>
aload_1
monitorexit // 2
goto 24 (+8)
astore_2
aload_1
monitorexit // 3
aload_2
athrow
return

synchronized修飾程式碼塊,編譯成了兩條指令:

我們注意到,monitorexit出現了兩次。註釋2的部分是程式執行正常,註釋3的部分是程式執行異常。Java團隊連程式異常的情況都替你考慮到了,他真的,我哭死。

Tips

  • 使用synchronized修飾程式碼塊作為範例的原因是,修飾方法時僅在access_flag設定ACC_SYNCHRONIZED標誌,並不直觀;
  • Java並不是只能通過monitorexit退出監視器, Java曾在Unsafe類中提供過進出監視器的方法。
Unsafe.getUnsafe.monitorEnter(obj);
Unsafe.getUnsafe.monitorExit(obj);

Java 8可以使用,Java 11已經移除,具體移除的版本我就不太清楚了。

jol使用範例

可以通過jol-core來跟蹤物件狀態。

Maven依賴:

<dependency>  
    <groupId>org.openjdk.jol</groupId>  
    <artifactId>jol-core</artifactId>  
    <version>0.16</version>  
</dependency>

使用範例:

public static void main(String[] args) {
	Object obj = new Object();
	System.out.println(ClassLayout.parseInstance(obj).toPrintable());
}

從monitorenter處開始

在HotSpot中,monitorenter指令對應這兩大類解析方式:

由於bytecodeInterpreter基本退出了歷史舞臺,我們以模板直譯器X86實現templateTable_x86為例。

Tips

monitorenter的執行方法是templateTable_x86#monitorenter,該方法中,我們只需要關注4438行執行的__ lock_object(rmon),呼叫了interp_masm_x86#lock_object方法:

void InterpreterMacroAssembler::lock_object(Register lock_reg) {
	if (UseHeavyMonitors) {// 1
		// 重量級鎖邏輯
		call_VM(noreg, CAST_FROM_FN_PTR(address, InterpreterRuntime::monitorenter),  lock_reg);  
} else {
	Label done;
	Label slow_case;
	if (UseBiasedLocking) {// 2
		// 偏向鎖邏輯
		biased_locking_enter(lock_reg, obj_reg, swap_reg, tmp_reg, false, done, &slow_case);
	}
	// 3
	bind(slow_case);
	call_VM(noreg,   CAST_FROM_FN_PTR(address, InterpreterRuntime::monitorenter),  lock_reg);
    bind(done);
	......
}

註釋1和註釋2的部分,是兩個JVM引數:

// 啟用重量級鎖
-XX:+UseHeavyMonitors
// 啟用偏向鎖
-XX:+UseBiasedLocking

註釋1和註釋3,呼叫InterpreterRuntime::monitorenter方法,註釋1是直接使用重量級鎖的設定,那麼可以猜到,註釋3是獲取偏向鎖失敗鎖升級為重量級鎖的邏輯。

物件頭(markOop)

正式開始前,先來了解物件頭(markOop)。實際上,markOop的註釋已經揭露了它的「祕密「:

The markOop describes the header of an object.
......
Bit-format of an object header (most significant first, big endian layout below):
64 bits:
unused:25 hash:31 -->| unused:1 age:4 biased_lock:1 lock:2 (normal object)
JavaThread:54 epoch:2 unused:1 age:4 biased_lock:1 lock:2 (biased object)
......
[JavaThread
| epoch | age | 1 | 01]       lock is biased toward given thread
[0           | epoch | age | 1 | 01]       lock is anonymously biase

註釋詳細的描述了64位元大端模式下Java物件頭的結構:

Tips

物件頭中的大部分結構都很容易理解,但epoch是什麼?

註釋中將epoch描述為「used in support of biased locking」。OpenJDK wiki中Synchronization是這樣描述epoch的:

An epoch value in the class acts as a timestamp that indicates the validity of the bias.

epoch類似於時間戳,表示偏向鎖的有效性。它的在批次重偏向階段(biasedLocking#bulk_revoke_or_rebias_at_safepoint)更新:

static BiasedLocking::Condition bulk_revoke_or_rebias_at_safepoint(oop o, bool bulk_rebias, bool attempt_rebias_of_object, JavaThread* requesting_thread) {
	{
		if (bulk_rebias) {
			if (klass->prototype_header()->has_bias_pattern()) {
				klass->set_prototype_header(klass->prototype_header()->incr_bias_epoch());
			}
		}
	}
}

JVM通過epoch來判斷是否適合偏向鎖,超過閾值後JVM會升級偏向鎖。JVM提供了引數來調節這個閾值。

// 批次重偏向閾值
-XX:BiasedLockingBulkRebiasThreshold=20
// 批次復原閾值
-XX:BiasedLockingBulkRevokeThreshold=40

Tips:更新的是klassepoch

偏向鎖(biasedLocking)

系統開啟了偏向鎖,會進入macroAssembler_x86#biased_locking_enter方法。該方法首先是獲取物件的markOop

Address mark_addr         (obj_reg, oopDesc::mark_offset_in_bytes());
Address saved_mark_addr(lock_reg, 0);

我將接下來的流程分為5個分支,按照執行順序和大家一起分析偏向鎖的實現邏輯。

Tips

  • 瞭解偏向鎖流程即可,因此以圖示為主,原始碼分析放在偏向鎖原始碼分析中;
  • 偏向鎖原始碼分析以註釋為主,詳細標註了每個分支;
  • 這部分實際上包含了復原重偏向兩個跳轉標籤,分支圖示中有說明;
  • 原始碼使用位掩碼技術,為了便於區分,二進位制數位用0B開頭,並補齊4位元。

分支1:是否可偏向?

偏向鎖的前置條件,邏輯非常簡單,判斷當前物件markOop的鎖標誌,如果已經升級,執行升級流程;否則繼續向下執行。

Tips:虛線部分邏輯位於其它類中。

分支2:是否重入偏向?

目前JVM已知markOop的鎖標誌位為0B0101,處於可偏向狀態,但不清楚是已經偏向還是尚未偏向。HotSopt中使用anonymously形容可偏向但尚未偏向某個執行緒的狀態,稱這種狀態為匿名偏向。此時物件頭如下:

此時要做的事情就比較簡單了,判斷是否為當前執行緒重入偏向鎖。如果是重入,直接退出即可;否則繼續向下執行。

Tips:今天刷到一個貼文,Javaer和C++er爭論可重入鎖和遞迴鎖,有興趣的可以看一文看懂並行程式設計中的鎖我簡單解釋了可重入鎖和遞迴鎖的關係。

分支3:是否依舊可偏向?

註釋描述了不是重入偏向鎖的情況:

At this point we know that the header has the bias pattern and that we are not the bias owner in the current epoch. We need to figure out more details about the state of the header in order to know what operations can be legally performed on the object's header.

此時可能存在兩種情況:

  • 不存在競爭,重新偏向某個執行緒;
  • 存在競爭,嘗試復原。

偏向鎖復原的部分稍微複雜,使用物件klassmarkOop替換物件的markOop,關鍵技術是CAS

分支4:epoch是否過期?

目前偏向鎖的狀態是可偏向,且偏向其他執行緒。此時的邏輯只需要片段epoch是否有效即可。

重新偏向的可以用一句話描述,構建markOop進行CAS替換。

分支5:重新偏向

目前偏向鎖的狀態是,可偏向,偏向其它執行緒,epoch未過期。此時要做的是在markOop中設定當前執行緒,也就是偏向鎖重新偏向的過程,和分支4的部分非常相似。

復原和重偏向

獲取偏向鎖失敗後,執行InterpreterRuntime::monitorenter方法,位於interpreterRuntime中:

IRT_ENTRY_NO_ASYNC(void, InterpreterRuntime::monitorenter(JavaThread* thread, BasicObjectLock* elem))
	if (UseBiasedLocking) {
		// 完整的鎖升級路徑
		// 偏向鎖->輕量級鎖->重量級鎖
	  ObjectSynchronizer::fast_enter(h_obj, elem->lock(), true, CHECK);
	} else {
		// 跳過偏向鎖的鎖升級路徑
		// 輕量級鎖->重量級鎖
		ObjectSynchronizer::slow_enter(h_obj, elem->lock(), CHECK);
	}
IRT_END

ObjectSynchronizer::fast_enter位於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 {
			BiasedLocking::revoke_at_safepoint(obj);
		}
	}
	// 跳過偏向鎖
	slow_enter(obj, lock, THREAD);
}

BiasedLocking::revoke_and_rebias的精簡註釋版放在了偏向鎖原始碼分析的第2部分。

輕量級鎖(basicLock)

如果獲取偏向鎖失敗,此時會執行ObjectSynchronizer::slow_enter,該方法位於synchronizer#slow_enter

void ObjectSynchronizer::slow_enter(Handle obj, BasicLock* lock, TRAPS) {
	markOop mark = obj->mark();
	// 無鎖狀態 ,獲取偏向鎖失敗後有復原邏輯,此時變為無鎖狀態
	if (mark->is_neutral()) {
		// 將物件的markOop複製到displaced_header(Displaced Mark Word)上
		lock->set_displaced_header(mark);
		// CAS將物件markOop中替換為指向鎖記錄的指標
		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(Displaced Mark Word)
	lock->set_displaced_header(markOopDesc::unused_mark());
	// 鎖膨脹
	ObjectSynchronizer::inflate(THREAD, obj(), inflate_cause_monitor_enter)->enter(THREAD);  
}

直接參照《Java並行程式設計的藝術》中關於輕量級鎖加鎖的過程:

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

輕量級鎖的邏輯非常簡單,使用到的關鍵技術也是CAS

此時markOop的結構如下:

在monitorexit處結束

處於偏向鎖或者輕量級鎖時,monitorexit的邏輯非常簡單。有了monitorenter的經驗,我們很容易分析到monitorexit的呼叫邏輯:

  1. templateTable_x86#monitorexit
  2. interp_masm_x86#un_lock
  3. 鎖的退出邏輯
    1. 偏向鎖:macroAssembler_x86#biased_locking_exit
    2. 輕量級鎖:interpreterRuntime#monitorexit
      1. ObjectSynchronizer#slow_exit
      2. ObjectSynchronizer#fast_exit

程式碼就留給大家自行探索了,在這裡給出我的理解。

通常,我會簡單的認為偏向鎖退出時,什麼都不需要做(即偏向鎖不會主動釋放);而對於輕量級鎖來說,至少需要經歷兩個步驟:

  • 重置displaced_header
  • 釋放鎖記錄

因此,從退出邏輯上來說,輕量級鎖的效能是稍遜於偏向鎖的。

總結

我們對這一階段的內容做個簡單的總結,偏向鎖和輕量級鎖的邏輯並不複雜,尤其是輕量級鎖。

偏向鎖和輕量級鎖的關鍵技術都是CAS,當CAS競爭失敗,說明有其它執行緒嘗試搶奪,從而導致鎖升級。

偏向鎖在物件markOop中記錄第一次持有它的執行緒,當該執行緒不斷持有偏向鎖時,只需要簡單的比對即可,適合絕大部分場景是單執行緒執行,但偶爾可能會存線上程競爭的場景。

但問題是,如果執行緒交替持有執行,偏向鎖的復原和重偏向邏輯複雜,效能差。因此引入了輕量級鎖,用來保證交替進行這種「輕微」競爭情況的安全。

另外,關於偏向鎖的爭議比較多,主要在兩點:

  • 偏向鎖的復原對效能影響較大;
  • 大量並行時,偏向鎖非常雞肋。

實際上,Java 15中已經放棄了偏向鎖(JEP 374: Deprecate and Disable Biased Locking),但由於大部分應用還跑在Java 8上,我們還是要了解偏向鎖的邏輯。

最後再闢個謠(或者是被打臉?),輕量級鎖中並沒有任何自旋的邏輯

Tips:好像漏掉了批次復原和批次重偏向~~


好了,今天就到這裡了,Bye~~