從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級別的阻塞隊列了。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 227,818評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,185評論 3 414
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 175,656評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,647評論 1 309
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,446評論 6 405
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 54,951評論 1 321
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,041評論 3 440
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,189評論 0 287
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,718評論 1 333
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,602評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,800評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,316評論 5 358
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,045評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,419評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,671評論 1 281
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,420評論 3 390
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,755評論 2 371

推薦閱讀更多精彩內容