從0到1實現自己的阻塞佇列(上)

兜裡有辣條發表於2019-04-28

阻塞佇列不止是一道熱門的面試題,同時也是許多併發處理模型的基礎,比如常用的執行緒池類ThreadPoolExecutor內部就使用了阻塞佇列來儲存等待被處理的任務。而且在大多數經典的多執行緒程式設計資料中,阻塞佇列都是其中非常重要的一個實踐案例。甚至可以說只有自己動手實現了一個阻塞佇列才能真正掌握多執行緒相關的API。

在這篇文章中,我們會從一個最簡單的原型開始一步一步完善為一個類似於JDK中阻塞佇列實現的真正實用的阻塞佇列。在這個過程中,我們會一路涉及synchronized關鍵字、條件變數、顯式鎖ReentrantLock等等多執行緒程式設計的關鍵技術,最終掌握Java多執行緒程式設計的完整理論和實踐知識。

閱讀本文需要了解基本的多執行緒程式設計概念與互斥鎖的使用,還不瞭解的讀者可以參考一下這篇文章多執行緒中那些看不見的陷阱中到ReentrantLock部分為止的內容。

什麼是阻塞佇列?

阻塞佇列是這樣的一種資料結構,它是一個佇列(類似於一個List),可以存放0到N個元素。我們可以對這個佇列執行插入或彈出元素操作,彈出元素操作就是獲取佇列中的第一個元素,並且將其從佇列中移除;而插入操作就是將元素新增到佇列的末尾。當佇列中沒有元素時,對這個佇列的彈出操作將會被阻塞,直到有元素被插入時才會被喚醒;當佇列已滿時,對這個佇列的插入操作就會被阻塞,直到有元素被彈出後才會被喚醒。

線上程池中,往往就會用阻塞佇列來儲存那些暫時沒有空閒執行緒可以直接執行的任務,等到執行緒空閒之後再從阻塞佇列中彈出任務來執行。一旦佇列為空,那麼執行緒就會被阻塞,直到有新任務被插入為止。

一個最簡單的版本

程式碼實現

我們先來實現一個最簡單的佇列,在這個佇列中我們不會新增任何執行緒同步措施,而只是實現了最基本的佇列與阻塞特性。 那麼首先,一個佇列可以存放一定量的元素,而且可以執行插入元素和彈出元素的操作。然後因為這個佇列還是一個阻塞佇列,那麼在佇列為空時,彈出元素的操作將會被阻塞,直到佇列中被插入新的元素可供彈出為止;而在佇列已滿的情況下,插入元素的操作將會被阻塞,直到佇列中有元素被彈出為止。

下面我們會將這個最初的阻塞佇列實現類拆解為獨立的幾塊分別講解和實現,到最後就能拼裝出一個完整的阻塞佇列類了。為了在阻塞佇列中儲存元素,我們首先要定義一個陣列來儲存元素,也就是下面程式碼中的items欄位了,這是一個Object陣列,所以可以儲存任意型別的物件。在最後的構造器中,會傳入一個capacity引數來指定items陣列的大小,這個值也就是我們的阻塞佇列的大小了。

takeIndexputIndex就是我們插入和彈出元素的下標位置了,為什麼要分別用兩個整型來儲存這樣的位置呢?因為阻塞佇列在使用的過程中會不斷地被插入和彈出元素,所以可以認為元素在陣列中是像貪吃蛇一樣一步一步往前移動的,每次彈出的都是佇列中的第一個元素,而插入的元素則會被新增到佇列的末尾。當下標到達末尾時會被設定為0,從陣列的第一個下標位置重新開始向後增長,形成一個不斷迴圈的過程。

那麼如果佇列中儲存的個數超過items陣列的長度時,新插入的元素豈不是會覆蓋佇列開頭還沒有被彈出的元素了嗎?這時我們的最後一個欄位count就能派上用場了,當count等於items.length時,插入操作就會被阻塞,直到佇列中有元素被彈出時為止。那麼這種阻塞是如何實現的呢?我們接下來來看一下put()方法如何實現。

    /** 存放元素的陣列 */
    private final Object[] items;
    
    /** 彈出元素的位置 */
    private int takeIndex;

    /** 插入元素的位置 */
    private int putIndex;
    
    /** 佇列中的元素總數 */
    private int count;
    
    /**
     * 指定佇列大小的構造器
     *
     * @param capacity  佇列大小
     */
    public BlockingQueue(int capacity) {
        if (capacity <= 0)
            throw new IllegalArgumentException();
        // putIndex, takeIndex和count都會被預設初始化為0
        items = new Object[capacity];
    }
複製程式碼

下面是put()take()方法的實現,put()方法向佇列末尾新增新元素,而take()方法從佇列中彈出最前面的一個元素,我們首先來看一下我們目前最關心的put()方法。在put()方法的開頭,我們可以看到有一個判斷count是否達到了items.length(佇列大小)的if語句,如果count不等於items.length,那麼就表示佇列還沒有滿,隨後就直接呼叫了enqueue方法對元素進行了入隊。enqueue方法的實現會在稍後介紹,這裡我們只需要知道這個入隊方法會將元素放入到佇列中並對count加1就可以了。在成功插入元素之後我們就會通過break語句跳出最外層的無限while迴圈,從方法中返回。

但是如果這時候佇列已滿,那麼count的值就會等於items.length,這將會導致我們呼叫Thread.sleep(200L)使當前執行緒休眠200毫秒。當執行緒從休眠中恢復時,又會進入下一次迴圈,重新判斷條件count != items.length。也就是說,如果佇列沒有彈出元素使我們可以完成插入操作,那麼執行緒就會一直處於“判斷 -> 休眠”的迴圈而無法從put()方法中返回,也就是進入了“阻塞”狀態。

隨後的take()方法也是一樣的道理,只有在佇列不為空的情況下才能順利彈出元素完成任務並返回,如果佇列一直為空,呼叫執行緒就會在迴圈中一直等待,直到佇列中有元素插入為止。

    /**
     * 將指定元素插入佇列
     *
     * @param e 待插入的物件
     */
    public void put(Object e) throws InterruptedException {
        while (true) {
            // 直到佇列未滿時才執行入隊操作並跳出迴圈
            if (count != items.length) {
                // 執行入隊操作,將物件e實際放入佇列中
                enqueue(e);
                break;
            }

            // 佇列已滿的情況下休眠200ms
            Thread.sleep(200L);
        }
    }

    /**
     * 從佇列中彈出一個元素
     *
     * @return  被彈出的元素
     */
    public Object take() throws InterruptedException {
        while (true) {
            // 直到佇列非空時才繼續執行後續的出隊操作並返回彈出的元素
            if (count != 0) {
                // 執行出隊操作,將佇列中的第一個元素彈出
                return dequeue();
            }

            // 佇列為空的情況下休眠200ms
            Thread.sleep(200L);
        }
    }
複製程式碼

在上面的put()take()方法中分別呼叫了入隊方法enqueue和出隊方法dequeue,那麼這兩個方法到底需要如何實現呢?下面是這兩個方法的原始碼,我們可以看到,在入隊方法enqueue()中,總共有三步操作:

  1. 首先把指定的物件e儲存到items[putIndex]中,putIndex指示的就是我們插入元素的位置。
  2. 之後,我們會將putIndex向後移一位,來確定下一次插入元素的下標位置,如果已經到了佇列末尾我們就會把putIndex設定為0,回到佇列的開頭。
  3. 最後,入隊操作會將count值加1,讓count值和佇列中的元素個數一致。

而出隊方法dequeue中執行的操作則與入隊方法enqueue相反。

    /**
     * 入隊操作
     *
     * @param e 待插入的物件
     */
    private void enqueue(Object e) {
        // 將物件e放入putIndex指向的位置
        items[putIndex] = e;

        // putIndex向後移一位,如果已到末尾則返回佇列開頭(位置0)
        if (++putIndex == items.length)
            putIndex = 0;

        // 增加元素總數
        count++;
    }

    /**
     * 出隊操作
     *
     * @return  被彈出的元素
     */
    private Object dequeue() {
        // 取出takeIndex指向位置中的元素
        // 並將該位置清空
        Object e = items[takeIndex];
        items[takeIndex] = null;

        // takeIndex向後移一位,如果已到末尾則返回佇列開頭(位置0)
        if (++takeIndex == items.length)
            takeIndex = 0;

        // 減少元素總數
        count--;

        // 返回之前程式碼中取出的元素e
        return e;
    }
複製程式碼

到這裡我們就可以將這個三個模組拼接為一個完整的阻塞佇列類BlockingQueue了。完整的程式碼如下,大家可以拷貝到IDE中,或者自己重新實現一遍,然後我們就可以開始上手用一用我們剛剛完成的阻塞佇列了。

public class BlockingQueue {

    /** 存放元素的陣列 */
    private final Object[] items;

    /** 彈出元素的位置 */
    private int takeIndex;

    /** 插入元素的位置 */
    private int putIndex;

    /** 佇列中的元素總數 */
    private int count;

    /**
     * 指定佇列大小的構造器
     *
     * @param capacity  佇列大小
     */
    public BlockingQueue(int capacity) {
        if (capacity <= 0)
            throw new IllegalArgumentException();
        items = new Object[capacity];
    }

    /**
     * 入隊操作
     *
     * @param e 待插入的物件
     */
    private void enqueue(Object e) {
        // 將物件e放入putIndex指向的位置
        items[putIndex] = e;

        // putIndex向後移一位,如果已到末尾則返回佇列開頭(位置0)
        if (++putIndex == items.length)
            putIndex = 0;

        // 增加元素總數
        count++;
    }

    /**
     * 出隊操作
     *
     * @return  被彈出的元素
     */
    private Object dequeue() {
        // 取出takeIndex指向位置中的元素
        // 並將該位置清空
        Object e = items[takeIndex];
        items[takeIndex] = null;

        // takeIndex向後移一位,如果已到末尾則返回佇列開頭(位置0)
        if (++takeIndex == items.length)
            takeIndex = 0;

        // 減少元素總數
        count--;

        // 返回之前程式碼中取出的元素e
        return e;
    }

    /**
     * 將指定元素插入佇列
     *
     * @param e 待插入的物件
     */
    public void put(Object e) throws InterruptedException {
        while (true) {
            // 直到佇列未滿時才執行入隊操作並跳出迴圈
            if (count != items.length) {
                // 執行入隊操作,將物件e實際放入佇列中
                enqueue(e);
                break;
            }

            // 佇列已滿的情況下休眠200ms
            Thread.sleep(200L);
        }
    }

    /**
     * 從佇列中彈出一個元素
     *
     * @return  被彈出的元素
     */
    public Object take() throws InterruptedException {
        while (true) {
            // 直到佇列非空時才繼續執行後續的出隊操作並返回彈出的元素
            if (count != 0) {
                // 執行出隊操作,將佇列中的第一個元素彈出
                return dequeue();
            }

            // 佇列為空的情況下休眠200ms
            Thread.sleep(200L);
        }
    }

}
複製程式碼

測驗阻塞佇列實現

既然已經有了阻塞佇列的實現,那麼我們就寫一個測試程式來測試一下吧。下面是一個對阻塞佇列進行併發的插入和彈出操作的測試程式,在這個程式中,會建立2個生產者執行緒向阻塞佇列中插入數字0~19;同時也會建立2個消費者執行緒從阻塞佇列中彈出20個數字,並列印這些數字。而且在程式中也統計了整個程式的耗時,會在所有子執行緒執行完成之後列印出程式的總耗時。

這裡我們期望這個測驗程式能夠以任意順序輸出0~19這20個數字,然後列印出程式的總耗時,那麼實際執行情況會如何呢?

public class BlockingQueueTest {

    public static void main(String[] args) throws Exception {

        // 建立一個大小為2的阻塞佇列
        final BlockingQueue q = new BlockingQueue(2);

        // 建立2個執行緒
        final int threads = 2;
        // 每個執行緒執行10次
        final int times = 10;

        // 執行緒列表,用於等待所有執行緒完成
        List<Thread> threadList = new ArrayList<>(threads * 2);
        long startTime = System.currentTimeMillis();

        // 建立2個生產者執行緒,向佇列中併發放入數字0到19,每個執行緒放入10個數字
        for (int i = 0; i < threads; ++i) {
            final int offset = i * times;
            Thread producer = new Thread(() -> {
                try {
                    for (int j = 0; j < times; ++j) {
                        q.put(new Integer(offset + j));
                    }
                } catch (Exception e) {
                    e.printStackTrace();
                }
            });

            threadList.add(producer);
            producer.start();
        }

        // 建立2個消費者執行緒,從佇列中彈出20次數字並列印彈出的數字
        for (int i = 0; i < threads; ++i) {
            Thread consumer = new Thread(() -> {
                try {
                    for (int j = 0; j < times; ++j) {
                        Integer element = (Integer) q.take();
                        System.out.println(element);
                    }
                } catch (Exception e) {
                    e.printStackTrace();
                }
            });

            threadList.add(consumer);
            consumer.start();
        }

        // 等待所有執行緒執行完成
        for (Thread thread : threadList) {
            thread.join();
        }

        // 列印執行耗時
        long endTime = System.currentTimeMillis();
        System.out.println(String.format("總耗時:%.2fs", (endTime - startTime) / 1e3));
    }
}
複製程式碼

在我的電腦上執行這段程式的輸出為:

0
1
2
3
4
5
null
10
8
7
14
9
16
15
18
17
null
複製程式碼

不僅是列印出了很多個null,而且列印出17行之後就不再列印更多資料,而且程式也就一直沒有列印總耗時並結束了。為什麼會發生這種情況呢?

原因就是在我們實現的這個阻塞佇列中完全沒有執行緒同步機制,所以同時併發進行的4個執行緒(2個生產者和2個消費者)會同時執行阻塞佇列的put()take()方法。這就可能會導致各種各樣併發執行順序導致的問題,比如兩個生產者同時對阻塞佇列進行插入操作,有可能就會在putIndex沒更新的情況下對同一下標位置又插入了一次資料,導致了資料還沒被消費就被覆蓋了;而兩個消費者也可能會在takeIndex沒更新的情況下又獲取了一次已經被清空的位置,導致列印出了null。最後因為這些原因都有可能會導致消費者執行緒最後還沒有彈出20個數字count就已經為0了,這時消費者執行緒就會一直處於阻塞狀態無法退出了。

那麼我們應該如何給阻塞佇列加上執行緒同步措施,使它的執行不會發生錯誤呢?

一個執行緒安全的版本

使用互斥鎖來保護佇列操作

之前碰到的併發問題的核心就是多個執行緒同時對阻塞佇列進行插入或彈出操作,那麼我們有沒有辦法讓同一時間只能有一個執行緒對阻塞佇列進行操作呢?

也許很多讀者已經想到了,我們最常用的一種併發控制方式就是synchronized關鍵字。通過synchronized,我們可以讓一段程式碼同一時間只能有一個執行緒進入;如果在同一個物件上通過synchronized加鎖,那麼put()take()兩個方法可以做到同一時間只能有一個執行緒呼叫兩個方法中的任意一個。比如如果有一個執行緒呼叫了put()方法插入元素,那麼其他執行緒再呼叫put()方法或者take()就都會被阻塞直到前一個執行緒完成對put()方法的呼叫了。

在這裡,我們只修改put()take()方法,把這兩個方法中對enqueuedequeue的呼叫都包裝到一個synchronized (this) {...}的語句塊中,保證了同一時間只能有一個執行緒進入這兩個語句塊中的任意一個。如果對synchronized之類的執行緒同步機制還不熟悉的讀者,建議先看一下這篇介紹多執行緒同步機制的文章《多執行緒中那些看不見的陷阱》再繼續閱讀之後的內容,相信會有事半功倍的效果。

    /**
     * 將指定元素插入佇列
     *
     * @param e 待插入的物件
     */
    public void put(Object e) throws InterruptedException {
        while (true) {
            synchronized (this) {
                // 直到佇列未滿時才執行入隊操作並跳出迴圈
                if (count != items.length) {
                    // 執行入隊操作,將物件e實際放入佇列中
                    enqueue(e);
                    break;
                }
            }

            // 佇列已滿的情況下休眠200ms
            Thread.sleep(200L);
        }
    }

    /**
     * 從佇列中彈出一個元素
     *
     * @return  被彈出的元素
     */
    public Object take() throws InterruptedException {
        while (true) {
            synchronized (this) {
                // 直到佇列非空時才繼續執行後續的出隊操作並返回彈出的元素
                if (count != 0) {
                    // 執行出隊操作,將佇列中的第一個元素彈出
                    return dequeue();
                }
            }

            // 佇列為空的情況下休眠200ms
            Thread.sleep(200L);
        }
    }
複製程式碼

再次測試

我們再來試一試這個新的阻塞佇列實現,在我的電腦上測試程式的輸出如下:

0
1
2
3
10
11
4
5
6
12
13
14
15
7
8
9
16
17
18
19
總耗時:1.81s
複製程式碼

這下看起來結果就對了,而且多跑了幾次也都能穩定輸出所有0~19的20個數字。看起來非常棒,我們成功了,來給自己鼓個掌吧!

但是仔細那麼一看,好像最後的耗時是不是有一些高了?雖然“1.81秒”也不是太長的時間,但是好像一般計算機程式做這麼一點事情只要一眨眼的功夫就能完成才對呀。為什麼這個阻塞佇列會這麼慢呢?

一個更快的阻塞佇列

讓我們先來診斷一下之前的阻塞佇列中到底是什麼導致了效率的降低,因為put()take()方法是阻塞佇列的核心,所以我們自然從這兩個方法看起。在這兩個方法裡,我們都看到了同一段程式碼Thread.sleep(200L),這段程式碼會讓put()take()方法分別在佇列已滿和佇列為空的情況下進入一次固定的200毫秒的休眠,防止執行緒佔用過多的CPU資源。但是如果佇列在這200毫秒裡發生了變化,那麼執行緒也還是在休眠狀態無法馬上對變化做出響應。比如如果一個呼叫put()方法的執行緒因為佇列已滿而進入了200毫秒的休眠,那麼即使佇列已經被消費者執行緒清空了,它也仍然會忠實地等到200毫秒之後才會重新嘗試向佇列中插入元素,中間的這些時間就都被浪費了。

但是如果我們去掉這段休眠的程式碼,又會導致CPU的使用率過高的問題。那麼有沒有一種方法可以平衡兩者的利弊,同時得到兩種情況的好處又沒有各自的缺點呢?

使用條件變數優化阻塞喚醒

為了完成上面這個困難的任務,既要馬兒跑又要馬兒不吃草。那麼我們就需要有一種方法,既讓執行緒進入休眠狀態不再佔用CPU,但是在佇列發生改變時又能及時地被喚醒來重試之前的操作了。既然用了物件鎖synchronized,那麼我們就找找有沒有與之相搭配的同步機制可以實現我們的目標。

Object類,也就是所有Java類的基類裡,我們找到了三個有意思的方法Object.wait()Object.notify()Object.notifyAll()。這三個方法是需要搭配在一起使用的,其功能與作業系統層面的條件變數類似。條件變數是這樣的一種執行緒同步工具:

  1. 每個條件變數都會有一個對應的互斥鎖,要呼叫條件變數的wait()方法,首先需要持有條件變數對應的這個互斥鎖。之後,在呼叫條件變數的wait()方法時,首先會釋放已持有的這個互斥鎖,然後當前執行緒進入休眠狀態,等待被Object.notify()或者Object.notifyAll()方法喚醒;
  2. 呼叫Object.notify()或者Object.notifyAll()方法可以喚醒因為Object.wait()進入休眠狀態的執行緒,區別是Object.notify()方法只會喚醒一個執行緒,而Object.notifyAll()會喚醒所有執行緒。

因為我們之前的程式碼中通過synchronized獲取了對應於this引用的物件鎖,所以自然也就要用this.wait()this.notify()this.notifyAll()方法來使用與這個物件鎖對應的條件變數了。下面是使用條件變數改造後的put()take()方法。還是和之前一樣,我們首先以put()方法為例分析具體的改動。首先,我們去掉了最外層的while迴圈,然後我們把Thread.sleep替換為了this.wait(),以此在佇列已滿時進入休眠狀態,等待佇列中的元素被彈出後再繼續。在佇列滿足條件,入隊操作成功後,我們通過呼叫this.notifyAll()喚醒了可能在等待佇列非空條件的呼叫take()的執行緒。take()方法的實現與put()也基本類似,只是操作相反。

    /**
     * 將指定元素插入佇列
     *
     * @param e 待插入的物件
     */
    public void put(Object e) throws InterruptedException {
        synchronized (this) {
            if (count == items.length) {
                // 佇列已滿時進入休眠
                this.wait();
            }

            // 執行入隊操作,將物件e實際放入佇列中
            enqueue(e);

            // 喚醒所有休眠等待的程式
            this.notifyAll();
        }
    }

    /**
     * 從佇列中彈出一個元素
     *
     * @return  被彈出的元素
     */
    public Object take() throws InterruptedException {
        synchronized (this) {
            if (count == 0) {
                // 佇列為空時進入休眠
                this.wait();
            }

            // 執行出隊操作,將佇列中的第一個元素彈出
            Object e = dequeue();

            // 喚醒所有休眠等待的程式
            this.notifyAll();

            return e;
        }
    }
複製程式碼

但是我們在測試程式執行之後發現結果好像又出現了問題,在我電腦上的輸出如下:

0
19
null
null
null
null
null
null
null
null
null
18
null
null
null
null
null
null
null
null
總耗時:0.10s
複製程式碼

雖然我們解決了耗時問題,現在的耗時已經只有0.10s了,但是結果中又出現了大量的null,我們的阻塞佇列好像又出現了正確性問題。那麼問題出在哪呢?建議讀者可以先自己嘗試分析一下,這樣有助於大家積累解決多執行緒併發問題的能力。

while迴圈判斷條件是否滿足

經過分析,我們看到,在呼叫this.wait()後,如果執行緒被this.notifyAll()方法喚醒,那麼就會直接開始直接入隊/出隊操作,而不會再次檢查count的值是否滿足條件。而在我們的程式中,當佇列為空時,可能會有很多消費者執行緒在等待插入元素。此時,如果有一個生產者執行緒插入了一個元素並呼叫了this.notifyAll(),則所有消費者執行緒都會被喚醒,然後依次執行出隊操作,那麼第一個消費者執行緒之後的所有執行緒拿到的都將是null值。而且同時,在這種情況下,每一個執行完出隊操作的消費者執行緒也同樣會呼叫this.notifyAll()方法,這樣即使佇列中已經沒有元素了,後續進入等待的消費者執行緒仍然會被自己的同類所喚醒,消費根本不存在的元素,最終只能返回null

所以要解決這個問題,核心就是線上程從this.wait()中被喚醒時也仍然要重新檢查一遍count值是否滿足要求,如果count不滿足要求,那麼當前執行緒仍然呼叫this.wait()回到等待狀態當中去繼續休眠。而我們是沒辦法預知程式在第幾次判斷條件時可以得到滿足條件的count值從而繼續執行的,所以我們必須讓程式迴圈執行“判斷條件 -> 不滿足條件繼續休眠”這樣的流程,直到count滿足條件為止。那麼我們就可以使用一個while迴圈來包裹this.wait()呼叫和對count的條件判斷,以此達到這個目的。

下面是具體的實現程式碼,我們在其中把count條件(佇列未滿/非空)作為while條件,然後在count值還不滿足要求的情況下呼叫this.wait()方法使當前執行緒進入等待狀態繼續休眠。

    /**
     * 將指定元素插入佇列
     *
     * @param e 待插入的物件
     */
    public void put(Object e) throws InterruptedException {
        synchronized (this) {
            while (count == items.length) {
                // 佇列已滿時進入休眠
                this.wait();
            }

            // 執行入隊操作,將物件e實際放入佇列中
            enqueue(e);

            // 喚醒所有休眠等待的程式
            this.notifyAll();
        }
    }

    /**
     * 從佇列中彈出一個元素
     *
     * @return  被彈出的元素
     */
    public Object take() throws InterruptedException {
        synchronized (this) {
            while (count == 0) {
                // 佇列為空時進入休眠
                this.wait();
            }

            // 執行出隊操作,將佇列中的第一個元素彈出
            Object e = dequeue();

            // 喚醒所有休眠等待的程式
            this.notifyAll();

            return e;
        }
    }
複製程式碼

再次執行我們的測試程式,在我的電腦上得到了如下的輸出:

0
10
1
2
11
12
13
3
4
14
5
6
15
16
7
17
8
18
9
19
總耗時:0.11s
複製程式碼

耗時只有0.11s,而且結果也是正確的,看來我們得到了一個又快又好的阻塞佇列實現。這是一個里程碑式的版本,我們實現了一個真正可以在程式程式碼中使用的阻塞佇列,到這裡可以說你已經學會了如何實現一個阻塞佇列了,讓我們為自己鼓個掌吧。

當時進度條出賣了我,這篇文章還有不少內容。既然我們已經學會如何實現一個真正可用的阻塞佇列了,我們為什麼還要繼續看這麼多內容呢?別慌,雖然我們已經實現了一個真正可用的版本,但是如果我們更進一步的話就可以實現一個JDK級別的高強度版本了,這聽起來是不是非常的誘人?讓我們繼續我們的旅程吧。

一個更安全的版本

我們之前的版本中使用這些同步機制:synchronized (this)this.wait()this.notifyAll(),這些同步機制都和當前物件this有關。因為synchronized (obj)可以使用任意物件對應的物件鎖,而Object.wati()Object.notifyAll()方法又都是public方法。也就是說不止在阻塞佇列類內部可以使用這個阻塞佇列物件的物件鎖及其對應的條件變數,在外部的程式碼中也可以任意地獲取阻塞佇列物件上的物件鎖和對應的條件變數,那麼就有可能發生外部程式碼濫用阻塞佇列物件上的物件鎖導致阻塞佇列效能下降甚至是發生死鎖的情況。那我們有沒有什麼辦法可以讓阻塞佇列在這方面變得更安全呢?

使用顯式鎖

最直接的方式當然是請出JDK在1.5之後引入的代替synchronized關鍵字的顯式鎖ReentrantLock類了。ReentrantLock類是一個可重入互斥鎖,互斥指的是和synchronized一樣,同一時間只能有一個執行緒持有鎖,其他獲取鎖的執行緒都必須等待持有鎖的執行緒釋放該鎖。而可重入指的就是同一個執行緒可以重複獲取同一個鎖,如果在獲取鎖時這個鎖已經被當前執行緒所持有了,那麼這個獲取鎖的操作仍然會直接成功。

一般我們使用ReentrantLock的方法如下:

lock.lock();
try {
    做一些操作
}
finally {
    lock.unlock();
}
複製程式碼

上面的lock變數就是一個ReentrantLock型別的物件。在這段程式碼中,釋放鎖的操作lock.unlock()被放在了finally塊中,這是為了保證執行緒在獲取到鎖之後,不論出現異常或者什麼特殊情況都能保證正確地釋放互斥鎖。如果不這麼做就可能會導致持有鎖的執行緒異常退出後仍然持有該鎖,其他需要獲取同一個鎖的執行緒就永遠執行不了。

那麼在我們的阻塞佇列中應該如何用ReentrantLock類來改寫呢?

首先,我們顯然要為我們的阻塞佇列類新增一個例項變數lock來儲存用於在不同執行緒間實現互斥訪問的ReentrantLock鎖。然後我們要將原來的synchronized(this) {...}格式的程式碼修改為上面使用ReentrantLock進行互斥訪問保護的實現形式,也就是lock.lock(); try {...} finally {lock.unlock();}這樣的形式。

但是原來與synchronized所加的物件鎖相對應的條件變數使用方法this.wait()this.notifyAll()應該如何修改呢?ReentrantLock已經為你做好了準備,我們可以直接呼叫lock.newCondition()方法來建立一個與互斥鎖lock相對應的條件變數。然後為了在不同執行緒中都能訪問到這個條件變數,我們同樣要新增一個例項變數condition來儲存這個新建立的條件變數物件。然後我們原來使用的this.wait()就需要修改為condition.await(),而this.notifyAll()就修改為了condition.signalAll()

    /** 顯式鎖 */
    private final ReentrantLock lock = new ReentrantLock();

    /** 鎖對應的條件變數 */
    private final Condition condition = lock.newCondition();
    
    /**
     * 將指定元素插入佇列
     *
     * @param e 待插入的物件
     */
    public void put(Object e) throws InterruptedException {
        lock.lockInterruptibly();
        try {
            while (count == items.length) {
                // 佇列已滿時進入休眠
                // 使用與顯式鎖對應的條件變數
                condition.await();
            }

            // 執行入隊操作,將物件e實際放入佇列中
            enqueue(e);

            // 通過條件變數喚醒休眠執行緒
            condition.signalAll();
        } finally {
            lock.unlock();
        }
    }

    /**
     * 從佇列中彈出一個元素
     *
     * @return  被彈出的元素
     */
    public Object take() throws InterruptedException {
        lock.lockInterruptibly();
        try {
            while (count == 0) {
                // 佇列為空時進入休眠
                // 使用與顯式鎖對應的條件變數
                condition.await();
            }

            // 執行出隊操作,將佇列中的第一個元素彈出
            Object e = dequeue();

            // 通過條件變數喚醒休眠執行緒
            condition.signalAll();

            return e;
        } finally {
            lock.unlock();
        }
    }
複製程式碼

到這裡,我們就完成了使用顯式鎖ReentrantLock所需要做的所有改動了。整個過程中並不涉及任何邏輯的變更,我們只是把synchronized (this) {...}修改為了lock.lock() try {...} finally {lock.unlock();},把this.wait()修改為了condition.await(),把this.notifyAll()修改為了condition.signalAll()。就這樣,我們的鎖和條件變數因為是private欄位,所以外部的程式碼就完全無法訪問了,這讓我們的阻塞佇列變得更加安全,是時候可以提供給其他人使用了。

但是這個版本的阻塞佇列仍然還有很大的優化空間,繼續閱讀下一篇文章,相信你就可以實現出JDK級別的阻塞佇列了。

相關文章