阻塞队列BlockingQueue实战及其原理分析

140 阅读6分钟

1. 阻塞队列介绍

1.1 队列

  • 是限定在一端进行插入,另一端进行删除的特殊的线性表
  • 先进先出线性表
  • 允许出队的一端称为队头,允许入队的一段称为队尾 有基于数组的队列、基于链表的队列

image.png Queue接口

public interface Queue<E> extends Collection<E> { 
//添加一个元素,添加成功返回true, 如果队列满了,就会抛出异常
boolean add(E e);
//添加一个元素,添加成功返回true, 如果队列满了,返回false boolean offer(E e);
//返回并删除队首元素,队列为空则抛出异常 
E remove();
//返回并删除队首元素,队列为空则返回
null E poll(); 
//返回队首元素,但不移除,队列为空则抛出异常 
E element(); 
//获取队首元素,但不移除,队列为空则返回
null E peek(); }

1.2 阻塞队列

队列满了生产者线程阻塞,队列空了,把消费者线程阻塞

阻塞队列 (BlockingQueue)是Java util.concurrent包下重要的数据结构,BlockingQueue提供了线程 安全的队列访问方式:当阻塞队列插入数据时,如果队列已满,线程将会阻塞等待直到队列非满;从阻塞队列取数据时,如果队列已空,线程将会阻塞等待直到队列非空。并发包下很多高级同步类的实 现都是基于BlockingQueue实现的。

BlockingQueue接口

在Queue接口中方法的基础上新增了入队和出队的方法:

  • 入队:put(e)
  • 出队:take()

应用场景

阻塞队列在实际应用中有很多应用场景,以下是常见的应用场景。

线程池

线程池中的任务队列通常是一个阻塞队列。当任务数超过线程池的容量时,新提交的任务将被放入任 务队列中等待执行。线程池中的工作线程从任务队列中取出任务进行处理,如果队列为空,则工作线 程会被阻塞,直到队列中有新的任务被提交。

生产者-消费者模型

在生产者-消费者模型中,生产者向队列中添加元素,消费者从队列中取出元素进行处理。阻塞队列可 以很好地解决生产者和消费者之间的并发问题,避免线程间的竞争和冲突。

消息队列

  • 消息队列使用阻塞队列来存储消息,生产者将消息放入队列中,消费者从队列中取出消息进行处理。
  • 消息队列可以实现异步通信,提高系统的吞吐量和响应性能,同时还可以将不同的组件解耦,提高系 统的可维护性和可扩展性。

缓存系统

缓存系统使用阻塞队列来存储缓存数据,当缓存数据被更新时,它会被放入队列中,其他线程可以从 队列中取出最新的数据进行使用。使用阻塞队列可以避免并发更新缓存数据时的竞争和冲突。

并发任务处理

在并发任务处理中,可以将待处理的任务放入阻塞队列中,多个工作线程可以从队列中取出任务进行 处理。使用阻塞队列可以避免多个线程同时处理同一个任务的问题,并且可以将任务的提交和执行解 耦,提高系统的可维护性和可扩展性。 总之,阻塞队列在实际应用中有很多场景,它可以帮助我们解决并发问题,提高程序的性能和可靠 性。

JUC包下面的阻塞队列

BlockingQueue 接口的实现类都被放在了 juc 包中,它们的区别主要体现在存储结构上或对元素操作 上的不同,但是对于take与put操作的原理却是类似的。

image.png

ArrayBlockingQueue

其内部使用数组存储元素的,初始化时侯需要指定容量大下,利用ReentrantLock保证线程安全。ArraryBlockingQueue是典型的有界阻塞队列。主要用于场景:

  • 实现数据缓存
  • 限流
  • 生产者-消费者模式 、、、、、、、

ArraryBlockingQueue的使用

BlockingQueue queue = new ArraryBlockingQueue(1024);
queue.put("1");//向队列中添加元素
Object object = queue.take();//从队列中取出元素

ArraryBlockingQueue的原理

ArrayBlockingQueue使用独占锁ReentrantLock实现线程安全,入队和出队操作使用同一个锁对象, 也就是只能有一个线程可以进行入队或者出队操作;这也就意味着生产者和消费者无法并行操作,在 高并发场景下会成为性能瓶颈。

数据结构

使用Lock锁的Condition通知机制进行阻塞控制 核心:一把锁,两个条件。高并发下性能比较慢。

//数据元素数组
final Object[] items; 
//下一个待取出元素索引 
int takeIndex; 
//下一个待添加元素索引 
int putIndex; 
//元素个数 
int count; 
//内部锁 
final ReentrantLock lock; 
//消费者 
private final Condition notEmpty; 
//生产者 
private final Condition notFull; 
public ArrayBlockingQueue(int capacity) { 
this(capacity, false); 
} 
public ArrayBlockingQueue(int capacity, boolean fair) { 
lock = new ReentrantLock(fair); //公平,非公平 
notEmpty = lock.newCondition(); 
notFull = lock.newCondition(); 
}

入队put

public void put(E e) throws InterruptedException{
//检查是否为空
checkNotNull(e);
final ReentrantLock lock = this.lock;

//加锁,如果线程中断抛出异常 
lock.lockInterruptibly(); 
try { 
//阻塞队列已满,则将生产者挂起,等待消费者唤醒
//设计注意点: 用while不用if是为了防止虚假唤醒 
while (count == items.length) 
notFull.await();
//队列满了,使用notFull等待(生产者阻塞) 
// 入队 
enqueue(e); 
} finally { 
lock.unlock();
// 唤醒消费者线程  } 
}
private void enqueue(E x) { 
final Object[] items = this.items; 
//入队 使用的putIndex 
items[putIndex] = x; 
if (++putIndex == items.length) 
putIndex = 0; //设计的精髓: 环形数组,putIndex指针到数组尽头了,返回头部 
count++;  //notEmpty条件队列转同步队列,准备唤醒消费者线程,因为入队了一个元素,肯定不为空了 
notEmpty.signal(); 
}

}

为什么ArraryListBlockingQueue对数组操作要设置成双指针?

使用双指针的好处在于可以避免数组的复制操作。如果使用单指针,每次删除元素时需要将后面的元 素全部向前移动,这样会导致时间复杂度为 O(n)。而使用双指针,我们可以直接将 takeIndex 指向下 一个元素,而不需要将其前面的元素全部向前移动。同样地,插入新的元素时,我们可以直接将新元 素插入到 putIndex 所指向的位置,而不需要将其后面的元素全部向后移动。这样可以使得插入和删除 的时间复杂度都是 O(1) 级别,提高了队列的性能。

出队take

```java
public E take() throws InterruptedException { 
final ReentrantLock lock = this.lock; 
//加锁,如果线程中断抛出异常 
lock.lockInterruptibly(); 
try { 
//如果队列为空,则消费者挂起
 while (count == 0) 
 notEmpty.await(); 
 //出队 
 return dequeue(); 
 } finally { 
 lock.unlock();// 唤醒生产者线程 
 } 
 } 
 private E dequeue() { 
 final Object[] items = this.items; 
 @SuppressWarnings("unchecked") 
 E x = (E) items[takeIndex]; //取出takeIndex位置的元素 
 items[takeIndex] = null; 
 if (++takeIndex == items.length) 
 takeIndex = 0; //设计的精髓: 环形数组,takeIndex 指针到数组尽头了,返回头部  count--; 
 if (itrs != null) 
 itrs.elementDequeued(); 
 //notFull条件队列转同步队列,准备唤醒生产者线程,此时队列有空位  
 notFull.signal(); 
 return x; 
 }