一、常见的阻塞队列
BlockingQueue 接口的实现类都被放在了 juc 包中,它们的区别主要体现在存储结构上或对元素操作上的不同,但是对于take与put操作的原理,却是类似的。
| 队列 | 描述 |
|---|---|
| ArrayBlockingQueue | 基于数组结构实现的一个有界阻塞队列 |
| LinkedBlockingQueue | 基于链表结构实现的一个有界阻塞队列 |
| PriorityBlockingQueue | 支持按优先级排序的无界阻塞队列 |
| DelayQueue | 基于优先级队列(PriorityBlockingQueue)实现的无界阻塞队列 |
| SynchronousQueue | 不存储元素的阻塞队列 |
| LinkedTransferQueue | 基于链表结构实现的一个无界阻塞队列 |
| LinkedBlockingDeque | 基于链表结构实现的一个双端阻塞队列 |
二、ArrayBlockingQueue
ArrayBlockingQueue是最典型的有界阻塞队列,其内部是用数组存储元素的,初始化时需要指定容量大小,利用 ReentrantLock 实现线程安全。
在生产者-消费者模型中使用时,如果生产速度和消费速度基本匹配的情况下,使用ArrayBlockingQueue是个不错选择;当如果生产速度远远大于消费速度,则会导致队列填满,大量生产线程被阻塞。
使用独占锁ReentrantLock实现线程安全,入队和出队操作使用同一个锁对象,也就是只能有一个线程可以进行入队或者出队操作;这也就意味着生产者和消费者无法并行操作,在高并发场景下会成为性能瓶颈。
2-1、ArrayBlockQueue的使用
BlockingQueue queue = new ArrayBlockingQueue(1024);
//向队列中添加元素
queue.put("1");
//从队列中获取元素-使用take()从头节点获取并移除
queue.take();
2-2、ArrayBlockingQueue的原理
2-2-1、ArrayBlockQueue队列的基本结构
2-2-1-1、数据结构
ArrayBlockQueue底层使用数组存储,并且使用先进先出的(FIFO)的规则(使用栈存储一般使用FIFO规则)。
- 通过看源码可以明确看到ArrayBlockQueue使用
Object[]数组进行数据存储,使用takeIndex作为取值的指针用于take, poll, peek, remove操作;使用putIndex作为存储的指针用于put, offer, add。然后使用count用于存储队列中的值
2-2-1-2、利用独占锁进行读写互斥,使用条件队列进行阻塞
- 在源码中还是用到了ReentrantLock独占锁用于处理ArrayBlockQueue的读写互斥锁,同时使用
Condition notEmpty作为取值时,如果队列中无值时,进行条件判断阻塞操作。使用Condition notFull最为入队时,队列已满,然后阻塞操作。
简单的来说队列已满时,使用notFull进行入队阻塞,当队列值为空时,使用notEmpty进行出队阻塞
2-2-1-3、构造函数
ArrayBlockQueue有三个构造函数,先说前两个
第一个可以指定队列的大小,默认使用非公平锁
第二个可以同时指定队列的大小和锁类型,第二个构造会完成独占锁的初始化,以及两个条件队列notEmpty和notFull的初始化
第三个可以指定队列的大小,锁类型,同时值支持传入集合,直接放入队列中
第三个队列会将传入的集合通过for循环的方式将值依次放入队列数组中,同时完成了数组大小count的计算,以及putIndex的指向
2-2-2、数据的入队及出队
2-2-2-1、put()入队操作
以put()入队方法举例来看一下ArrayBlockQueue的操作
加上注释如下:
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(); // 唤醒消费者线程
}
}
通过以上代码我们可以看到最终使用enqueue()进行入队操作,如下:
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();
}
2-2-2-2、take()出队操作
public E take() throws InterruptedException {
final ReentrantLock lock = this.lock;
//加锁,如果线程中断抛出异常
lock.lockInterruptibly();
try {
//如果队列为空,则消费者挂起
while (count == 0)
notEmpty.await();
//出队
return dequeue();
} finally {
lock.unlock();// 唤醒生产者线程
}
}
最终使用dequeue()进行出队操作
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;
}
通过以上入队出队代码的解析,我们很容易发现,在入队出队的过程中,并没有对数组队列大小的扩容或者删除。
入队的时候通过putIndex指针,向对应的下标放入值,同时++putIndex然后判断++之后的putIndex是否和数组的大小一致,如果一致则将putIndex=0
出队的时候首先通过takeIndex获得值,然后将当前takeIndex指向的下标值设置为null,然后进行++takeIndex,当++takeIndex之后的值和数组大小一致的时候,则takeIndex=0
通过以上入队出队的方式就形成了一个**环形数组**,取值从0到n;存值也是从0到n;
这样的好处就是不用对数组进行删除删除,减少了数组移位带来的影响,比如数组有1000个位置,删了第200个,那么后面800的下标都需要向前进行移位。
2-2-1-4、takeIndex和putIndex的指向
- 1、数组指针默认的指向
- 2、put入队时候putIndex指针变化
指针默认指向值为null的下标,当值入队成功的时候,指针会下移指向下一个值为null的下标
- 3、take获取元素时候指针的变化
获取元素之后,会将当前下标的值设置为null,同时指针下移指向下一个不为null的下标
2-2-3、小结
使用put入队的时候:
- 当入队成功的时候会使用
notEmpty.signal()对出队的队列进行唤醒,当队列中的值已经满的时候,会使用notFull.await()进行阻塞,让后面入队的线程进行等待,同时++putIndex==items.length?putIndex=0。
当使用take出队的时候:
- 当出队成功的时候会使用
notFull.signal()唤醒入队的线程,当队列中的值已经全部完成出队的时候,会使用notEmpty.await()进行阻塞,让后面出队的线程等待。同时++takeIndex==items.length?takeIndex=0。
这样入队和出队就形成了互补,互相唤醒,同时在入队和出队的时候,都是用了ReentrantLock独占锁,这样读写就形成了互斥。