并发编程-BlockingQueue
本篇我们要讲述的是并发编程中的阻塞队列,在上一篇中我们了解了Condition的原理,Condition的特性适用于生产者/消费者模型。同样的BlockingQueue(阻塞队列)也是适用于生产者/消费者模型,所以二者肯定是有相互联系的。接下来我们将逐步介绍阻塞队列的原理
概念
众所周知,队列是一种特殊的线性表,特殊之处在于它只允许在表的前端(front)进行删除操作,而在表的后端(rear)进行插入操 作,和栈一样,队列是一种操作受限制的线性表。进行插入操作的端称为队尾,进行删除操作的端称为队头。用通俗的话说就是跟我们在日常生活中排队一样,队列中的每个人(或元素)都按照他们到达的顺序排列。在队列中,新来的人(或元素)总是加入到队尾,也就是队列的最后面。而离开队列的人(或元素)总是从队头,也就是队列的最前面离开。这就是所谓的“先进先出”原则,即最早进入队列的元素最先离开队列。
阻塞队列
那阻塞队列是什么,阻塞队列是在队列的基础上增加了两个操作
- 支持阻塞插入:就是在队列满的情况下,阻塞往队列中添加数据的线程,直到队列中有元素被释放
- 支持阻塞移除:即在队列为空的情况下,阻塞从队列中获取元素的线程,直到队列里边添加了新的元素
另外,队列中的元素肯定需要一个数据结构来存储,可能是数组,也可能是链表。所以阻塞队列也是有大小的,阻塞队列又分为有界队列和无界队列
- 有界队列: 是指有固定大小的队列
- 无界队列:是指没有固定大小的队列(注:这里的无界队列其实也是有长度大小的,只不过它的最大值是Integer.MAX_VALUE)
业务场景
- 生产者消费者模型:这是阻塞队列最常见的应用场景。生产者负责生产数据,并将其放入阻塞队列中,而消费者则从队列中取出数据进行消费。当队列满时,生产者线程会阻塞,直到队列中有空闲位置;当队列空时,消费者线程会阻塞,直到队列中有新的数据。这种模型可以有效地解决生产者和消费者之间的速度不匹配问题,提高程序的并发性和性能。
- 任务队列:在多线程编程中,可以将阻塞队列作为任务队列使用。一个或多个线程负责生成任务并将其放入队列,而其他线程则从队列中取出任务并执行。这种模式可以有效地平衡任务生成和执行的速度,避免任务堆积或线程空闲。
- 线程池:在Java的线程池中,阻塞队列被用作等待执行的任务队列。当线程池中的线程数量达到最大值时,新提交的任务会被放入队列中等待执行。当队列满时,根据线程池的配置,可能会创建新的线程来执行任务,或者拒绝新任务。
- 消息队列:阻塞队列也可以作为消息队列使用,用于实现进程间或线程间的通信。一个线程或进程负责发送消息并将其放入队列,而另一个线程或进程则从队列中取出消息并处理。这种模式可以实现异步通信,提高系统的性能和响应速度。
- 数据缓冲:在需要缓冲数据的情况下,可以使用阻塞队列来暂存数据。
java中的阻塞队列
在J.U.C中提供了7种阻塞队列,如下所示
队列 | 简述 |
---|---|
ArrayBlockingQueue | 由数组结构组成的有界阻塞队列,此队列按 FIFO(先进先出)原则对元素进行排序。 |
LinkedBlockingQueue | 由链表结构组成的有界阻塞队列,但默认大小为 Integer.MAX_VALUE,实际上是个无界队列。此队列按FIFO(先进先出) 排序元素,吞吐量通常要高于ArrayBlockingQueue |
PriorityBlockingQueue | 是一个支持优先级排序的无界阻塞队列。默认情况下元素采取自然顺序进行排序,可以通过构造函数传入自定义的Comparator进行排序 |
DelayQueue | 是一个支持延时获取元素的无界阻塞队列。只有在延迟期满时才能从队列中获取元素,队列的头部是延迟期满后保存时间最长的元素。如果没有任何延迟元素,那么就不会有任何头元素,并且poll将返回null |
SynchronousQueue | 是一个不存储元素的阻塞队列。每一个put操作必须等待一个take操作,否则不能继续添加。它支持公平访问和非公平访问 |
LinkedTransferQueue | 由链表结构组成的无界阻塞TransferQueue队列。相对于其他阻塞队列,多了tryTransfer和transfer方法 |
LinkedBlockingDeque | 由链表结构组成的双向阻塞队列。所谓双向队列指的是该队列两端都可以同时入队和出队 |
了解了基本概念之后,我们来看一下它们的类关系图
类图
从类图中我们可以看到,这7个阻塞队列都实现了BlockingQueue接口、继承了AbstractQueue类,我们来看一下阻塞队列有哪些主要的方法
阻塞队列的主要方法
阻塞队列的主要方法有添加、移除、检查,每种操作为适应不同使用场景又提供了不同的方法
添加
-
add(e)
往队列中添加元素,当队列满时再调用add()方法添加元素会抛出throw new IllegalStateException(“Queue full”)异常。
-
offer(e)
调用offer()方法添加元素成功返回true,失败返回false。
-
offer(e,time,unit)
该方法传入超时时间的参数。当队列满了,调用该方法添加元素时,阻塞添加元素的线程,阻塞的时间是由传入的参数来决定的。如果阻塞时间到了以后队列还是满的,则唤醒被阻塞的线程并返回false。
-
put(e)
put()方法也是往队列中添加元素,但是它有限制条件。当队列已满的情况下,调用put()方法会使添加元素的线程一直阻塞,直到队列不满或者响应中断操作退出阻塞。
移除
-
remove()
从队列中移除元素,当队列为空,调用remove()方法会抛出throw new NoSuchElementException() 异常。
-
poll()
从队列中移除元素,特点是当队列为空时返回null,否则从队列中取出一个元素
-
poll(time,unit)
该方法传入超时时间的参数。当队列为空,调用该方法时阻塞获取元素的线程,阻塞的时间是由传入的参数来决定的。超时之后仍然没获取到元素则返回null。
-
take()
当队列为空,调用take()方法移除元素时会阻塞获取元素的线程,直到队列不为空时唤醒线程
检查
-
element()
获取队列中的元素但不移除元素,元素依旧在队列中。跟我们使用获取List集合中List.get(0)的意思接近。如果队列为空,则抛出throw new NoSuchElementException()异常。
-
peek()
如果队列为空则返回null,取出队列头部的元素。
源码分析
在上半个章节中我们了解了阻塞队列的概念、使用场景以及主要方法,有了这些铺垫接下来我们就要聚焦到源码层面了,阻塞队列我们看几个主要的队列逐步进行分析与比较
ArrayBlockingQueue
我们还是老规矩从构造方法开始,前面我们已经知道了它是基于数组来实现的阻塞队列,通过数组存储元素。它有三种构造方法如下图所示
-
ArrayBlockingQueue(int)
//该构造方法表明接收一个int类型的参数,这个参数代表了ArrayBlockingQueue中数组的长度 public ArrayBlockingQueue(int capacity) { //调用的this 是另一个构造方法ArrayBlockingQueue(int,boolean) this(capacity, false); }
-
ArrayBlockingQueue(int,boolean)
/** 该构造方法有两个参数,capacity代表数组长度,fair代表的是公平锁还是非公平锁。这里为什么要这样做是因为涉及到队列满了或者队列空了会阻塞对应的线程,同时在多线程环境下,又因为队列空或不空满足唤醒的条件会同时唤醒多个线程。至于是公平还是非公平就取决于fair参数的值 */ public ArrayBlockingQueue(int capacity, boolean fair) { //capacity 小于等于0直接抛出异常 if (capacity <= 0) throw new IllegalArgumentException(); //构建一个capacity 长度的数组 this.items = new Object[capacity]; //重入锁保证线程安全性 lock = new ReentrantLock(fair); //用两个condition队列来存储需要阻塞和唤醒 //notEmpty阻塞的是 取出元素的线程 notEmpty = lock.newCondition(); //notFull阻塞的是 添加元素的线程 notFull = lock.newCondition(); }
-
ArrayBlockingQueue(int,boolean,Collection<? extends E>)
//看到该构造方法的参数,前两个我们已经知道是干什么的了,大致扫一眼整个方法,那第三个参数应该就是传入一个初始值集合 public ArrayBlockingQueue(int capacity, boolean fair, Collection<? extends E> c) { this(capacity, fair); final ReentrantLock lock = this.lock; // Lock only for visibility, not mutual exclusion /**这个注释需要注意一下,这个lock.lock()方法并不是为了加锁实现互斥,而是为了确保其他线程能看到队列的当前状态(保证 * 可见性) */ lock.lock(); try { int i = 0; try { //这里就很明确了遍历传进来的集合,并构建数组 for (E e : c) { checkNotNull(e); items[i++] = e; } } catch (ArrayIndexOutOfBoundsException ex) { throw new IllegalArgumentException(); } //count代表的队列中的元素的数量 count = i; /**putIndex 是下一个要插入元素的位置的索引。如果队列已满(即 i 等于 capacity),则 putIndex 被设置为 0, * 表示下一个插入操作将覆盖队列的开头。 */ putIndex = (i == capacity) ? 0 : i; } finally { lock.unlock(); } }
offer(e)
在了解了ArrayBlockingQueue的构造方法之后 我们来看下它的几个主要的添加方法是如何实现的
public boolean add(E e) {
//add是调用的父类的方法 也就是AbstractQueue中的方法,接着往下看
return super.add(e);
}
//这里很明显核心方法就是offer()方法
public boolean add(E e) {
if (offer(e))
return true;
else
throw new IllegalStateException("Queue full");
}
public boolean offer(E e) {
//校验元素是否为空,为空抛出异常
checkNotNull(e);
final ReentrantLock lock = this.lock;
//加锁保证线程安全性
lock.lock();
try {
//如果队列中元素的数量(count)跟数组的长度(items.length)相等则证明队列已满,返回false
if (count == items.length)
return false;
else {
//这里是往队列中添加元素
enqueue(e);
return true;
}
} finally {
lock.unlock();
}
}
//着重来看一下enqueue(e)方法
private void enqueue(E x) {
// assert lock.getHoldCount() == 1;
// assert items[putIndex] == null;
final Object[] items = this.items;
//将元素加入数组
items[putIndex] = x;
//刚才解释构造方式解释了putIndex是下一个要插入元素的位置的索引,如果等于数组长度则重置为0
if (++putIndex == items.length)
putIndex = 0;
//元素数量+1
count++;
//去唤醒Condition队列中阻塞的取出元素的线程,这里就跟我们上一篇中介绍的Condition呼应上了
notEmpty.signal();
}
//我们再简单看一下put(e) 方法
public void put(E e) throws InterruptedException {
checkNotNull(e);
final ReentrantLock lock = this.lock;
lock.lockInterruptibly();
try {
//put方法整体上就比较简单了,无非就是当队列满了调用await()方法去阻塞当前添加元素的线程
while (count == items.length)
notFull.await();
enqueue(e);
} finally {
lock.unlock();
}
}
offer(e,time,unit)
我们来看带超时时间的offer(e,time,unit) 方法 它的特点是阻塞时间到了以后队列还是满的,则唤醒被阻塞的线程并返回false
public boolean offer(E e, long timeout, TimeUnit unit)
throws InterruptedException {
checkNotNull(e);
//得到超时时间 转化成纳秒
long nanos = unit.toNanos(timeout);
final ReentrantLock lock = this.lock;
//加锁保证线程安全性 这里用了一个可中断的锁是为了避免再队列满且超时的情况下还一直在等待,避免死锁
lock.lockInterruptibly();
try {
while (count == items.length) {
//超时返回false
if (nanos <= 0)
return false;
//超时的主要操作就在notFull.awaitNanos(nanos)
nanos = notFull.awaitNanos(nanos);
}
enqueue(e);
return true;
} finally {
lock.unlock();
}
}
//来看一下notFull.awaitNanos(nanos)这个方法
//这个方法看上去就非常的眼熟,跟Lock和Condition篇中的源码大差不差,每个方法都见过
public final long awaitNanos(long nanosTimeout)
throws InterruptedException {
//判断是否被中断了,这个在之前我们也看到了lock.lockInterruptibly()加的是个可中断的锁
if (Thread.interrupted())
throw new InterruptedException();
//把当前线程加入到一个Condition队列
Node node = addConditionWaiter();
//当前线程要阻塞一定的时间,所以要释放锁,若一直持有锁,其他线程无法获得锁
int savedState = fullyRelease(node);
//得到截止时间
final long deadline = System.nanoTime() + nanosTimeout;
int interruptMode = 0;
while (!isOnSyncQueue(node)) {
if (nanosTimeout <= 0L) {
//如果超时时间已经用完(或小于等于0)尝试将当前线程从Condition队列加入到AQS队列中也就是唤醒该线程
transferAfterCancelledWait(node);
break;
}
if (nanosTimeout >= spinForTimeoutThreshold)
LockSupport.parkNanos(this, nanosTimeout);
//检查是否被中断了
if ((interruptMode = checkInterruptWhileWaiting(node)) != 0)
break;
//计算剩余等待的时间
nanosTimeout = deadline - System.nanoTime();
}
//下面的内容在Condition篇中有详细的解释,这里就不占太多篇幅来解释了
if (acquireQueued(node, savedState) && interruptMode != THROW_IE)
interruptMode = REINTERRUPT;
if (node.nextWaiter != null)
unlinkCancelledWaiters();
if (interruptMode != 0)
reportInterruptAfterWait(interruptMode);
//返回剩余等待时间
return deadline - System.nanoTime();
}
总体来看ArrayBlockingQueue 核心的添加方法还是相对简单和容易理解的,我们再看移除操作的源码
poll()
//通过remove() 方法我们可以看出移除的核心方式是poll()
public E remove() {
E x = poll();
if (x != null)
return x;
else
throw new NoSuchElementException();
}
//我们看一下poll()方法做了什么
public E poll() {
final ReentrantLock lock = this.lock;
//加锁
lock.lock();
try {
//判断元素数量是不是0 若不等于0 就执行dequeue()操作
return (count == 0) ? null : dequeue();
} finally {
lock.unlock();
}
}
private E dequeue() {
// assert lock.getHoldCount() == 1;
// assert items[takeIndex] != null;
//获取到数组
final Object[] items = this.items;
@SuppressWarnings("unchecked")
//根据索引取出元素
E x = (E) items[takeIndex];
//该索引位置赋值为空,等待jvm回收
items[takeIndex] = null;
/**递增 takeIndex 以指向下一个要取出的元素。如果 takeIndex 等于数组的长度(即队列已满),则将其重置为0,和putIndex 一样,只不过takeIndex是取,和putIndex是添加
*/
if (++takeIndex == items.length)
takeIndex = 0;
//元素数量减一
count--;
/**
* 这里是个迭代器,但在ArrayBlockingQueue中不会被掉用,因为ArrayBlockingQueue是基于数组构建的,而这个迭代器处理的 * 是链表
*/
if (itrs != null)
itrs.elementDequeued();
//唤醒添加元素所阻塞的线程
notFull.signal();
return x;
}
LinkedBlockingQueue
采用链表做底层数据结构,这是它和ArrayBlockingQueue的主要区别,我们先来看它的三种构造方法
-
LinkedBlockingQueue()
//默认的构造方法,其链表长度是Integer.MAX_VALUE public LinkedBlockingQueue() { //调用的this 是另一个构造方法LinkedBlockingQueue(int) this(Integer.MAX_VALUE); }
-
LinkedBlockingQueue(int)
public LinkedBlockingQueue(int capacity) { if (capacity <= 0) throw new IllegalArgumentException(); //指定链表的长度 this.capacity = capacity; //构建一个空的node节点,并赋值给last、head。此时这个node节点既是头节点也是尾节点 last = head = new Node<E>(null); }
-
LinkedBlockingQueue(Collection<? extends E>)
//这个构造方法很明显就是传入一个集合 public LinkedBlockingQueue(Collection<? extends E> c) { this(Integer.MAX_VALUE); final ReentrantLock putLock = this.putLock; putLock.lock(); // Never contended, but necessary for visibility try { int n = 0; //遍历集合 for (E e : c) { if (e == null) throw new NullPointerException(); if (n == capacity) throw new IllegalStateException("Queue full"); //将传入的集合元素封装成node,并构建一个单项链表 enqueue(new Node<E>(e)); ++n; } /**记录队列中元素的数量,这里有小伙伴会问为什么用的是AtomicInteger,假设当一个线程调用 put 方法来添加元素到 * 满队列时,它会先检查 count 是否已经达到 capacity。这个检查以及随后的增加 count 的操作必须是原子的, * 防止其他线程在这两个操作之间更改 count 的值 */ count.set(n); } finally { putLock.unlock(); } }
offer()
因为LinkedBlockingQueue 也继承了AbstractQueue 所以add()方法就不再重复了,我们直接看它的offer()方法
//从整体上看LinkedBlockingQueue 的offer()方法也比较简单
public boolean offer(E e) {
if (e == null) throw new NullPointerException();
final AtomicInteger count = this.count;
//判断队列是否满了,满就返回false
if (count.get() == capacity)
return false;
int c = -1;
Node<E> node = new Node<E>(e);
final ReentrantLock putLock = this.putLock;
putLock.lock();
try {
//判断如果队列没有满则,往链表里添加一个node节点
if (count.get() < capacity) {
enqueue(node);
//count.getAndIncrement()原子地增加计数器
c = count.getAndIncrement();
if (c + 1 < capacity)
/**如果入队操作后队列仍然没有满,调用 notFull.signal() 来唤醒可能在等待队列不满需要添加元素的线程
*/
notFull.signal();
}
} finally {
putLock.unlock();
}
/**
* 对于c==0 的判断小伙伴们可能会有疑惑,这不代表队列空了吗,怎么去唤醒需要那些在阻塞中需要取出元素的线程
* 这个地方的关键是理解原子操作 getAndIncrement()的行为。这个操作会先读取当前的值(在这个上下文中是队列的元素数量),然后 * 增加这个值,并返回原来读取的值。因此,如果 c 是 0,那么这意味着在增加操作之前,队列是空的,增加操作之后,新元素被添加到队 * 列中,队列现在包含一个元素,所以才去唤醒
*/
if (c == 0)
signalNotEmpty();
//最后添加成功返回true
return c >= 0;
}
offer(e,time,unit)
带超时时间的offer()方法跟不带超时时间的offer()方法大同小异 ,这个超时后的操作我们跟ArrayBlockingQueue中的offer(e,time,unit)是一模一样的,我们就不做重复描述了
public boolean offer(E e, long timeout, TimeUnit unit)
throws InterruptedException {
if (e == null) throw new NullPointerException();
long nanos = unit.toNanos(timeout);
int c = -1;
final ReentrantLock putLock = this.putLock;
final AtomicInteger count = this.count;
putLock.lockInterruptibly();
try {
while (count.get() == capacity) {
if (nanos <= 0)
return false;
nanos = notFull.awaitNanos(nanos);
}
enqueue(new Node<E>(e));
c = count.getAndIncrement();
if (c + 1 < capacity)
notFull.signal();
} finally {
putLock.unlock();
}
if (c == 0)
signalNotEmpty();
return true;
}
take()
我们来看一下它的take() 方法
public E take() throws InterruptedException {
E x;
int c = -1;
final AtomicInteger count = this.count;
final ReentrantLock takeLock = this.takeLock;
//加锁,这个就不多说了
takeLock.lockInterruptibly();
try {
//如果count.get() == 0 则队列为空,阻塞要取出元素的线程
while (count.get() == 0) {
notEmpty.await();
}
//从队列头部移除并返回一个元素
x = dequeue();
//count.getAndDecrement() 会原子地减少count 的值,并返回减少之前的值
c = count.getAndDecrement();
//如果 c 大于 1 则证明还队列中还有元素,则唤醒阻塞的线程
if (c > 1)
notEmpty.signal();
} finally {
//释放锁
takeLock.unlock();
}
/**
* 如果c == capacity 判断队列是不是满了 c是队列元素减少前的原值,证明已经有元素移除成功了,所以就能唤醒在阻塞的添加元 * 素的线程,感觉这里像一个临界条件,假设队列长度为1,count.getAndDecrement()==1 也就是 c==1 此时队列已经为空,如果这 * 时候有线程调用take方法会阻塞,直到被signalNotFull()唤醒的线程添加元素成功才唤醒
*/
if (c == capacity)
signalNotFull();
return x;
}
//补充一下dequeue()方法
private E dequeue() {
// assert takeLock.isHeldByCurrentThread();
// assert head.item == null;
//因为是从队列头部取出,所以先找到head节点,head节点是个空的node,所以有效节点是它的下一个节点
Node<E> h = head;
//把next节点赋值为first节点
Node<E> first = h.next;
//头节点的next指向自己,该节点已经不作为头节点了需要回收掉
h.next = h; // help GC
//把first作为头节点
head = first;
//取出first节点的有效值
E x = first.item;
//并把first节点的有效值 赋值为null,这样first节点就变成了一个空的node 即头节点
first.item = null;
return x;
}
由于poll()方法与take()方法大致相同,就不做详细解释了,我们再来看下一个阻塞队列
PriorityBlockingQueue
是一个支持优先级排序的无界阻塞队列。默认情况下元素采取自然顺序进行排序,可以通过构造函数传入自定义的Comparator进行排序,我们来看看它的构造方法
-
PriorityBlockingQueue()
//默认构造方法,不需要传队列长度,默认长度是11,但它也能随着元素增加动态扩容,最大扩容长度是Integer.MAX_VALUE public PriorityBlockingQueue() { this(DEFAULT_INITIAL_CAPACITY, null); } public PriorityBlockingQueue(int initialCapacity) { this(initialCapacity, null); }
-
PriorityBlockingQueue(int,Comparator<? super E> comparator)
/** *该构造方法传入两个参数,一个是传队列的长度,另一个是传一个Comparator比较器,用来比较元素优先级,这个构造方法比较简单不多 *说了 */ public PriorityBlockingQueue(int initialCapacity, Comparator<? super E> comparator) { if (initialCapacity < 1) throw new IllegalArgumentException(); this.lock = new ReentrantLock(); this.notEmpty = lock.newCondition(); this.comparator = comparator; //从这里我们可以看出,PriorityBlockingQueue底层是基于数组实现的 this.queue = new Object[initialCapacity]; }
-
PriorityBlockingQueue(Collection<? extends E> c)
//这第三个构造方法相对复杂一些,直观上看是传入一个集合,来作为PriorityBlockingQueue初始化数据 public PriorityBlockingQueue(Collection<? extends E> c) { this.lock = new ReentrantLock(); this.notEmpty = lock.newCondition(); boolean heapify = true; // true if not known to be in heap order boolean screen = true; // true if must screen for nulls //判断传入的集合是不是SortedSet, if (c instanceof SortedSet<?>) { SortedSet<? extends E> ss = (SortedSet<? extends E>) c; //SortedSet本身就是一个有序的集合,所以直接用SortedSet中的comparator比较器 this.comparator = (Comparator<? super E>) ss.comparator(); heapify = false; } //判断传入的是不是PriorityBlockingQueue else if (c instanceof PriorityBlockingQueue<?>) { PriorityBlockingQueue<? extends E> pq = (PriorityBlockingQueue<? extends E>) c; //同理 PriorityBlockingQueue本身也是有序的,所以获取其比较器 this.comparator = (Comparator<? super E>) pq.comparator(); screen = false; //判断传入的是不是PriorityBlockingQueue的子类 注释exact match代表完全匹配 if (pq.getClass() == PriorityBlockingQueue.class) // exact match heapify = false; } //传入的集合转化为对象数组 Object[] a = c.toArray(); int n = a.length; // If c.toArray incorrectly doesn't return Object[], copy it. //如果c.toArray没有正确的转化为Object[],那复制一个 if (a.getClass() != Object[].class) a = Arrays.copyOf(a, n, Object[].class); //这里是检查遍历是否有空值,有的话抛出异常 if (screen && (n == 1 || this.comparator != null)) { for (int i = 0; i < n; ++i) if (a[i] == null) throw new NullPointerException(); } this.queue = a; this.size = n; if (heapify) //这个方法是保证优先级排序的关键方法 heapify(); }
在看heapify() 方法之前,我们需要搞明白两个概念——堆 和 完全二叉树
什么是堆
堆(Heap)是计算机科学中一类特殊的数据结构的统称。堆通常可以被看作是一棵完全二叉树(在逻辑层面上),而物理层面上,它常常被实现为一个数组对象。堆具有以下性质:
- 堆中某个节点的值总是不大于(或不小于)其父节点的值。
- 堆总是一棵完全二叉树。
根据根节点的大小,堆可以被分为两种类型:最大堆(或大根堆)和最小堆(或小根堆)。在最大堆中,根节点的值是最大的,而在最小堆中,根节点的值是最小的。常见的堆有二叉堆、斐波那契堆等。堆常被用于在一组变化频繁的数据中寻找最值,例如在优先级队列中,最高(或最低)优先级的元素总是位于堆的根节点。
完全二叉树
完全二叉树(Complete Binary Tree)是一种特殊的二叉树,它的定义是:对于深度为h的完全二叉树,除第h层外,其它各层的节点数都达到最大个数,第h层有节点,并且节点都是靠左排列的。也就是说,完全二叉树除了最后一层外,其它层的节点数都达到最大,并且最后一层的节点都集中在左侧。
完全二叉树的一个重要特性是,它可以通过数组来紧凑地存储。对于数组中的任意位置i的元素,它的左子节点位于位置2i+1,右子节点位于位置2i+2(数组的索引通常从0开始)。同时,父节点位于位置(i-1)/2。这种存储方式使得完全二叉树的很多操作(如插入、删除、查找等)都可以利用数组索引的特性来高效实现。
堆排序算法中使用的堆通常就是完全二叉树的一种实现,特别是最大堆或最小堆。在堆排序中,完全二叉树(堆)被用来高效地实现选择排序的思想,即每次从堆中选择最大(或最小)的元素进行排序。
理解概念
可能有些小伙伴看完以上两个概念会有点懵,又是堆又是树的到底什么意思。PriorityBlockingQueue是用数组来构建的,那它的数据存储在数组中,它有优先级排列顺序的功能。所以它就是给数组中的数据进行了排序,那使用堆则是一种非常高效和稳定的办法,可以很快的实现数组的排序,而它的结构可以用树来表示 也就是存储的数据在物理上是一个数组,在逻辑上是一颗完全二叉树。也就是说PriorityBlockingQueue 的排序就是堆的排序,那堆是如何进行排序的呢,堆排序的基本步骤有以下几点
- 构建堆(Build Heap):将待排序的数组调整为一个最大堆或最小堆。在最大堆中,父节点的值总是大于或等于其子节点的值;在最小堆中,父节点的值总是小于或等于其子节点的值。
- 交换堆顶和最后一个元素:将堆顶元素(最大或最小)与堆的最后一个元素交换。这样,最大或最小的元素就被移到了正确的位置(数组的末尾)。
- 调整堆(Heapify):将剩余的元素重新调整为堆结构。这通常涉及到将最后一个元素移除(或视为已移除),然后将剩余元素中最大的(或最小的)元素移动到堆顶。这可以通过一系列的下沉(Sink)操作来实现。
- 重复以上步骤:重复步骤 2 和 3,直到堆中只剩下一个元素。每次重复,数组中的一个元素就会被放到正确的位置。
- 排序完成:当所有元素都被放到正确的位置时,数组就排好序了。
单纯的看这些概念比较抽象,接下来我们用图型的方式来解释一下堆排序是怎么一回事,我们以数组 [16,7,11,10,5,1,15]为例模拟堆排序升序。
-
第一步,建堆(排升序用大堆,排降序用小堆)
-
第二步:将堆顶元素(最大或最小)与堆的最后一个元素交换。这样,最大或最小的元素就被移到了正确的位置(数组的末尾)
-
第三步:依次类推,找到剩余最大元素并调整堆
-
最后直到最后一个元素找完,数组排序完成
有了前面这些概念以及图形的理解后,我们再回过头来看heapify() 方法
//我们来看一下这个至关重要的方法
private void heapify() {
Object[] array = queue;
//获得当前数组的大小
int n = size;
//最后一个非叶子节点的索引。由于数组是从索引0开始的,所以最后一个非叶子节点的索引是(n/2)-1。
int half = (n >>> 1) - 1;
Comparator<? super E> cmp = comparator;
//判断有没有比较器,若无使用元素自然顺序排序
if (cmp == null) {
/**
从最后一个非叶子节点开始,向前遍历到根节点(索引0)。由于堆的性质,从最后一个非叶子节点开始调整可以保证所有子树都已 经是最大堆。
*/
for (int i = half; i >= 0; i--)
siftDownComparable(i, (E) array[i], array, n);
}
else {
for (int i = half; i >= 0; i--)
siftDownUsingComparator(i, (E) array[i], array, n, cmp);
}
}
//着重来看一下这个方法,先来看这几个参数
//k:当前要调整的节点的索引
//x:当前要调整的节点的值
//array:包含堆元素的数组
//n:堆中有效元素的数量
private static <T> void siftDownComparable(int k, T x, Object[] array,int n) {
//元素数量大于0才进行调整
if (n > 0) {
Comparable<? super T> key = (Comparable<? super T>)x;
//找到最后一个非叶子节点的索引也就是最后一个子节点
int half = n >>> 1; // loop while a non-leaf
//当k< half
while (k < half) {
//得到k的左子树的节点的索引
int child = (k << 1) + 1; // assume left child is least
//获取左子树节点的值
Object c = array[child];
//得到右子树节点的索引
int right = child + 1;
/**
检查右子树是否存在且其值是否大于左子树的值。如果是,则选择右子树作为较小的节点。如果右子树的值较小,则更新c和 child 为右子树的值和索引
*/
if (right < n &&
((Comparable<? super T>) c).compareTo((T) array[right]) > 0)
c = array[child = right];
/**
比较当前节点的值key和较小的节点c。如果key不大于c,则不需要进一步调整,因为key已经在其正确的位置上(在最 大堆中,父节点的值应大于或等于其子节点的值)。
*/
if (key.compareTo((T) c) <= 0)
break;
//这里就是我们之前画图时说的比较并替换位置
array[k] = c;
//更新节点索引
k = child;
}
//将元素放入数组正确的位置
array[k] = key;
}
}
DelayQueue
DelayQueue是一个支持延时获取元素的无界阻塞队列。添加数据时可以自定义delay时间进行排序,顾名思义队列中元素是按时间排序的,当delay<0 或 delay==0 的时候才能取出元素。老规矩,我们来看构造方法
//整体构造方法比较简单,就不做过多描述了
public DelayQueue() {}
public DelayQueue(Collection<? extends E> c) {
this.addAll(c);
}
public boolean addAll(Collection<? extends E> c) {
if (c == null)
throw new NullPointerException();
if (c == this)
throw new IllegalArgumentException();
boolean modified = false;
for (E e : c)
if (add(e))
modified = true;
return modified;
}
这里需要注意的是 DelayQueue队列中的元素继承了Delayed接口,而Delayed又继承了Comparable。这说明getDelay()定义了到期时间,
Comparable() 定义了元素的排序,而且它是基于PriorityQueue来实现的
public class DelayQueue<E extends Delayed> extends AbstractQueue<E>
implements BlockingQueue<E> {
private final PriorityQueue<E> q = new PriorityQueue<E>();
//。。。。。。省略
}
public interface Delayed extends Comparable<Delayed> {
long getDelay(TimeUnit unit);
}
offer()
//DelayQueue 的offer方法很简单,重点是在q.offer(e) 这里
public boolean offer(E e) {
final ReentrantLock lock = this.lock;
lock.lock();
try {
q.offer(e);
//看加入的元素是不是队列的头部元素
if (q.peek() == e) {
leader = null;
//唤醒等待取出元素的线程
available.signal();
}
return true;
} finally {
lock.unlock();
}
}
//这里的offer(e)方法其实是PriorityQueue中的方法
public boolean offer(E e) {
if (e == null)
throw new NullPointerException();
modCount++;
//队列中元素的数量
int i = size;
//如果元素数量大于队列的长度则进行扩容
if (i >= queue.length)
grow(i + 1);
//元素数量+1
size = i + 1;
//如果元素数量为0 则e是队列中第一个元素
if (i == 0)
queue[0] = e;
else
//否则通过堆来移动到数组中的正确位置
siftUp(i, e);
return true;
}
//我们来看一下扩容方法
private void grow(int minCapacity) {
//得到队列的长度
int oldCapacity = queue.length;
// Double size if small; else grow by 50%
/**
*计算新的扩容长度 如果oldCapacity<64 那么在这基础上加2 如果oldCapacity大于或等于64,那么新容量是旧容量加上旧容量的一 *半,即oldCapacity + (oldCapacity >> 1)。这里的>>是右移运算符,oldCapacity >> 1也就是oldCapacity / 2,因此这部 *分实际上是将容量增加50%。
*/
int newCapacity = oldCapacity + ((oldCapacity < 64) ?
(oldCapacity + 2) :
(oldCapacity >> 1));
// 判断一下是否超过最大数据的大小
if (newCapacity - MAX_ARRAY_SIZE > 0)
newCapacity = hugeCapacity(minCapacity);
//将原有的queue复制到新扩容的数组中
queue = Arrays.copyOf(queue, newCapacity);
}
//这个方法就不多解释了,就是单纯的比较大小返回值
private static int hugeCapacity(int minCapacity) {
if (minCapacity < 0) // overflow
throw new OutOfMemoryError();
return (minCapacity > MAX_ARRAY_SIZE) ?
Integer.MAX_VALUE :
MAX_ARRAY_SIZE;
}
在看完offer()方法后,小伙伴们会有疑问,它的延迟特性体现在哪,整个添加方法都没看到有关延迟的操作。这个在开头我们也提到过,它的元素继承了Delay接口。具体我们通过一个例子来穿插着理解下DelayQueue延迟是怎么做的
DelayQueue使用
首先,我们需要创建一个实现了Delayed接口的对象。假设我们有一个任务类DelayTask,它实现了Delayed接口
public class DelayTask implements Delayed {
// 延迟时间
private final long delayTime;
// 执行时间
private final long executeTime;
public DelayTask(long delayTime) {
this.delayTime = delayTime;
this.executeTime = System.currentTimeMillis() + delayTime;
}
@Override
public long getDelay(TimeUnit unit) {
//计算延迟时间
long diff = executeTime - System.currentTimeMillis();
return unit.convert(diff, TimeUnit.MILLISECONDS);
}
@Override
public int compareTo(Delayed d) {
if (this.executeTime < ((DelayTask) d).executeTime) {
return -1;
}
if (this.executeTime > ((DelayTask) d).executeTime) {
return 1;
}
return 0;
}
// 这里可以添加任务的具体执行逻辑
public void execute() {
SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
System.out.println("执行时间是:"+simpleDateFormat.format(new Date())+" 执行任务: " + System.currentTimeMillis());
}
}
我们用DelayQueue来存储这些DelayTask对象,并在它们到期时执行它们
public class DelayQueueExample {
public static void main(String[] args) throws InterruptedException {
DelayQueue<DelayTask> delayQueue = new DelayQueue<>();
// 添加任务到DelayQueue,每个任务有不同的延迟时间
delayQueue.add(new DelayTask(3000)); // 延迟3秒执行
delayQueue.add(new DelayTask(5000)); // 延迟5秒执行
System.out.println("当前时间是 "+new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date()));
while (true) {
try {
// take方法会阻塞,直到队列中有到期的元素
DelayTask task = delayQueue.take();
// 执行任务
task.execute();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
执行结果如下
有了以上铺垫之后,我们来看take()方法
take()
public E take() throws InterruptedException {
//加锁的操作就不过多阐述了
final ReentrantLock lock = this.lock;
lock.lockInterruptibly();
try {
for (;;) {
//取队列头部元素
E first = q.peek();
//如果为空证明队列是空的,阻塞要取出元素的线程
if (first == null)
available.await();
else {
//不为空的话判断延迟时间,如果小于等于0则取出元素,反之继续等待
long delay = first.getDelay(NANOSECONDS);
if (delay <= 0)
return q.poll();
first = null; // don't retain ref while waiting
//这里的leader的使用是为了避免多个线程同时等待,这样可以减少不必要的唤醒和等待
if (leader != null)
available.await();
else {
Thread thisThread = Thread.currentThread();
leader = thisThread;
try {
available.awaitNanos(delay);
} finally {
if (leader == thisThread)
leader = null;
}
}
}
}
} finally {
if (leader == null && q.peek() != null)
available.signal();
lock.unlock();
}
}
整体上take()方法的逻辑不复杂,就是从 DelayQueue中获取元素时,看该元素的延迟时间是不是大于0,如果是调用available.awaitNanos(delay)阻塞当前线程。
总结
本篇我们主要围绕阻塞队列来进行的分析,java为了适应不同的场景使用为我们提供了七种不同阻塞队列。它们的核心都是基于数组或者链表来实现的,阻塞的操作与唤醒更是充分利用了ReentrantLock与Condition。由于自身水平原因,本篇只讲述了四种阻塞队列的源码分析,像SynchronousQueue无存储元素的阻塞队列相对比较复杂且实际使用比较少。少量篇幅讲述不全。后续争取将阻塞队列补充完善。