Java並發編程實戰(3)- 互斥鎖


我們在這篇文章中主要討論如何使用互斥鎖來解決並發編程中的原子性問題。

概述

並發編程中的原子性問題的源頭是線程切換,那么禁止線程切換可以解決原子性問題嗎?

這需要分情況討論,在單核CPU的情況下,同一時刻只有一個線程執行,禁止CPU中斷,就意味着操作系統不會重新調度線程,也就禁止了線程切換,這樣獲取CPU使用權的線程就可以不間斷的執行。

在多核CPU的情況下,同一時刻,有可能有兩個線程同時執行,一個線程執行在CPU-1上,另外一個線程執行在CPU-2上,這時禁止CPU中斷,只能保證某一個CPU上的線程連續執行,但並不能保證只有一個線程在運行。

同一時刻只有一個線程執行,我們稱之為互斥,如果我們能夠保證對共享變量的修改是互斥的,那么無論是單核CPU還是多核CPU,就都能保證原子性了。

如何能做到呢?答案就是互斥鎖

互斥鎖模型

互斥鎖簡易模型

當我們談論互斥鎖時,我們一般會把一段需要互斥執行的代碼稱為臨界區,下面是一個簡單的示意圖。

當線程進入臨界區之前,首先嘗試加鎖,如果成功,可以進去臨界區,如果失敗,需要等待。當臨界區的代碼被執行完畢或者發生異常時,線程釋放鎖。

互斥鎖改進模型

上面的模型雖然直觀,但是過於簡單,我們需要考慮2個問題:

  • 我們鎖的是什么?
  • 我們保護的又是什么?

在現實世界中,鎖和鎖要保護的資源是有對應關系的,通俗的講,你用你家的鎖保護你家的東西,我用我家的鎖保護我家的東西。

在並發編程的世界中,鎖和資源也應該有類似的對應關系。

下面是改進后的鎖模型。

首先,我們要把臨界區中要保護的資源R標注出來,然后,我們為資源R創建一個鎖LR,最后,在我們進入和離開臨界區時,需要對鎖LR進行加鎖和解鎖操作。

通過這樣的處理,我們就在鎖和資源之間建立了關聯關系,不會出現類似於“用我家的鎖去保護你家的資源”的問題。

Java世界中的互斥鎖

在Java語言中,我們通過synchronized關鍵字來實現互斥鎖。

synchronized關鍵字可以應用在方法上,也可以直接應用在代碼塊中。

我們來看下面的示例代碼。

public class SynchronizedDemo {

	// 修飾實例方法
	synchronized void updateData() {
		// 業務代碼
	}
	
	// 修飾靜態方法
	synchronized static void retrieveData() {
		// 業務代碼
	}
	
	// 修飾代碼塊
	Object obj = new Object();
	
	void createData() {
		synchronized(obj) {
			// 業務代碼
		}
	}
}

和我們描述的互斥鎖模型相比,我們並沒有在上述代碼中看到加鎖和解鎖相關的代碼,這是因為Java編譯器已經自動為我們在synchronized關鍵字修改的方法或者代碼塊前后添加了加鎖和解鎖邏輯。這樣做的好處是我們不用擔心執行加鎖操作后,忘了解鎖操作。

synchronized中的鎖和鎖對象

我們在使用synchronized關鍵字時,它鎖定的對象是什么呢?如果沒有顯式指定鎖對象,Java有如下默認規則

  • 當修飾靜態方法時,鎖定的是當前類的Class對象。
  • 當修飾非靜態方法時,鎖定的是當前實例對象this。

根據上述規則,下面的代碼是等價的。

// 修飾實例方法
	synchronized void updateData() {
		// 業務代碼
	}
	
	// 修飾實例方法
	synchronized(this) void updateData2() {
		// 業務代碼
	}
	// 修飾靜態方法
	synchronized static void retrieveData() {
		// 業務代碼
	}
	
	// 修飾靜態方法
	synchronized(SynchronizedDemo.class) static void retrieveData2() {
		// 業務代碼
	}

synchronized示例

我們在之前的文章中描述過count=count+1的例子,當時沒有做並發控制,結果引發了原子性問題,我們現在看一下,如何使用synchronized關鍵字來解決並發問題。

首先我們來復習一下Happens-Before規則,synchronized修飾的臨界區是互斥的,也就是說同一時刻只有一個線程執行臨界區的代碼,而Happens-Before中的“對一個鎖解鎖Happens-Before后續對這個鎖的加鎖”,指的是前一個線程解鎖操作對后一個線程的加鎖操作是可見的,然后結合Happens-Before傳遞性原則,我們可以得出前一個線程在臨界區修改的共享變量,對於后續完成加鎖進入臨界區的線程是可見的。

下面是修改后的代碼:

public class ConcurrencySafeAddDemo {

	private long count = 0;

	private synchronized void safeAdd() {
		int index = 0;
		while (index < 10000) {
			count = count + 1;
			index++;
		}
	}

	private void reset() {
		this.count = 0;
	}

	private void addTest() throws InterruptedException {

		List<Thread> threads = new ArrayList<Thread>();

		for (int i = 0; i < 6; i++) {
			threads.add(new Thread(() -> {
				this.safeAdd();
			}));
		}
		
		for (Thread thread : threads) {
			thread.start();
		}
		
		for (Thread thread : threads) {
			thread.join();
		}
		
		threads.clear();

		System.out.println(String.format("Count is %s", count));
	}

	public static void main(String[] args) throws InterruptedException {
		ConcurrencySafeAddDemo demoObj = new ConcurrencySafeAddDemo();
		for (int i = 0; i < 10; i++) {
			demoObj.addTest();
			demoObj.reset();
		}
	}
}

執行結果如下。

Count is 60000
Count is 60000
Count is 60000
Count is 60000
Count is 60000
Count is 60000
Count is 60000
Count is 60000
Count is 60000
Count is 60000

這里和我們的預期是一致的。

和第一版的代碼相比,我們只是用synchronized關鍵字修飾了safeAdd()方法。

鎖與受保護的資源的關系

對於互斥鎖來說,鎖與受保護的資源之間的關聯關系非常重要,那么這兩者之間到底是什么關系呢?一個合理的解釋是:鎖與受保護的資源之間是N:1的關系,也就是說:

  • 一個鎖可以應用到多個受保護資源
  • 一個受保護資源上只能有一個鎖

我們可以用球賽門票來做類比,其中座位是資源,門票是鎖。一個座位只能用一張門票來保護,如果是“包場”的情況,一張包場門票就可以對應多個座位。不會出現一個座位有多張門票的情況。

同理,在互斥鎖的場景下,如果兩個鎖使用了不同的鎖對象,那么這兩個所對應的臨界區不是互斥的。 這一點很重要,忽視它的話,很容易引發莫名其妙的並發問題。

例如,我們把上面示例代碼中的safeAdd()方法改成下面的樣子,它還能正常工作嗎?

	private void safeAdd() {
		int index = 0;
		synchronized(new Object()) {
			while (index < 10000) {
				count = count + 1;
				index++;
			}
		}
	}

這里,我們在為synchronized關鍵字設置鎖對象時,每次都新建一個Object對象,那么每個線程在運行到這里時,都是使用不同的鎖對象,那么臨界區中的代碼就不是互斥的,最后得出的結果也不會是我們期望的。

Count is 17355
Count is 18215
Count is 19244
Count is 20863
Count is 60000
Count is 60000
Count is 60000
Count is 20430
Count is 60000
Count is 60000

一個鎖保護多個資源

上面我們談到一個互斥鎖可以保護多個資源,但是一個資源不可以被多個互斥鎖保護。

那么,我們如何用一個鎖來保護多個資源呢?

一個鎖保護多個沒有關聯關系的資源

對於多個沒有關聯關系的資源,我們很容易用一個鎖去保護。

以銀行賬戶為例,銀行賬戶可以有取款操作,也有修改密碼操作,那么賬戶余額和賬戶密碼就是兩個沒有關聯關系的資源。

我們來看下面的示例代碼。

public class BankAccountLockDemo {

	private double balance;
	private String password;
	
	private Object commonLockObj = new Object();
	
	// 取錢
	private void withdrawMoney(double amount) {
		synchronized(commonLockObj) {
			// 業務代碼
			balance = balance - amount;
		}
	}
	
	// 修改密碼
	private void changePassword(String newPassword) {
		synchronized(commonLockObj) {
			// 業務代碼
			password = newPassword;
		}
	}
}

我們可以看到,上述代碼使用了共享鎖commonLockObj來保護balance和password,是可以正常工作的。

但是這樣做存在的問題是取款和修改密碼操作不能同時進行,從業務角度看,這兩塊業務是沒有關聯的, 應該是可以並行的。

解決辦法是每個業務使用各自的互斥鎖對相關資源進行保護。上述代碼中可以創建兩個鎖對象:balanceLockObjpasswordLockObj,這樣兩個業務操作就不會互相影響了,這樣的鎖也被稱為細粒度鎖

一個鎖保護多個有關聯關系的資源

對於有關聯關系的資源,情況會復雜一些。

我們以轉賬操作為例進行說明,轉賬的過程會涉及兩個賬戶的余額,這兩個余額就是兩個有關聯關系的資源。

我們來看下面的示例代碼。

public class BankAccountTransferLockDemo {
	private double balance;
	
	private Object lockObj = new Object();
	
	private void transfer(BankAccountTransferLockDemo sourceAccount, BankAccountTransferLockDemo targetAccount, double amount) {
		synchronized(lockObj) {
			sourceAccount.balance = sourceAccount.balance - amount;
			targetAccount.balance = targetAccount.balance + amount;
		}
	}
}

上述代碼有問題嗎? 答案是有問題。

看上去我們在操作balance的時候,使用了加鎖處理,但是需要注意這里的鎖對象是lockObj,是一個Object對象,如果此時有其他業務也需要操作相同賬戶的balance,例如存取款操作,其他業務是沒有辦法使用lockObj來創建鎖的,從而造成多個業務同時操作balance,引發並發問題。

問題的解決辦法是我們創建的鎖需要能夠覆蓋受保護資源的所有場景。

回到我們上面的示例,如果使用Object對象作為鎖對象不能覆蓋所有相關業務,那么我們需要升級鎖對象,將其由Object對象變為Class對象,代碼如下:

	private void transfer(BankAccountTransferLockDemo sourceAccount, BankAccountTransferLockDemo targetAccount, double amount) {
		synchronized(BankAccountTransferLockDemo.class) {
			sourceAccount.balance = sourceAccount.balance - amount;
			targetAccount.balance = targetAccount.balance + amount;
		}
	}

上述資源之間的關聯關系,如果用更具體、更專業的語言來描述,其實是一種“原子性”的特征,原子性有兩層含義:1) CPU指令級別的原子性,2)業務含義上的原子性。

“原子性”的本質什么?

原子性的表象是不可分割,其本質是多個資源間有一致性的要求,操作的中間狀態對外不可見。

解決原子性問題,就是要保證中間狀態對外不可見,這也是互斥鎖要解決的問題。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM