队列也是一种数据结构,今天我们主要学习几种基本的队列,然后学习下队列在线程池中的应用。
1. 什么是队列?
队列具有“先进先出,后进后出”的特点。支持操作的也有限,最基本的操作也只有两种:
-
入队enqueue():放一个数据到队列尾部
-
出队dequeue():从队列头部取出一个元素
从上图可以看出,队列和栈一样,都是操作受限的线性表数据结构。
队列作为一种非常基础的数据结构,应用非常广泛,特别是一些具有某些额外特性的队列,比如循环队列、阻塞队列、并发队列。它们在很多底层系统、框架、中间件的开发中,起着非常关键的作用。比如高性能Disruptor、Linux环形缓存等,都用到了循环并发队列;Java concurrent并发包利用ArrayBlockingQueue来实现公平锁等。
2. 顺序队列和链式队列
队列跟栈一样,都可以使用数组和链表来实现。
-
用数组实现的队列叫作顺序队列
-
用链表实现的队列叫作链式队列
顺序队列
// 用数组实现的队列
public class ArrayQueue {
// 数组:items,数组大小:n
private String[] items;
private int n = 0;
// head 表示队头下标,tail 表示队尾下标
private int head = 0;
private int tail = 0;
// 申请一个大小为 capacity 的数组
public ArrayQueue(int capacity) {
items = new String[capacity];
n = capacity;
}
// 入队
public boolean enqueue(String item) {
// 如果 tail == n 表示队列已经满了
if (tail == n) return false;
items[tail] = item;
++tail;
return true;
}
// 出队
public String dequeue() {
// 如果 head == tail 表示队列为空
if (head == tail) return null;
// 为了让其他语言的同学看的更加明确,把 -- 操作放到单独一行来写了
String ret = items[head];
++head;
return ret;
}
}
在栈中,我们只需要一个栈顶指针,但在数列中需要两个指针:
-
head指针:指向队头
-
tail指针:指向队尾
为了方便理解,可以结合下面的图来理解:当a、b、c、d依次入队后,队列中的head指针指向下标为0的位置,tail指针指向下标为4的位置。
当调用了两次出队操作后,队列中head指针指向下标为2的位置,tail指针仍然指向下标为4的位置。
从图中我们可以看出,随着不停的进行入队、出队操作,head和tail都会不断的往后移动。当tail移动到最右边时,即使数组中还有空闲的空间,但还是无法继续往队列中添加数据了。为了优化这种情况,我们只需要在出队时,不搬移数据。在入队是,如果没有空闲空间,就集中触发一次数据搬移操作。
所以优化方案就是:出队函数dequeue()保持不变,修改下enqueue()的实现:
// 入队操作,将 item 放入队尾
public boolean enqueue(String item) {
// tail == n 表示队列末尾没有空间了
if (tail == n) {
// tail ==n && head==0,表示整个队列都占满了
if (head == 0) return false;
// 数据搬移
for (int i = head; i < tail; ++i) {
items[i-head] = items[i];
}
// 搬移完之后重新更新 head 和 tail
tail -= head;
head = 0;
}
items[tail] = item;
++tail;
return true;
}
从上面的代码中可以看到,当队列的tail指针移动到数组最右边后,如果有新的数据入队,就将head和tail之间的数据,整体搬移到数组中0到tail-head的位置。
这种实现思路,出队操作的时间复杂度是O(1)。
链式队列
基于链表的实现,同样需要两个指针:
-
head指针:指向链表的第一个结点
-
tail指针:指向链表的最后一个结点
代码实现如下:
package queue;
public class QueueBasedOnLinkedList {
// 队列的队首和队尾
private Node head = null;
private Node tail = null;
// 入队
public void enqueue(String value) {
if (tail == null) {
Node newNode = new Node(value, null);
head = newNode;
tail = newNode;
} else {
tail.next = new Node(value, null);
tail = tail.next;
}
}
// 出队
public String dequeue() {
if (head == null) return null;
String value = head.data;
head = head.next;
if (head == null) {
tail = null;
}
return value;
}
public void printAll() {
Node p = head;
while (p != null) {
System.out.print(p.data + " ");
p = p.next;
}
System.out.println();
}
private static class Node {
private String data;
private Node next;
public Node(String data, Node next) {
this.data = data;
this.next = next;
}
public String getData() {
return data;
}
}
}
为了方便理解,画了如下图:
从图中可以看出:
入队时:tail->next=new_node,tail=tail->next
出队时:head=head->next
3. 循环队列
在前面用数组实现队列的时候,当tail=n时,就会进行数据的搬移操作,这样在入队的时候性能就会受影响。而循环队列就可以有效的避免这种情况下的数据搬移。
循环队列的首尾相连,形成一个环形,如下图所示:
上面队列的大小是8,当前head=4,tail=7。
当有一个新元素a入队时,将其放到下标为7的位置,但此时并不将tail更新为8,而是将其在环中后移一位,到下标为0的位置。
当再有一个新元素b入队时,将其放到下标为0的位置,然后tail加1更新为1。所以,在a、b依次入队后,循环队列就变成了如下所示:
通过上面的方法,就可以避免数据搬移的操作。
在用数组实现的非循环队列中,队满的判断条件是tail == n,队空的判断条件是head==tail。
在循环队列中,队空的条件仍然是head==tail,但队满的条件有点复杂,为了总结出规律,画了一张队满的图,如下所示:
在上图中,head=4,tail=3,n=8,故总结出的规律是(3+1)%8=4,所以在循环队列中,队满的判断条件是:(tail+1)%n=head。
从上面队满的图中可以看到,其实,当队列满的时候,tail指向的位置实际上没有存储数据,所以循环队列会浪费一个数组的存储空间。
循环队列代码实现如下:
public class CircularQueue {
// 数组:items,数组大小:n
private String[] items;
private int n = 0;
// head 表示队头下标,tail 表示队尾下标
private int head = 0;
private int tail = 0;
// 申请一个大小为 capacity 的数组
public CircularQueue(int capacity) {
items = new String[capacity];
n = capacity;
}
// 入队
public boolean enqueue(String item) {
// 队列满了
if ((tail + 1) % n == head) return false;
items[tail] = item;
tail = (tail + 1) % n;
return true;
}
// 出队
public String dequeue() {
// 如果 head == tail 表示队列为空
if (head == tail) return null;
String ret = items[head];
head = (head + 1) % n;
return ret;
}
}
4. 阻塞队列和并发队列
阻塞队列
在队列的基础上加上阻塞操作。
在队列为空的时候,从队头取数据会被阻塞。因为此时没有数据可取,直到队列中有了数据才能返回;在队列满了的时候,从队尾插入数据就会被阻塞,直到队列中有空闲位置时,再插入数据,然后再返回。
从上面的图可以看出,阻塞队列是一种“生产者——消费者模型”,这种模型可以有效地协调生产和消费的速度。当“生产者”的生产速度过快,“消费者”来不及消费时,存储数据的队列很快就会变满,此时,生产者就会阻塞等待,直到“消费者”消费了数据,“生产者”才会被唤醒继续“生产”。
其实,基于阻塞队列,我们可以通过协调“生产者”和“消费者”的个数来提高数据的处理效率,例如我们可以配置多个“消费者”,如下图所示:
并发队列
在多线程情况下,会有多个线程同时操作队列,而线程安全的队列我们叫作“并发队列”。
并发队列最简单的实现方式就是直接在enqueue()、dequeue()的方法上加锁,同一时刻只允许一个存或取的操作。但当锁粒度较大时,并发度会比较低。
5. 队列在线程池中的应用
当线程池中没有空闲线程时,此时线程池一般有两种处理策略:
-
非阻塞式的处理方式,直接拒绝任务请求
-
阻塞式的处理方式,将请求排队,等到有空闲线程时,取出排队的请求继续处理。
为了公平的处理每个排队的请求,我们可以采用队列这种数据结构去处理。从前面我们可以知道队列有基于链表和数组的两种实现方式,但这两种实现方式对于排队请求又有区别:
-
基于链表的实现方式,可以实现一个支持无限排队的无解队列,但可能会导致过多的请求排队等待,请求处理响应的时间过长,所以,针对响应时间比较敏感的系统,基于链表实现的无限排队线程池是不合适的。
-
基于数组实现的有界队列,队列的大小有限,在线程池中排队请求超过队列大小时,后续的请求就会直接被拒绝。这种方式对响应时间敏感的系统来说,就会更加合理。但是,设置一个合理的队列大小,也是非常重要的。队列太大会导致等待的请求太多,队列太小会导致无法充分利用系统资源,不能发挥出最大性能。
实际上,对于大部分资源有限的场景,当没有空闲资源时,基本上都可以通过“队列”这种数据结构来实现请求排队。
本文介绍了队列的基本概念和操作,包括顺序队列、链式队列、循环队列以及阻塞队列和并发队列。重点讨论了队列在线程池中的应用,分析了不同实现方式对响应时间和资源利用的影响,强调了队列在系统设计中的重要性。
138

被折叠的 条评论
为什么被折叠?



