队列在线程池等有限资源池中的应用
一 概述
CPU的资源是有限的,任务的处理速度与线程个数并不是完全线性正相关的。相反,过多的线程反而会导致CPU资源频繁的切换,处理性能下降。所以,线程池的大小一般都是综合考虑要处理任务的特点和硬件环境,来事先设置的。
二 队列
队列的概念非常好理解,你可以把它想象成排队买票,先来的先买,后来的人只能站末尾,不允许插队。先进者先出,这就是典型的”队列”。队列是一种操作受限的线程表数据结构,存在两个基本操作:入队enqueue(),放一个数据到队列尾部;出队dequeue(),从队列头部取一个元素。
对了作为一种非常基础的数据结构,队列的应用也非常广泛,特别是一些具有某些额外特性的队列,比如循环队列,阻塞队列,并发队列。其实队列是很多偏底层系统的,框架,中间件开发的重要底层数据结构。比如高性能队列Disruptor,Linux环型缓存,都用到了循环并发队列;此外Java concurrent并发包利用ArrayBlockingQueue来实现公平锁等。
三 顺序队列和链式队列
队列和栈一样,也是一种抽象的数据结构。它具有先进先出的特性,支持在队尾插入元素,在对头删除元素。我们可以用数组来实现,也可以用链表来实现。用数组实现的队列叫做顺序队列,用链表实现的队列叫作链式队列。
顺序队列
栈的出栈入栈都是操作同一个头结点,队列的入队和出队分别操作队尾和队头。
当我们调用两次出队操作之后,队列中的head指针指向下标为2的位置,tail指针仍然指向下标为4的位置。
随着不停地进行入队,出队操作,head和tail都会持续往后移动。当tail移动到最右边,即使数组中还有空闲空间,也无法继续往队列中添加数据。此时可以通过数据搬移,使得每次出队操作都相当于删除数组下标为0的数据,要搬移整个队列中的数据,这样出队操作的时间复杂度就会从原来的O(1)变为O(n),实际上,我们在出队时可以不用搬移数据,如果没有空闲时间了,我们只需要在入队时,再集中触发一次数据的搬移操作。
当队列的tail指针移动到数组的最右边后,如果有新的数据入队,我们可以将head到tail之间的数据,整体搬移到数组下标为0的位置。
链式队列
基于链表的实现的队列中,同样需要两个指针:head指针和tail指针。它们分别指向链表的第一个结点和最后一个结点。
入队时:tail ->next = new node(增加结点), tail = tail->next(增加的结点为尾部结点);
出队时:head = head->next(将删除前的头节点下一个作为新的头结点)。
四 循环队列
当通过数组实现循环队列的时候,再tail = n的时候,会有数据搬移操作,这样入队的性能就会收到影响。为了解决数据搬移的问题,我们可以通过循环队列来解决问题。
循环队列,顾名思义,他长得像一个环。原来数组时有头有尾的,时一条直线。现在我们把首尾相连,组成一个队列环。
图中的队列的大小为8,当前head为4,tail为7。当有一个新的元素a入队时,我们放入下标为7的位置。但这个时候,我们并不把tail更新为8,而是将其在环中后移一位,到下标为0 的位置。当再有一个元素b入队时,我们将b放入下标为0的位置。然后tail加1更新为1。所以,在a,b依次入队之后的样子。
通过这样的方法,我们成功避免了数据搬移操作。看起来不难理解,但是循环队列的代码实现难度要比前面讲的非循环队列难多了。要像写出没有bug的循环队列的实现代码,最关键的时,确定好队空和队满的判定条件。
在用数组实现的非循环队列中,队满的判断条件是tail == n,队空的判断条件是head == tail。在循环队列中,队列为空的判断条件仍然是head == tail,但队列满的判断条件就稍微有点复杂了。
如图为队满的情况,tail = 3,head = 4,n = 8;有数据发现(3+1)%8=4。根据规律,队列满时,可以得到队满的公式为(tail +1)%n = head。
当队列满时,图中的tail指向的位置实际上是没有存储数据的。所以,循环队列会浪费一个数组的内存空间。
五 阻塞队列和并发队列
阻塞队列
阻塞队列其实就是在队列的基础上增加了阻塞操作。简单来说,就是在队列为空的时候,从队头取数据会被阻塞。因为此时还没有数据可取,直到队列中有了数据才能返回;如果队列已经满了,那么插入数据的操作就会被阻塞,直到队列中有空闲的位置后再插入数据,然后再返回。
“生产者-消费者模型”可以通过阻塞队列来完成。这种基于阻塞队列实现的”生产者-消费者模型”,可以有效的协调生产和消费的速度。当”生产者”生产数据的速度过快,”消费者”来不及消费时,存储数据的队列很快就会满了。这个时候,生产者就阻塞等待,直到”消费者”消费了数据,”生产者”才会被唤醒继续”生产”。
不仅如此,基于阻塞队列,我们还可以通过协调”生产者”和”消费者”的个数,来提高数据的处理效率。
在多线程情况下,会有多个线程同时操作队列,这个时候就会存在线程安全问题。线程安全的队列叫作并发队列。最简单直接的实现方式是直接在enqueue(),dequeue()方法上加锁,但是锁粒度大并发度比较低,同一时刻仅允许一个存或者取操作。实际上,基于数组的循环队列,利用CAS原子操作,可以实现非常高效的并发队列。这也是循环队列比链式队列应用更加广泛的原因。
六 队列在线程池等有限资源池中的应用
当线程池没有空闲线程时,处理新的任务请求资源时,线程池存在两种处理策略:
- 第一种是非阻塞的处理方式,直接拒绝任务请求;
- 第二种是阻塞的处理方式,将请求排队,等到有空闲线程时,取出排队的请求继续处理。
我们希望公平地处理每个排队的请求,先进者先服务,所以队列这种数据很适合来存储排队请求。我们前面说过,队列有基于链表和基于数组这两种实现方式。
基于链表的实现方式,可以实现一个支持无限队列的无界队列(unbounded queue),但是可能会导致过多的请求排队等待,请求处理的响应时间过长。所以,针对响应时间比较敏感的系统,基于链表实现的无线排队的线程池时不合适的。
基于数组实现的有界队列(bounded queue),队列的大小有限,所以线程池中排队的请求超过队列大小时,接下来的请求就会被拒绝,这种方式对响应时间铭感的系统来说,就相对更加合理。不过,设置一个合理的队列大小,也是非常有讲究的。队列太大导致等待的请求太多,队列太小会导致无法充分利用系统资源,发挥最大性能。
实际上,对于大部分资源有限的场景,当没有空闲资源时,基本上都可以通过”队列”这种数据结构来实现请求排队。
还没有评论,来说两句吧...