計算機程式的思維邏輯 (71) – 顯式鎖

swiftma發表於2019-02-25

本系列文章經補充和完善,已修訂整理成書《Java程式設計的邏輯》(馬俊昌著),由機械工業出版社華章分社出版,於2018年1月上市熱銷,讀者好評如潮!各大網店和書店有售,歡迎購買:京東自營連結

計算機程式的思維邏輯 (71) – 顯式鎖

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程式設計及計算機技術的本質。用心原創,保留所有版權。

計算機程式的思維邏輯 (71) – 顯式鎖

相關文章