在并發編程中有兩個重要的概念:線程和鎖,多線程是一把雙刃劍,它在提高程序性能的同時,也帶來了編碼的復雜性,對開發者的要求也提高了一個檔次。而鎖的出現就是為了保障多線程在同時操作一組資源時的數據一致性,當我們給資源加上鎖之后,只有擁有此鎖的線程才能操作此資源,而其他線程只能排隊等待使用此鎖。當然,在所有的面試中也都少不了關于“鎖”方面的相關問題。
我們本課時的面試題是,如何手動模擬一個死鎖?談談你對鎖的理解?
#### 典型回答
死鎖是指兩個線程同時占用兩個資源,又在彼此等待對方釋放鎖資源,如下圖所示:

死鎖的代碼演示如下:
```
import java.util.concurrent.TimeUnit;
public class LockExample {
public static void main(String[] args) {
deadLock(); // 死鎖
}
/**
* 死鎖
*/
private static void deadLock() {
Object lock1 = new Object();
Object lock2 = new Object();
// 線程一擁有 lock1 試圖獲取 lock2
new Thread(() -> {
synchronized (lock1) {
System.out.println("獲取 lock1 成功");
try {
TimeUnit.SECONDS.sleep(3);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 試圖獲取鎖 lock2
synchronized (lock2) {
System.out.println(Thread.currentThread().getName());
}
}
}).start();
// 線程二擁有 lock2 試圖獲取 lock1
new Thread(() -> {
synchronized (lock2) {
System.out.println("獲取 lock2 成功");
try {
TimeUnit.SECONDS.sleep(3);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 試圖獲取鎖 lock1
synchronized (lock1) {
System.out.println(Thread.currentThread().getName());
}
}
}).start();
}
}
```
以上程序執行結果如下:
```
獲取 lock1 成功
獲取 lock2 成功
```
可以看出當我們使用線程一擁有鎖 lock1 的同時試圖獲取 lock2,而線程二在擁有 lock2 的同時試圖獲取 lock1,這樣就會造成彼此都在等待對方釋放資源,于是就形成了死鎖。
鎖是指在并發編程中,當有多個線程同時操作一個資源時,為了保證數據操作的正確性,我們需要讓多線程排隊一個一個的操作此資源,而這個過程就是給資源加鎖和釋放鎖的過程,就好像去公共廁所一樣,必須一個一個排隊使用,并且在使用時需要鎖門和開門一樣。
#### 考點分析
鎖的概念不止出現在 Java 語言中,比如樂觀鎖和悲觀鎖其實很早就存在于數據庫中了。鎖的概念其實不難理解,但要真正的了解鎖的原理和實現過程,才能打動面試官。
和鎖相關的面試問題,還有以下幾個:
* 什么是樂觀鎖和悲觀鎖?它們的應用都有哪些?樂觀鎖有什么問題?
* 什么是可重入鎖?用代碼如何實現?它的實現原理是什么?
* 什么是共享鎖和獨占鎖?
#### 知識擴展
* [ ] 1. 悲觀鎖和樂觀鎖
悲觀鎖指的是數據對外界的修改采取保守策略,它認為線程很容易會把數據修改掉,因此在整個數據被修改的過程中都會采取鎖定狀態,直到一個線程使用完,其他線程才可以繼續使用。
我們來看一下悲觀鎖的實現流程,以 synchronized 為例,代碼如下:
```
public class LockExample {
public static void main(String[] args) {
synchronized (LockExample.class) {
System.out.println("lock");
}
}
}
```
我們使用反編譯工具查到的結果如下:
```
Compiled from "LockExample.java"
public class com.lagou.interview.ext.LockExample {
public com.lagou.interview.ext.LockExample();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
public static void main(java.lang.String[]);
Code:
0: ldc #2 // class com/lagou/interview/ext/LockExample
2: dup
3: astore_1
4: monitorenter // 加鎖
5: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream;
8: ldc #4 // String lock
10: invokevirtual #5 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
13: aload_1
14: monitorexit // 釋放鎖
15: goto 23
18: astore_2
19: aload_1
20: monitorexit
21: aload_2
22: athrow
23: return
Exception table:
from to target type
5 15 18 any
18 21 18 any
}
```
可以看出被 synchronized 修飾的代碼塊,在執行之前先使用 monitorenter 指令加鎖,然后在執行結束之后再使用 monitorexit 指令釋放鎖資源,在整個執行期間此代碼都是鎖定的狀態,這就是典型悲觀鎖的實現流程。
樂觀鎖和悲觀鎖的概念恰好相反,樂觀鎖認為一般情況下數據在修改時不會出現沖突,所以在數據訪問之前不會加鎖,只是在數據提交更改時,才會對數據進行檢測。
Java 中的樂觀鎖大部分都是通過 CAS(Compare And Swap,比較并交換)操作實現的,CAS 是一個多線程同步的原子指令,CAS 操作包含三個重要的信息,即內存位置、預期原值和新值。如果內存位置的值和預期的原值相等的話,那么就可以把該位置的值更新為新值,否則不做任何修改。
CAS 可能會造成 ABA 的問題,ABA 問題指的是,線程拿到了最初的預期原值 A,然而在將要進行 CAS 的時候,被其他線程搶占了執行權,把此值從 A 變成了 B,然后其他線程又把此值從 B 變成了 A,然而此時的 A 值已經并非原來的 A 值了,但最初的線程并不知道這個情況,在它進行 CAS 的時候,只對比了預期原值為 A 就進行了修改,這就造成了 ABA 的問題。
以警匪劇為例,假如某人把裝了 100W 現金的箱子放在了家里,幾分鐘之后要拿它去贖人,然而在趁他不注意的時候,進來了一個小偷,用空箱子換走了裝滿錢的箱子,當某人進來之后看到箱子還是一模一樣的,他會以為這就是原來的箱子,就拿著它去贖人了,這種情況肯定有問題,因為箱子已經是空的了,這就是 ABA 的問題。
ABA 的常見處理方式是添加版本號,每次修改之后更新版本號,拿上面的例子來說,假如每次移動箱子之后,箱子的位置就會發生變化,而這個變化的位置就相當于“版本號”,當某人進來之后發現箱子的位置發生了變化就知道有人動了手腳,就會放棄原有的計劃,這樣就解決了 ABA 的問題。
JDK 在 1.5 時提供了 AtomicStampedReference 類也可以解決 ABA 的問題,此類維護了一個“版本號” Stamp,每次在比較時不止比較當前值還比較版本號,這樣就解決了 ABA 的問題。
相關源碼如下:
```
public class AtomicStampedReference<V> {
private static class Pair<T> {
final T reference;
final int stamp; // “版本號”
private Pair(T reference, int stamp) {
this.reference = reference;
this.stamp = stamp;
}
static <T> Pair<T> of(T reference, int stamp) {
return new Pair<T>(reference, stamp);
}
}
// 比較并設置
public boolean compareAndSet(V expectedReference,
V newReference,
int expectedStamp, // 原版本號
int newStamp) { // 新版本號
Pair<V> current = pair;
return
expectedReference == current.reference &&
expectedStamp == current.stamp &&
((newReference == current.reference &&
newStamp == current.stamp) ||
casPair(current, Pair.of(newReference, newStamp)));
}
//.......省略其他源碼
}
```
可以看出它在修改時會進行原值比較和版本號比較,當比較成功之后會修改值并修改版本號。
小貼士:樂觀鎖有一個優點,它在提交的時候才進行鎖定的,因此不會造成死鎖。
* [ ] 2. 可重入鎖
可重入鎖也叫遞歸鎖,指的是同一個線程,如果外面的函數擁有此鎖之后,內層的函數也可以繼續獲取該鎖。在 Java 語言中 ReentrantLock 和 synchronized 都是可重入鎖。
下面我們用 synchronized 來演示一下什么是可重入鎖,代碼如下:
```
public class LockExample {
public static void main(String[] args) {
reentrantA(); // 可重入鎖
}
/**
* 可重入鎖 A 方法
*/
private synchronized static void reentrantA() {
System.out.println(Thread.currentThread().getName() + ":執行 reentrantA");
reentrantB();
}
/**
* 可重入鎖 B 方法
*/
private synchronized static void reentrantB() {
System.out.println(Thread.currentThread().getName() + ":執行 reentrantB");
}
}
```
以上代碼的執行結果如下:
```
main:執行 reentrantA
main:執行 reentrantB
```
從結果可以看出 reentrantA 方法和 reentrantB 方法的執行線程都是“main” ,我們調用了 reentrantA 方法,它的方法中嵌套了 reentrantB,如果 synchronized 是不可重入的話,那么線程會被一直堵塞。
可重入鎖的實現原理,是在鎖內部存儲了一個線程標識,用于判斷當前的鎖屬于哪個線程,并且鎖的內部維護了一個計數器,當鎖空閑時此計數器的值為 0,當被線程占用和重入時分別加 1,當鎖被釋放時計數器減 1,直到減到 0 時表示此鎖為空閑狀態。
* [ ] 3. 共享鎖和獨占鎖
只能被單線程持有的鎖叫獨占鎖,可以被多線程持有的鎖叫共享鎖。
獨占鎖指的是在任何時候最多只能有一個線程持有該鎖,比如 synchronized 就是獨占鎖,而 ReadWriteLock 讀寫鎖允許同一時間內有多個線程進行讀操作,它就屬于共享鎖。
獨占鎖可以理解為悲觀鎖,當每次訪問資源時都要加上互斥鎖,而共享鎖可以理解為樂觀鎖,它放寬了加鎖的條件,允許多線程同時訪問該資源。
#### 小結
本課時我們講了悲觀鎖和樂觀鎖,其中悲觀鎖的典型應用為 synchronized,它的特性為獨占式互斥鎖;而樂觀鎖相比于悲觀鎖而言,擁有更好的性能,但樂觀鎖可能會導致 ABA 的問題,常見的解決方案是添加版本號來防止 ABA 問題的發生。同時,還講了可重入鎖,在 Java 中,synchronized 和 ReentrantLock 都是可重入鎖。最后,講了獨占鎖和共享鎖,其中獨占鎖可以理解為悲觀鎖,而共享鎖可以理解為樂觀鎖。
#### 課后問答
* 1、老師,有個問題不太明白,cas既然是原子操作,為什么在a線程取到期望值之后沒有比較,而被b線程搶占了執行權,我理解的原子操作,在執行過程中不會受任何的干擾,不知道哪里理解不太對,還請老師指點
講師回復: CAS 保證了原子性,但存在 ABA 的問題。可以理解為原子操作只能保證一個步驟執行的完整性,但ABA問題是組合操作,所以會存在問題。
* 2、可重入鎖那個例子沒看懂,單線程順序執行,怎么就說明他是可重入的
講師回復: 可重入鎖指的就是一個線程可以“重入”
* 3、老師,為什么樂觀鎖在提交時進行鎖定,就不會造成死鎖了呀,死鎖有四個必要條件,他破壞了哪個呢
講師回復: 樂觀鎖不是互斥的哦
* 4、第五課說的是Reentrantlock 基于AQS
講師回復: 是
- 前言
- 開篇詞
- 開篇詞:大廠技術面試“潛規則”
- 模塊一:Java 基礎
- 第01講:String 的特點是什么?它有哪些重要的方法?
- 第02講:HashMap 底層實現原理是什么?JDK8 做了哪些優化?
- 第03講:線程的狀態有哪些?它是如何工作的?
- 第04講:詳解 ThreadPoolExecutor 的參數含義及源碼執行流程?
- 第05講:synchronized 和 ReentrantLock 的實現原理是什么?它們有什么區別?
- 第06講:談談你對鎖的理解?如何手動模擬一個死鎖?
- 第07講:深克隆和淺克隆有什么區別?它的實現方式有哪些?
- 第08講:動態代理是如何實現的?JDK Proxy 和 CGLib 有什么區別?
- 第09講:如何實現本地緩存和分布式緩存?
- 第10講:如何手寫一個消息隊列和延遲消息隊列?
- 模塊二:熱門框架
- 第11講:底層源碼分析 Spring 的核心功能和執行流程?(上)
- 第12講:底層源碼分析 Spring 的核心功能和執行流程?(下)
- 第13講:MyBatis 使用了哪些設計模式?在源碼中是如何體現的?
- 第14講:SpringBoot 有哪些優點?它和 Spring 有什么區別?
- 第15講:MQ 有什么作用?你都用過哪些 MQ 中間件?
- 模塊三:數據庫相關
- 第16講:MySQL 的運行機制是什么?它有哪些引擎?
- 第17講:MySQL 的優化方案有哪些?
- 第18講:關系型數據和文檔型數據庫有什么區別?
- 第19講:Redis 的過期策略和內存淘汰機制有什么區別?
- 第20講:Redis 怎樣實現的分布式鎖?
- 第21講:Redis 中如何實現的消息隊列?實現的方式有幾種?
- 第22講:Redis 是如何實現高可用的?
- 模塊四:Java 進階
- 第23講:說一下 JVM 的內存布局和運行原理?
- 第24講:垃圾回收算法有哪些?
- 第25講:你用過哪些垃圾回收器?它們有什么區別?
- 第26講:生產環境如何排除和優化 JVM?
- 第27講:單例的實現方式有幾種?它們有什么優缺點?
- 第28講:你知道哪些設計模式?分別對應的應用場景有哪些?
- 第29講:紅黑樹和平衡二叉樹有什么區別?
- 第30講:你知道哪些算法?講一下它的內部實現過程?
- 模塊五:加分項
- 第31講:如何保證接口的冪等性?常見的實現方案有哪些?
- 第32講:TCP 為什么需要三次握手?
- 第33講:Nginx 的負載均衡模式有哪些?它的實現原理是什么?
- 第34講:Docker 有什么優點?使用時需要注意什么問題?
- 彩蛋
- 彩蛋:如何提高面試成功率?