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

阻塞隊列不止是一道熱門的面試題,同時也是許多併發處理模型的基礎,比如常用的線程池類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級別的阻塞隊列了。

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