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


前言

我们知道,CPU 资源是有限的,任务的处理速度与线程个数并不是线性正相关。相反,过多的线程反而会导致 CPU 频繁切换,处理性能下降。 所以,线程池的大小一般都是综合考虑要处理任务的特点和硬件环境,来事先设置的。

当我们向固定大小的线程池中请求一个线程时,如果线程池中没有空闲资源了,这个时候线程池如何处理这个请求?是拒绝请求还是排队请求?各种处理策略又是怎么实现的呢?

实际上,这些问题并不复杂,其底层的数据结构就是我们今天要学的内容,队列(queue)。


一、如何理解“队列”?

典型的队列就像排队买票,先来的先买。

队列跟栈一样,也是一种操作受限的线性表数据结构。

在这里插入图片描述

二、顺序队列和链式队列

跟栈一样,队列可以用数组来实现,也可以用链表来实现。用数组实现的栈叫作顺序栈,用链表实现的栈叫作链式栈。同样,用数组实现的队列叫作顺序队列,用链表实现的队列叫作链式队列

1. 基于数组的队列实现方法

// 用数组实现的队列
public class ArrayQuenen {
	// 数组: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;
	}

你可以结合下面这张图来理解。当 a、b、c、d 依次入队之后,队列中的 head 指针指向下标为 0 的位置,tail 指针指向下标为 4 的位置。
在这里插入图片描述
当我们调用两次出队操作之后,队列中 head 指针指向下标为 2 的位置,tail 指针仍然指向下标为 4 的位置。
在这里插入图片描述
你肯定已经发现了,随着不停地进行入队、出队操作,head 和 tail 都会持续往后移动。当 tail 移动到最右边,即使数组中还有空闲空间,也无法继续往队列中添加数据了。这个问题该如何解决呢?

你是否还记得,在数组那一节,我们也遇到过类似的问题,就是数组的删除操作会导致数组中的数据不连续。你还记得我们当时是怎么解决的吗?对,用数据搬移

但是,每次进行出队操作都相当于删除数组下标为 0 的数据,要搬移整个队列中的数据,这样出队操作的时间复杂度就会从原来的 O(1) 变为 O(n)。能不能优化一下呢?

实际上,我们在出队时可以不用搬移数据。如果没有空闲空间了,我们只需要在入队时,再集中触发一次数据的搬移操作。借助这个思想,出队函数 dequeue() 保持不变,我们稍加改造一下入队函数 enqueue() 的实现,就可以轻松解决刚才的问题了。下面是具体的代码:

// 入队操作,将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;
}

从代码中我们看到,当队列的 tail 指针移动到数组的最右边后,如果有新的数据入队,我们可以将 head 到 tail 之间的数据,整体搬移到数组中 0 到 tail-head 的位置。
在这里插入图片描述
这种实现思路中,出队操作的时间复杂度仍然是 O(1),但入队操作的时间复杂度还是 O(1) 吗?
答案是肯定的。最好情况时间复杂度是O(1),最差情况时间复杂度是O(n),均摊实践复杂度是O(1)。

2. 基于链表的队列实现方法

在这里插入图片描述
实现代码后续补充,这里先挖个坑。

循环队列

当 tail 等于数组长度 n 时,要进行数据移动,这时就影响入队操作。如何避免数据搬移呢?答案是循环队列。

循环队列,顾名思义,它长得像一个环。原本数组是有头有尾的,是一条直线。现在我们把首尾相连,扳成了一个环。
在这里插入图片描述
图中这个队列的大小为 8,当前 head=4,tail=7。当有一个新的元素 a 入队时,我们放入下标为 7 的位置。但这个时候,我们并不把 tail 更新为 8,而是将其在环中后移一位,到下标为 0 的位置。当再有一个元素 b 入队时,我们将 b 放入下标为 0 的位置,然后 tail 加 1 更新为 1。所以,在 a,b 依次入队之后,循环队列中的元素就变成了下面的样子:
在这里插入图片描述
队列为空的条件依然是 head == tail,但是队列满的判断条件就有点复杂了。根据图进行总结:
在这里插入图片描述
就像图中画的队满的情况,tail=3,head=4,n=8,所以总结一下规律就是:(3+1)%8=4。多画几张队满的图,你就会发现,当队满时,(tail+1)%n=head

(tail+1)%n=head

同时我们发现,tail 指向的位置实际上没有存储数据。
实现代码如下:

public class CircularQueue {
	// 数组:items,数组大小:n
	private String[] items;
	private int n;
	// head 表示队头下标,tail 表示队尾下标
	private int head;
	private int tail;
	
	// 申请一个大小为 capacity 的数组
	public CircularQuenen (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 () {
		if (head == tail) {
			return false;
		}
		String ret = items[head];
		head = (head + 1) % n;
		return ret;
	}
}

阻塞队列和并发队列

前面讲的内容理论比较多,看起来很难跟实际的项目开发扯上关系。

确实,队列这种数据结构很基础,平时的业务开发不大可能从零实现一个队列,甚至都不会直接用到。而一些具有特殊特性的队列应用却比较广泛,比如阻塞队列和并发队列。

阻塞队列其实就是在队列基础上增加了阻塞操作。简单来说,就是在队列为空的时候,从队头取数据会被阻塞。因为此时还没有数据可取,直到队列中有了数据才能返回;如果队列已经满了,那么插入数据的操作就会被阻塞,直到队列中有空闲位置后再插入数据,然后再返回。

在这里插入图片描述
你应该已经发现了,上述的定义就是一个“生产者 - 消费者模型”!是的,我们可以使用阻塞队列,轻松实现一个“生产者 - 消费者模型”!


课后思考

  1. 除了线程池这种池结构会用到队列排队请求,你还知道有哪些类似的池结构或者场景中会用到队列的排队请求呢?

答:分布式应用中的消息队列,也是一种队列结构

  1. 今天讲到并发队列,关于如何实现无锁并发队列,网上有非常多的讨论。对这个问题,你怎么看呢?

答:考虑使用CAS实现无锁队列,则在入队前,获取tail位置,入队时比较tail是否发生变化,如果否,则允许入队,反之,本次入队失败。出队则是获取head位置,进行cas。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值