本系列文章經補充和完善,已修訂整理成書《Java程式設計的邏輯》(馬俊昌著),由機械工業出版社華章分社出版,於2018年1月上市熱銷,讀者好評如潮!各大網店和書店有售,歡迎購買:京東自營連結
在66節,我們介紹了利用synchronized實現鎖,我們提到了synchronized的一些侷限性,本節,我們探討Java併發包中的顯式鎖,它可以解決synchronized的限制。
Java併發包中的顯式鎖介面和類位於包java.util.concurrent.locks下,主要介面和類有:
- 鎖介面Lock,主要實現類是ReentrantLock
- 讀寫鎖介面ReadWriteLock,主要實現類是ReentrantReadWriteLock
本節主要介紹介面Lock和實現類ReentrantLock,關於讀寫鎖,我們後續章節介紹。
介面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()/unlock():就是普通的獲取鎖和釋放鎖方法,lock()會阻塞直到成功。
- lockInterruptibly():與lock()的不同是,它可以響應中斷,如果被其他執行緒中斷了,丟擲InterruptedException。
- tryLock():只是嘗試獲取鎖,立即返回,不阻塞,如果獲取成功,返回true,否則返回false。
- tryLock(long time, TimeUnit unit) :先嚐試獲取鎖,如果能成功則立即返回true,否則阻塞等待,但等待的最長時間為指定的引數,在等待的同時響應中斷,如果發生了中斷,丟擲InterruptedException,如果在等待的時間內獲得了鎖,返回true,否則返回false。
- newCondition:新建一個條件,一個Lock可以關聯多個條件,關於條件,我們留待下節介紹。
可以看出,相比synchronized,顯式鎖支援以非阻塞方式獲取鎖、可以響應中斷、可以限時,這使得它靈活的多。
可重入鎖ReentrantLock
基本用法
Lock介面的主要實現類是ReentrantLock,它的基本用法lock/unlock實現了與synchronized一樣的語義,包括:
- 可重入,一個執行緒在持有一個鎖的前提下,可以繼續獲得該鎖
- 可以解決競態條件問題
- 可以保證記憶體可見性
ReentrantLock有兩個構造方法:
public ReentrantLock()
public ReentrantLock(boolean fair)
複製程式碼
引數fair表示是否保證公平,不指定的情況下,預設為false,表示不保證公平。所謂公平是指,等待時間最長的執行緒優先獲得鎖。保證公平會影響效能,一般也不需要,所以預設不保證,synchronized鎖也是不保證公平的,待會我們還會再分析實現細節。
使用顯式鎖,一定要記得呼叫unlock,一般而言,應該將lock之後的程式碼包裝到try語句內,在finally語句內釋放鎖,比如,使用ReentrantLock實現Counter,程式碼可以為:
public class Counter {
private final Lock lock = new ReentrantLock();
private volatile int count;
public void incr() {
lock.lock();
try {
count++;
} finally {
lock.unlock();
}
}
public int getCount() {
return count;
}
}
複製程式碼
使用tryLock避免死鎖
使用tryLock(),可以避免死鎖。在持有一個鎖,獲取另一個鎖,獲取不到的時候,可以釋放已持有的鎖,給其他執行緒機會獲取鎖,然後再重試獲取所有鎖。
我們來看個例子,銀行賬戶之間轉賬,用類Account表示賬戶,程式碼如下:
public class Account {
private Lock lock = new ReentrantLock();
private volatile double money;
public Account(double initialMoney) {
this.money = initialMoney;
}
public void add(double money) {
lock.lock();
try {
this.money += money;
} finally {
lock.unlock();
}
}
public void reduce(double money) {
lock.lock();
try {
this.money -= money;
} finally {
lock.unlock();
}
}
public double getMoney() {
return money;
}
void lock() {
lock.lock();
}
void unlock() {
lock.unlock();
}
boolean tryLock() {
return lock.tryLock();
}
}
複製程式碼
Account裡的money表示當前餘額,add/reduce用於修改餘額。在賬戶之間轉賬,需要兩個賬戶都鎖定,如果不使用tryLock,直接使用lock,程式碼看上去可以這樣:
public class AccountMgr {
public static class NoEnoughMoneyException extends Exception {}
public static void transfer(Account from, Account to, double money)
throws NoEnoughMoneyException {
from.lock();
try {
to.lock();
try {
if (from.getMoney() >= money) {
from.reduce(money);
to.add(money);
} else {
throw new NoEnoughMoneyException();
}
} finally {
to.unlock();
}
} finally {
from.unlock();
}
}
}
複製程式碼
但這麼寫是有問題的,如果兩個賬戶同時給對方轉賬,都先獲取了第一個鎖,則會發生死鎖。我們寫段程式碼來模擬這個過程:
public static void simulateDeadLock() {
final int accountNum = 10;
final Account[] accounts = new Account[accountNum];
final Random rnd = new Random();
for (int i = 0; i < accountNum; i++) {
accounts[i] = new Account(rnd.nextInt(10000));
}
int threadNum = 100;
Thread[] threads = new Thread[threadNum];
for (int i = 0; i < threadNum; i++) {
threads[i] = new Thread() {
public void run() {
int loopNum = 100;
for (int k = 0; k < loopNum; k++) {
int i = rnd.nextInt(accountNum);
int j = rnd.nextInt(accountNum);
int money = rnd.nextInt(10);
if (i != j) {
try {
transfer(accounts[i], accounts[j], money);
} catch (NoEnoughMoneyException e) {
}
}
}
}
};
threads[i].start();
}
}
複製程式碼
以上程式碼建立了10個賬戶,100個執行緒,每個執行緒執行100次迴圈,在每次迴圈中,隨機挑選兩個賬戶進行轉賬。在我的電腦上,每次執行該段程式碼,都會發生死鎖。讀者可以更改這些數值進行試驗。
我們使用tryLock來進行修改,先定義一個tryTransfer方法:
public static boolean tryTransfer(Account from, Account to, double money)
throws NoEnoughMoneyException {
if (from.tryLock()) {
try {
if (to.tryLock()) {
try {
if (from.getMoney() >= money) {
from.reduce(money);
to.add(money);
} else {
throw new NoEnoughMoneyException();
}
return true;
} finally {
to.unlock();
}
}
} finally {
from.unlock();
}
}
return false;
}
複製程式碼
如果兩個鎖都能夠獲得,且轉賬成功,則返回true,否則返回false,不管怎樣,結束都會釋放所有鎖。transfer方法可以迴圈呼叫該方法以避免死鎖,程式碼可以為:
public static void transfer(Account from, Account to, double money)
throws NoEnoughMoneyException {
boolean success = false;
do {
success = tryTransfer(from, to, money);
if (!success) {
Thread.yield();
}
} while (!success);
}
複製程式碼
獲取鎖資訊
除了實現Lock介面中的方法,ReentrantLock還有一些其他方法,通過它們,可以獲取關於鎖的一些資訊,這些資訊可以用於監控和除錯目的,比如:
//鎖是否被持有,只要有執行緒持有就返回true,不一定是當前執行緒持有
public boolean isLocked()
//鎖是否被當前執行緒持有
public boolean isHeldByCurrentThread()
//鎖被當前執行緒持有的數量,0表示不被當前執行緒持有
public int getHoldCount()
//鎖等待策略是否公平
public final boolean isFair()
//是否有執行緒在等待該鎖
public final boolean hasQueuedThreads()
//指定的執行緒thread是否在等待該鎖
public final boolean hasQueuedThread(Thread thread)
//在等待該鎖的執行緒個數
public final int getQueueLength()
複製程式碼
實現原理
ReentrantLock的用法是比較簡單的,它是怎麼實現的呢?在最底層,它依賴於上節介紹的CAS方法,另外,它依賴於類LockSupport中的一些方法。
LockSupport
類LockSupport也位於包java.util.concurrent.locks下,它的基本方法有:
public static void park()
public static void parkNanos(long nanos)
public static void parkUntil(long deadline)
public static void unpark(Thread thread)
複製程式碼
park使得當前執行緒放棄CPU,進入等待狀態(WAITING),作業系統不再對它進行排程,什麼時候再排程呢?有其他執行緒對它呼叫了unpark,unpark需要指定一個執行緒,unpark會使之恢復可執行狀態。我們看個例子:
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread (){
public void run(){
LockSupport.park();
System.out.println("exit");
}
};
t.start();
Thread.sleep(1000);
LockSupport.unpark(t);
}
複製程式碼
執行緒t啟動後呼叫park,會放棄CPU,主執行緒睡眠1秒鐘後,呼叫unpark,執行緒t恢復執行,輸出exit。
park不同於Thread.yield(),yield只是告訴作業系統可以先讓其他執行緒執行,但自己依然是可執行狀態,而park會放棄排程資格,使執行緒進入WAITING狀態。
需要說明的是,park是響應中斷的,當有中斷髮生時,park會返回,執行緒的中斷狀態會被設定。另外,還需要說明一下,park可能會無緣無故的返回,程式應該重新檢查park等待的條件是否滿足。
park有兩個變體:
- parkNanos:可以指定等待的最長時間,引數是相對於當前時間的納秒數。
- parkUntil:可以指定最長等到什麼時候,引數是絕對時間,是相對於紀元時的毫秒數。
當等待超時的時候,它們也會返回。
這些park方法還有一些變體,可以指定一個物件,表示是由於該物件進行等待的,以便於除錯,通常傳遞的值是this,這些方法有:
public static void park(Object blocker)
public static void parkNanos(Object blocker, long nanos)
public static void parkUntil(Object blocker, long deadline)
複製程式碼
LockSupport有一個方法,可以返回一個執行緒的blocker物件:
public static Object getBlocker(Thread t)
複製程式碼
這些park/unpark方法是怎麼實現的呢?與CAS方法一樣,它們也呼叫了Unsafe類中的對應方法,Unsafe類最終呼叫了作業系統的API,從程式設計師的角度,我們可以認為LockSupport中的這些方法就是基本操作。
AQS (AbstractQueuedSynchronizer)
利用CAS和LockSupport提供的基本方法,就可以用來實現ReentrantLock了。但Java中還有很多其他併發工具,如ReentrantReadWriteLock、Semaphore、CountDownLatch,它們的實現有很多類似的地方,為了複用程式碼,Java提供了一個抽象類AbstractQueuedSynchronizer,我們簡稱為AQS,它簡化了併發工具的實現。AQS的整體實現比較複雜,我們主要以ReentrantLock的使用為例進行簡要介紹。
AQS封裝了一個狀態,給子類提供了查詢和設定狀態的方法:
private volatile int state;
protected final int getState()
protected final void setState(int newState)
protected final boolean compareAndSetState(int expect, int update)
複製程式碼
用於實現鎖時,AQS可以儲存鎖的當前持有執行緒,提供了方法進行查詢和設定:
private transient Thread exclusiveOwnerThread;
protected final void setExclusiveOwnerThread(Thread t)
protected final Thread getExclusiveOwnerThread()
複製程式碼
AQS內部維護了一個等待佇列,藉助CAS方法實現了無阻塞演算法進行更新。
下面,我們以ReentrantLock的使用為例簡要介紹下AQS的原理。
ReentrantLock
ReentrantLock內部使用AQS,有三個內部類:
abstract static class Sync extends AbstractQueuedSynchronizer
static final class NonfairSync extends Sync
static final class FairSync extends Sync
複製程式碼
Sync是抽象類,NonfairSync是fair為false時使用的類,FairSync是fire為true時使用的類。ReentrantLock內部有一個Sync成員:
private final Sync sync;
複製程式碼
在構造方法中sync被賦值,比如:
public ReentrantLock() {
sync = new NonfairSync();
}
複製程式碼
我們來看ReentrantLock中的基本方法lock/unlock的實現,先看lock方法,程式碼為:
public void lock() {
sync.lock();
}
複製程式碼
NonfairSync的lock程式碼為:
final void lock() {
if (compareAndSetState(0, 1))
setExclusiveOwnerThread(Thread.currentThread());
else
acquire(1);
}
複製程式碼
ReentrantLock使用state表示是否被鎖和持有數量,如果當前未被鎖定,則立即獲得鎖,否則呼叫acquire(1)獲得鎖,acquire是AQS中的方法,程式碼為:
public final void acquire(int arg) {
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
複製程式碼
它呼叫tryAcquire獲取鎖,tryAcquire必須被子類重寫,NonfairSync的實現為:
protected final boolean tryAcquire(int acquires) {
return nonfairTryAcquire(acquires);
}
複製程式碼
nonfairTryAcquire是sync中實現的,程式碼為:
final boolean nonfairTryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
if (compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
else if (current == getExclusiveOwnerThread()) {
int nextc = c + acquires;
if (nextc < 0) // overflow
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
return false;
}
複製程式碼
這段程式碼應該容易理解,如果未被鎖定,則使用CAS進行鎖定,否則,如果已被當前執行緒鎖定,則增加鎖定次數。
如果tryAcquire返回false,則AQS會呼叫:
acquireQueued(addWaiter(Node.EXCLUSIVE), arg)
複製程式碼
其中,addWaiter會新建一個節點Node,代表當前執行緒,然後加入到內部的等待佇列中,限於篇幅,具體程式碼就不列出來了。放入等待佇列後,呼叫acquireQueued嘗試獲得鎖,程式碼為:
final boolean acquireQueued(final Node node, int arg) {
boolean failed = true;
try {
boolean interrupted = false;
for (;;) {
final Node p = node.predecessor();
if (p == head && tryAcquire(arg)) {
setHead(node);
p.next = null; // help GC
failed = false;
return interrupted;
}
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
interrupted = true;
}
} finally {
if (failed)
cancelAcquire(node);
}
}
複製程式碼
主體是一個死迴圈,在每次迴圈中,首先檢查當前節點是不是第一個等待的節點,如果是且能獲得到鎖,則將當前節點從等待佇列中移除並返回,否則最終呼叫LockSupport.park放棄CPU,進入等待,被喚醒後,檢查是否發生了中斷,記錄中斷標誌,在最終方法返回時返回中斷標誌。如果發生過中斷,acquire方法最終會呼叫selfInterrupt方法設定中斷標誌位,其程式碼為:
private static void selfInterrupt() {
Thread.currentThread().interrupt();
}
複製程式碼
以上就是lock方法的基本過程,能獲得鎖就立即獲得,否則加入等待佇列,被喚醒後檢查自己是否是第一個等待的執行緒,如果是且能獲得鎖,則返回,否則繼續等待,這個過程中如果發生了中斷,lock會記錄中斷標誌位,但不會提前返回或丟擲異常。
ReentrantLock的unlock方法的程式碼為:
public void unlock() {
sync.release(1);
}
複製程式碼
release是AQS中定義的方法,程式碼為:
public final boolean release(int arg) {
if (tryRelease(arg)) {
Node h = head;
if (h != null && h.waitStatus != 0)
unparkSuccessor(h);
return true;
}
return false;
}
複製程式碼
tryRelease方法會修改狀態釋放鎖,unparkSuccessor會呼叫LockSupport.unpark將第一個等待的執行緒喚醒,具體程式碼就不列舉了。
FairSync和NonfairSync的主要區別是,在獲取鎖時,即在tryAcquire方法中,如果當前未被鎖定,即c==0,FairSync多個一個檢查,如下:
protected final boolean tryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
if (!hasQueuedPredecessors() &&
compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
...
複製程式碼
這個檢查是指,只有不存在其他等待時間更長的執行緒,它才會嘗試獲取鎖。
這樣保證公平不是很好嗎?為什麼預設不保證公平呢?保證公平整體效能比較低,低的原因不是這個檢查慢,而是會讓活躍執行緒得不到鎖,進入等待狀態,引起上下文切換,降低了整體的效率,通常情況下,誰先執行關係不大,而且長時間執行,從統計角度而言,雖然不保證公平,也基本是公平的。
需要說明是,即使fair引數為true,ReentrantLock中不帶引數的tryLock方法也是不保證公平的,它不會檢查是否有其他等待時間更長的執行緒,其程式碼為:
public boolean tryLock() {
return sync.nonfairTryAcquire(1);
}
複製程式碼
ReentrantLock對比synchronized
相比synchronized,ReentrantLock可以實現與synchronized相同的語義,但還支援以非阻塞方式獲取鎖、可以響應中斷、可以限時等,更為靈活。
不過,synchronized的使用更為簡單,寫的程式碼更少,也更不容易出錯。
synchronized代表一種宣告式程式設計,程式設計師更多的是表達一種同步宣告,由Java系統負責具體實現,程式設計師不知道其實現細節,顯式鎖代表一種指令式程式設計,程式設計師實現所有細節。
宣告式程式設計的好處除了簡單,還在於效能,在較新版本的JVM上,ReentrantLock和synchronized的效能是接近的,但Java編譯器和虛擬機器可以不斷優化synchronized的實現,比如,自動分析synchronized的使用,對於沒有鎖競爭的場景,自動省略對鎖獲取/釋放的呼叫。
簡單總結,能用synchronized就用synchronized,不滿足要求,再考慮ReentrantLock。
小結
本節主要介紹了顯式鎖ReentrantLock,介紹了其用法和實現原理,在用法方面,我們重點介紹了使用tryLock避免死鎖,在原理上,ReentrantLock使用CAS、LockSupport和AQS,最後,我們比較了ReentrantLock和synchronized,建議優先使用synchronized。
下一節,我們來看顯式條件。
(與其他章節一樣,本節所有程式碼位於 github.com/swiftma/pro…)
未完待續,檢視最新文章,敬請關注微信公眾號“老馬說程式設計”(掃描下方二維碼),深入淺出,老馬和你一起探索Java程式設計及計算機技術的本質。用心原創,保留所有版權。