手擼MQ訊息佇列——迴圈陣列

牛初九發表於2024-09-14

佇列是咱們開發中經常使用到的一種資料結構,它與的結構類似。然而棧是後進先出,而佇列是先進先出,說的專業一點就是FIFO。在生活中到處都可以找到佇列的,最常見的就是排隊,吃飯排隊,上地鐵排隊,其他就不過多舉例了。

佇列的模型

在資料結構中,和排隊這種場景最像的就是陣列了,所以我們的佇列就用陣列去實現。在排隊的過程中,有兩個基本動作就是入隊出隊,入隊就是從隊尾插入一個元素,而出隊就是從隊頭移除一個元素。基本的模型我們可以畫一個簡圖:

看了上面的模型,我們很容易想到使用陣列去實現佇列,

  1. 先定義一個陣列,並確定陣列的長度,我們暫定陣列長度是5,而上面圖中的長度是一樣的;
  2. 再定義兩個陣列下標,fronttail,front是隊頭的下標,每一次出隊的操作,我們直接取front下標的元素就可以了。tail是隊尾的下標,每一次入隊的操作,我們直接給tail下標的位置插入元素就可以了。

我們看一下具體的過程,初始狀態是一個空的佇列,

隊頭下標和隊尾下標都是指向陣列中的第0個元素,現在我們插入第一個元素“a”,如圖:

陣列的第0個元素賦值“a”,tail的下標+1,由指向第0個元素變為指向第1個元素。這些變化我們都要記住啊,後續在程式設計實現的過程中,每一個細節都不能忽略。然後我們再做一次出隊操作:

第0個元素“a”在陣列中移除了,並且front下標+1,指向第1個元素。

這些看起來不難實現啊,不就是給陣列元素賦值,然後下標+1嗎?但是我們想一想極端的情況, 我們給陣列的最後一個元素賦值後,陣列的下標怎麼辦?

tail如果再+1,就超越了陣列的長度了呀,這是明顯的越界了。同樣front如果取了陣列中的最後一個元素,再+1,也會越界。這怎麼辦呢?

迴圈陣列

我們最開始想到的方法,就是當tail下標到達陣列的最後一個元素的時候,對陣列進行擴容,陣列的長度又5變為10。這種方法可行嗎?如果一直做入隊操作,那麼陣列會無限的擴容下去,佔滿磁碟空間,這是我們不想看到的。

另外一個方法,當front或tail指向陣列最後一個元素時,再進行+1操作,我們將下標指向佇列的開頭,也就是第0個元素,形成一個迴圈,這就叫做迴圈陣列。那麼這裡又引申出一個問題,我們的下標怎麼計算呢?

  1. 陣列的長度是5;
  2. tail當前的下標是4,也就是陣列的最後一個元素;
  3. 我們給最後一個元素賦值後,tail怎麼由陣列的最後一個下標4,變為陣列的第一個下標0?

這裡我們可以使用取模來解決:tail = (tail + 1) % mod,模(mod)就是我們的陣列長度5,我們可以試一下,tail當前值是4,套入公式計算得到0,符合我們的需求。我們再看看其他的情況符不符合,假設tail當前值是1,套入公式計算得出2,也相當於是+1操作,沒有問題的。只有當tail+1=5時,才會變為0,這是符合我們的條件的。那麼我們實現佇列的方法就選用迴圈陣列,而且陣列下標的計算方法也解決了。

佇列的空與滿

佇列的空與滿對入隊和出隊的操作是有影響的,當佇列是滿的狀態時,我們不能進行入隊操作,要等到佇列中有空餘位置才可以入隊。同樣當佇列時空狀態時,我們不能進行出隊操作,因為此時佇列中沒有元素,要等到佇列中有元素時,才能進行出隊操作。那麼我們怎麼判斷佇列的空與滿呢?

我們先看看佇列空與滿時的狀態:

空時的狀態就是佇列的初始狀態,front和tail的值是相等的。

滿時的狀態也是front == tail,我們得到的結論是,front == tail時,佇列不是空就是滿,那麼到底是空還是滿呢?這裡我們要看看是什麼操作導致的front == tail,如果是入隊導致的front == tail,那麼就是滿;如果是出隊導致的front == tail,那就是空。

手擼程式碼

好了,佇列的模型以及基本的問題都解決了,我們就可以手擼程式碼了,我先把程式碼貼出來,然後再給大家講解。

public class MyQueue<T> {

    //迴圈陣列
    private T[] data;
    //陣列長度
    private int size;
    //出隊下標
    private int front =0;
    //入隊下標
    private int tail = 0;
    //導致front==tail的原因,0:出隊;1:入隊
    private int flag = 0;

    //構造方法,定義佇列的長度
    public MyQueue(int size) {
        this.size = size;
        data = (T[])new Object[size];
    }
    
    /**
     * 判斷對佇列是否滿
     * @return
     */
    public boolean isFull() {
        return front == tail && flag == 1;
    }

    /**
     * 判斷佇列是否空
     * @return
     */
    public boolean isEmpty() {
        return front == tail && flag == 0;
    }

    /**
     * 入隊操作
     * @param e
     * @return
     */
    public boolean add(T e) {
        if (isFull()) {
            throw new RuntimeException("佇列已經滿了");
        }
        data[tail] = e;
        tail = (tail + 1) % size;
        if (tail == front) {
            flag = 1;
        }

        return true;
    }

    /**
     * 出隊操作
     * @return
     */
    public T poll() {
        if (isEmpty()) {
            throw new RuntimeException("佇列中沒有元素");
        }
        T rtnData = data[front];
        front = (front + 1) % size;
        if (front == tail) {
            flag = 0;
        }
        return rtnData;
    }
}

在類的開始,我們分別定義了,迴圈陣列,陣列的長度,入隊下標,出隊下標,還有一個非常重要的變數flag,它表示導致front == tail的原因,0代表出隊,1代表入隊。這裡我們初始化為0,因為佇列初始化的時候是空的,而且front == tail,這樣我們判斷isEmpty()的時候也是正確的。

接下來是構造方法,在構造方法中,我們定義了入參size,也就是佇列的長度,其實就是我們迴圈陣列的長度,並且對迴圈陣列進行了初始化。

再下面就是判斷佇列空和滿的方法,實現也非常的簡單,就是依照上一小節的原理。

然後就是入隊操作,入隊操作要先判斷佇列是不是已經滿了,如果滿了,我們進行報錯,不進行入隊的操作。有的同學可能會說,這裡應該等待,等待佇列有空位了再去執行。這種說法是非常正確的,我們先把最基礎的佇列寫完,後面還會再完善,大家不要著急。下面就是對迴圈陣列的tail元素進行賦值,賦值後,使用我們的公式移動tail下標,tail到達最後一個元素時,透過公式計算,可以回到第0個元素。最後再判斷一下,這個入隊操作是不是導致了front==tail,如果導致了,就將flag置為1。

出隊操作和入隊操作類似,只不過是取值的步驟,這裡不給大家詳細解釋了。

我們做個簡單的測試吧,

 public static void main(String[] args) {
        MyQueue<String> myQueue = new MyQueue<>(5);
        System.out.println("isFull:"+myQueue.isFull()+" isEmpty:"+myQueue.isEmpty());
        myQueue.add("a");
        System.out.println("isFull:"+myQueue.isFull()+" isEmpty:"+myQueue.isEmpty());
        myQueue.add("b");
        myQueue.add("c");
        myQueue.add("d");
        myQueue.add("e");
        System.out.println("isFull:"+myQueue.isFull()+" isEmpty:"+myQueue.isEmpty());
        myQueue.add("f");
    }

我們定義長度是5的佇列,分別加入a b c d e f6個元素,並且看一下空和滿的狀態。

列印日誌如下:

isFull:false isEmpty:true
isFull:false isEmpty:false
isFull:true isEmpty:false
Exception in thread "main" java.lang.RuntimeException: 佇列已經滿了
	at org.example.queue.MyQueue.add(MyQueue.java:29)
	at org.example.queue.MyQueue.main(MyQueue.java:82)

空和滿的狀態都是對的,而且再插入f元素的時候,報錯了”佇列已經滿了“,是沒有問題的。出隊的測試這裡就不做了,留個小夥伴們去做吧。

併發與等待

佇列的基礎程式碼已經實現了,我們再看看有沒有其他的問題。對了,第一個問題就是併發,我們多個執行緒同時入隊或者出隊時,就會引發問題,那麼怎麼辦呢?其實也很簡單,加上synchronized關鍵字就可以了,如下:

/**
 * 入隊操作
 * @param e
 * @return
 */
public synchronized boolean add(T e) {
    if (isFull()) {
        throw new RuntimeException("佇列已經滿了");
    }
    data[tail] = e;
    tail = (tail + 1) % size;
    if (tail == front) {
        flag = 1;
    }

    return true;
}

/**
 * 出隊操作
 * @return
 */
public synchronized T poll() {
    if (isEmpty()) {
        throw new RuntimeException("佇列中沒有元素");
    }
    T rtnData = data[front];
    front = (front + 1) % size;
    if (front == tail) {
        flag = 0;
    }
    return rtnData;
}

這樣入隊出隊操作就不會有併發的問題了。下面我們再去解決上面小夥伴提出的問題,就是入隊時,佇列滿了要等待,出隊時,佇列空了要等待,這個要怎麼解決呢?這裡要用的wait()notifyAll()了,再進行編碼前,我們先理清一下思路,

  1. 目前佇列的長度是5,並且已經滿了;
  2. 現在要向佇列插入第6個元素,插入時,判斷佇列滿了,要進行等待wait();
  3. 此時有一個出隊操作,佇列有空位了,此時應該喚起之前等待的執行緒,插入元素;

相反的,出隊時,佇列是空的,也要等待,當佇列有元素時,喚起等待的執行緒,進行出隊操作。好了,擼程式碼,

/**
 * 入隊操作
 * @param e
 * @return
 */
public synchronized boolean add(T e) throws InterruptedException {
    if (isFull()) {
        wait();
    }
    data[tail] = e;
    tail = (tail + 1) % size;
    if (tail == front) {
        flag = 1;
    }
    notifyAll();
    return true;
}

/**
 * 出隊操作
 * @return
 */
public synchronized T poll() throws InterruptedException {
    if (isEmpty()) {
        wait();
    }
    T rtnData = data[front];
    front = (front + 1) % size;
    if (front == tail) {
        flag = 0;
    }
    notifyAll();
    return rtnData;
}

之前我們拋異常的地方,統一改成了wait(),而且方法執行到最後進行notifyAll(),喚起等待的執行緒。我們進行簡單的測試,

public static void main(String[] args) throws InterruptedException {
    MyQueue<String> myQueue = new MyQueue<>(5);
    new Thread(() -> {
        try {
            System.out.println(myQueue.poll());
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
    }).start();

    myQueue.add("a");
}

測試結果沒有問題,可以正常列印"a"。這裡只進行了出隊的等待測試,入隊的測試,小夥伴們自己完成吧。

if還是while

到這裡,我們手擼的訊息佇列還算不錯,基本的功能都實現了,但是有沒有什麼問題呢?我們看看下面的測試程式,

public static void main(String[] args) throws InterruptedException {
    MyQueue<String> myQueue = new MyQueue<>(5);
    new Thread(() -> {
        try {
            System.out.println(myQueue.poll());
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
    }).start();
    new Thread(() -> {
        try {
            System.out.println(myQueue.poll());
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
    }).start();

    Thread.sleep(5000);
    myQueue.add("a");
}

我們啟動了兩個消費者執行緒,同時從佇列裡獲取資料,此時,佇列是空的,兩個執行緒都進行等待,5秒後,我們插入元素"a",看看結果如何,

a
null

結果兩個消費者都列印出了日誌,一個獲取到null,一個獲取到”a“,這是什麼原因呢?還記得我們怎麼判斷空和滿的嗎?對了,使用的是if,我們捋一下整體的過程,

  1. 兩個消費者執行緒同時從佇列獲取資料,佇列是空的,兩個消費者透過if判斷,進入等待;
  2. 5秒後,向佇列中插入"a"元素,並喚起所有等待執行緒;
  3. 兩個消費者執行緒被依次喚起,一個取到值,一個沒有取到。沒有取到是因為取到的執行緒將front加了1導致的。這裡為什麼說依次喚起等待執行緒呢?因為notifyAll()不是同時喚起所有等待執行緒,是依次喚起,而且順序是不確定的。

我們希望得到的結果是,一個消費執行緒得到”a“元素,另一個消費執行緒繼續等待。這個怎麼實現呢?對了,就是將判斷是用到的if改為while,如下:

/**
 * 入隊操作
 * @param e
 * @return
 */
public synchronized boolean add(T e) throws InterruptedException {
    while (isFull()) {
        wait();
    }
    data[tail] = e;
    tail = (tail + 1) % size;
    if (tail == front) {
        flag = 1;
    }
    notifyAll();
    return true;
}

/**
 * 出隊操作
 * @return
 */
public synchronized T poll() throws InterruptedException {
    while (isEmpty()) {
        wait();
    }
    T rtnData = data[front];
    front = (front + 1) % size;
    if (front == tail) {
        flag = 0;
    }
    notifyAll();
    return rtnData;
}

在判斷空還是滿的時候,我們使用while去判斷,當兩個消費執行緒被依次喚起時,還會再進行空和滿的判斷,這時,第一個消費執行緒判斷佇列中有元素,會進行獲取,第二個消費執行緒被喚起時,判斷佇列沒有元素,會再次進入等待。我們寫段程式碼測試一下,

public static void main(String[] args) throws InterruptedException {
    MyQueue<String> myQueue = new MyQueue<>(5);
    new Thread(() -> {
        try {
            System.out.println(myQueue.poll());
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
    }).start();
    new Thread(() -> {
        try {
            System.out.println(myQueue.poll());
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
    }).start();

    Thread.sleep(5000);
    myQueue.add("a");
    Thread.sleep(5000);
    myQueue.add("b");
}

同樣,有兩個消費執行緒去佇列獲取資料,此時佇列為空,然後,我們每隔5秒,插入一個元素,看看結果如何,

a
b

10秒過後,插入的兩個元素正常列印,說明我們的佇列沒有問題。入隊的測試,大家自己進行吧。

總結

好了,我們手擼的訊息佇列完成了,看看都有哪些重點吧,

  1. 迴圈陣列;
  2. 陣列下標的計算,用取模法;
  3. 佇列空與滿的判斷,注意flag;
  4. 併發;
  5. 喚起執行緒注意使用while

相關文章