【06】队列:队列在线程池等有限资源池中的应用

1. 什么是队列(queue)?

  1. 先进者先出,这就是典型的“队列”结构。
  2. 支持两个操作:入队enqueue(),放一个数据到队尾;出队dequeue(),从队头取一个元素。
  3. 和栈一样,队列也是一种操作受限的线性表

2. 如何实现队列?

队列同样可以由数组和链表来实现。用数组实现的队列叫顺序队列,用链表实现的队列叫链式队列。
队列需要两个指针:一个是 head 指针,指向队头;一个是 tail 指针,指向队尾。

  1. 顺序队列
    顺序队列存在一个问题,随着不停地进行入队、出队操作,head 和 tail 都会持续往后移动。当 tail 移动到最右边,即使数组中还有空闲空间,也无法继续往队列中添加数据了。
    解决方法,我们在出队时可以不用搬移数据。如果没有空闲空间了,只需要在入队时,再集中触发一次数据的搬移操作。出队函数 dequeue() 保持不变,我们稍加改造一下入队函数 enqueue() 的实现,当队列的 tail 指针移动到数组的最右边后,如果有新的数据入队,我们可以将 head 到 tail 之间的数据,整体搬移到数组中 0 到 tail-head 的位置。
  2. 链式队列
    基于链表的实现,同样需要两个指针:head 指针和 tail 指针。分别指向链表的第一个结点和最后一个结点。入队时,tail->next= new_node, tail = tail->next;出队时,head = head->next。
    链式队列与顺序队列的区别是没有容量限制,在请求排队的场景下,如果排队的请求数量过多,请求处理的响应时间会过长。
  3. 循环队列
    1)循环队列,原本数组是有头有尾的,是一条直线。把首尾相连,扳成了一个环。
    2)在数组实现队列的时候,会有数据搬移操作,要想解决数据搬移的问题,需要像环一样的循环队列。
    3)要想写出没有 bug 的循环队列的实现代码,最关键的是,确定好队空和队满的判定条件,判定条件如下:
    (1)队列为空的判断条件仍然是 head == tail。
    (2)当队满时,(tail+1)%n=head。 tail 指向的位置实际上是没有存储数据的。所以,循环队列会浪费一个数组的存储空间。
    【注】循环队列:队列满的表达式
    这里讲一下,这个表达式是怎么来的。在一般情况下,我们可以看出来,当队列满时,tail+1=head。但是,有个特殊情况,就是tail=n-1,而head=0时,这时候,tail+1=n,而head=0,所以用(tail+1)%n == n%n == 0。而且,tail+1最大的情况就是 n ,不会大于 n,这样,tail+1 除了最大情况,不然怎么余 n 都是 tail+1 本身,也就是 head。这样,表达式就出现了。
    4)循环队列的长度设定需要对并发数据有一定的预测,否则会丢失太多请求。
    【循环队列的数组实现的另一个思路】:
    定义一个记录队列大小的值size,当这个值与数组大小相等时,表示队列已满,当tail达到最底时,size不等于数组大小时,tail就指向数组第一个位置。当出队时,size—,入队时size++

2.1 队列API

public interface Queue<T> {
public void enqueue(T item); //入队
public T dequeue(); //出队
public int size(); //统计元素数量
public boolean isNull(); //是否为空
}

2.2 数组实现(顺序队列):

public class ArrayQueue {
//存储数据的数组
private String[] items;
//记录数组容量
private int n;
private int size;
//head记录队头索引,tail记录队尾索引
private int head = 0;
private int tail = 0;
//申请一个指定容量的队列
public ArrayQueue(int capacity){
items = new String[capacity];
n = capacity;
}
/*
* 入队:
* 1.堆满的时,入队失败
* 1.1频繁出入队,造成数组使用不连续
* 1.2在入队的时候,集中触发进行数据搬移
* 2.在末尾插入数据,注意tail指向队尾元素的索引+1
*/
public boolean enqueue(String item){
//表示队满
if(head == 0 && tail == n)
return false;
//表示需要数据搬移
else if(head != 0 && tail == n){
for (int i = head; i < tail; i++) {
items[i-head] = items[i];
}
head = 0;
tail = tail - head;
}
//将数据加入队列
items[tail++] = item;
size++;
return true;
}
//出队:1.队空时,出队失败;2.出队,head索引+1
public String dequeue(){
String res = null;
if(head == tail) return res;
res = items[head++];
size--;
return res;
}
}

2.3 链表实现(链式队列):

public class LinkedQueue {
//定义一个节点类
private class Node{
String value;
Node next;
}
//记录队列元素个数
private int size = 0;
//head指向队头结点,tail指向队尾节点
private Node head;
private Node tail;
//申请一个队列
public LinkedQueue(){}
//入队
public boolean enqueue(String item){
Node newNode = new Node();
newNode.value = item;
if (size == 0) head = newNode; 
else tail.next = newNode;
tail = newNode;
size++;
return true;
}
//出队
public String dequeue(){
String res = null;
if(size == 0) return res;
if(size == 1) tail = null;
res = head.value;
head = head.next;
size--;
return res;
}
}

2.4 循环队列(基于数组):

public class LoopArrayQueue {
//存储数据的数组
private String[] items;
//记录数组容量
private int n;
private int size = 0;
//head记录队头索引,tail记录队尾索引
private int head = 0;
private int tail = 0;
//申请一个指定容量的队列
public LoopArrayQueue(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;
size++;
return true;
}
//出队:关键在于队空的条件
public String dequeue(){
String res = null;
if(head == tail) return res;
res = items[head];
head = (head + 1) % n;
size--;
return res;
}
}

3. 队列有哪些常见的应用?

  1. 阻塞队列
    1)在队列的基础上增加阻塞操作,就成了阻塞队列。
    2)阻塞队列就是在队列为空的时候,从队头取数据会被阻塞,因为此时还没有数据可取,直到队列中有了数据才能返回;如果队列已经满了,那么插入数据的操作就会被阻塞,直到队列中有空闲位置后再插入数据,然后在返回。
    3)从上面的定义可以看出这就是一个“生产者-消费者模型”。这种基于阻塞队列实现的“生产者-消费者模型”可以有效地协调生产和消费的速度。当“生产者”生产数据的速度过快,“消费者”来不及消费时,存储数据的队列很快就会满了,这时生产者就阻塞等待,直到“消费者”消费了数据,“生产者”才会被唤醒继续生产。不仅如此,基于阻塞队列,我们还可以通过协调“生产者”和“消费者”的个数,来提高数据处理效率,比如配置几个消费者,来应对一个生产者。
  2. 并发队列
    1)在多线程的情况下,会有多个线程同时操作队列,这时就会存在线程安全问题。能够有效解决线程安全问题的队列就称为并发队列。
    2)并发队列简单的实现就是在enqueue()、dequeue()方法上加锁,但是锁粒度大并发度会比较低,同一时刻仅允许一个存或取操作。
    3)实际上,基于数组的循环队列利用CAS原子操作,可以实现非常高效的并发队列。这也是循环队列比链式队列应用更加广泛的原因。
  3. 线程池没有空闲线程时,新的任务请求线程资源时,线程池该如何处理?各种处理策略又是如何实现的呢?
    一般有两种处理策略。第一种是非阻塞的处理方式,直接拒绝任务请求;另一种是阻塞的处理方式,将请求排队,等到有空闲线程时,取出排队的请求继续处理。
    1)基于链表的实现方式,可以实现一个支持无限排队的无界队列(unbounded queue),但是可能会导致过多的请求排队等待,请求处理的响应时间过长。所以,针对响应时间比较敏感的系统,基于链表实现的无限排队的线程池是不合适的。
    2)基于数组实现的有界队列(bounded queue),队列的大小有限,所以线程池中排队的请求超过队列大小时,接下来的请求就会被拒绝,这种方式对响应时间敏感的系统来说,就相对更加合理。不过,设置一个合理的队列大小,也是非常有讲究的。队列太大导致等待的请求太多,队列太小会导致无法充分利用系统资源、发挥最大性能。
    (除了前面讲到队列应用在线程池请求排队的场景之外,队列可以应用在任何有限资源池中,用于排队请求,比如数据库连接池等。实际上,对于大部分资源有限的场景,当没有空闲资源时,基本上都可以通过“队列”这种数据结构来实现请求排队。)

4. 思考

  1. 除了线程池这种池结构会用到队列排队请求,还有哪些类似线程池结构或者场景中会用到队列的排队请求呢?
    1)像windows操作系统的消息队列,略高级一些带有优先级。还有qt中的信号与槽函数机制,使用connect链接,其中的参数就是设置为把窗口界面消息放到消息队列,然后一次取出。比如优先级消息,窗口系统关闭,优先级高,则就直接执行关闭操作。
    2)sockets网络连接队列。
    3)数据库连接队列。
    4)一种集群操作,很多客户端像服务端请求资源,处理高并发大量请求。把这些请求放到队列中。
    5)分布式应用中的消息队列,也是一种队列结构,如 kafka 也是一种队列。
  2. 今天讲到并发队列,关于如何实现无锁的并发队列,网上有很多讨论。对这个问题,你怎么看?
    考虑使用CAS实现无锁队列,则在入队前,获取tail位置,入队时比较tail是否发生变化,如果否,则允许入队,反之,本次入队失败。出队则是获取head位置,进行cas。

5. 参考资料

  1. 王争老师在极客时间的专栏《数据结构与算法之美》
  2. 专栏下的所有评论

6. 声明

本文章是学习王争老师在极客时间专栏——《数据结构与算法之美》的学习总结,文章很多内容直接引用了专栏下的回复,推荐大家购买王争老师的专栏进行更加详细的学习。本文仅供学习使用,勿作他用,如侵犯权益,请联系我,立即删除。

发布了128 篇原创文章 · 获赞 157 · 访问量 13万+

猜你喜欢

转载自blog.csdn.net/qq_27283619/article/details/100609958
今日推荐