寫在前面
如果覺得有所收穫,記得的點個關注和點個贊,感謝支持。
本篇文章要講的是Lock
接口,重點強調 ReentrantLock
類,相關的接口在JUC 包裏面,自 JDK 5 起,Java 類庫中新提供了 java.util.concurrent
包(通常簡稱爲 JUC 包)。Java 中有兩種對併發資源加鎖的方式,除了 synchronized
之外(不清楚的可以查看我之前寫過的一篇關於synchronize文章),還有本篇文章要講的 Lock
。synchronized
是 JVM 通過底層實現的,而 Lock
是通過 JDK 純粹在軟件層面上實現的。
先來講講 Lock 接口
Lock 類本身是一個接口,對鎖進行了規範,Lock 接口的定義如下(我這裏刪除了源碼的註釋,這樣不佔用版面):
public interface Lock {
void lock();
void lockInterruptibly() throws InterruptedException;
boolean tryLock();
boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
void unlock();
Condition newCondition();
}
上面可以看到,Lock 接口一共規範給定了 6 個方法。其中最爲常用的,是 lock()
方法和 unlock()
方法,這兩個方法必須成對出現,否則就有可能出現異常,使用邏輯如下:
// 假如已經創建了一個lock對象
lock.lock();
try {
// ...
} finally {
lock.unlock();
}
這裏使用 lock
上鎖,與使用 synchronized
上鎖的效果是相同的,但在使用上從大括號代碼塊變爲 try 代碼塊,並且一定要使用 finally 語句爲 lock
對象解鎖。可以查閱阿里巴巴的 Java 代碼規約,在裏面已經說的非常明白了,內容如下:
Lock 接口規定了四種上鎖,除了上文說到的最傳統的 lock() 方法之外,還有以下三種:
- lockInterruptibly() 會處理線程中斷的上鎖
- tryLock() 嘗試上鎖並立即返回,上鎖成功則 true,上鎖失敗則 false
- tryLock(long time, TimeUnit unit) 嘗試一段時間上鎖後返回,上鎖成功則 true,上鎖失敗則 false
除以上上鎖方法之外,最後還有一個方法 newCondition(),該方法用於協調線程,這個後面再提。
講講線程相關的知識
在講解線程中斷之前呢,需要來了解一下線程相關的一些知識,我之前寫過一篇博文,是有關在Java中如何使用線程,不清楚的可以過去看看,這裏講解線程的使用邏輯,即線程的狀態,以及線程中斷的邏輯。
通常意義上線程有六種狀態,但依我來看線程實際上只有兩種狀態:可運行狀態、不可運行狀態。
- 可運行狀態:線程可以運行,但是並不一定正在運行,細分的話可以分爲正在運行和等待運行兩種狀態。
- 不可運行狀態:線程不能運行,可能是主動的(主動等待),也可能是被動的(要用的資源被鎖住了)。細分的話能分爲三種狀態:無限期等待狀態、限期等待狀態、阻塞狀態,前兩種是線程自己發起的,第三種是線程被迫的。
對各個狀態分別進行解釋:
-
New
新增:線程剛剛創建(例如Thread t = new Thread()
),還沒有執行代碼 -
Runnable
可運行:線程可以運行(例如thread.start()
),但並不代表一定在運行,是否正在運行要看虛擬機和 CPU 的線程調度情況。CPU 將時間劃分爲10-20 ms
的一個個時間片,在每一個時間片中執行一條線程,到時間就切換(切換地太快導致似乎在並行執行多條線程),這被稱爲 CPU 在調度線程。在Runnable
狀態下,每一條線程都有可能會被執行,但是執行和切換的速度都很快,非要分出來是在執行還是在等待並沒有太大的意義。Ready
等待運行:等待 CPU 調度Running
正在運行:CPU 正在執行
-
Waiting
無限期等待:線程主動等待,並且不設置等待結束的時間,直到被其他線程“喚醒”(例如thread.join()
)。 -
Timed Waiting
限期等待:線程主動等待,但是設置一個等待的時長,到時間就自動喚醒(例如thread.sleep(sleepTime)
),在等待的這段時間也可以被其他線程“喚醒”。 -
Blocked
阻塞等待:線程被動等待,因爲搶鎖失敗了,被迫等着(例如使用synchronized
同時讓多條線程獲取資源,總有線程會被迫等待)。
有關線程狀態還可以剖析地更深一些:
- Java 的
Thread
類看似是一個尋常的 Java 對象,實際上可以視爲對底層系統操作線程的封裝,因此使用Thread
類時不能完全按照面向對象的常規思維來思考,而是要以底層硬件的實現邏輯來思考。 - 上文我將線程分爲了可運行狀態和不可運行狀態,細分析的話,這實際上是指 CPU 有沒有爲線程分配時間片。在另外的地方(線程和進程的區別)學習到,線程是操作系統能夠調度的最小單位,“能調度的最小單位“這種說法,就是指 CPU 劃分出一個個時間片,每一個時間片”調度“一個線程。可運行狀態指的是 CPU 能夠調度線程,而不可運行狀態指的是 CPU 不能調度線程,比如某一個線程中執行
Thread.sleep(sleepTime)
方法,那麼這個線程進入Timed Waiting
狀態,在這種狀態下 CPU 不再調度該線程,直到該線程休眠時間結束,回到Runnable
狀態,CPU 纔可以調度該線程,這個行爲被稱作線程的“掛起”。 - 線程通過
sleep(time)
和wait(time)
方法都可以進入Timed Waiting
狀態,CPU 都不再會調度該線程,但是sleep
的一方不會釋放鎖,wait
的一方會釋放鎖。其他線程如果需要正在sleep
的線程的資源,將一直阻塞到那個線程醒來再釋放資源。 - 只有使用
synchronized
才能導致線程進入Blocked
狀態,線程從Waiting
狀態無法直接進入Runnable
狀態,只能先進入Blocked
狀態去獲取鎖。(順便一提,進入Waiting
狀態的wait()、notify()、notifyAll()
方法,只能在synchronized
代碼塊中使用)
線程中斷,這裏的“中斷”是一個頗有迷惑性的詞語,它並不是指線程就此停止,而是指線程收到了一個“中斷信號”,線程應該根據這個信號來自行了斷一些事情(但是收到中斷信號也可以不處理)。比如,線程 1 向線程 2 發送了一條中斷信息,線程 2 的中斷狀態發生了改變,線程 2 根據中斷狀態來進行邏輯處理。所以我認爲,中斷是線程間通信的一種方式,通信的內容是“建議另一條線程停止行爲”,但是線程並不一定採取意見,即使採取意見也絕不是終止線程,而是停止某個一直重複運行的行爲,繼續執行後續的代碼。我目前所見,中斷有兩種使用場景:
- 線程根據中斷狀態,停止某個循環(例如下面這段僞代碼)
while(還沒中斷){
循環執行
}
中斷了,進行後續操作
- 如果線程處於阻塞、限期等待或者無限期等待狀態,那麼就會拋出
InterruptedException
,從而提前結束該線程,但是不能中斷I/O
阻塞和synchronized
鎖阻塞。這裏的用法是,當線程處於不可運行狀態時(暫停 CPU 調度),以異常的形式,強制讓線程處理中斷,以恢復回到可運行狀態(CPU 可調度)。雖然這是在處理異常,但實際上並不是指程序有什麼錯誤,而是代表一種強制手段:必須要對中斷進行處理。再換句話說,這是一種恢復線程狀態,停止發呆的一種機制。
try {
// 當前線程休眠1秒
Thread.sleep(1000);
} catch (InterruptedException e) {
// 線程中斷,不讓繼續休眠了,處理後續的業務邏輯
}
線程中斷有三個相關方法:
API | 介紹 |
---|---|
public void interrupt() | 中斷線程 |
public boolean isInterrupted() | 查看線程是否中斷 |
public static boolean interrupted() | 靜態方法,查看當前線程是否中斷的同時,清除中斷狀態,即如果線程中斷,執行之後將不再處於中斷狀態 |
中斷的源碼,以及阻塞狀態下的線程拋出中斷異常的原理,這裏暫不考究了。在此只掌握到兩點即可:
- 線程中斷不代表線程活動終止
- 線程中斷的基本原理,是給線程的中斷標誌位賦 true
聊一聊AQS
AQS 可以算是 JUC 包的核心,一大片併發類,包括要學習的 ReentrantLock
鎖,都是以 AQS 爲內核,不瞭解 AQS 則無法繼續學習。
AQS 的全稱是 AbstractQueuedSynchronizer
(抽象隊列同步器,中文一般簡稱“隊列同步器”),它的作用正如其名,是一個隊列,需要同步的線程們在隊列裏排隊,每次讓一個線程佔用資源,剩下的線程在隊列同步器裏待命。這樣的設計實現了這種效果:當多個線程爭搶資源時,保證只會有一條線程在運行,其他線程都在等待隊列裏等候安排。打開 AQS 接口看源碼,會看到多如牛毛的方法,初識 AQS 如果從這些方法着手,就可以準備去世了,因此我們從 AQS 的成員變量着手,對 AQS 進行猜測性學習。以下代碼部分,基本全部參考自《一行一行源碼分析清楚 AbstractQueuedSynchronizer》,這篇博文寫的真的非常好
AQS 重要的成員變量有四個,分別是:
// 頭結點,你直接把它當做【當前持有鎖的線程】可能是最好理解的
private transient volatile Node head;
// 阻塞的尾節點,每個新的節點進來,都插入到最後,也就形成了一個鏈表
private transient volatile Node tail;
// 這個是最重要的,代表當前鎖的狀態,0代表沒有被佔用,大於 0 代表有線程持有當前鎖
// 這個值可以大於 1,是因爲鎖可以重入,每次重入都加上 1
private volatile int state;
// 代表當前持有獨佔鎖的線程(該變量繼承自父類),舉個最重要的使用例子
// 因爲鎖可以重入,reentrantLock.lock()可以嵌套調用多次,所以每次用這個來判斷當前線程是否已經擁有了鎖
// if (currentThread == getExclusiveOwnerThread()) {state++}
private transient Thread exclusiveOwnerThread;
AQS 接口中定義了一個內部類:Node,這個類是 AQS 隊列的基本構成元素,即併發線程們在 AQS 隊列裏等候時,都是裝在這個 Node 對象裏排序的。Node 類源碼如下:
static final class Node {
// 標識節點當前在共享模式下
static final Node SHARED = new Node();
// 標識節點當前在獨佔模式下
static final Node EXCLUSIVE = null;
// ======== 下面的幾個int常量是給waitStatus用的 ===========
// 代表此線程取消了爭搶這個鎖
static final int CANCELLED = 1;
// 官方的描述是,其表示當前node的後繼節點對應的線程需要被喚醒
static final int SIGNAL = -1;
// 本文不分析condition
static final int CONDITION = -2;
// 同樣的不分析,略過吧
static final int PROPAGATE = -3;
// =====================================================
// 取值爲上面的1、-1、-2、-3,或者0(以後會講到)
// 這麼理解,暫時只需要知道如果這個值 大於0 代表此線程取消了等待,
// ps: 半天搶不到鎖,不搶了,ReentrantLock是可以指定timeouot的。。。
volatile int waitStatus;
// 前驅節點的引用
volatile Node prev;
// 後繼節點的引用
volatile Node next;
// 這個就是線程本尊
volatile Thread thread;
}
Node 類的代碼容易看得人一頭霧水,初學時應當將其視爲一個普通的鏈表節點,它必須需要
- Node prev:指向前個節點
- Node next:指向後個節點
- Thread Thread:本節點需要存儲的內容
除此之外該節點還有一個狀態位:
- int waitStatus:節點狀態,在之後的代碼中很重要
Node 類定義的其他內容不用太過糾結,看之後的代碼會懂。根據學習這個類,以及參考學習其他 AQS 相關的博文,可以大概知道 AQS 隊列的基本結構和設計邏輯是這樣的:
看圖應該就能明白 AQS 的數據結構,需要注意的是,head 並不在 AQS 的阻塞隊列當中。以下部分是 AQS 的源碼分析,這部分的內容很難,可以不看,不會影響到 Lock 接口的學習。之前的代碼中說過,使用 Lock 接口上鎖的基本步驟是:
lock.lock(); --> AQS#acquire()
try {
// ...
} finally {
lock.unlock(); --> AQS#release()
}
實際上,lock()
和 unlock()
方法的原理,是使用 AQS 的 acquire()
和 release()
方法實現的,因此我們來粗略地學習這兩個方法,並大致瞭解 AQS 的原理。(以下代碼說明均爲簡略版,查看詳細代碼說明請參見上述博文)
上鎖(新線程加入隊列)
解鎖(老線程執行完畢,傳喚下一個線程)
AQS 的具體實現代碼,我自認爲是又長又難的,因此不把全部代碼整理出來了,只在此記錄一些點吧:
- AQS 中有大量的方法,是爲了處理併發的,例如隊列還是空的,同時有兩個線程進來申請鎖,如何來讓一個線程拿到鎖,另一個線程去隊列裏排隊等候。AQS 解決併發問題的原理是 CAS(CAS 的原理去看上篇介紹 synchronized 的博文),AQS 去調用 JDK5 剛剛出現的
sun.misc.Unsafe
類裏面的方法,這個類對 CPU 的 CAS 指令進行了封裝。 - 進入阻塞隊列排隊的線程會被掛起,而喚醒的操作是由前驅節點完成的。當佔用鎖的線程結束,調用
unlock()
方法,此時 AQS 會去隊列裏喚醒排在最前面的節點線程。 - AQS 接口確定了隊列同步的主要邏輯,也就是上鎖時線程先嚐試獲取鎖,失敗則加入隊列;解鎖時隊列先嚐試解除鎖,如果解鎖成功則喚醒後繼節點。但是嘗試獲取鎖和嘗試解除鎖這兩個操作,都是交由子類去實現的。這就使得 AQS 框架確立了基礎的併發隊列機制,但鎖的形式可以有各種不同。實際上每個鎖(每個 AQS 接口的實現類)就是在重寫 AQS 的
tryAcquire()
和tryRelease()
方法,其他的都依賴於 AQS 接口代碼。 - AQS 有兩個很重要的變量,分別是隊列的狀態
state
,以及隊列節點的狀態waitStatus
。state
:0 代表鎖沒有被佔用,1 代表有線程正在佔用鎖,1 往上代表有線程正在重入佔用鎖waitStatus
:0 代表初始化,大於 0 代表該節點取消了等待,-1 代表後繼節點需要被喚醒
ReentrantLock
不容易呀,終於到了ReentrantLock,ReentrantLock 的字面意義是可重入鎖,代表線程可以多次執行 lock()
方法佔有鎖,不會導致死鎖問題。ReentrantLock 允許公平鎖,只要在構造方法中傳入 true(new ReentrantLock(true)
)即可。公平鎖的意思是,當多個線程獲取鎖時,按照先來後到的順序,先申請鎖的線程一定先得到鎖,後申請鎖的線程一定後得到鎖。如果是非公平鎖,那麼各個線程獲取到鎖的順序是“隨機”的。對於 ReentrantLock 的非公平鎖而言,後到的線程可以先試着獲取一次鎖,獲取到了就直接返回,獲取不到就跟公平鎖一樣在後面排隊。ReentrantLock 實現公平鎖和非公平鎖的方式,是在內部維護兩種 AQS 隊列。
// 非公平鎖(Sync是一個AQS隊列)
static final class NonfairSync extends Sync {...}
// 公平鎖
static final class FairSync extends Sync {...}
經過剛纔對 AQS 的學習,我們知道學習鎖實際上只需要看 tryAcquire() 和 tryRelease() 方法,其他都交由 AQS 接口就可以了。
上鎖 tryAcquire()
公平鎖
// 嘗試直接獲取鎖,返回值是boolean,代表是否獲取到鎖
// 返回true:1.沒有線程在等待鎖;2.重入鎖,線程本來就持有鎖,也就可以理所當然可以直接獲取
protected final boolean tryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
// state == 0 此時此刻沒有線程持有鎖
if (c == 0) {
// 雖然此時此刻鎖是可以用的,但是這是公平鎖,既然是公平,就得講究先來後到,
// 看看有沒有別人在隊列中等了半天了
if (!hasQueuedPredecessors() &&
// 如果沒有線程在等待,那就用CAS嘗試一下,成功了就獲取到鎖了,
// 不成功的話,只能說明一個問題,就在剛剛幾乎同一時刻有個線程搶先了 =_=
// 因爲剛剛還沒人的,我判斷過了
compareAndSetState(0, acquires)) {
// 到這裏就是獲取到鎖了,標記一下,告訴大家,現在是我佔用了鎖
setExclusiveOwnerThread(current);
return true;
}
}
// 會進入這個else if分支,說明是重入了,需要操作:state=state+1
// 這裏不存在併發問題
else if (current == getExclusiveOwnerThread()) {
int nextc = c + acquires;
if (nextc < 0)
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
// 如果到這裏,說明前面的if和else if都沒有返回true,說明沒有獲取到鎖
// 回到上面一個外層調用方法(AQS的acquire()方法)繼續看:
// if (!tryAcquire(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
// selfInterrupt();
return false;
}
非公平鎖
protected final boolean tryAcquire(int acquires) {
// 調用了nonfairTryAcquire()方法,往下看
return nonfairTryAcquire(acquires);
}
final boolean nonfairTryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
// 與公平鎖相比,只有這裏有區別
// 非公平鎖不會先判斷AQS隊列中是否有等候的節點,而是直接試着獲取一次鎖
// 如果這次嘗試獲取不到,則和公平鎖一樣尾插隊列
if (compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
else if (current == getExclusiveOwnerThread()) {
int nextc = c + acquires;
if (nextc < 0)
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
return false;
}
公平鎖和非公平鎖只有兩點區別
- 非公平鎖實際上會先 CAS 獲取一次鎖,如果失敗則調用 AQS 的
acquire()
方法(這段上面沒提)
// 非公平鎖的lock()方法(會先CAS獲取一次鎖,獲取不到再走AQS接口)
final void lock() {
if (compareAndSetState(0, 1))
setExclusiveOwnerThread(Thread.currentThread());
else
acquire(1);
}
// 公平鎖的lock()方法
final void lock() {
acquire(1);
}
- 在首次試着獲取鎖失敗的情況下,非公平鎖會在
tryAcquire()
方法中再試着獲取一次鎖,但是公平鎖會嚴格地按照先來後到的順序獲取
可以總結出來,非公平鎖比公平鎖多嘗試獲取了兩次鎖,如果成功就不用進入隊列了。這樣可以提高併發的線程吞吐量,但是有可能導致先等待的線程一直獲取不到鎖。
解鎖 tryRelease()
公平鎖和非公平鎖,共用一套解鎖方法,也就是 Lock#unlock() -> AQS#release() -> Lock#tryRelease() -> AQS#unparkSuccessor()
,其中 tryRelease()
方法是交由實現類 ReentrantLock 去重寫的(不明白的話回到上面看一看 AQS 的解鎖邏輯)。ReentrantLock 重寫的 tryRelease()
方法的代碼如下:
protected final boolean tryRelease(int releases) {
int c = getState() - releases;
if (Thread.currentThread() != getExclusiveOwnerThread())
throw new IllegalMonitorStateException();
// 是否完全釋放鎖
boolean free = false;
// 處理重入的問題,如果c==0,也就是說沒有嵌套鎖了,可以釋放了,否則還不能釋放掉
if (c == 0) {
free = true;
setExclusiveOwnerThread(null);
}
setState(c);
return free;
}
ReentrantLock 作爲可重入鎖,每次上鎖就使 AQS 隊列的狀態(初始化是 0)增加 1,解鎖使狀態減少 1,如果 AQS 隊列的狀態變爲 0 了,就代表沒有線程持有鎖。
ReentrantLock使用
這裏模擬售票,通過ReentrantLock的方式實現線程的安全
public class LockMain {
public static void main(String[] args) {
Window window = new Window();
Thread thread1 = new Thread(window);
Thread thread2 = new Thread(window);
Thread thread3 = new Thread(window);
thread1.start();
thread2.start();
thread3.start();
}
}
/**
* 售票窗口
*/
class Window implements Runnable{
private volatile int num = 100;
ReentrantLock lock = new ReentrantLock();
@Override
public void run() {
while (true){
lock.lock();
try {
if (num > 0){
System.out.println(Thread.currentThread().getName()+"窗口在售票,票號爲"+ num);
num --;
}else {
break;
}
}finally {
lock.unlock();
}
}
}
}