Java 中的各種鎖和 CAS + 面試題
如果說快速理解多線程有什么捷徑的話,那本文介紹的各種鎖無疑是其中之一,它不但為我們開發多線程程序提供理論支持,還是面試中經常被問到的核心面試題之一。因此下面就讓我們一起深入地學習一下這些鎖吧。
樂觀鎖和悲觀鎖
悲觀鎖和樂觀鎖並不是某個具體的“鎖”而是一種並發編程的基本概念。樂觀鎖和悲觀鎖最早出現在數據庫的設計當中,后來逐漸被 Java 的並發包所引入。
悲觀鎖
悲觀鎖認為對於同一個數據的並發操作,一定是會發生修改的,哪怕沒有修改,也會認為修改。因此對於同一個數據的並發操作,悲觀鎖采取加鎖的形式。悲觀地認為,不加鎖的並發操作一定會出問題。
樂觀鎖
樂觀鎖正好和悲觀鎖相反,它獲取數據的時候,並不擔心數據被修改,每次獲取數據的時候也不會加鎖,只是在更新數據的時候,通過判斷現有的數據是否和原數據一致來判斷數據是否被其他線程操作,如果沒被其他線程修改則進行數據更新,如果被其他線程修改則不進行數據更新。
公平鎖和非公平鎖
根據線程獲取鎖的搶占機制,鎖又可以分為公平鎖和非公平鎖。
公平鎖
公平鎖是指多個線程按照申請鎖的順序來獲取鎖。
非公平鎖
非公平鎖是指多個線程獲取鎖的順序並不是按照申請鎖的順序,有可能后申請的線程比先申請的線程優先獲取鎖。
ReentrantLock 提供了公平鎖和非公平鎖的實現。
- 公平鎖:new ReentrantLock(true)
- 非公平鎖:new ReentrantLock(false)
如果構造函數不傳任何參數的時候,默認提供的是非公平鎖。
獨占鎖和共享鎖
根據鎖能否被多個線程持有,可以把鎖分為獨占鎖和共享鎖。
獨占鎖
獨占鎖是指任何時候都只有一個線程能執行資源操作。
共享鎖
共享鎖指定是可以同時被多個線程讀取,但只能被一個線程修改。比如 Java 中的 ReentrantReadWriteLock 就是共享鎖的實現方式,它允許一個線程進行寫操作,允許多個線程讀操作。
ReentrantReadWriteLock 共享鎖演示代碼如下:
public class ReadWriteLockTest {
public static void main(String[] args) throws InterruptedException {
final MyReadWriteLock rwLock = new MyReadWriteLock();
// 創建讀鎖 r1 和 r2
Thread r1 = new Thread(new Runnable() {
@Override
public void run() {
rwLock.read();
}
}, "r1");
Thread r2 = new Thread(new Runnable() {
@Override
public void run() {
rwLock.read();
}
}, "r2");
r1.start();
r2.start();
// 等待同時讀取線程執行完成
r1.join();
r2.join();
// 開啟寫鎖的操作
new Thread(new Runnable() {
@Override
public void run() {
rwLock.write();
}
}, "w1").start();
new Thread(new Runnable() {
@Override
public void run() {
rwLock.write();
}
}, "w2").start();
}
static class MyReadWriteLock {
ReadWriteLock lock = new ReentrantReadWriteLock();
public void read() {
try {
lock.readLock().lock();
System.out.println("讀操作,進入 | 線程:" + Thread.currentThread().getName());
Thread.sleep(3000);
System.out.println("讀操作,退出 | 線程:" + Thread.currentThread().getName());
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.readLock().unlock();
}
}
public void write() {
try {
lock.writeLock().lock();
System.out.println("寫操作,進入 | 線程:" + Thread.currentThread().getName());
Thread.sleep(3000);
System.out.println("寫操作,退出 | 線程:" + Thread.currentThread().getName());
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.writeLock().unlock();
}
}
}
}
以上程序執行結果如下:
讀操作,進入 | 線程:r1
讀操作,進入 | 線程:r2
讀操作,退出 | 線程:r1
讀操作,退出 | 線程:r2
寫操作,進入 | 線程:w1
寫操作,退出 | 線程:w1
寫操作,進入 | 線程:w2
寫操作,退出 | 線程:w2
可重入鎖
可重入鎖指的是該線程獲取了該鎖之后,可以無限次的進入該鎖鎖住的代碼。
自旋鎖
自旋鎖是指嘗試獲取鎖的線程不會立即阻塞,而是采用循環的方式去嘗試獲取鎖,這樣的好處是減少線程上下文切換的消耗,缺點是循環會消耗 CPU。
CAS 與 ABA
CAS(Compare and Swap)比較並交換,是一種樂觀鎖的實現,是用非阻塞算法來代替鎖定,其中 java.util.concurrent 包下的 AtomicInteger 就是借助 CAS 來實現的。
但 CAS 也不是沒有任何副作用,比如著名的 ABA 問題就是 CAS 引起的。
ABA 問題描述
老王去銀行取錢,余額有 200 元,老王取 100 元,但因為程序的問題,啟動了兩個線程,線程一和線程二進行比對扣款,線程一獲取原本有 200 元,扣除 100 元,余額等於 100 元,此時阿里給老王轉賬 100 元,於是啟動了線程三搶先在線程二之前執行了轉賬操作,把 100 元又變成了 200 元,而此時線程二對比自己事先拿到的 200 元和此時經過改動的 200 元值一樣,就進行了減法操作,把余額又變成了 100 元。這顯然不是我們要的正確結果,我們想要的結果是余額減少了 100 元,又增加了 100 元,余額還是 200 元,而此時余額變成了 100 元,顯然有悖常理,這就是著名的 ABA 的問題。
執行流程如下。
- 線程一:取款,獲取原值 200 元,與 200 元比對成功,減去 100 元,修改結果為 100 元。
- 線程二:取款,獲取原值 200 元,阻塞等待修改。
- 線程三:轉賬,獲取原值 100 元,與 100 元比對成功,加上 100 元,修改結果為 200 元。
- 線程二:取款,恢復執行,原值為 200 元,與 200 元對比成功,減去 100 元,修改結果為 100 元。
最終的結果是 100 元。
ABA 問題的解決
常見解決 ABA 問題的方案加版本號,來區分值是否有變動。以老王取錢的例子為例,如果加上版本號,執行流程如下。
- 線程一:取款,獲取原值 200_V1,與 200_V1 比對成功,減去 100 元,修改結果為 100_V2。
- 線程二:取款,獲取原值 200_V1 阻塞等待修改。
- 線程三:轉賬,獲取原值 100_V2,與 100_V2 對比成功,加 100 元,修改結果為 200_V3。
- 線程二:取款,恢復執行,原值 200_V1 與現值 200_V3 對比不相等,退出修改。
最終的結果為 200 元,這顯然是我們需要的結果。
在程序中,要怎么解決 ABA 的問題呢?
在 JDK 1.5 的時候,Java 提供了一個 AtomicStampedReference 原子引用變量,通過添加版本號來解決 ABA 的問題,具體使用示例如下:
String name = "洲洋";
String newName = "Java";
AtomicStampedReference<String> as = new AtomicStampedReference<String>(name, 1);
System.out.println("值:" + as.getReference() + " | Stamp:" + as.getStamp());
as.compareAndSet(name, newName, as.getStamp(), as.getStamp() + 1);
System.out.println("值:" + as.getReference() + " | Stamp:" + as.getStamp());
以上程序執行結果如下:
值:老王 | Stamp:1
值:Java | Stamp:2
相關面試題
1.synchronized 是哪種鎖的實現?為什么?
答:synchronized 是悲觀鎖的實現,因為 synchronized 修飾的代碼,每次執行時會進行加鎖操作,同時只允許一個線程進行操作,所以它是悲觀鎖的實現。
2.new ReentrantLock() 創建的是公平鎖還是非公平鎖?
答:非公平鎖,查看 ReentrantLock 的實現源碼可知。
/\*\* \* Creates an instance of {@code ReentrantLock}. \* This is equivalent to using {@code ReentrantLock(false)}. \*/
public ReentrantLock() {
sync = new NonfairSync();
}
3.synchronized 使用的是公平鎖還是非公平鎖?
答:synchronized 使用的是非公平鎖,並且是不可設置的。這是因為非公平鎖的吞吐量大於公平鎖,並且是主流操作系統線程調度的基本選擇,所以這也是 synchronized 使用非公平鎖原由。
4.為什么非公平鎖吞吐量大於公平鎖?
答:比如 A 占用鎖的時候,B 請求獲取鎖,發現被 A 占用之后,堵塞等待被喚醒,這個時候 C 同時來獲取 A 占用的鎖,如果是公平鎖 C 后來者發現不可用之后一定排在 B 之后等待被喚醒,而非公平鎖則可以讓 C 先用,在 B 被喚醒之前 C 已經使用完成,從而節省了 C 等待和喚醒之間的性能消耗,這就是非公平鎖比公平鎖吞吐量大的原因。
5.volatile 的作用是什么?
答:volatile 是 Java 虛擬機提供的最輕量級的同步機制。
當變量被定義成 volatile 之后,具備兩種特性:
- 保證此變量對所有線程的可見性,當一條線程修改了這個變量的值,修改的新值對於其他線程是可見的(可以立即得知的);
- 禁止指令重排序優化,普通變量僅僅能保證在該方法執行過程中,得到正確結果,但是不保證程序代碼的執行順序。
6.volatile 對比 synchronized 有什么區別?
答:synchronized 既能保證可見性,又能保證原子性,而 volatile 只能保證可見性,無法保證原子性。比如,i++ 如果使用 synchronized 修飾是線程安全的,而 volatile 會有線程安全的問題。
7.CAS 是如何實現的?
答: CAS(Compare and Swap)比較並交換,CAS 是通過調用 JNI(Java Native Interface)的代碼實現的,比如,在 Windows 系統 CAS 就是借助 C 語言來調用 CPU 底層指令實現的。
8.CAS 會產生什么問題?應該怎么解決?
答:CAS 是標准的樂觀鎖的實現,會產生 ABA 的問題(詳見正文)。
ABA 通常的解決辦法是添加版本號,每次修改操作時版本號加一,這樣數據對比的時候就不會出現 ABA 的問題了。
9.以下說法錯誤的是?
A:獨占鎖是指任何時候都只有一個線程能執行資源操作
B:共享鎖指定是可以同時被多個線程讀取和修改
C:公平鎖是指多個線程按照申請鎖的順序來獲取鎖
D:非公平鎖是指多個線程獲取鎖的順序並不是按照申請鎖的順序,有可能后申請的線程比先申請的線程優先獲取鎖
答:B
題目解析:共享鎖指定是可以同時被多個線程讀取,但只能被一個線程修改。
總結
本文介紹了 Java 中各種鎖,明白了 Java 程序中比較常用的為非公平鎖而非公平鎖,原因在於非公平鎖的吞吐量要更大,並且發生線程“飢餓”的情況很少,是風險遠小於收益的事所以可以廣而用之。又重點介紹了 CAS 和著名的 ABA 的問題,以及解決 ABA 的常見手段:添加版本號,可以通過 Java 自身提供的 AtomicStampedReference(原子引用變量)來解決 ABA 的問題,至此我們對 Java 多線程的了解又向前邁了一大步。
歡迎關注我的公眾號,回復關鍵字“Java” ,將會有大禮相送!!! 祝各位面試成功!!!
%97%E5%8F%B7%E4%BA%8C%E7%BB%B4%E7%A0%81.png)