本文是学习算法的笔记,《数据结构与算法之美》,极客时间的课程
电脑的CPU资源是有限的,任务的处理速度与线程数量之间并不是正相关。当线程数量过多,CPU要频繁的在不同线程切换,反而会引起处理性能的下降。线程池中最大的线程数,是考虑多种因素来事先设定的,比如硬件的条件,业务的类型等等。
当我们向一个固定大小的的线程池中请求一个线程时,当线程池中没有空闲资源了,这个时候线程池如何处理这个请求?是拒绝请求还是排队请求?各种策略又是如何实现的呢?
实际上,这些问题的处理并不复杂,底层的数据结构,就是今天要说的队列(queue)
队列这个概念很好理解,典型的先进先出,好比排队买票,先来的先买,后来的后买,不允许插队。最基本的操作是入队 enqueue() 和出队 dequeue() 。会不会觉得这和栈有点类似呢!
队列作为一种基本的数据结构,应用很广泛,特别是具有某些额外功能的队列,比如循环队列、阻塞队列、并发队列等等。它们在很多偏底层系统、框架、中间件的开发中,起着关键作用。比如高性能队列Disruptor 、Linux环形缓存,都用到了循环并发队列;Java concurrent 并发包利用ArrayBlockingQueue来实现公平锁等。
顺序队列和链式队列
与栈类似,用数组实现的队列叫作顺序队列,用链表实现的队列叫作链式队列。
public class ArrayQueue{
private String[] items; // 声明一个数组
private int n; // 数组大小
private int head = 0; // 队头下标
private int tail = 0; // 队尾下标
public ArrayQueue(int capacity) {
items = new String[capacity];
n = capacity;
}
// 入队
public boolean enqueue(String item) {
if (tail == n) { // tail == n 表示队列已经满了
return false;
}
items[tail] = item;
++tail;
return true;
}
// 出队
public String dequeue() {
if(head == tail) { // head == tail 表示队列为空
return null;
}
String ret = items[head];
++head;
return ret;
}
}
可以结合下图来理解
当我们两次调用出队操作之后,队列中的head指针指向下标为2的位置,tail指针仍然指向下标为4的位置。
你肯定可以看出来,随着不停的出队入队操作,两个指针都会往后移动,当tail指针移动到最右边,即使数组中有空闲空间,也无法继续往队列中添加数据了。这个问题该如何解决呢?
当然每次出队操作后,进行数组移动就可以解决问题了,但时间复杂度就由O(1)变为O(n)。要怎样优化呢?每次出队操作时,不进行数据搬移,只有当没有tail指针到最右边时,集中进行一次数据搬移。只需要改动下入队的代码即可。
// 入队
public boolean enqueue(String item) {
if (tail == n) {
if (head == 0) {
return false;
}
// 数据搬移
for (int i = head; i < tail; i++) {
items[i-head] = items[i];
}
// 搬移后,重置指针位置
tail -= head;
head = 0;
}
items[tail] = item;
++tail;
return true;
}
循环队列
刚才,我们用数组实现的队列,当tail == n 时,会有搬移数据的操作,这样的入队操作会影响性能,有没有办法避免数据移动呢?我们看看循环队列的解决思路。
循环队列,就像一个环,如下图,可以直观的感受下。
如图,当前 head = 4, tail = 7。当把一个新元素 a 入队时,就把它放在下标为7的位置,此时 tail 往后移一位,到下标为 0 的位置。同理再放入新元素 b 时,下标更新为1。如下图的样子
循环队列中,tail指向的位置实际上是没有存储数据的,会浪费一个数组的存储空间。
判断队空的条件是 head == tail ,判断队满的条件有点不好想 (tail+1)%n = head。可以直接看代码来理解
public class CircularQueue {
private String[] items; // 声明一个数组
private int n; // 数组大小
private int head = 0; // 队头下标
private int tail = 0; // 队尾下标
public CircularQueue(int capacity) {
items = new String[capacity];
n = capacity;
}
// 入队
public boolean enqueue(String item) {
if (head == (tail + 1) % n) {
return false;
}
items[tail] = item;
tail = (tail + 1) % n;
return true;
}
// 出队
public String dequeue() {
if (head == tail) { // head == tail 表示队列为空
return null;
}
String ret = items[head];
head = (head + 1) % n;
return ret;
}
}
阻塞队列和并发队列
阻塞队列,是在队列的基础上增加了阻塞操作。简单来说,当队列为空时,从队头取数据会被阻塞,直到队列中有数据才返回。同样,当队满时,插入数据的操作会被阻塞,直到队列中的空闲位置后再插入数据,然后返回。
线程安全队列又叫作并发队列,最简单直接的实现方法是在 enqueue()、dequeue() 方法上加锁,这样的锁的粒度较大。实际上,基于数组的循环队列,利用CAS原子操作,可以实现非常高效的并发队列。这也是是循环队列比链式队列应用更加广泛的原因。这个在之后的篇章中再细说。
最后我们说说开篇提出的问题。当线程池中没有空闲线程时,有两种处理策略,一个是直接拒绝请求,另一个是请求排队,当有空闲的线程时,再响应请求。
当请求排队的时候,我们当然希望遵循先来后到的原则, 这时用队列就可以很好的实现这个需求。队列的实现又有两种,其一是以链表来实现的无界队列(unbounded queue),另外一个是以数组实现的有界队列(bounded queue)。这两种适应不同的场景。比如对于响应时间很敏感的系统来说,使用无界队列,可能会有无限多个请求等待,这显然不合适。此时用有界队列来实现,当队满时就拒绝请求。设置合适的队列大小,过小会浪费资源,过大可能有长时间的等待。