主题
大家好,我是热爱编程的斌斌。
大家都知道,CPU是有处理上限的,也就意味着单位时间内只能处理有限数量的线程,线程又是从线程池中获得的,也就是说线程池的大小也是有限的。那么,如果此时线程池的资源已经被用完了,那新来的请求如何处理呢?是拒绝还是排队请求?各种处理出了事如何实现的那?
那就让我们带着这个问题往下看下去,相信你会找到答案!
一、如何理解队列?
和栈一样,队列也只有两个操作:入队列和出队列,是一种操作受限的数据结构。入队列:将一个元素插入到队列的末尾;出队列:从对头获取一个元素。如下图所示,简单理解,就好比排队买票,排在前面的人优先买到票,后面的人只能等前面的人处理完毕之后才能处理。也就是:先进先出。
上面对队列的概念和操作进行了简单的描述,也很容易理解。不仅如此,队列的应用也特别广泛,下面就来看看五个特殊的队列。
二、顺序队列与链式队列
1.如何实现一个队列?
你发现没有?在数据结构的学习中,我们首先接受接触了简单的类型:数组和链表,后面学习的内容复杂度成上升趋势。这还不是主要的规律,主要的是:后面的复杂数据结构几乎可以给予前面的数组和链表来实现。这里的队列也不例外,它有两种实现,一种是顺序队列;另一种是链式队列。
方便理解,废话少说,上代码:
顺序队列:
public class ArrayQueue {
private String[] items;
private int n = 0;
//head指向队头,tail指向队尾,tail指向空
private int head = 0;
private int tail = 0;
//初始化队列,根据长度
public ArrayQueue(int capacity) {
items = new String[capacity];
n = capacity;
}
//入队
public boolean enQueue(String item) {
//队列满了
//队列的元素一直入队出队,head和tail指针会一直往后移,直至队列末尾没有空间,就填入不了元素了
//不用每次出队列都搬移元素
//而是,在当存满了之后,再入队列,集中执行一次搬移操作
if (tail == n) {
//如果队列是满的,就不存
if (head == 0) return false;
//队列没有存满,但tail已经指向最后一个元素
//进行数据搬移:将[head,tail-1]的所有元素搬到0开始长度为tail-head
for (int i = head; i < tail; i++) {
items[i - head] = items[i];
}
tail = tail - head;
head = 0;
}
//入队列
items[tail++] = item;
return true;
}
//出队
public String deQueue() {
//队列为空
if (head == tail) return null;
return items[head++];
}
}
上面是对队列优化之后的代码,那对哪一点进行了优化呢?
首先来说说队列的实现,设置了两个指针,head指向对头,tail指向队尾。队列在不断的入队列和出队列的过程中,head和tail两个指针会不停地往后边移动,当tail移动到数组最后一个位置的时候,已经没有插入的空间了。这里,分为两种情况,一种是队满,一种是队列未满。如果队列已经满了,可以拒绝插入,返回false;如果数据未满,那就需要搬移元素。
那在哪个操作进行数据搬移呢?入队列还是出队列?入队列比较简单,所以应该不要给太多实现,那就把数据搬移放到出队列。当tail指向队尾了,在出队列时,不要进行任何操作;在入队列时,集中执行一次数据搬移操作。
链式队列:
链式队列和顺序队列思路一直,只不过把数组变成了链表,如图所示:
代码实现:
2.普通队列中存在什么问题?
上面说过,队列在tail指向队尾的时候,再插入数据时需要搬移数据,数组中搬移数据的时间复杂度为O(n),耗时比较长,如何来解决这个问题呢?下面讲到的循环队列就是来解决这个问题的!
三、循环队列
1.如何理解循环队列?
看下面这幅图,它是不是长得很像一个环?这就是一个循环队列,和普通的队列一样,它也有两个指针,一个指向对头,一个指向队尾。队列是怎么解决数据搬移的呢?咱们分析一下下面这种情况,当前队列的小为8,当tail指向7,也就是队列最后一个元素。继续往队列中插入a和b元素。当a插入数组时,并不需要搬移元素,而是把tail指针在环中往前面移动一个位置,指向0.
接着把b插入到0的位置,在插入a和b之后循环队列变成了下面这个样子:
2.如何实现循环队列?
要实现一个循环队列,就离不开队列的两个操作:出队列和入队列。只有数组还有空间时,才能入队列;只有队列不为空才能出队列。这就决定了循环队列实现的两个核心点:队空和队满的判断条件。
在没有元素插入时,队列处于队空状态,此时tail和head两个指针都指向第一个元素,也就是headtail时,队列处于队空;经过简单的列举法,你会发现,当满足(tail+1) % nhead时,队列处于队满的状态。
3.代码实现:
public class CircularQueue {
private String[] items;
private int n = 0;
private int head = 0;
private int tail = 0;
public CircularQueue(int capacity) {
items = new String[capacity];
n = capacity;
}
public boolean enQueue(String item) {
//表示入队列的位置
int loc = (tail + 1) % n;
//队列满了
//tail指向了最后一个位置
//tail + 1 表示在顺序队列中的位置
//%n表示相对再循环队列中的位置
if (loc == head) return false;
items[loc] = item;
tail = loc;
return true;
}
public String deQueue() {
//队列为空
if (head == tail) return null;
String ret = items[head];
head = (head + 1) % n;
return ret;
}
}
四、阻塞队列和并发队列
1.阻塞队列
首先,队列在什么时候可以处于阻塞状态?在队满情况,还有入队列操作时,队列就可以处于阻塞状态,直到数组中有空闲空间,阻塞状态结束;同样,在队空的情况下,好存在出队列的时候,队列可以处于阻塞状态,直到有元素插入队列时,阻塞状态结束。很好理解,添加了阻塞操作的队列就是阻塞队列。
阻塞队列的特点非常像“生产者-消费者”模式,使用阻塞队列可以实现这种模式,进而有效协调生成者和消费者的速度。当“生产者”生产过快,“消费者”来不及消费时,可以让生产者进入阻塞状态,直到消费者消费了之后,才唤醒“生产者”继续生成。除此之外,还可以协调“生产者”和“消费者”的个数,来提高数据的出列效率。对应上面的情况,我们可以设置多个“消费者”对应一个“生产者”。
2.并发队列
线程安全的队列被称为并发队列。如何实现线程安全呢?最直接的方式:把入队列和出队列方法填上锁。但是锁粒度大并发度会比较低,同一时刻仅允许一个存或者取操作。实际上,基于数组的循环队列,利用CAS原子操作,可以实现非常高效的并发队列。
四、解答开篇
五、总结
队列最大的特点就是先进先出,主要的两个操作是入队和出队。跟栈一样,它既可以用数组来实现,也可以用链表来实现。用数组实现的叫顺序队列,用链表实现的叫链式队列。特别是长得像一个环的循环队列。在数组实现队列的时候,会有数据搬移操作,要想解决数据搬移的问题,我们就需要像环一样的循环队列。循环队列是我们这节的重点。要想写出没有bug的循环队列实现代码,关键要确定好队空和队满的判定条件,具体的代码你要能写出来。除此之外,我们还讲了几种高级的队列结构,阻塞队列、并发队列,底层都还是队列这种数据结构,只不过在之上附加了很多其他功能。阻塞队列就是入队、出
队操作可以阻塞,并发队列就是队列的操作多线程安全。
六、课后思考
- 除了线程池这种池结构会用到队列排队请求,你还知道有哪些类似的池结构或者场景中会用到队列的排队请求呢?
- 今天讲到并发队列,关于如何实现无锁并发队列,网上有非常多的讨论。对这个问题,你怎么看呢?
1.分布式应用中的消息队列,也是一种队列结构
2.考虑使用CAS实现无锁队列,则在入队前,获取tail位置,入队时比较tail是否发生变化,如果否,则允许入队,反之,本次入队失败。出队则是获取head位
置,进行cas。