02-Java中的鎖詳解

XXXTaye發表於2021-11-08

I. 使用Lock介面

只要不涉及到複雜用法,一般採用的是Java的synchronized機制

不過,Lock可以提供一些synchronized不支援的機制

  • 非阻塞的獲取鎖:嘗試獲取鎖,如果能獲取馬上獲取,不能獲取馬上返回,不會阻塞
  • 中斷獲取鎖:當獲取鎖的執行緒被中斷時,丟擲異常,鎖被釋放
  • 超時獲取鎖:為嘗試獲取鎖設定超時時間

相應API:

  • void lock():普通的獲取鎖
  • void lockInterruptibly() throws InterruptedException:可中斷的獲取鎖,鎖的獲取中可以中斷執行緒
  • boolean tryLock():非阻塞獲取鎖
  • boolean tryLock(long time, TimeUnit unit):超時獲取鎖
  • void unlock():釋放鎖

一般框架:

//不要將lock寫進try塊,防止無故釋放
Lock lock = new ReentrantLock();
lock.lock();
try{
 ...;   
}finally{
    lock.unlock();
}

II. 佇列同步器AQS

AbstractQueuedSynchronizer:佇列同步器,簡稱AQS,用來構建鎖或者其他同步元件的基礎框架

使用一個int的成員變數表示同步狀態,通過內建的FIFO佇列完成資源的排隊工作

AQS實現鎖可以看作:獲取同步狀態,成功則加鎖成功;失敗則加鎖失敗

呼叫AQS內部的獲取同步狀態的API,保證是執行緒安全的

  • getState()
  • setState(int newState)
  • compareAndSetState(int expect, int update)

image-20211101174622290

1. 自己實現一個Mutex互斥鎖

首先要繼承一個Lock介面,然後自己實現裡面的方法

public class Mutex implements Lock {...}

Lock裡面的方法是沒有預設實現的,因此都需要重寫

image-20211101172630798

一般會實現一個繼承於AQS的內部類來執行獲取同步狀態的實現:加鎖相當於獲取同步狀態

public class Mutex implements Lock {
    private static class Syn extends AbstractQueuedSynchronizer{...}
}

可以看到,AQS的方法和鎖需要實現的方法是對應的

先實現對應的AQS的幾個方法

private static class Syn extends AbstractQueuedSynchronizer{
    //判斷同步器是否被執行緒佔用
    @Override
    protected boolean isHeldExclusively() {
        return getState() == 1;
    }
    //獲取鎖
    @Override
    protected boolean tryAcquire(int arg) {
        if(compareAndSetState(0,1)){
            setExclusiveOwnerThread(Thread.currentThread());    //設定佔用執行緒
            return true;
        }
        return false;
    }
    //釋放鎖
    @Override
    protected boolean tryRelease(int arg) {
        if(getState() == 0) throw new IllegalMonitorStateException();
        setExclusiveOwnerThread(null);  //清空佔用執行緒
        setState(0);
        return true;
    }
}

鎖的獲取和AQS獲取同步狀態其實是一個道理

通過代理模式可以像下面這樣實現

public class Mutex implements Lock {
    private static class Syn extends AbstractQueuedSynchronizer{...}
    Syn syn = new Syn();
    @Override
    public void lock() {
        syn.acquire(1);
    }

    @Override
    public void lockInterruptibly() throws InterruptedException {
        syn.acquireInterruptibly(1);
    }

    @Override
    public boolean tryLock() {
        return syn.tryAcquire(1);
    }

    @Override
    public boolean tryLock(long time, TimeUnit unit) throws InterruptedException {
        return syn.tryAcquireNanos(1, unit.toNanos(time));
    }

    @Override
    public void unlock() {
        syn.release(1);
    }

    @Override
    public Condition newCondition() {
        return null;
    }
}

2. AQS實現分析

鎖實現的本質:訊號量機制,互斥鎖也就是0和1兩個訊號量

AQS維護了一個FIFO的佇列,執行緒獲取同步狀態失敗則會加入這個佇列,然後阻塞,直到同步狀態釋放,佇列首節點的執行緒被喚醒

同步佇列中的節點儲存的資訊有:獲取同步狀態失敗的執行緒引用,等待狀態,前驅和後繼節點

同步器有一個頭節點和尾節點

加入新的阻塞執行緒:

構造節點,加入佇列的尾節點

使用compareAndSetTail()加到尾部,這是一個原子操作

image-20211108124223446

2.1 獨佔式的獲取和釋放

獲取同步狀態:

image-20211108125056784

acquire()方法會呼叫tryAcquire(),如果獲取失敗,則開始呼叫addWaiter()來給尾節點新增新節點,再呼叫acquireQueued()等待請求排程

addWaiter()的作用是給FIFO佇列新增尾節點,並返回這個節點的引用

因為可能會多個執行緒申請失敗,因此需要使用原子操作compareAndSetTail()

image-20211108125619724

enq()的作用是快速新增失敗後的反覆嘗試,直到新增尾節點成功

image-20211108125813282

acquireQueued()用來請求排程

image-20211108130254437

可見等待排程期間是支援中斷的

這個請求排程有兩個條件:

  1. 該節點是首節點
  2. 申請互斥訊號量成功

for迴圈的這個操作被稱為自旋

release()釋放互斥訊號量,根據上文提到的獲取訊號量,除了tryRelease(),還應該喚醒後繼節點

image-20211108130918463

2.2 共享式狀態獲取和釋放

最典型的場景就是讀寫場景:一個資源允許多個執行緒進行讀取,此時寫執行緒阻塞;而寫執行緒執行時,所有讀執行緒阻塞

共享鎖鎖也就是資源訊號量的應用,主要解決下面問題:只想要有限的執行緒執行

呼叫tryAcquireShared()來申請資源訊號量

image-20211108132000597

doAcquireShared()是申請失敗後,構造節點加入FIFO佇列然後自旋的操作

image-20211108132239857

使用releaseShared()來釋放

注意:共享式的釋放可能有多個執行緒,需要用CAS操作來實現tryReleaseShared()

image-20211108132450657

3. 自己實現一個TwinsLock共享鎖

需要自己實現的:

  • tryAcquiredShared()

  • tryReleaseShared():要保證釋放操作的原子性

State()的取值就是資源訊號量的取值

public class TwinsLock {
    private int count;
    TwinsLock(int count){
        this.count = count;
    }
    private final Sync sync = new Sync(count);
    private static final class Sync extends AbstractQueuedSynchronizer{
        Sync(int count){
            if(count < 0) throw new IllegalArgumentException();
            setState(count);    //設定資源總數
        }
        @Override
        protected int tryAcquireShared(int arg) {
            for(;;){
                int current = getState();
                int newCount = current - arg;
                if(newCount<0 || compareAndSetState(current, newCount)){
                    return newCount;
                }
            }
        }
        @Override
        protected boolean tryReleaseShared(int arg) {
            for(;;){
                int current = getState();
                int newCount = current + arg;
                if(compareAndSetState(current, newCount)){
                    return true;
                }
            }
        }
    }
    public void lock(){
        sync.acquireShared(1);
    }
    public void unlock(){
        sync.releaseShared(1);
    }
}

III. 可重入鎖

可重入鎖:支援一個執行緒對資源反覆加鎖

synchronized支援可重入

ReentrantLock是可重入鎖的一種實現,支援反覆加鎖

鎖的公平性:

  • 公平:先對鎖進行獲取的請求先被滿足
  • 不公平:先對鎖進行獲取的請求不一定先被滿足

1. 實現可重入

只需要判斷當前執行緒是否是獲取了鎖的執行緒,如果是,則同步狀態加一

每次釋放同步狀態減一,減到0的時候設定獲取鎖的執行緒為null,此時允許其他執行緒獲取

接下來來看看ReetrantLock的實現

image-20211108145243069

image-20211108145354670

2. 公平鎖與非公平鎖

繼續觀察nofairTryAcquire()方法,發現只要CAS成功,則執行緒直接獲取到鎖

image-20211108145705513

而公平鎖需要確定佇列中沒有前驅節點,即自己就是首節點

image-20211108145927580

公平鎖:確保執行緒的FIFO,先上下文切換開銷大

非公平鎖:可能造成執行緒飢餓,但執行緒切換少,吞吐量更大

IV. 讀寫鎖

讀寫鎖,是一種提供共享式和獨佔式兩種方式的鎖

  • 支援公平鎖和非公平鎖
  • 支援重進入
  • 支援鎖降級

一個資源允許多個執行緒進行讀取,此時寫執行緒阻塞;而寫執行緒執行時,所有讀執行緒阻塞

1. 讀寫鎖的實現

讀寫鎖的同步狀態是按位切割使用的

維護了一個int型的同步狀態,32位

高16為讀狀態,低16位為寫狀態

1.1 寫鎖的獲取

image-20211108152531993

w是c與0x0000FFFF做與運算後的值,w=0有兩種情況:

  1. 有讀鎖,低16位全0
  2. 無讀鎖也無寫鎖,需要後面的條件判斷是否為當前執行緒

1.2 讀鎖的獲取

和寫鎖的獲取類似,需要判斷先有沒有寫鎖

不過讀鎖是共享式的,可以允許多個執行緒獲取讀鎖

不過讀鎖也支援重進入,因此不光要維護獲取讀鎖的總狀態,還要維護每個執行緒獲取讀鎖的狀態

2. 鎖降級

鎖降級指:執行緒先獲取寫鎖,然後再獲取讀鎖,最後釋放寫鎖,實現從寫鎖降到讀鎖

目的:保證讀寫操作的連貫性

使用場景:寫操作執行完馬上需要讀一次,不加讀鎖的話可能會被其他寫執行緒修改,再讀資料可能就變了

V. LockSupport工具

用於阻塞和喚醒執行緒

image-20211108154154801

VI. Condition介面

Condition介面依賴於Lock物件,用於實現等待-通知模式

核心API就是兩個,這兩個API的擴充套件可以增加超時時間,設定中斷不敏感等等:

  • await()
  • signal()

1. 使用Condition實現一個阻塞佇列

佇列滿的時候,填充操作阻塞;佇列空的時候,取出操作阻塞

public class BoundedQueue <T>{
    private Object[] items;
    private int addIndex, revIndex, count;
    private ReentrantLock lock = new ReentrantLock();
    private Condition empty = lock.newCondition();
    private Condition full = lock.newCondition();

    public BoundedQueue(int size){
        items = new Object[size];
    }

    /**
     * 新增元素
     * @param t
     */
    public void add(T t) throws InterruptedException {
        lock.lock();
        try{
            while(count == items.length){
                System.out.println("已滿,請等待消耗");
                empty.await();
            }
            items[addIndex] = t;
            if(++addIndex == items.length) addIndex = 0;
            count++;
            full.signal();
        }finally {
            lock.unlock();
        }
    }

    /**
     * 取出元素
     * @return
     */
    public T remove() throws InterruptedException {
        lock.lock();
        try{
            while(count == 0){
                System.out.println("已空,請等待生產");
                full.await();
            }
            Object temp = items[revIndex];
            if(++revIndex == items.length) revIndex = 0;
            count--;
            empty.signal();
            return (T) temp;
        }finally {
            lock.unlock();
        }
    }
}

2. Condition的實現分析

每個Condition會維護一個等待佇列,一個鎖支援支援多個等待佇列

image-20211108162202120

獲取到鎖的執行緒也就是同步佇列的首節點

此時再呼叫await,則首節點進入等待佇列,直到其他執行緒喚醒

image-20211108165508671

相應的,呼叫signal則是將等待佇列的首節點拆下來放到同步佇列,喚醒執行緒開始自旋

當節點回到同步佇列,之前呼叫的await()中的isOnsyncQueue()會返回true,結束等待,在呼叫acquireQueued()加入競爭

image-20211108165935327

通過isHeldExclusively判斷有沒有拿到鎖

相關文章