首頁 > 軟體

淺談Java鎖的膨脹過程以及一致性雜湊對鎖膨脹的影響

2022-02-28 13:01:01

1、鎖優化

在JDK6之前,通過synchronized來實現同步效率是很低的,被synchronized包裹的程式碼塊經過javac編譯後,會在程式碼塊前後加上monitorentermonitorexit位元組碼指令,被synchronized修飾的方法則會被加上ACC_SYNCHRONIZED標識,不論是在位元組碼中如何表示,作用和功能都是一樣的,執行緒要想執行同步程式碼塊或同步方法,首先需要競爭鎖。

synchronized保證了任意時刻最多隻有一個執行緒可以競爭到鎖,那麼競爭不到鎖的的執行緒該如何處理呢?

在JDK6之前,Java直接通過OS級別的互斥量(Mutex)來實現同步,獲取不到鎖的執行緒被阻塞掛起,直到持有鎖的執行緒釋放鎖後再將其喚醒,這需要OS頻繁的將執行緒從使用者態切換到核心態,這個切換過程開銷是很大的,OS需要暫停原執行緒並儲存資料,喚醒新執行緒並恢復資料,因此synchronized也被稱為“重量級鎖”。

也正是由於效能原因,開發者慢慢擯棄了synchronized,投入ReentrantLock的懷抱。

官方意識到這個問題以後,便將“高效並行”作為JDK6的一個重要改進專案,經過開發團隊的重重優化,如今synchronized的效能已經和ReentrantLock保持在一個數量級了,雖然還是慢一丟丟,但是官方表示未來synchronized仍然有優化的餘地。

1.1、鎖消除

設計一個類時,考慮到存在並行安全問題,往往會對程式碼塊上鎖。
但是有時候這個被設計為“執行緒安全”的類在使用時壓根就不存在多執行緒競爭,那麼還有什麼理由加鎖呢?

鎖消除優化得益於逃逸分析技術的成熟,即時編譯器在執行時會對程式碼進行掃描,會對不存在共用資料競爭的鎖消除。
例如:在方法中(棧記憶體執行緒私有)範例化一個執行緒安全的類,該範例既沒有傳遞給其他方法,又沒有作為物件返回出去(沒有發生逃逸),那麼JVM就會對進行鎖消除。

如下程式碼,儘管StringBuffer的append()是被synchronized修飾的,但是不存線上程競爭,鎖會消除。

public String method(){
	StringBuffer sb = new StringBuffer();
	sb.append("1");//append()是被synchronized修飾的
	sb.append("2");
	return sb.toString();
}

1.2、鎖粗化

由於鎖的競爭和釋放開銷比較大,如果程式碼中對鎖進行了頻繁的競爭和釋放,那麼JVM會進行優化,將鎖的範圍適當擴大。

如下程式碼,在迴圈內使用synchronized,JVM鎖粗化後,會將鎖範圍擴大到迴圈外。

public void method(){
	for (int i= 0; i < 100; i++) {
		synchronized (this){
			...
		}
	}
}

1.3、自旋鎖

當有多個執行緒在競爭同一把鎖時,競爭失敗的執行緒如何處理?

兩種情況:

  • 將執行緒掛起,鎖釋放後再將其喚醒。
  • 執行緒不掛起,進行自旋,直到競爭成功。

如果鎖競爭非常激烈,且短時間得不到釋放,那麼將執行緒掛起效率會更高,因為競爭失敗的執行緒不斷自旋會造成CPU空轉,浪費效能。

如果鎖競爭並不激烈,且鎖會很快得到釋放,那麼自旋效率會更高。因為將執行緒掛起和喚醒是一個開銷很大的操作。

自旋鎖的優化是針對“鎖競爭不激烈,且會很快釋放”的場景,避免了OS頻繁掛起和喚醒執行緒。

1.4、自適應自旋鎖

當執行緒競爭鎖失敗時,自旋和掛起哪一種更高效?

當執行緒競爭鎖失敗時,會自旋10次,如果仍然競爭不到鎖,說明鎖競爭比較激烈,繼續自旋會浪費效能,JVM就會將執行緒掛起。

在JDK6之前,自旋的次數通過JVM引數-XX:PreBlockSpin設定,但是開發者往往不知道該設定多少比較合適,於是在JDK6中,對其進行了優化,加入了“自適應自旋鎖”。

自適應自旋鎖的大致原理:執行緒如果自旋成功了,那麼下次自旋的最大次數會增加,因為JVM認為既然上次成功了,那麼這一次也很大概率會成功。
反之,如果很少會自旋成功,那麼下次會減少自旋的次數甚至不自旋,避免CPU空轉。

1.5、鎖膨脹

除了上述幾種優化外,JDK6加入了新型的鎖機制,不直接採用OS級的“重量級鎖”,鎖型別分為:偏向鎖、輕量級鎖、重量級鎖。隨著鎖競爭的激烈程度不斷膨脹,大大提升了競爭不太激烈的同步效能。

“synchronized鎖的是物件,而非程式碼!”

每一個Java物件,在JVM中是存在物件頭(Object Header)的,物件頭中又分Mark Word和Klass Pointer,其中Mark Word就儲存了物件的鎖狀態資訊,其結構如下圖所示:

無鎖:初始狀態
一個物件被範例化後,如果還沒有被任何執行緒競爭鎖,那麼它就為無鎖狀態(01)。

偏向鎖:單執行緒競爭
當執行緒A第一次競爭到鎖時,通過CAS操作修改Mark Word中的偏向執行緒ID、偏向模式。如果不存在其他執行緒競爭,那麼持有偏向鎖的執行緒將永遠不需要進行同步。

輕量級鎖:多執行緒競爭,但是任意時刻最多隻有一個執行緒競爭
如果執行緒B再去競爭鎖,發現偏向執行緒ID不是自己,那麼偏向模式就會立刻不可用。即使兩個執行緒不存在競爭關係(執行緒A已經釋放,執行緒B再去獲取),也會升級為輕量級鎖(00)。

重量級鎖:同一時刻多執行緒競爭
一旦輕量級鎖CAS修改失敗,說明存在多執行緒同時競爭鎖,輕量級鎖就不適用了,必須膨脹為重量級鎖(10)。此時Mark Word儲存的就是指向重量級鎖(互斥量)的指標,後面等待鎖的執行緒必須進入阻塞狀態。

2、鎖膨脹實戰

說了這麼多,理論終歸是理論,不如實戰一把來的直接。

通過編寫一些多執行緒競爭程式碼,以及列印物件的頭資訊,來分析哪些情況下鎖會膨脹,以及膨脹成哪種型別的鎖。

2.1、jol工具

openjdk提供了jol工具,可以列印物件的記憶體佈局資訊,依賴如下:

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

2.2、鎖膨脹測試程式碼

程式啟動時先sleep5秒是為了等待偏向鎖系統啟動。

編寫一段鎖逐步膨脹的測試程式碼,如下所示:

public class LockTest {

	static class Lock{}

	public static void main(String[] args) {
		sleep(5000);
		Lock lock = new Lock();

		System.err.println("無鎖");
		print(lock);

		synchronized (lock) {
			//main執行緒首次競爭鎖,可偏向
			System.err.println("偏向鎖");
			print(lock);
		}

		new Thread(()->{
			synchronized (lock){
				//執行緒A來競爭,偏向執行緒ID不是自己,升級為:輕量級鎖
				System.err.println("輕量級鎖");
				print(lock);
			}
		},"Thread-A").start();

		sleep(2000);

		new Thread(()->{
			synchronized (lock){
				sleep(1000);
			}
		},"Thread-B").start();

		//確保執行緒B啟動並獲得鎖,sleep 100毫秒
		sleep(100);

		synchronized (lock){
			//main執行緒競爭時,執行緒B還未釋放,多執行緒同時競爭,升級為:重量級鎖
			System.err.println("重量級鎖");
			print(lock);
		}
	}

	static void print(Object o){
		System.err.println("==========物件資訊開始...==========");
		System.out.println(ClassLayout.parseInstance(o).toPrintable());
		//jol非同步輸出,防止列印重疊,sleep1秒
		sleep(1000);
		System.err.println("==========物件資訊結束...==========");
	}

	static void sleep(long l){
		try {
			Thread.sleep(l);
		} catch (InterruptedException e) {
			e.printStackTrace();
		}
	}
}

2.3、輸出分析

執行後分析一下控制檯輸出資訊,這裡貼上截圖並寫上註釋:

無鎖

偏向鎖

輕量級鎖

重量級鎖

以上,就是JVM中鎖逐步膨脹的過程,另外:鎖不支援回退復原。

2.4、鎖釋放

偏向鎖是不會主動釋放的,只要沒有其他執行緒競爭,會永遠偏向持有鎖的執行緒,這樣在以後的執行中,都不用再進行同步處理了,節省了同步開銷。

public static void main(String[] args) {
	sleep(5000);
	Lock lock = new Lock();

	synchronized (lock){
		System.err.println("Main執行緒首次競爭鎖");
		print(lock);
	}

	System.out.println();
	sleep(1000);
	System.err.println("同步程式碼塊退出以後");
	print(lock);
}

輕量級和重量級鎖均會主動釋放,這裡只貼出輕量級鎖。

public static void main(String[] args) {
	sleep(5000);
	Lock lock = new Lock();

	synchronized (lock){
		//偏向鎖
	}
	
	new Thread(()->{
		synchronized (lock){
			System.err.println("輕量級鎖");
			print(lock);
		}
	},"Thread-A").start();

	sleep(5000);
	System.err.println("n執行緒A釋放鎖後");
	print(lock);
}

重量級鎖類似,這裡就不貼測試結果了。

3、一致性雜湊對鎖膨脹的影響

一個物件如果計算過雜湊碼,就應該一直保持該值不變(強烈推薦但不強制,因為使用者可以過載hashCode()方法按自己的意願返回雜湊碼)。

在Java中,如果類沒有重寫hashCode(),那麼會自動繼承自Object::hashCode(),Object::hashCode()就是一致性雜湊,只要計算過一次,就會將雜湊碼寫入到物件頭中,且永遠不會改變。

和具體的雜湊演演算法有關,JVM裡有五種雜湊演演算法,通過引數-XX:hashCode=[0|1|2|3|4]指定。

只要物件計算過一致性雜湊,偏向模式就置為0了,也就意味著該物件鎖不能再偏向了,最低也會膨脹會輕量級鎖。
如果物件鎖處於偏向模式時遇到計算一致性雜湊請求,那麼會跳過輕量級鎖模式,直接膨脹為重量級鎖。

鎖膨脹為輕量級或重量級鎖後,Mark Word中儲存的分別是執行緒棧幀裡的鎖記錄指標和重量級鎖指標,已經沒有位置再儲存雜湊碼,GC年齡了,那麼這些資訊被移動到哪裡去了呢?

升級為輕量級鎖時,JVM會在當前執行緒的棧幀中建立一個鎖記錄(Lock Record)空間,用於儲存鎖物件的Mark Word拷貝,雜湊碼和GC年齡自然儲存在此,釋放鎖後會將這些資訊寫回到物件頭。

升級為重量級鎖後,Mark Word儲存的重量級鎖指標,代表重量級鎖的ObjectMonitor類裡有欄位記錄無鎖狀態下的Mark Word,鎖釋放後也會將資訊寫回到物件頭。

程式碼實戰,跳過偏向鎖,直接膨脹輕量級鎖

public static void main(String[] args) {
	sleep(5000);
	Lock lock = new Lock();

	//沒有重寫,一致性雜湊,重寫後無效
	lock.hashCode();

	synchronized (lock){
		System.err.println("本應是偏向鎖,但是由於計算過一致性雜湊,會直接膨脹為輕量級鎖");
		print(lock);
	}
}

偏向鎖過程中遇到一致性雜湊計算請求,立馬復原偏向模式,膨脹為重量級鎖

public static void main(String[] args) {
	sleep(5000);
	Lock lock = new Lock();

	synchronized (lock){
		//沒有重寫,一致性雜湊,重寫後無效
		lock.hashCode();
		System.err.println("偏向鎖過程中遇到一致性雜湊計算請求,立馬復原偏向模式,膨脹為重量級鎖");
		print(lock);
	}
}

4、鎖效能測試

這裡只做了一個簡單的測試,實際應用環境比測試環境要複雜的多。

單執行緒下,各型別鎖效能測試:

public class PerformanceTest {
	final static int TEST_COUNT = 100000000;
	static class Lock{}

	public static void main(String[] args) {
		sleep(5000);
		System.err.println("各型別鎖效能測試");
		Lock lock = new Lock();
		long start;
		long end;

		start = System.currentTimeMillis();
		for (int i = 0; i < TEST_COUNT; i++) {

		}
		end = System.currentTimeMillis();
		System.out.println("無鎖:" + (end - start));

		//偏向鎖
		biasedLock(lock);
		start = System.currentTimeMillis();
		for (int i = 0; i < TEST_COUNT; i++) {
			synchronized (lock) {}
		}
		end = System.currentTimeMillis();
		System.out.println("偏向鎖耗時:" + (end - start));

		//輕量級鎖
		lightweightLock(lock);
		start = System.currentTimeMillis();
		for (int i = 0; i < TEST_COUNT; i++) {
			synchronized (lock) {}
		}
		end = System.currentTimeMillis();
		System.out.println("輕量級鎖耗時:" + (end - start));

		//重量級鎖
		weightLock(lock);
		start = System.currentTimeMillis();
		for (int i = 0; i < TEST_COUNT; i++) {
			synchronized (lock) {}
		}
		end = System.currentTimeMillis();
		System.out.println("重量級鎖耗時:" + (end - start));
	}

	static void biasedLock(Object o){
		synchronized (o){}
	}

	//將鎖升級為輕量級
	static void lightweightLock(Object o){
		biasedLock(o);

		Thread thread = new Thread(() -> {
			synchronized (o) {}
		});
		thread.start();
		try {
			thread.join();
		} catch (InterruptedException e) {
			e.printStackTrace();
		}
	}

	//將鎖升級為重量級
	static void weightLock(Object o){
		lightweightLock(o);

		Thread t1 = new Thread(() -> {
			synchronized (o){
				sleep(1000);
			}
		});
		Thread t2 = new Thread(() -> {
			synchronized (o){
				sleep(1000);
			}
		});
		t1.start();
		t2.start();
		try {
			t1.join();
			t2.join();
		} catch (InterruptedException e) {
			e.printStackTrace();
		}
	}
	
	static void sleep(long l){
		try {
			Thread.sleep(l);
		} catch (InterruptedException e) {
			e.printStackTrace();
		}
	}
}

各型別鎖效能測試
無鎖:6
偏向鎖耗時:252
輕量級鎖耗時:2698
重量級鎖耗時:1471

由於是單執行緒,不涉及鎖競爭,重量級鎖反而比輕量級鎖更快,因為不需要OS對執行緒進行額外的排程,執行緒無需掛起和喚醒,而且不用拷貝Mark Word。

在多執行緒競爭環境下,重量級鎖效能下降是毋庸置疑的,如下測試:

public static void main(String[] args) throws InterruptedException {
	System.err.println("多執行緒測試");
	Lock lock = new Lock();
	long start;
	long end;

	//輕量級鎖
	lightweightLock(lock);
	start = System.currentTimeMillis();
	for (int i = 0; i < TEST_COUNT; i++) {
		synchronized (lock) {}
	}
	end = System.currentTimeMillis();
	System.out.println("輕量級鎖耗時:" + (end - start));

	//重量級鎖
	weightLock(lock);
	Thread t1 = new Thread(() -> {
		for (int i = 0; i < TEST_COUNT / 2; i++) {
			synchronized (lock) {}
		}
	});
	Thread t2 = new Thread(() -> {
		for (int i = 0; i < TEST_COUNT / 2; i++) {
			synchronized (lock) {}
		}
	});
	t1.start();
	t2.start();
	start = System.currentTimeMillis();
	t1.join();
	t2.join();
	end = System.currentTimeMillis();
	System.out.println("重量級鎖耗時:" + (end - start));
}

多執行緒測試
輕量級鎖耗時:2581
重量級鎖耗時:4460

實際的應用環境遠比測試環境複雜的多,鎖效能和執行緒競爭的激烈程度、鎖佔用的時間也有很大關係,測試結果僅供參考。

到此這篇關於淺談Java鎖的膨脹過程以及一致性雜湊對鎖膨脹的影響的文章就介紹到這了,更多相關Java鎖膨脹內容請搜尋it145.com以前的文章或繼續瀏覽下面的相關文章希望大家以後多多支援it145.com!


IT145.com E-mail:sddin#qq.com