Java併發之ReentrantLock原始碼解析(三)

北洛發表於2021-06-30

ReentrantLock和BlockingQueue

首先,看到這個標題,不要懷疑自己進錯文章,也不要懷疑筆者寫錯,哈哈。本章筆者會從BlockingQueue(阻塞佇列)的角度,看看juc包下的阻塞佇列是如何使用ReentrantLock。這個章節筆者會介紹部分阻塞佇列的原始碼,但不會著墨過多,我們的重點依舊在ReentrantLock上。

BlockingQueue(阻塞佇列)是juc包下提供一種資料結構,相比普通的佇列,阻塞佇列可以讓我們在不需要關心執行緒安全的情況下往佇列中存取資料。此外,阻塞佇列還支援我們從一個佇列中獲取元素時,如果佇列為空則陷入阻塞,直到佇列有元素入隊為止;也支援我們向佇列中儲存元素時,如果佇列已滿則陷入阻塞,直到佇列有多餘的位置可以容納待入隊的元素。

不論是入隊、出隊亦或是檢查佇列元素,阻塞佇列都提供了多種方法,這些方法都可以實現入隊、出隊和檢查,但每個方法都有自己的特性和適用場景:

  • 入隊:
    • add(E e):嘗試將指定元素e插入到佇列,如果沒有超過佇列的容量限制則返回true表示成功,否則丟擲IllegalStateException異常表示佇列沒有多餘的空間容納元素。
    • offer(E e):嘗試將指定元素e插入到佇列,如果沒有超過佇列的容量限制則返回true表示成功,如果佇列沒有多餘的空間容納元素則返回false。
    • put(E e):嘗試將指定元素e插入到佇列,如果沒有超過佇列的容量則立即返回,否則陷入阻塞直到佇列有多餘的空間容納元素。
    • offer(E e, long timeout, TimeUnit unit):嘗試將指定元素e插入到佇列,如果沒有超過佇列的容量則立即返回;如果超時前佇列有多餘的空間可容納元素則返回true,否則返回false。
  • 出隊:
    • take():返回並移除隊頭元素,如果佇列為空則陷入阻塞直到有元素入隊。
    • poll():返回並移除隊頭元素,此方法不會陷入阻塞,如果佇列為空則返回null。
    • poll(time, unit):返回並移除隊頭元素,佇列為空則陷入阻塞,如果在超時前都沒有元素入隊則返回null。
    • remove():返回並移除隊頭元素,如果佇列為空則丟擲NoSuchElementException異常。 
  • 檢查:
    • element():返回隊頭元素,但不移除,如果佇列為空則丟擲NoSuchElementException異常。
    • peek():返回隊頭元素,但不移除,如果佇列為空則返回null。
public interface Queue<E> extends Collection<E> {
	//...
	element();
	peek();
	E poll();
	//...
}
public interface BlockingQueue<E> extends Queue<E> {
	boolean add(E e);
	boolean offer(E e);
	void put(E e) throws InterruptedException;
	boolean offer(E e, long timeout, TimeUnit unit)
        throws InterruptedException;
	E take() throws InterruptedException;
	E poll(long timeout, TimeUnit unit)
        throws InterruptedException;
	boolean remove(Object o);
	//...
}

  

當我們試圖向阻塞佇列中新增一個空元素(null),阻塞佇列會丟擲NullPointerException異常。在阻塞佇列中null是一個敏感值,一般用於表示佇列無元素或者超時獲取元素失敗,如果允許往佇列中新增空元素,就無法分辨返回的null到底是獲取元素失敗,還是這個空元素本身就是佇列中的元素。
阻塞佇列可以設定容量,如果我們新增的元素超過佇列剩餘可容納的數量,可能會陷入阻塞。阻塞佇列主要用於生產者-消費者佇列這樣的場景,此外阻塞佇列還實現了Collection介面。我們可以呼叫remove(Object o)從佇列中移除一個元素,例如從佇列中移除一條訊息,但這個方法的實現效率通常不是很高,應謹慎使用。

阻塞佇列是執行緒安全的,其實現需要通過內部鎖或者其他形式的併發控制來保證原子性。但是阻塞佇列繼承自Collection介面的addAll、containsAll、retainAll 、removeAll並不保證原子性。例如:addAll(c)的實現是迴圈將元素通過add(e)方法入隊,那麼在超過佇列容量的時候,就會丟擲異常。

下面,讓我們思考下如何實現一個執行緒安全的阻塞佇列。可能很多人看到文章的標題,都會直接想到ReentrantLock,只要涉及到元素的存取,我們都可以在業務執行前和業務執行後加上lock()和unlock(),這樣就能保證執行緒的安全性。於是,我們阻塞佇列的實現就變得尤為簡單了:

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class MyBlockingQueue<E> {
    private Lock lock = new ReentrantLock();

    public void put(E e) {
        try {
            lock.lock();
            //put method body...
        } finally {
            lock.unlock();
        }
    }

    public E take() {
        try {
            lock.lock();
            //take method body...
        } finally {
            lock.unlock();
        }
    }
}

  

但如果我們的實現真的這麼簡單暴力,會出現一個問題,假設Thread-1執行緒執行put(e)方法並佔用了鎖,但發現佇列已滿陷入阻塞。此時Thread-2需要從佇列中獲取元素,同樣要佔有鎖後再從佇列中獲取元素。但先前Thread-1已經佔有了鎖,Thread-2還能佔有鎖嗎?或者換一個說法,在佇列是空的時候Thread-2要從佇列中獲取元素,此時會先佔有鎖在陷入阻塞,Thread-1想往佇列中新增元素,它能獲取到鎖嗎?如果僅僅是使用ReentrantLock,那肯定是獲取不到的。

按照我們現有的實現方式,佇列滿時獲取元素的執行緒一定要優先在新增元素的執行緒,如果新增元素的執行緒優先於獲取元素的執行緒,會出現新增元素的執行緒佔有了鎖,並等待佇列空出多餘的位置容納元素,而獲取元素的執行緒等待前一個執行緒釋放鎖,兩個執行緒永遠無法結束;或者佇列為空的時候新增元素的執行緒一定要優先在獲取元素的執行緒,如果獲取元素的執行緒優先於新增元素的執行緒,會出現獲取元素佔有了鎖,並等待有元素入隊,同時新增元素的執行緒等待前一個執行緒釋放鎖,兩個執行緒同樣無法結束。

但我們要知道,這種實現方式一定是不合理的,我們不能要求開發者在往阻塞佇列中存取元素的時候,要求哪個執行緒要優先哪個執行緒,甚至是開發者自己都很難做這個優先順序。那麼像juc包下的阻塞佇列又是如何在保證執行緒安全的情況下,避免出現上面所說的死鎖呢?我們來看看實現較為簡單的ArrayBlockingQueue:

public class ArrayBlockingQueue<E> extends AbstractQueue<E>
        implements BlockingQueue<E>, java.io.Serializable {
	final ReentrantLock lock;
	private final Condition notEmpty;
	private final Condition notFull;
	//...
	public E take() throws InterruptedException {
        final ReentrantLock lock = this.lock;
        lock.lockInterruptibly();
        try {
            while (count == 0)
                notEmpty.await();
            return dequeue();
        } finally {
            lock.unlock();
        }
    }
	//...
	public void put(E e) throws InterruptedException {
        Objects.requireNonNull(e);
        final ReentrantLock lock = this.lock;
        lock.lockInterruptibly();
        try {
            while (count == items.length)
                notFull.await();
            enqueue(e);
        } finally {
            lock.unlock();
        }
    }
	//...
}

  

可以看到,在ArrayBlockingQueue的實現中,也是非常簡單暴力的用ReentrantLock的lock()、unlock()來實現佇列的存取。那麼我們用一段程式碼來測試下,看看ArrayBlockingQueue在佇列滿時還有執行緒往內新增元素的時候,能否從佇列獲取元素。

putAndTake(BlockingQueue<Integer> queue, int n)接收一個阻塞佇列和一個數值n,會啟動n個執行緒往阻塞佇列新增元素,之後休眠1s,確認目前n個執行緒要嘛新增元素成功,要嘛佇列已滿處於阻塞狀態,最後迴圈n次從阻塞佇列中獲取元素。這裡我們只要保證n的數值大於阻塞佇列的容量,就可以保證n個執行緒裡會存在部分執行緒往阻塞佇列新增元素時被阻塞。在main方法中我們設定執行緒數n為5,阻塞執行緒的容量為5/2=2,我們分別建立兩個阻塞佇列ArrayBlockingQueue和LinkedBlockingQueue,看看當佇列滿時仍然有執行緒向阻塞佇列存放元素,獲取元素的執行緒能否正常從佇列中獲取元素。

import java.util.Random;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;

public class BlockQueueTest {

    public static void putAndTake(BlockingQueue<Integer> queue, int n) {
        for (int i = 0; i < n; i++) {//<1>
            new Thread(() -> {
                try {
                    int r = new Random().nextInt(20);
                    queue.put(r);
                    System.out.println("往佇列存放數值:" + r);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }).start();
        }

        try {
            Thread.sleep(1000);//<2>
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        for (int i = 0; i < n; i++) {//<3>
            try {
                System.out.println("從佇列取出數值:" + queue.take());
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }

    public static void main(String[] args) {
        int n = 5;
        int capacity = n / 2;
        BlockingQueue<Integer> queue = new ArrayBlockingQueue<>(capacity);
        System.out.println("_____ArrayBlockingQueue_____");
        putAndTake(queue, n);
        System.out.println("_____LinkedBlockingQueue_____");
        queue = new LinkedBlockingQueue<>(capacity);
        putAndTake(queue, n);
    }
}

  

執行結果:

_____ArrayBlockingQueue_____
往佇列存放數值:9
往佇列存放數值:16
往佇列存放數值:11
從佇列取出數值:9
從佇列取出數值:16
往佇列存放數值:10
往佇列存放數值:4
從佇列取出數值:11
從佇列取出數值:10
從佇列取出數值:4
_____LinkedBlockingQueue_____
往佇列存放數值:4
往佇列存放數值:19
從佇列取出數值:4
往佇列存放數值:13
往佇列存放數值:17
從佇列取出數值:19
從佇列取出數值:13
往佇列存放數值:4
從佇列取出數值:17
從佇列取出數值:4

  

可以看到ArrayBlockingQueue和LinkedBlockingQueue並沒有我們先前假定的情況,但按照先前我們看到的ArrayBlockingQueue的take()和put(e)的實現,又會在方法的開始便佔有鎖。那麼ArrayBlockingQueue是怎麼做到當佇列滿時,存放元素的執行緒佔有鎖後陷入阻塞,又允許獲取元素的執行緒搶鎖,然後從佇列中獲取元素呢?

首先我們能肯定,只要是不同的執行緒在競爭同一個可重入互斥鎖,如果鎖被佔用,一定要等到鎖被完全釋放成為無主狀態,別的執行緒才可以佔有鎖。因此不論時佇列滿時有執行緒佔有鎖並嘗試往佇列存放元素,又或者佇列為空時有執行緒佔有鎖從佇列獲取元素,這兩種情況一定會在某一個時機釋放鎖,因為這兩個執行緒一定會被阻塞起來,直到有執行緒從已滿的佇列中獲取元素,或者有執行緒向空佇列存放元素,且這兩個執行緒不能影響別的執行緒從已滿的佇列獲取元素,或者向空佇列存放元素。

那麼在下面的take()和put(e)兩個方法中會是哪一段程式碼偷偷完成釋放鎖並阻塞當前執行緒這一操作呢?很有可能是<1>處和<2>處。這裡我們看到ReentrantLock另外一種用法,我們可以用ReentrantLock生成一個Condition條件物件,相信會有人產生疑問,Condition物件在下面的程式碼究竟起到什麼樣的作用呢?

public class ArrayBlockingQueue<E> extends AbstractQueue<E>
        implements BlockingQueue<E>, java.io.Serializable {
	final Object[] items;
	final ReentrantLock lock;
	private final Condition notEmpty;
	private final Condition notFull;
	//...
	public ArrayBlockingQueue(int capacity, boolean fair) {
        if (capacity <= 0)
            throw new IllegalArgumentException();
        this.items = new Object[capacity];
        lock = new ReentrantLock(fair);
        notEmpty = lock.newCondition();
        notFull =  lock.newCondition();
    }
	//...
	public E take() throws InterruptedException {
        final ReentrantLock lock = this.lock;
        lock.lockInterruptibly();
        try {
            while (count == 0)
                notEmpty.await();//<2>
            return dequeue();
        } finally {
            lock.unlock();
        }
    }
	//...
	public void put(E e) throws InterruptedException {
        Objects.requireNonNull(e);
        final ReentrantLock lock = this.lock;
        lock.lockInterruptibly();
        try {
            while (count == items.length)
                notFull.await();//<1>
            enqueue(e);
        } finally {
            lock.unlock();
        }
    }
	//...
}

  

在介紹Condition之前,我們先來回顧下synchronized關鍵字,我們知道synchronized可以對一個物件加鎖以保證併發情況下執行緒以序列的方式訪問同步程式碼塊,當執行緒執行完同步程式碼塊裡的程式碼,就會釋放物件鎖。但是synchronized也支援執行緒在尚未執行完同步程式碼塊中的程式碼時,就呼叫object.wait()釋放物件鎖從而讓當前執行緒進入阻塞,當需要執行緒繼續往下執行時,呼叫object.notify()或者object.notifyAll()喚醒阻塞執行緒。

synchronized (object){
	//...
}

  

下面我們用一個例子來加深對synchronized的理解,Restaurant類的main方法中,我們先在<1>處生成一個廚師物件cook,然後在<2>處會迴圈生成6個執行緒,每個執行緒都作為一個顧客獲取cook的物件鎖,在通知廚師做菜後陷入阻塞。之後主執行緒休眠1s,確保6個執行緒都陷入阻塞後,呼叫<3>處的cook.finishOne()方法後間接呼叫cook.notify()隨機選擇一個執行緒喚醒,被喚醒的執行緒會退出object.wait()然後重新競爭object物件鎖,在佔有物件鎖後執行緒繼續執行cook.wait()之後的程式碼,取走菜並銷燬當前執行緒。之後主方法又休眠了1s,呼叫<4>處的cook.finishAll()方法後間接呼叫cook.notifyAll()喚醒所有等待cook物件鎖的執行緒,所有等待執行緒會退出cook.wait()開始競爭cook物件鎖,接著按照之前的邏輯,搶鎖成功的執行緒取走菜然後銷燬執行緒。

public class Restaurant {
    //廚師類
    static class Cook {
        public synchronized void finishOne() {
            System.out.println("廚師完成一道菜");
            this.notify();
        }

        public synchronized void finishAll() {
            System.out.println("廚師完成所有菜");
            this.notifyAll();
        }
    }

    public static void main(String[] args) {
        final Cook cook = new Cook();//<1>
        for (int i = 1; i <= 6; i++) {
            final int no = i;
            new Thread(() -> {//<2>
                synchronized (cook) {
                    try {
                        System.out.println("顧客" + no + "號通知廚師做菜");
                        cook.wait();
                        System.out.println("顧客" + no + "號取菜");
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }).start();
        }
        try {
            Thread.sleep(1000);
            cook.finishOne();//<3>
            Thread.sleep(1000);
            cook.finishAll();//<4>
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

    }

}

  

執行結果:

顧客1號通知廚師做菜
顧客2號通知廚師做菜
顧客5號通知廚師做菜
顧客3號通知廚師做菜
顧客4號通知廚師做菜
顧客6號通知廚師做菜
廚師完成一道菜
顧客1號取菜
廚師完成所有菜
顧客2號取菜
顧客6號取菜
顧客4號取菜
顧客3號取菜
顧客5號取菜

  

那麼會有人問,這裡的synchronized和之前我們說的Condition又有什麼關係呢?彆著急,現在就來回答這個問題。如果我們把synchronized等同於ReentrantLock,那麼Condition就相當於物件鎖,當我們佔有互斥鎖後,我們可以呼叫Condition.await()釋放當前執行緒對鎖的佔用,並讓當前執行緒陷入阻塞,當我們希望當前執行緒重新執行時,可以呼叫Condition.signal()或Condition.signalAll()喚醒陷入阻塞的執行緒,被喚醒的執行緒會重新開始搶鎖,搶到鎖的執行緒會繼續執行Condition.await()之後的程式碼,沒有搶到鎖的執行緒則接著等待。

我們用新的一個Restaurant2類來模擬上面的通知廚師做菜->廚師通知取菜的例子,這裡我們用ReentrantLock和Condition來實現。我們在<1>處生成一個ReentrantLock型別的廚師鎖cook,在<2>處根據廚師鎖生成一個條件condition物件。之後我們在main方法的<3>處迴圈啟動6個執行緒,每個執行緒代表一個顧客去佔有廚師鎖通知廚師做菜然後陷入阻塞。之後主執行緒分次呼叫finishOne()和finishAll(),繼而呼叫condition.signal()和condition.signalAll()通知一個執行緒取菜和通知所有執行緒取菜。

import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;

public class Restaurant2 {
    private static final ReentrantLock cook = new ReentrantLock();//<1>
    private static final Condition condition = cook.newCondition();//<2>

    public static void finishOne() {
        try {
            cook.lock();
            System.out.println("廚師完成一道菜");
            condition.signal();
        } finally {
            cook.unlock();
        }
    }

    public static void finishAll() {
        try {
            cook.lock();
            System.out.println("廚師完成所有菜");
            condition.signalAll();
        } finally {
            cook.unlock();
        }
    }

    public static void main(String[] args) {
        for (int i = 1; i <= 6; i++) {
            final int no = i;
            new Thread(() -> {//<3>
                try {
                    cook.lock();
                    System.out.println("顧客" + no + "通知廚師做菜");
                    condition.await();
                    System.out.println("顧客" + no + "號取菜");
                } catch (InterruptedException e) {
                    e.printStackTrace();
                } finally {
                    cook.unlock();
                }
            }).start();
        }
        try {
            Thread.sleep(1000);
            finishOne();//<4>
            Thread.sleep(1000);
            finishAll();//<5>
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

  

執行結果:

顧客3通知廚師做菜
顧客5通知廚師做菜
顧客2通知廚師做菜
顧客4通知廚師做菜
顧客1通知廚師做菜
顧客6通知廚師做菜
廚師完成一道菜
顧客3號取菜
廚師完成所有菜
顧客5號取菜
顧客2號取菜
顧客4號取菜
顧客1號取菜
顧客6號取菜

  

可以看到ReentrantLock也可以完成類似synchronized阻塞-通知的工作,那麼ReentrantLock相比於synchronized的優勢又在哪裡呢?是可擴充套件性,之前介紹ReentrantLock的時候就說過,ReentrantLock完成的工作雖然和synchronized相似,但比synchronized多了擴充套件性。如果在synchronized程式碼塊裡呼叫物件鎖的wait()方法,當前佔有物件鎖的執行緒會釋放鎖並進入等待,JVM會幫我們維護這個物件鎖的等待執行緒集合,當我們呼叫object.notify(),JVM會幫我們選擇一個執行緒喚醒,我們只知道這個執行緒會退出object.wait()方法並開始搶鎖,但搶到鎖之後,我們並不清楚這個執行緒會從哪裡開始執行。

比如下面的程式碼,methodA()和methodB()都有物件鎖object的同步程式碼塊,呼叫object.wait()都會陷入阻塞,但是當我們呼叫object.notify()時,我們無法指定要喚醒的執行緒是執行methodB()的執行緒而非執行methodA()的執行緒,JVM喚醒的執行緒,也可能是正在執行methodA()的執行緒。

public void methodA() {
	synchronized (object) {
		//...
		object.wait();
		//...
	}
}

public void methodB() {
	synchronized (object) {
		//...
		object.wait();
		//...
	}
}

  

基於wait()、notify()、notifyAll()帶來的限制,juc包的作者Doug Lea大師推出了Condition物件,我們可以基於一個鎖生成多個Condition物件,每個Condition物件都類似synchronized的物件鎖,會維護自己的一個等待執行緒集合。當執行緒要執行某項工作,發現條件不滿足時可以呼叫Condition.await()方法釋放鎖並陷入阻塞,當執行緒在執行某項工作後發現條件滿足,也可以呼叫Condition.singal()通知需要此條件的執行緒。這樣講可能有些人還是不太理解,這裡筆者接著以之前的阻塞佇列為例,看看如何藉助ReentrantLock和Condition在阻塞佇列滿的情況下,儲存執行緒在佔有鎖後釋放鎖,直到收到佇列有多餘空間的訊息為止,同理我們也可以看看,在佇列為空時,獲取執行緒在佔有鎖後釋放鎖,直到收到佇列不為空的訊息為止。

在初始化MyBlockingQueue的時候,會在<1>處生成一個可重入互斥鎖lock,同時會分別在<2>、<3>處生成佇列未滿(notFull)和佇列非空(notEmpty)兩個條件物件。當有儲存執行緒要儲存元素,呼叫put(e)方法並佔有了鎖,如果發現佇列已滿,會先呼叫<4>處的未滿條件物件(notFull)釋放鎖並陷入阻塞,之後獲取執行緒要獲取元素,呼叫take()方法發現佇列非空,於是在取走一個元素後多出空餘的空間,呼叫<7>處的未滿條件物件(notFull)通知其陷入等待的儲存執行緒,現在佇列中有空餘的空間可以容納元素。同理,當有獲取執行緒要從佇列獲取元素,呼叫take()方法並佔有了鎖,如果發現佇列是空的情況下會呼叫<6>處非空條件物件(notEmpty)釋放鎖並陷入阻塞,之後儲存執行緒向佇列儲存元素,先呼叫put(e)佔有了鎖,發現佇列未滿,於是將元素儲存進佇列,然後呼叫<5>處的非空條件物件通知等待元素的執行緒可以從佇列中獲取元素了。

import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class MyBlockingQueue<E> {
    final Lock lock = new ReentrantLock();//<1>
    final Condition notFull = lock.newCondition();//<2>
    final Condition notEmpty = lock.newCondition();//<3>
    final Object[] items;
    int putptr, takeptr, count;

    public MyBlockingQueue(int n) {
        items = new Object[n];
    }

    public void put(E x) throws InterruptedException {
        lock.lock();
        try {
            while (count == items.length)
                notFull.await();//<4>
            items[putptr] = x;
            if (++putptr == items.length) putptr = 0;
            ++count;
            notEmpty.signal();//<5>
        } finally {
            lock.unlock();
        }
    }

    public E take() throws InterruptedException {
        lock.lock();
        try {
            while (count == 0)
                notEmpty.await();//<6>
            E x = (E) items[takeptr];
            if (++takeptr == items.length) takeptr = 0;
            --count;
            notFull.signal();//<7>
            return x;
        } finally {
            lock.unlock();
        }
    }
}

  

相關文章