瞭解Java中的鎖,看這一篇就夠了!

仲秋廿八發表於2020-04-04

1 Lock介面

鎖是用來控制多個執行緒訪問同一個共享資源的方式,一般來說,一個鎖能防止多個執行緒同時訪問共享資源,在Lock介面出來之前,Java是通過synchronized關鍵字來實現鎖的功能,而Java1.5之後,併發包新增了Lock介面(以及相關實現類)用來實現鎖的功能,它提供了與synchronized關鍵字類似的同步功能,只是在使用方式上有所不同,需要顯式的獲取鎖和釋放鎖。雖然缺少了隱式的便捷性,但卻擁有了鎖獲取和釋放的可操作性,可中斷的獲取所以及超時獲取鎖的的同步特性

1.1 Lock介面提供的synchronized不具備的特性:

特性 描述
嘗試非阻塞式獲取鎖 當前執行緒嘗試獲取鎖,如果這一刻沒有被其他執行緒獲取到,則成功獲取並持有鎖
能被中斷的獲取鎖 與synchronized關鍵字不同,獲取到鎖的執行緒能夠響應中斷,當獲取到鎖的執行緒被中斷時,中斷異常會被丟擲,同時鎖會被釋放
超時獲取鎖 在指定的截止時間之前獲取到鎖,入夥截止時間到了仍舊無法獲取鎖,則返回

1.2 Lock介面 API

方法名稱 描述
void lock() 獲取鎖,呼叫該方法當前執行緒將會獲取鎖,當鎖獲取到時,從該方法返回
void lockInterruptibly() throws InterruptedException() 可中斷的獲取鎖,和lock()方法的不同之處在於該方法可響應中斷,即在鎖的獲取中和中斷當前執行緒
boolean tryLock() 嘗試非阻塞的獲取鎖,呼叫該方法立刻返回,如果能夠獲取則返回true,否則返回false
boolean tryLock(long time,TimeUnit unit) throws InterruptedException() 超時的獲取鎖,當前執行緒在以下三種情況會返回:1.當前執行緒在超時時間內獲取到鎖 2. 當前執行緒在超時時間內被中斷 3. 超時時間結束,返回false
void unlock() 釋放鎖
Condition newCondition() 獲取等待通知元件,該元件和當前的鎖繫結,當前執行緒只有獲得了鎖,才能呼叫該元件的wait()方法,而呼叫後,當前執行緒釋放鎖

1.3 AbstractQueueSynchronized(佇列同步器)

以下簡稱AQS AQS是用來構建鎖和其他同步元件的基礎框架,它使用了一個int成員變數表示同步狀態,通過內建的FIFO佇列來完成資源獲取執行緒排隊工作

1.3.1 AQS API

AQS給予模板方法設計模式設計的,也就是說,使用者需要繼承AQS並重寫指定的方法進行實現

AQS提供如下三個方法來訪問和修改同步狀態:

  • getState():獲取當前執行緒的同步狀態。
  • getState(int newState):設定當前執行緒同步狀態。
  • compareAndSetState(int expect,int update):使用CAS設定當前狀態,該方法能夠保證狀態設定的原子性

1.3.2 AQS可重寫的方法

方法名稱 描述
protected boolean tryAcquire(int arg) 獨佔式的獲取同步狀態,實現該方法需要查詢當前狀態並判斷同步狀態是否符合預期,然後再進行CAS設定同步狀態
protected boolean tryRelease(int arg) 獨佔式釋放同步狀態,等待獲取同步狀態的執行緒將有機會獲取同步狀態
protected int tryAcquireShared(int arg) 共享式獲取同步狀態,返回大於等於0的值,表示獲取成功,反之獲取失敗
protected boolean tryReleaseShared(int arg) 共享式釋放同步狀態
protected boolean isHeldExclusively() 當前同步器是否在獨佔模式下被執行緒佔用,一般該方法表示是否被當前執行緒所獨佔

1.3.3 AQS提供的模板方法

方法名稱 描述
void acquire(int arg) 獨佔式獲取同步狀態,如果當前執行緒獲取同步狀態成功,則該方法返回,否則,將進入同步佇列等待,該方法將會呼叫重寫的tryAcquire(int arg)方法
void acquireInterruptibly(int arg) 與acquire(int arg)相同,但是該方法響應中斷,當前執行緒未獲取到同步狀態而進入同步佇列中,如果當前執行緒被中斷,則該方法丟擲異常並返回
boolean tryAcquireNanos(int arg,long nanos) 在acquireInterruptibly(int arg)基礎上增加了超時限制,如果當前執行緒在超時時間內沒有獲取到同步狀態,那麼將會返回false,如果獲取到了則返回true
void acquireShared(int arg) 共享式的獲取同步狀態,如果當前執行緒未獲取到同步狀態,將會進入同步佇列中進行等待,與獨佔式的區別主要在於同一時刻可以有多個執行緒獲取到同步狀態
void acquireSharedInterruptibly(int arg) 與acquireInterruptibly(int arg)相同,該方法可響應中斷
boolean tryAcquireSharedNanos(int arg,long nanos) 在acquireSharedInterruptibly(int arg)基礎上增加了超時限制
boolean release(int arg) 獨佔式的釋放同步狀態,該方法會在釋放同步狀態之後,將同步佇列中的第一個節點包含的執行緒喚醒
boolean releaseShared(int arg) 共享式的釋放同步狀態
Collection getQueueThreads() 獲取等待在同步佇列上的執行緒集合

1.3.4 獨佔鎖和共享鎖的區別

  • 獨佔鎖,顧名思義,就是在同一時刻只能有一個執行緒獲取到鎖,而其他的執行緒只能在同步佇列中等待,只有獲取鎖的執行緒釋放了鎖,後繼執行緒才能獲取到鎖
  • 共享鎖就是在同一時刻可以有多個執行緒獲取鎖
  • 獨享鎖與共享鎖也是通過AQS來實現的,通過實現不同的方法,來實現獨享或者共享

2 常見的鎖

2.1 重入鎖

重入鎖,也叫做遞迴鎖,指的是同一執行緒外層函式獲得鎖之後,內層遞迴函式仍然有獲取該鎖的程式碼,但不受影響。

在JAVA環境下ReentrantLock和sypnchronized都是可重入鎖

public class Test implements Runnable {
	public  synchronized void get() {
		System.out.println("name:" + Thread.currentThread().getName() + " get();");
		set();
	}
	public synchronized  void set() {
		System.out.println("name:" + Thread.currentThread().getName() + " set();");
	}
	@Override
	public void run() {
		get();
	}
	public static void main(String[] args) {
		Test ss = new Test();
		new Thread(ss).start();
		new Thread(ss).start();
		new Thread(ss).start();
		new Thread(ss).start();
	}
}
複製程式碼
public class Test02 extends Thread {
	ReentrantLock lock = new ReentrantLock();
	public void get() {
		lock.lock();
		System.out.println(Thread.currentThread().getId());
		set();
		lock.unlock();
	}
	public void set() {
		lock.lock();
		System.out.println(Thread.currentThread().getId());
		lock.unlock();
	}
	@Override
	public void run() {
		get();
	}
	public static void main(String[] args) {
		Test ss = new Test();
		new Thread(ss).start();
		new Thread(ss).start();
		new Thread(ss).start();
	}
}
複製程式碼

2.2 讀寫鎖

相比Java中的鎖(Locks in Java)裡Lock實現,讀寫鎖更復雜一些。假設你的程式中涉及到對一些共享資源的讀和寫操作,且寫操作沒有讀操作那麼頻繁。在沒有寫操作的時候,兩個執行緒同時讀一個資源沒有任何問題,所以應該允許多個執行緒能在同時讀取共享資源。但是如果有一個執行緒想去寫這些共享資源,就不應該再有其它執行緒對該資源進行讀或寫(譯者注:也就是說:讀-讀能共存,讀-寫不能共存,寫-寫不能共存)。 這就需要一個讀/寫鎖來解決這個問題。Java5在java.util.concurrent包中已經包含了讀寫鎖。儘管如此,我們還是應該瞭解其實現背後的原理。

public class Cache {
	static Map<String, Object> map = new HashMap<String, Object>();
	static ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
	static Lock r = rwl.readLock();
	static Lock w = rwl.writeLock();
	// 獲取一個key對應的value
	public static final Object get(String key) {
		r.lock();
		try {
			System.out.println("正在做讀的操作,key:" + key + " 開始");
			Thread.sleep(100);
			Object object = map.get(key);
			System.out.println("正在做讀的操作,key:" + key + " 結束");
			System.out.println();
			return object;
		} catch (InterruptedException e) {
		} finally {
			r.unlock();
		}
		return key;
	}
	// 設定key對應的value,並返回舊有的value
	public static final Object put(String key, Object value) {
		w.lock();
		try {
			System.out.println("正在做寫的操作,key:" + key + ",value:" + value + "開始.");
			Thread.sleep(100);
			Object object = map.put(key, value);
			System.out.println("正在做寫的操作,key:" + key + ",value:" + value + "結束.");
			System.out.println();
			return object;
		} catch (InterruptedException e) {
		} finally {
			w.unlock();
		}
		return value;
	}
	// 清空所有的內容
	public static final void clear() {
		w.lock();
		try {
			map.clear();
		} finally {
			w.unlock();
		}
	}
	public static void main(String[] args) {
		new Thread(new Runnable() {
			@Override
			public void run() {
				for (int i = 0; i < 10; i++) {
					Cache.put(i + "", i + "");
				}
			}
		}).start();
		new Thread(new Runnable() {
			@Override
			public void run() {
				for (int i = 0; i < 10; i++) {
					Cache.get(i + "");
				}
			}
		}).start();
	}
}
複製程式碼

2.3 樂觀鎖

總是認為不會產生併發問題,每次去取資料的時候總認為不會有其他執行緒對資料進行修改,因此不會上鎖,但是在更新時會判斷其他執行緒在這之前有沒有對資料進行修改,一般會使用版本號機制或CAS操作實現,本質沒有鎖,效率比較高,無阻塞,無等待,重試

實現方式

  • version方式:一般是在資料表中加上一個資料版本號version欄位,表示資料被修改的次數,當資料被修改時,version值會加1。當執行緒A要更新資料值時,在讀取資料的同時也會讀取version值,在提交更新時,若剛才讀取到的version值為當前資料庫中的version值相等時才更新,否則重試更新操作,直到更新成功。 核心SQL語句
update table set x=x+1, version=version+1 where id=#{id} and version=#{version};
複製程式碼
  • CAS操作方式:即compare and swap 或者 compare and set,涉及到三個運算元,資料所在的記憶體值,預期值,新值。當需要更新時,判斷當前記憶體值與之前取到的值是否相等,若相等,則用新值更新,若失敗則重試,一般情況下是一個自旋操作,即不斷的重試。

2.4 悲觀鎖

總是假設最壞的情況,每次取資料時都認為其他執行緒會修改,所以都會加鎖(讀鎖、寫鎖、行鎖等),當其他執行緒想要訪問資料時,都需要阻塞掛起。可以依靠資料庫實現,如行鎖、讀鎖和寫鎖等,都是在操作之前加鎖,在Java中,synchronized的思想也是悲觀鎖。屬於重量級鎖,會阻塞,會等待

2.5 synchronized

  • 優點
  1. 具有可重入性,保證原子性和可見性
  • 缺點
  1. 鎖的本質是重量級鎖,開銷大,不能禁止重排序,產生阻塞,效率低下

2.6 分散式鎖

如果想在不同的jvm中保證資料同步,使用分散式鎖技術。有資料庫實現、快取redis實現、Zookeeper分散式鎖

2.7 自旋鎖和互斥鎖的區別

  • 自旋鎖(Spin lock) 自旋鎖與互斥鎖有點類似,只是自旋鎖不會引起呼叫者睡眠,如果自旋鎖已經被別的執行單元保持,呼叫者就一直迴圈在那裡看是 否該自旋鎖的保持者已經釋放了鎖,"自旋"一詞就是因此而得名。其作用是為了解決某項資源的互斥使用。因為自旋鎖不會引起呼叫者睡眠,所以自旋鎖的效率遠 高於互斥鎖。雖然它的效率比互斥鎖高,但是它也有些不足之處:
  1. 自旋鎖一直佔用CPU,他在未獲得鎖的情況下,一直執行--自旋,所以佔用著CPU,如果不能在很短的時 間內獲得鎖,這無疑會使CPU效率降低。
  2. 在用自旋鎖時有可能造成死鎖,當遞迴呼叫時有可能造成死鎖,呼叫有些其他函式也可能造成死鎖,如 copy_to_user()、copy_from_user()、kmalloc()等。

因此我們要慎重使用自旋鎖,自旋鎖只有在核心可搶佔式或SMP的情況下才真正需要,在單CPU且不可搶佔式的核心下,自旋鎖的操作為空操作。自旋鎖適用於鎖使用者保持鎖時間比較短的情況下。

  • 兩種鎖的加鎖原理
  1. 互斥鎖:執行緒會從sleep(加鎖)——>running(解鎖),過程中有上下文的切換,cpu的搶佔,訊號的傳送等開銷。
  2. 自旋鎖:執行緒一直是running(加鎖——>解鎖),死迴圈檢測鎖的標誌位,機制不復雜。 互斥鎖屬於sleep-waiting型別的鎖。例如在一個雙核的機器上有兩個執行緒(執行緒A和執行緒B),它們分別執行在Core0和 Core1上。假設執行緒A想要通過pthread_mutex_lock操作去得到一個臨界區的鎖,而此時這個鎖正被執行緒B所持有,那麼執行緒A就會被阻塞 (blocking),Core0 會在此時進行上下文切換(Context Switch)將執行緒A置於等待佇列中,此時Core0就可以執行其他的任務(例如另一個執行緒C)而不必進行忙等待。而自旋鎖則不然,它屬於busy-waiting型別的鎖,如果執行緒A是使用pthread_spin_lock操作去請求鎖,那麼執行緒A就會一直在 Core0上進行忙等待並不停的進行鎖請求,直到得到這個鎖為止。
  • 兩種鎖的區別 互斥鎖的起始原始開銷要高於自旋鎖,但是基本是一勞永逸,臨界區持鎖時間的大小並不會對互斥鎖的開銷造成影響,而自旋鎖是死迴圈檢測,加鎖全程消耗cpu,起始開銷雖然低於互斥鎖,但是隨著持鎖時間,加鎖的開銷是線性增長。
  • 兩種鎖的應用 互斥鎖用於臨界區持鎖時間比較長的操作,比如下面這些情況都可以考慮
  1. 臨界區有IO操作
  2. 臨界區程式碼複雜或者迴圈量大
  3. 臨界區競爭非常激烈
  4. 單核處理器

至於自旋鎖就主要用在臨界區持鎖時間非常短且CPU資源不緊張的情況下,自旋鎖一般用於多核的伺服器。

2.8 公平鎖和非公平鎖的區別

非公平鎖:在等待鎖的過程中,如果有人以新的執行緒妄圖獲取鎖,都是有很大機率直接獲取到鎖的。白話文:公平鎖是先到先得,按序進行,非公平鎖就是不排隊直接拿,失敗再說。

3 CAS

Compare and Swap,即比較再交換。jdk5增加了併發包java.util.concurrent.*,其下面的類使用CAS演算法實現了區別於synchronouse同步鎖的一種樂觀鎖。JDK 5之前Java語言是靠synchronized關鍵字保證同步的,這是一種獨佔鎖,也是是悲觀鎖。

  • CAS演算法理解

與鎖相比,使用比較交換(下文簡稱CAS)會使程式看起來更加複雜一些。但由於其非阻塞性,它對死鎖問題天生免疫,並且,執行緒間的相互影響也遠遠比基於鎖的方式要小。更為重要的是,使用無鎖的方式完全沒有鎖競爭帶來的系統開銷,也沒有執行緒間頻繁排程帶來的開銷,因此,它要比基於鎖的方式擁有更優越的效能。

  • 無鎖的好處:
  1. 在高併發的情況下,它比有鎖的程式擁有更好的效能;
  2. 它天生就是死鎖免疫的。
  • 優點 效率比較高,無阻塞,無等待,重試
  • 缺點:
  1. 會產生ABA問題:因為CAS需要在操作值的時候,檢查值有沒有發生變化,如果沒有變化則更新,但是如果一個值原來是A,變成了B,又變成了A,那麼CAS檢查時發現它的值沒有發生變化,但實際上發生了變化:A->B->A的過程
  2. 迴圈時間長,開銷大:自旋CAS如果長時間不成功,會給CPU帶來很大的執行開銷
  3. 只能保證一個共享變數的原子操作:當對一個共享變數操作時,我們可以採用CAS的方式來保證原子操作,但是對多個共享變數操作時,迴圈CAS就無法保證操作的原子性

4 原子類

java.util.concurrent.atomic包:原子類的小工具包,支援在單個變數上解除鎖的執行緒安全程式設計原子變數類相當於一種泛化的 volatile 變數,能夠支援原子的和有條件的讀-改-寫操作。AtomicInteger 表示一個int型別的值,並提供了 get 和 set 方法,這些 Volatile 型別的int變數在讀取和寫入上有著相同的記憶體語義。它還提供了一個原子的 compareAndSet 方法(如果該方法成功執行,那麼將實現與讀取/寫入一個 volatile 變數相同的記憶體效果),以及原子的新增、遞增和遞減等方法。AtomicInteger 表面上非常像一個擴充套件的 Counter 類,但在發生競爭的情況下能提供更高的可伸縮性,因為它直接利用了硬體對併發的支援。

如果同一個變數要被多個執行緒訪問,則可以使用該包中的類 AtomicBoolean AtomicInteger AtomicLong AtomicReference

相關文章