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

本文是学习算法的笔记,《数据结构与算法之美》,极客时间的课程

电脑的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)。这两种适应不同的场景。比如对于响应时间很敏感的系统来说,使用无界队列,可能会有无限多个请求等待,这显然不合适。此时用有界队列来实现,当队满时就拒绝请求。设置合适的队列大小,过小会浪费资源,过大可能有长时间的等待。

猜你喜欢

转载自blog.csdn.net/every__day/article/details/83900109
今日推荐