Java編程拾遺『顯式鎖』

在之前講線程同步時,介紹了synchronized的鎖的使用及底層原理,也介紹了synchronized鎖的一些使用侷限,本篇文章來介紹一下Java中提供的另一種線程同步機制——顯式鎖。Java併發包中的顯式鎖接口和類位於包java.util.concurrent.locks下,主要接口和類有:

  • 鎖接口Lock,主要實現類是ReentrantLock
  • 讀寫鎖接口ReadWriteLock,主要實現類是ReentrantReadWriteLock

1. Lock接口

S.N. 方法 說明
1 void lock() 獲取鎖,獲取鎖不成功會阻塞當前線程
2 void unlock() 釋放鎖
3 void lockInterruptibly() throws InterruptedException 獲取鎖,獲取鎖不成功會阻塞等待,如果等待期間被其他線程中斷了,拋出InterruptedException
4 boolean tryLock() 嘗試獲取鎖,立即返回,不阻塞,如果獲取成功,返回true,否則返回false
5 boolean tryLock(long time, TimeUnit unit) throws InterruptedException 先嚐試獲取鎖,如果能成功則立即返回true,否則阻塞等待,但等待的最長時間爲指定的參數,在等待的同時響應中斷,如果發生了中斷,拋出InterruptedException,如果在等待的時間內獲得了鎖,返回true,否則返回false
6 Condition newCondition() 新建一個條件,用於顯式鎖的協作,使用Condition條件,可以起到和synchronized鎖使用wait/signal同樣的效果

從上述方法可以看出,相比synchronized,顯式鎖支持以非阻塞方式獲取鎖、可以響應中斷、可以限時,比synchronized靈活的多。

2. ReentrantLock

Lock接口的主要實現類是ReentrantLock,它的基本用法lock/unlock實現了與synchronized一樣的語義,包括:

  • 可重入,一個線程在持有一個鎖的前提下,可以繼續獲得該鎖
  • 可以解決競態條件問題
  • 可以保證內存可見性

2.1 方法說明

S.N. 方法 說明
1 public ReentrantLock() 構造函數,獲取非公平所對象
2 public ReentrantLock(boolean fair) 構造函數,獲取鎖對象,fair參數用於控制是否爲公平鎖
3 public boolean isLocked() 判斷鎖是否被持有,只要有線程持有就返回true(不一定是當前線程持有)
4 public boolean isHeldByCurrentThread() 判斷鎖是否被當前線程持有
5 public int getHoldCount() 鎖被當前線程持有的數量,如果鎖不被當前線程持有返回0
6 public final boolean isFair() 判斷鎖是否公平
7 public final boolean hasQueuedThreads() 判斷是否有線程在等待該鎖
8 public final boolean hasQueuedThread(Thread thread) 判斷指定的線程thread是否在等待該鎖
9 public final int getQueueLength() 獲取在等待該鎖的線程個數

ReentrantLock實現了Lock接口,所以除了上述方法,還實現了上述Lock的所有方法。

2.2 使用示例

還用之前的計數器做示例,之前爲了安全地改變計數,使用了synchronized同步,這裏展示一下如何使用顯式鎖同步,如下:

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() {
        lock.lock();
        try {
            return count;
        } finally {
            lock.unlock();
        }
    }
}

需要注意的是,使用顯式鎖,一定要記得調用unlock,一般而言,應該將lock之後的代碼包裝到try語句內,在finally語句內釋放鎖。上述示例代碼是顯式鎖最基本的用法,使用起來跟synchronized非常類似,下面分別介紹一下Lock接口提供的lockInterruptibly和tryLock。

2.2.1 lockInterruptibly響應中斷

之前在Java編程拾遺『線程中斷』一文中講到, 
使用synchronized關鍵字獲取鎖的過程中不響應中斷請求,這是synchronized的侷限性。也就是講使用synchronized鎖,處於BLOCKED狀態的線程對象調用interrupt()方法,線程是不會響應的,所以線程自然也不會終止。但是顯式鎖中提供了lockInterruptibly()方法,可以在等待鎖的過程中響應interrupt()方法。先來看一下之前使用synchronized鎖的情況:

public class InterruptSynchronizedDemo {
    private static Object lock = new Object();

    private static class A extends Thread {
        @Override
        public void run() {
            synchronized (lock) {
                while (!Thread.currentThread().isInterrupted()) {
                }
            }
            System.out.println("exit");
        }
    }

    public static void test() throws InterruptedException {
        synchronized (lock) {
            A a = new A();
            a.start();
            Thread.sleep(1000);

            a.interrupt();
            a.join();
        }
    }

    public static void main(String[] args) throws InterruptedException {
        test();
    }
}

test方法在持有鎖lock的情況下啓動線程a,而線程a也去嘗試獲得鎖lock,所以會進入鎖等待隊列,隨後test調用線程a的interrupt方法並等待線程線程a結束。但事實上,test方法會一直運行下去,無法終止。說明synchronized鎖是無法響應中斷的。下面我們換成顯式鎖:

public class InterruptSynchronizedDemo {
    private static Lock lock = new ReentrantLock();

    private static class A extends Thread {
        @Override
        public void run() {
            try {
                lock.lockInterruptibly();
            } catch (InterruptedException e) {
                System.out.println("獲取鎖期間線程被中斷");
                return;
            }

            try {
                while (!Thread.currentThread().isInterrupted()) {
                }
            } finally {
                lock.unlock();
            }

            System.out.println("exit");
        }
    }

    public static void test() throws InterruptedException {
        lock.lockInterruptibly();

        try {
            A a = new A();
            a.start();
            Thread.sleep(1000);

            a.interrupt();
            a.join();

        } finally {
            lock.unlock();
        }

    }

    public static void main(String[] args) throws InterruptedException {
        test();
    }
}

運行結果:

獲取鎖期間線程被中斷

說明顯式鎖的lockInterruptibly方法獲取鎖,實可以響應中斷的。使用lockInterruptibly獲取鎖等待期間,如果線程被終止,線程將拋出InterruptedException。

2.2.2 tryLock避免死鎖

使用lock方法獲取鎖時,獲取不到鎖就會阻塞當前線程,之後重新獲取鎖。所以如果出現相互等待鎖的情況,lock獲取鎖的方式就會發生死鎖,如下例子,銀行賬戶之間轉賬,用類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);
}

上述代碼,使用tryLock(),可以避免死鎖。在持有一個鎖,獲取另一個鎖,獲取不到的時候,可以釋放已持有的鎖,給其他線程機會獲取鎖,然後再重試獲取所有鎖

2.2.3 顯式條件

鎖用於解決競態條件問題,條件是線程間的協作機制。顯式鎖與synchronzied相對應,而顯式條件與wait/notify相對應。wait/notify與synchronized配合使用,顯式條件與顯式鎖配合使用。

條件與鎖相關聯,創建條件變量需要通過顯式鎖,Lock接口定義了創建方法:

Condition newCondition()

Condition表示條件變量,是一個接口,定義了線程之間協作的各種方法,如下:

S.N. 方法 說明
1 void await() throws InterruptedException 對應Object的wait(),使當前線程等待,又RUNNING進入WAITTING狀態
  boolean await(long time, TimeUnit unit) throws InterruptedException 等待時間是相對時間,如果由於等待超時返回,返回值爲false,否則爲true,等待期間線程被中斷會拋異常
  long awaitNanos(long nanosTimeout) throws InterruptedException 等待時間是相對時間,參數單位是納秒,返回值是nanosTimeout減去實際等待的時間
  boolean awaitUntil(Date deadline) throws InterruptedException 等待時間是絕對時間,如果由於等待超時返回,返回值爲false,否則爲true
  void awaitUninterruptibly() 該方法不會由於中斷結束,但當它返回時,如果等待過程中發生了中斷,中斷標誌位會被設置
  void signal() 喚醒一個等待的線程
  void signalAll() 喚醒所有等待的線程

一般而言,與Object的wait方法一樣,調用await方法前需要先獲取鎖,如果沒有鎖,會拋出異常IllegalMonitorStateException。await在進入等待隊列後,會釋放鎖,釋放CPU,當其他線程將它喚醒後,或等待超時後,或發生中斷異常後,它都需要重新獲取鎖,獲取鎖後,纔會從await方法中退出

另外,與Object的wait方法一樣,await返回後,不代表其等待的條件就一定滿足了,通常要將await的調用放到一個循環內,只有條件滿足後才退出

一般而言,signal/signalAll與notify/notifyAll一樣,調用它們需要先獲取鎖,如果沒有鎖,會拋出異常IllegalMonitorStateException。signal與notify一樣,挑選一個線程進行喚醒,signalAll與notifyAll一樣,喚醒所有等待的線程,但這些線程被喚醒後都需要重新競爭鎖,獲取鎖後纔會從await調用中返回。

2.2.3.1 使用示例

之前講線程協作的文章Java編程拾遺『線程協作』中講到同時開始(發令槍)的協作場景,使用Object的wait()合notifyAll()方法實現。這裏來看一下如何使用顯示條件來實現:

public class WaitThread extends Thread {
    private volatile boolean fire = false;
    private Lock lock = new ReentrantLock();
    private Condition condition = lock.newCondition();

    @Override
    public void run() {
        try {
            lock.lock();
            try {
                while (!fire) {
                    condition.await();
                }
            } finally {
                lock.unlock();
            }
            System.out.println("fired");
        } catch (InterruptedException e) {
            Thread.interrupted();
        }
    }

    public void fire() {
        lock.lock();
        try {
            this.fire = true;
            condition.signalAll();
        } finally {
            lock.unlock();
        }
    }

    public static void main(String[] args) throws InterruptedException {
        WaitThread waitThread = new WaitThread();
        waitThread.start();
        Thread.sleep(1000);
        System.out.println("fire");
        waitThread.fire();
    }
}

需要特別注意的是,不要將signal/signalAll與notify/notifyAll混淆,notify/notifyAll是Object中定義的方法,Condition對象也有,稍不注意就會誤用,比如,對上面例子中的fire方法,可能會寫爲:

public void fire() {
    lock.lock();
    try {
        this.fire = true;
        condition.notify();
    } finally {
        lock.unlock();
    }
}

寫成這樣,編譯器不會報錯,但運行時會拋出IllegalMonitorStateException,因爲notify的調用不在synchronized語句內。

同樣,避免將鎖與synchronzied混用,那樣非常令人混淆,比如:

public void fire() {
    synchronized(lock){
        this.fire = true;
        condition.signal();
    }
}

總之,要記住一個規則:顯式條件與顯式鎖配合使用,wait/notify與synchronized配合使用

2.2.3.1 使用顯示條件實現消費者生產者模式

public class MyBlockingQueue<E> {
    private Queue<E> queue = null;
    private int limit;
    private Lock lock = new ReentrantLock();
    private Condition notFull  = lock.newCondition();
    private Condition notEmpty = lock.newCondition();


    public MyBlockingQueue(int limit) {
        this.limit = limit;
        queue = new ArrayDeque<>(limit);
    }

    public void put(E e) throws InterruptedException {
        lock.lockInterruptibly();
        try{
            while (queue.size() == limit) {
                notFull.await();
            }
            queue.add(e);
            notEmpty.signal();    
        }finally{
            lock.unlock();
        }
    }

    public E take() throws InterruptedException {
        lock.lockInterruptibly();
        try{
            while (queue.isEmpty()) {
                notEmpty.await();
            }
            E e = queue.poll();
            notFull.signal();
            return e;    
        }finally{
            lock.unlock();
        }
    }
}

定義了兩個等待條件:不滿(notFull)、不空(notEmpty),在put方法中,如果隊列滿,則在noFull上等待,在take方法中,如果隊列空,則在notEmpty上等待,put操作後通知notEmpty,take操作後通知notFull。而在之前Java編程拾遺『線程協作』那篇文章介紹的生產者消費者實現,使通過notifyAll通知所有等待線程實現的,喚醒了本不必要喚醒的線程。而使用顯示條件避免了不必要的喚醒和檢查。

3. ReadWriteLock

上面介紹了java.util.concurrent.locks包下的的顯式鎖Lock,接下來介紹該包下另一個鎖——讀寫鎖接口ReadWriteLock,及其主要實現類ReentrantReadWriteLock。

讀寫鎖模式將讀取與寫入分開處理,在讀取數據之前必須獲取用來讀取的鎖定,而寫入的時候必須獲取用來寫入的鎖定。因爲讀取時實例的狀態不會改變,所以多個線程可以同時讀取;但是,寫入會改變實例的狀態,所以當有一個線程寫入的時候,其它線程既不能讀取與不能寫入。也就是說,讀寫鎖要遵守以下三個原則:

  • 允許多個線程同時讀共享變量
  • 只允許一個線程寫共享變量
  • 如果一個寫線程正常執行寫操作,此時禁止讀線程讀取共享變量

ReentrantReadWriteLock提供一把讀鎖、一把寫鎖實現了讀寫鎖模式,本篇文章僅簡單介紹以下ReentrantReadWriteLock讀寫鎖的使用,關於實現原理會在下篇文章中介紹。

  • 線程進入讀鎖的條件(滿足其中一個即可):
    • 沒有任何線程持有寫鎖
    • 有線程持有寫鎖,但是持有寫鎖的線程是當前線程
  • 線程進入寫鎖的條件(滿足其中一個即可)
    • 沒有任何線程持有讀鎖或寫鎖
    • 有線程持有寫鎖,但是持有寫鎖的線程是當前線程

從上述條件我們可以看出,如果一個線程在持有讀鎖的前提下,是不能獲取寫鎖的(讀寫鎖不支持升級)。但是如果一個線程在持有寫鎖的前提下,實可以獲取讀鎖的(讀寫鎖支持降級)。並且單獨看讀鎖合寫鎖,都是可重入的

如下代碼會產生死鎖,因爲同一個線程中,在沒有釋放讀鎖的情況下,就去申請寫鎖,這屬於鎖升級,ReentrantReadWriteLock是不支持的:

 ReadWriteLock rtLock = new ReentrantReadWriteLock();
 rtLock.readLock().lock();
 System.out.println("get readLock.");
 rtLock.writeLock().lock();
 System.out.println("blocking");

ReentrantReadWriteLock支持鎖降級,如下代碼不會產生死鎖:

ReadWriteLock rtLock = new ReentrantReadWriteLock();
rtLock.writeLock().lock();
System.out.println("writeLock");

rtLock.readLock().lock();
System.out.println("get read lock");

但是這段鎖降級的代碼雖然不會導致死鎖,但沒有正確的釋放鎖。從寫鎖降級成讀鎖,並不會自動釋放當前線程獲取的寫鎖,仍然需要顯示的釋放,否則別的線程永遠也獲取不到寫鎖

下面通過緩存的示例,來看一下讀寫鎖的使用:

public class Cache {
    private Map<String, Object> map = new HashMap<>(128);
    private ReadWriteLock rwl = new ReentrantReadWriteLock();

    public Object get(String id) {
        Object value = null;
        rwl.readLock().lock();//首先開啓讀鎖,從緩存中去取
        try {
            if (map.get(id) == null) {  //如果緩存中沒有請求的數據,釋放讀鎖,上寫鎖
                rwl.readLock().unlock();
                rwl.writeLock().lock();
                try {
                    if (map.get(id) == null) { //防止多寫線程重複查詢賦值
                        value = getFromDB();  //此時可以去數據庫中查找,這裏簡單的模擬一下
                        map.put(id, value); //持有寫鎖,寫緩存
                    }
                    rwl.readLock().lock(); //加讀鎖降級寫鎖
                } finally {
                    rwl.writeLock().unlock(); //釋放寫鎖
                }
            } else {
                value = map.get(id);
                System.out.println("命中緩存");
            }
        } finally {
            rwl.readLock().unlock(); //最後釋放讀鎖
        }
        return value;
    }

    private String getFromDB() {
        String value = String.valueOf(new Random().nextInt(100));
        System.out.println("數據庫查詢");
        return value;
    }
}
public class CacheTest {
    private static Cache cache = new Cache();

    public static void main(String[] args) {
        Random random = new Random();

        Thread[] threads = new Thread[10];

        /**
         * 這裏啓動10個線程,每個線程進行50次查詢,所有線程每次查詢的key分佈比較集中(1 ~ 10)
         * 緩存命中的概率會很高
         */
        for (int i = 0; i < threads.length; i++) {

            Runnable runnable = () -> {
                for (int j = 0; j < 50; j ++) {
                    String key = String.valueOf(random.nextInt(10));
                    Object value = cache.get(key);
                }
            };

            threads[i] = new Thread(runnable);
            threads[i].start();
        }
    }
}

從運行結果可以看出,最開始會進行幾次數據庫查詢操作,之後都是從緩存中獲取的。

在多線程的環境下,對同一份數據進行讀寫,會涉及到線程安全的問題。比如在一個線程讀取數據的時候,另外一個線程在寫數據,而導致前後數據的不一致性;一個線程在寫數據的時候,另一個線程也在寫,同樣也會導致線程前後看到的數據的不一致性。這時候可以在讀寫方法中加入互斥鎖(synchronized、顯式鎖),任何時候只能允許一個線程的一個讀或寫操作,而不允許其他線程的讀或寫操作,這樣是可以解決這樣以上的問題,但是效率卻大打折扣了。因爲在真實的業務場景中,一份數據,讀取數據的操作次數通常高於寫入數據的操作,而線程與線程間的讀讀操作是不涉及到線程安全的問題,沒有必要加入互斥鎖,只要在讀-寫,寫-寫場景下互斥就行了,對於讀讀操作場景,多個線程可以共享讀鎖。總的來說,ReentrantReadWriteLock相比於ReentrantLock,是一種更細粒度的鎖,對於讀多寫少的場景,可以獲得更高的效率

最後總結一下顯式鎖和synchronized的區別:

  • synchronized在發生異常時,會自動釋放線程佔有的鎖,因此不會導致死鎖現象發生;而顯式鎖在發生異常時,如果沒有主動通過unLock()去釋放鎖,則很可能造成死鎖現象,因此使用顯式鎖時需要在finally塊中釋放鎖
  • 顯式鎖可以讓等待鎖的線程響應中斷,而synchronized不行,使用synchronized時,等待的線程會一直等待下去,不能夠響應中斷
  • 顯式鎖語義更豐富,可以提供公平鎖/非公平鎖,但是synchronized只能提供非公平鎖
  • 通過顯式鎖可以獲取鎖的狀態(鎖是否被持有、是否被當前線程持有等),而synchronized卻無法辦到
  • 顯式鎖可以提供更細粒度的鎖——讀寫鎖,可以提高多個線程進行讀操作的效率,synchronized只能做到所有操作都阻塞
  • 性能上來說,在資源競爭不激烈的情形下,Lock性能稍微比synchronized差點(JDK對synchronized進行了一些列優化)。但是當同步非常激烈的時候,ReentrantLock性能要高於synchronized的性能

參考鏈接:

1. 《Java編程的邏輯》

2. Java API

3. 【死磕Java併發】—–J.U.C之AQS(一篇就夠了)

4. Java鎖之ReentrantReadWriteLock

5. 併發庫應用之五 & ReadWriteLock場景應用

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章