队列:队列在线程池等有限资源池中的应用
一、如何理解队列?
队列跟栈一样,也是一种操作受限的线性表数据结构。
栈只支持两个基本操作:入栈 push()和出栈 pop()。队列,先进者先出,入队 enqueue(),放一个数据到队列尾部;出队 dequeue(),从队列头部取一个元素。
队列的应用:比如循环队列、阻塞队列、并发队列。它们在很多偏底层系统、框架、中间件的开发中,起着关键性的作用。比如高性能队列 Disruptor、Linux 环形缓存,都用到了循环并发队列;Java concurrent 并发包利用 ArrayBlockingQueue 来实现公平锁等。
二、顺序队列和链式队列
用数组实现的队列叫作顺序队列,用链表实现的队列叫作链式队列。
1、顺序队列
// 用数组实现的队列 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; } }
// 入队操作,将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; }
2、链式队列 : head 指针和 tail 指针。它们分别指向链表的第一个结点和最后一个结点。如图所示,入队时,tail->next= new_node, tail = tail->next;出队时,head = head->next。
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; } }
三、循环队列
上面用数组来实现队列的时候,在 tail==n 时,会有数据搬移操作,这样入队操作性能就会受到影响。那么循环队列可以解决。
写好一个循环队列的关键是判断好队列为空和满的情况。
- 队列为空:head == tail
- 队列为满: (tail + 1)/length == head
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; } }
四、阻塞队列和并发队列
阻塞队列就是在队列的基础上增加了阻塞的操作,即队列为空的时候,从队列头取数据会被阻塞,直到队列不为空的时候再获取。当队列满的时候,从队列尾插入数据会被阻塞,直到队列不满的时候再插入。
上述定义即为一个生产者消费者模型,因此使用阻塞队列能够很容易的实现该模型。参考链接:Java中的并发队列和阻塞队列、生产者消费者
enQueue()
和deQueue()
方法中加锁。但是锁的并发粒度比较低,同一时刻只能够进行一次操作。基于数组的循环队列,使用CAS操作就可以实现非常高效的并发队列,这也是循环队列比链式队列使用更广泛的原因。当“生产者”生产数据的速度过快,“消费者”来不及消费时,存储数据的队列很快就会满了,这时生产者就阻塞等待,直到“消费者”消费了数据,“生产者”才会被唤醒继续生产。不仅如此,基于阻塞队列,我们还可以通过协调“生产者”和“消费者”的个数,来提高数据处理效率,比如配置几个消费者,来应对一个生产者。
五、队列如何在线程池中使用
问题:线程池没有空闲线程时,新的任务请求线程资源时,线程池该如何处理?各种处理策略又是如何实现的呢?
答:两种处理策略。第一种是非阻塞的处理方式,直接拒绝任务请求;另一种是阻塞的处理方式,将请求排队,等到有空闲线程时,取出排队的请求继续处理。
那如何存储排队的请求呢?我们希望公平地处理每个排队的请求,先进者先服务,所以队列这种数据结构很适合来存储排队请求。我们前面说过,队列有基于链表和基于数组这两种实现方式。
这两种实现方式对于排队请求又有什么区别呢?
基于链表的实现方式,可以实现一个支持无限排队的无界队列(unbounded queue),但是可能会导致过多的请求排队等待,请求处理的响应时间过长。所以,针对响应时间比较敏感的系统,基于链表实现的无限排队的线程池是不合适的。而基于数组实现的有界队列(bounded queue),队列的大小有限,所以线程池中排队的请求超过队列大小时,接下来的请求就会被拒绝,这种方式对响应时间敏感的系统来说,就相对更加合理。不过,设置一个合理的队列大小,也是非常有讲究的。队列太大导致等待的请求太多,队列太小会导致无法充分利用系统资源、发挥最大性能。
除了前面讲到队列应用在线程池请求排队的场景之外,队列可以应用在任何有限资源池中,用于排队请求,比如数据库连接池等。实际上,对于大部分资源有限的场景,当没有空闲资源时,基本上都可以通过“队列”这种数据结构来实现请求排队。