Java多線程系列——深入重入鎖ReentrantLock


簡述

ReentrantLock 是一個可重入的互斥(/獨占)鎖,又稱為“獨占鎖”。

ReentrantLock通過自定義隊列同步器(AQS-AbstractQueuedSychronized,是實現鎖的關鍵)來實現鎖的獲取與釋放。

其可以完全替代 synchronized 關鍵字。JDK 5.0 早期版本,其性能遠好於 synchronized,但 JDK 6.0 開始,JDK 對 synchronized 做了大量的優化,使得兩者差距並不大。

“獨占”,就是在同一時刻只能有一個線程獲取到鎖,而其它獲取鎖的線程只能處於同步隊列中等待,只有獲取鎖的線程釋放了鎖,后繼的線程才能夠獲取鎖。

“可重入”,就是支持重進入的鎖,它表示該鎖能夠支持一個線程對資源的重復加鎖。

該鎖還支持獲取鎖時的公平和非公平性選擇。“公平”是指“不同的線程獲取鎖的機制是公平的”,而“不公平”是指“不同的線程獲取鎖的機制是非公平的”。

簡單實例

import java.util.concurrent.locks.ReentrantLock;
/**
 * Created by zhengbinMac on 2017/3/2.
 */
public class ReenterLock implements Runnable{
    public static ReentrantLock lock = new ReentrantLock();
    public static int i = 0;
    public void run() {
        for (int j = 0;j<100000;j++) {
            lock.lock();
//            lock.lock();
            try {
                i++;
            }finally {
                lock.unlock();
//                lock.unlock();
            }
        }
    }
    public static void main(String[] args) throws InterruptedException {
        ReenterLock reenterLock = new ReenterLock();
        Thread t1 = new Thread(reenterLock);
        Thread t2 = new Thread(reenterLock);
        t1.start();t2.start();
        t1.join();t2.join();
        System.out.println(i);
    }
}

與 synchronized 相比,重入鎖有着顯示的操作過程,何時加鎖,何時釋放,都在程序員的控制中。

為什么稱作是“重入”?這是因為這種鎖是可以反復進入的。將上面代碼中注釋部分去除注釋,也就是連續兩次獲得同一把鎖,兩次釋放同一把鎖,這是允許的。

注意,獲得鎖次數與釋放鎖次數要相同,如果釋放鎖次數多了,會拋出 java.lang.IllegalMonitorStateException 異常;如果釋放次數少了,相當於線程還持有這個鎖,其他線程就無法進入臨界區。

引出第一個問題:為什么 ReentrantLock 鎖能夠支持一個線程對資源的重復加鎖?

除了簡單的加鎖、解鎖操作,重入鎖還提供了一些更高級的功能,下面結合實例進行簡單介紹:

中斷響應(lockInterruptibly)

對於 synchronized 來說,如果一個線程在等待鎖,那么結果只有兩種情況,獲得這把鎖繼續執行,或者線程就保持等待。

而使用重入鎖,提供了另一種可能,這就是線程可以被中斷。也就是在等待鎖的過程中,程序可以根據需要取消對鎖的需求。

下面的例子中,產生了死鎖,但得益於鎖中斷,最終解決了這個死鎖:

 1 import java.util.concurrent.locks.ReentrantLock;
 2 /**
 3  * Created by zhengbinMac on 2017/3/2.
 4  */
 5 public class IntLock implements Runnable{
 6     public static ReentrantLock lock1 = new ReentrantLock();
 7     public static ReentrantLock lock2 = new ReentrantLock();
 8     int lock;
 9     /**
10      * 控制加鎖順序,產生死鎖
11      */
12     public IntLock(int lock) {
13         this.lock = lock;
14     }
15     public void run() {
16         try {
17             if (lock == 1) {
18                 lock1.lockInterruptibly(); // 如果當前線程未被 中斷,則獲取鎖。
19                 try {
20                     Thread.sleep(500);
21                 } catch (InterruptedException e) {
22                     e.printStackTrace();
23                 }
24                 lock2.lockInterruptibly();
25                 System.out.println(Thread.currentThread().getName()+",執行完畢!");
26             } else {
27                 lock2.lockInterruptibly();
28                 try {
29                     Thread.sleep(500);
30                 } catch (InterruptedException e) {
31                     e.printStackTrace();
32                 }
33                 lock1.lockInterruptibly();
34                 System.out.println(Thread.currentThread().getName()+",執行完畢!");
35             }
36         } catch (InterruptedException e) {
37             e.printStackTrace();
38         } finally {
39             // 查詢當前線程是否保持此鎖。
40             if (lock1.isHeldByCurrentThread()) {
41                 lock1.unlock();
42             }
43             if (lock2.isHeldByCurrentThread()) {
44                 lock2.unlock();
45             }
46             System.out.println(Thread.currentThread().getName() + ",退出。");
47         }
48     }
49     public static void main(String[] args) throws InterruptedException {
50         IntLock intLock1 = new IntLock(1);
51         IntLock intLock2 = new IntLock(2);
52         Thread thread1 = new Thread(intLock1, "線程1");
53         Thread thread2 = new Thread(intLock2, "線程2");
54         thread1.start();
55         thread2.start();
56         Thread.sleep(1000);
57         thread2.interrupt(); // 中斷線程2
58     }
59 }
View Code

上述例子中,線程 thread1 和 thread2 啟動后,thread1 先占用 lock1,再占用 lock2;thread2 反之,先占 lock2,后占 lock1。這便形成 thread1 和 thread2 之間的相互等待。

代碼 56 行,main 線程處於休眠(sleep)狀態,兩線程此時處於死鎖的狀態,代碼 57 行 thread2 被中斷(interrupt),故 thread2 會放棄對 lock1 的申請,同時釋放已獲得的 lock2。這個操作導致 thread1 順利獲得 lock2,從而繼續執行下去。

執行代碼,輸出如下:

鎖申請等待限時(tryLock)

除了等待外部通知(中斷操作 interrupt )之外,限時等待也可以做到避免死鎖。

通常,無法判斷為什么一個線程遲遲拿不到鎖。也許是因為產生了死鎖,也許是產生了飢餓。但如果給定一個等待時間,讓線程自動放棄,那么對系統來說是有意義的。可以使用 tryLock() 方法進行一次限時的等待。

 1 import java.util.concurrent.TimeUnit;
 2 import java.util.concurrent.locks.ReentrantLock;
 3 /**
 4  * Created by zhengbinMac on 2017/3/2.
 5  */
 6 public class TimeLock implements Runnable{
 7     public static ReentrantLock lock = new ReentrantLock();
 8     public void run() {
 9         try {
10             if (lock.tryLock(5, TimeUnit.SECONDS)) {
11                 Thread.sleep(6 * 1000);
12             }else {
13                 System.out.println(Thread.currentThread().getName()+" get Lock Failed");
14             }
15         } catch (InterruptedException e) {
16             e.printStackTrace();
17         }finally {
18             // 查詢當前線程是否保持此鎖。
19             if (lock.isHeldByCurrentThread()) {
20                 System.out.println(Thread.currentThread().getName()+" release lock");
21                 lock.unlock();
22             }
23         }
24     }
25     /**
26      * 在本例中,由於占用鎖的線程會持有鎖長達6秒,故另一個線程無法再5秒的等待時間內獲得鎖,因此請求鎖會失敗。
27      */
28     public static void main(String[] args) {
29         TimeLock timeLock = new TimeLock();
30         Thread t1 = new Thread(timeLock, "線程1");
31         Thread t2 = new Thread(timeLock, "線程2");
32         t1.start();
33         t2.start();
34     }
35 }
View Code

上述例子中,由於占用鎖的線程會持有鎖長達 6 秒,故另一個線程無法在 5 秒的等待時間內獲得鎖,因此,請求鎖失敗。

ReentrantLock.tryLock()方法也可以不帶參數直接運行。這種情況下,當前線程會嘗試獲得鎖,如果鎖並未被其他線程占用,則申請鎖成功,立即返回 true。否則,申請失敗,立即返回 false,當前線程不會進行等待。這種模式不會引起線程等待,因此也不會產生死鎖。

公平鎖

默認情況下,鎖的申請都是非公平的。也就是說,如果線程 1 與線程 2,都申請獲得鎖 A,那么誰獲得鎖不是一定的,是由系統在等待隊列中隨機挑選的。這就好比,買票的人不排隊,售票姐姐只能隨機挑一個人賣給他,這顯然是不公平的。而公平鎖,它會按照時間的先后順序,保證先到先得。公平鎖的特點是:不會產生飢餓現象。

重入鎖允許對其公平性進行設置。構造函數如下:

public ReentrantLock(boolean fair)

下面舉例來說明,公平鎖與非公平鎖的不同:

 1 import java.util.concurrent.locks.ReentrantLock;
 2 /**
 3  * Created by zhengbinMac on 2017/3/2.
 4  */
 5 public class FairLock implements Runnable{
 6     public static ReentrantLock fairLock = new ReentrantLock(true);
 7 
 8     public void run() {
 9         while (true) {
10             try {
11                 fairLock.lock();
12                 System.out.println(Thread.currentThread().getName()+",獲得鎖!");
13             }finally {
14                 fairLock.unlock();
15             }
16         }
17     }
18     public static void main(String[] args) {
19         FairLock fairLock = new FairLock();
20         Thread t1 = new Thread(fairLock, "線程1");
21         Thread t2 = new Thread(fairLock, "線程2");
22         t1.start();t2.start();
23     }
24 }
View Code

修改重入鎖是否公平,觀察輸出結果,如果公平,輸出結果始終為兩個線程交替的獲得鎖,如果是非公平,輸出結果為一個線程占用鎖很長時間,然后才會釋放鎖,另個線程才能執行。

引出第二個問題:為什么公平鎖例子中出現,公平鎖線程是不斷切換的,而非公平鎖出現同一線程連續獲取鎖的情況?

結合源碼再看“重入”

何為重進入(重入)?

  重進入是指任意線程在獲取到鎖之后能夠再次獲取該鎖而不會被鎖阻塞,該特性的實現需要解決以下兩個問題:

  • 線程再次獲取鎖:鎖需要去識別獲取鎖的線程是否為當前占據鎖的線程,如果是,則再次成功獲取。
  • 鎖的最終釋放。線程重復 n 次獲取了鎖,隨后在第 n 次釋放該鎖后,其它線程能夠獲取到該鎖。鎖的最終釋放要求鎖對於獲取進行計數自增,計數表示當前鎖被重復獲取的次數,而鎖被釋放時,計數自減,當計數等於 0 時表示鎖已經成功釋放。

以非公平鎖源碼分析:

獲取:

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;
}

acquireQueued 方法增加了再次獲取同步狀態的處理邏輯:通過判斷當前線程是否為獲取鎖的線程,來決定獲取操作是否成功,如果獲取鎖的線程再次請求,則將同步狀態值進行增加並返回 true,表示獲取同步狀態成功。
成功獲取鎖的線程再次獲取鎖,只是增加了同步狀態值,也就是要求 ReentrantLock 在釋放同步狀態時減少同步狀態值,釋放鎖源碼如下:

public void unlock() {
    sync.release(1);
}
public final boolean release(int arg) {
    if (tryRelease(arg)) {
        Node h = head;
        if (h != null && h.waitStatus != 0)
            unparkSuccessor(h);
        return true;
    }
    return false;
}
protected final boolean tryRelease(int releases) {
    int c = getState() - releases;
    if (Thread.currentThread() != getExclusiveOwnerThread())
        throw new IllegalMonitorStateException();
    boolean free = false;
    if (c == 0) {
        free = true;
        setExclusiveOwnerThread(null);
    }
    setState(c);
    return free;
}

如果鎖被獲取 n 次,那么前 (n-1) 次 tryRelease(int releases) 方法必須返回 false,只有同步狀態完全釋放了,才能返回 true。該方法將同步狀態是否為 0 作為最終釋放的條件,當同步狀態為 0 時,將占有線程設置為 null,並返回 true,表示釋放成功。

通過對獲取與釋放的分析,就可以解釋,以上兩個例子中出現的兩個問題:為什么 ReentrantLock 鎖能夠支持一個線程對資源的重復加鎖?為什么公平鎖例子中出現,公平鎖線程是不斷切換的,而非公平鎖出現同一線程連續獲取鎖的情況?

  • 為什么支持重復加鎖?因為源碼中用變量 c 來保存當前鎖被獲取了多少次,故在釋放時,對 c 變量進行減操作,只有 c 變量為 0 時,才算鎖的最終釋放。所以可以 lock 多次,同時 unlock 也必須與 lock 同樣的次數。
  • 為什么非公平鎖出現同一線程連續獲取鎖的情況?tryAcquire 方法中增加了再次獲取同步狀態的處理邏輯。

小結

對上面ReentrantLock的幾個重要方法整理如下:

  • lock():獲得鎖,如果鎖被占用,進入等待。
  • lockInterruptibly():獲得鎖,但優先響應中斷。
  • tryLock():嘗試獲得鎖,如果成功,立即放回 true,反之失敗返回 false。該方法不會進行等待,立即返回。
  • tryLock(long time, TimeUnit unit):在給定的時間內嘗試獲得鎖。
  • unLock():釋放鎖。

對於其實現原理,下篇博文將詳細分析,其主要包含三個要素:

  • 原子狀態:原子狀態有 CAS(compareAndSetState) 操作來存儲當前鎖的狀態,判斷鎖是否有其他線程持有。
  • 等待隊列:所有沒有請求到鎖的線程,會進入等待隊列進行等待。待有線程釋放鎖后,系統才能夠從等待隊列中喚醒一個線程,繼續工作。詳見:隊列同步器——AQS(待更新)
  • 阻塞原語 park() 和 unpark(),用來掛起和恢復線程。沒有得到鎖的線程將會被掛起。關於阻塞原語,詳見:線程阻塞工具類——LockSupport(待更新)。

參考資料

[1] Java並發編程的藝術, 5.3 - 重入鎖

[2] 實戰Java高並發程序設計, 3.1.1 - synchronized的功能擴展:重入鎖


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM