手擼MQ消息隊列——循環數組

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

隊列的模型

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

image-20240914101140833.png

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

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

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


image-20240914102724932.png

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


image-20240914103323115.png

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


image-20240914103815186.png

第0個元素“a”在數組中移除了,并且front下標+1,指向第1個元素。

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


image-20240914104554590.png

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,這是符合我們的條件的。那么我們實現隊列的方法就選用循環數組,而且數組下標的計算方法也解決了。

隊列的空與滿

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

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


image-20240914102724932.png

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


image-20240914114618562.png

滿時的狀態也是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;
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容