佇列是咱們開發中經常使用到的一種資料結構,它與棧的結構類似。然而棧是後進先出,而佇列是先進先出,說的專業一點就是FIFO。在生活中到處都可以找到佇列的,最常見的就是排隊,吃飯排隊,上地鐵排隊,其他就不過多舉例了。
佇列的模型
在資料結構中,和排隊這種場景最像的就是陣列了,所以我們的佇列就用陣列去實現。在排隊的過程中,有兩個基本動作就是入隊和出隊,入隊就是從隊尾插入一個元素,而出隊就是從隊頭移除一個元素。基本的模型我們可以畫一個簡圖:
看了上面的模型,我們很容易想到使用陣列去實現佇列,
- 先定義一個陣列,並確定陣列的長度,我們暫定陣列長度是5,而上面圖中的長度是一樣的;
- 再定義兩個陣列下標,front和tail,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個元素,形成一個迴圈,這就叫做迴圈陣列。那麼這裡又引申出一個問題,我們的下標怎麼計算呢?
- 陣列的長度是5;
- tail當前的下標是4,也就是陣列的最後一個元素;
- 我們給最後一個元素賦值後,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 f
6個元素,並且看一下空和滿的狀態。
列印日誌如下:
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()
了,再進行編碼前,我們先理清一下思路,
- 目前佇列的長度是5,並且已經滿了;
- 現在要向佇列插入第6個元素,插入時,判斷佇列滿了,要進行等待
wait()
; - 此時有一個出隊操作,佇列有空位了,此時應該喚起之前等待的執行緒,插入元素;
相反的,出隊時,佇列是空的,也要等待,當佇列有元素時,喚起等待的執行緒,進行出隊操作。好了,擼程式碼,
/**
* 入隊操作
* @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
,我們捋一下整體的過程,
- 兩個消費者執行緒同時從佇列獲取資料,佇列是空的,兩個消費者透過
if
判斷,進入等待; - 5秒後,向佇列中插入"a"元素,並喚起所有等待執行緒;
- 兩個消費者執行緒被依次喚起,一個取到值,一個沒有取到。沒有取到是因為取到的執行緒將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秒過後,插入的兩個元素正常列印,說明我們的佇列沒有問題。入隊的測試,大家自己進行吧。
總結
好了,我們手擼的訊息佇列完成了,看看都有哪些重點吧,
- 迴圈陣列;
- 陣列下標的計算,用取模法;
- 佇列空與滿的判斷,注意flag;
- 併發;
- 喚起執行緒注意使用
while
;