阻塞队列的原理及应用

阻塞队列是一种常用的并发编程工具,它能够在多线程环境下提供一种安全而高效的数据传输机制。本文将介绍阻塞队列的原理和使用场景,并通过实例演示其在多线程编程中的应用。

一、什么是阻塞队列

阻塞队列是一种特殊的队列,它具有以下几个特点:

  1. 阻塞特性:当队列为空时,从队列中获取元素的操作将会被阻塞,直到队列中有新的元素被添加;当队列已满时,向队列中添加元素的操作将会被阻塞,直到队列中有空的位置,这就是等待唤醒机制。
  2. 线程安全:阻塞队列内部通过锁或其他同步机制来保证多线程环境下的数据一致性。
  3. 有界性:阻塞队列可以设置容量上限,当队列满时,后续的元素将无法添加。
  4. 公平性:阻塞队列可以选择公平或非公平的策略来决定线程的获取顺序。公平队列会按照线程的请求顺序进行处理(线程按先来后到顺序排队获取元素),而非公平队列则允许新的线程插队执行(线程竞争)。比如:SynchronousQueue。

阻塞队列常用于解决生产者-消费者问题,它能够有效地衔接生产者和消费者之间的速度差异,提供一种协调和安全的数据交互方式。
阻塞队列底层一般采用数组和链表这两种数据结构存储元素,ArrayBlockingQueue和PriorityBlockingQueue底层都是采用数组存储的,但是ArrayBlockingQueue是必须指定数组大小,不能扩容,而PriorityBlockingQueue可以进行动态扩容(扩容的最大长度也是Integer.MAX_VALUE),LinkedBlockingQueue底层是链表结构存储,虽然是链表,但是也有长度限制,默认是Integer.MAX_VALUE,一般认为的无界阻塞队列,其实最大的队列长度也就是Integer.MAX_VALUE。

二、阻塞队列的核心方法

  • 添加
方法 描述 是否阻塞
add方法 往队列尾部添加元素,内部是调用offer方法
put方法 往队列尾部添加元素,如果队列已满,则阻塞等待
offer方法 往队列尾部添加元素,如果队列已满,则返回false,不会阻塞
  • 获取
方法 描述 是否阻塞
take方法 take方法:移除并返回队列头部的元素,如果队列为空,则阻塞等待
poll方法 移除并返回队列头部的元素,如果队列为空,则返回null,不会阻塞
peek方法 返回队列头部的元素(不移除),如果队列为空,则返回null,不会阻塞

三、常见的阻塞队列实现


通过图中可以看到,BlockingQueue集成了Queue接口的功能,有多种子类实现,常用的如下:

  1. ArrayBlockingQueue:基于数组实现的有界阻塞队列,它的容量在创建时指定,并且不能动态扩展。
  2. LinkedBlockingQueue:基于链表实现的有界阻塞队列,链表的长度可以通过构造函数显式指定,如果使用默认的构造函数,则默认大小是Integer.MAX_VALUE。
  3. PriorityBlockingQueue:基于优先级堆排序实现的阻塞队列(可扩容),元素按照优先级顺序进行排序。
  4. SynchronousQueue:不存储元素的阻塞队列,每个插入操作都必须等待一个相应的删除操作,反之亦然。

四、阻塞队列的原理

常用的阻塞队列,比如:ArrayBlockingQueue、LinkedBlockingQueue、PriorityBlockingQueue底层都是采用ReentrantLock锁来实现线程的互斥,而ReentrantLock底层采用了AQS框架实现线程队列的同步,线程的阻塞是调用LockSupport.park实现,唤醒是调用LockSupport.unpark实现,具体可以看我之前的文章,SynchronousQueue底层虽然没有用AQS框架,但也用的是LockSupport实现线程的阻塞与唤醒。
一文读懂LockSupport
AQS源码分析
阻塞队列的原理可以通过两个关键组件来解释:锁和条件变量。

阻塞队列使用锁来保护共享资源,控制线程的互斥访问。在队列为空或已满时,线程需要等待相应的条件满足才能继续执行。

  • 条件变量

条件变量是锁的一个补充,在某些特定的条件下,线程会进入等待状态。当条件满足时,其他线程会通过调用条件变量的唤醒方法(比如signal()或signalAll())来通知等待的线程进行下一步操作。
当一个线程试图从空的阻塞队列中获取元素时,它会获取队列的锁,并检查队列是否为空。如果为空,这个线程将进入等待状态,直到其他线程向队列中插入元素并通过条件变量唤醒它。当一个线程试图向已满的阻塞队列插入元素时,它会获取队列的锁,并检查队列是否已满。如果已满,这个线程将进入等待状态,直到其他线程从队列中获取元素并通过条件变量唤醒它。
接下来我们看下阻塞队列的获取元素和插入元素的核心代码:
ArrayBlockingQueue、LinkedBlockingQueue、PriorityBlockingQueue的带阻塞的插入和获取方法都是基于ReentrantLock锁+条件变量的等待和通知来实现的。
主要看看ArrayBlockingQueue带阻塞的插入和获取元素的主要方法吧。

/**
 * 插入元素,带阻塞
 */
public void put(E e) throws InterruptedException {
    checkNotNull(e);
    // 这里使用的是ReentrantLock锁
    final ReentrantLock lock = this.lock;
    // 获取锁并支持响应中断,注意:获取锁的过程中不响应中断,是在获取到锁后根据当前线程的中断标识来处理。
    lock.lockInterruptibly();
    try {
        // 元素大小等于数组长度时阻塞,说明放满了,生产者需要暂停,阻塞在条件变量上,等待被唤醒
        while (count == items.length)
            notFull.await();
        // 放入元素到数组指定的下标处
        enqueue(e);
    } finally {
        // 释放锁
        lock.unlock();
    }
}

/**
 * 插入元素,唤醒等待获取元素的线程
 */
private void enqueue(E x) {
    // assert lock.getHoldCount() == 1;
    // assert items[putIndex] == null;
    final Object[] items = this.items;
    items[putIndex] = x;
    if (++putIndex == items.length)
        putIndex = 0;
    count++;
	// 放入元素后,通知消费线程继续获取元素
    notEmpty.signal();
}

/**
 * 获取元素,带阻塞
 */
public E take() throws InterruptedException {
    final ReentrantLock lock = this.lock;
    lock.lockInterruptibly();
    try {
        // 数组无元素时阻塞,阻塞在条件变量上,等待被唤醒
        // 元素大小等于0时阻塞,说明数组被取空了,消费者需要暂停,阻塞在条件变量上,等待被唤醒
        while (count == 0)
            notEmpty.await();
        // 移除元素并返回
        return 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];
    items[takeIndex] = null;
    // 数组时循环使用的,取元素的index到达数组长度时,下次需要从第0个位置
    if (++takeIndex == items.length)
        takeIndex = 0;
    count--;
    if (itrs != null)
        itrs.elementDequeued();
    // 移除元素后,通知消费者线程可以继续放入元素
    notFull.signal();
    return x;
}

SynchronousQueue不存储元素,插入和删除是配套使用的,它的插入和删除有公平和非公平之分,公平是通过内部类TransferQueue实现的,非公平是通过TransferStack实现的,具体可以看transfer方法,最终会调用LockSupport.park实现线程阻塞,LockSupport.unpark实现线程继续执行,这个就不贴代码了。

五、阻塞队列的使用场景

  1. 生产者-消费者模型:阻塞队列能够很好地平衡生产者和消费者之间的速度差异,既能保护消费者不会消费到空数据,也能保护生产者不会造成队列溢出,能够有效地解耦生产者和消费者,提高系统的稳定性和吞吐量。
  2. 线程池:在线程池中,阻塞队列可以作为任务缓冲区,将待执行的任务放入队列中,由线程池中的工作线程按照一定的策略进行执行。
  3. 同步工具:阻塞队列还可以作为一种同步工具,在多线程环境下实现线程之间的协作。
  4. 数据缓冲:阻塞队列可以用作数据缓冲区,当生产者的速度大于消费者的速度时,数据可以先存储在队列中,等待消费者处理
  5. 事件驱动编程:阻塞队列可以用于事件驱动的编程模型,当事件发生时,将事件对象放入队列中,由消费者进行处理

六、阻塞队列的使用

import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.PriorityBlockingQueue;

public class BlockingQueueExample {
    public static void main(String[] args) {
        // 创建一个容量为10的ArrayBlockingQueue
        BlockingQueue<Integer> queue = new ArrayBlockingQueue<>(10);
//        BlockingQueue<Integer> queue = new LinkedBlockingQueue<>(10);
//        BlockingQueue<Integer> queue = new PriorityBlockingQueue<>(10);
        // 创建生产者线程
        Thread producerThread = new Thread(() -> {
            try {
                for (int i = 0; i <= 5; i++) {
                    // 将数据放入队列
                    queue.put(i);
                    System.out.println(Thread.currentThread().getName() + "Produced: " + i);
                    Thread.sleep(500);
                }
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });
        // 创建消费者线程
        Thread consumerThread = new Thread(() -> {
            try {
                for (int i = 0; i <= 5; i++) {
                    // 从队列中取出数据
                    int num = queue.take();
                    System.out.println(Thread.currentThread().getName() + "Consumed: " + num);
                    Thread.sleep(1000);
                }
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });
        // 启动生产者和消费者线程
        producerThread.start();
        consumerThread.start();
        // 等待线程执行完毕
        try {
            producerThread.join();
            consumerThread.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

执行输出:

Thread-0Produced: 0
Thread-1Consumed: 0
Thread-0Produced: 1
Thread-1Consumed: 1
Thread-0Produced: 2
Thread-0Produced: 3
Thread-1Consumed: 2
Thread-0Produced: 4
Thread-0Produced: 5
Thread-1Consumed: 3
Thread-1Consumed: 4
Thread-1Consumed: 5

阻塞队列的使用比较简单,这里是个简单的使用例子,可设置合适的队列大小和生产者消费者休眠时间来调试阻塞等待和唤醒通知。使用阻塞队列可解决多线程并发访问数据安全问题,也能方便的实现线程间的协调工作。

总结

通过了解阻塞队列的原理和使用场景,我们可以更好地应对多线程编程中的并发问题,提高代码的可维护性和可扩展性。阻塞队列作为一种常见的并发编程工具,能够帮助我们实现高效的数据传输和线程协作,为我们的应用程序提供更好的性能和可靠性保障。希望本文能够为读者对阻塞队列的理解和应用提供一些帮助。

posted @ 2023-09-06 10:41  小新成长之路  阅读(1186)  评论(2编辑  收藏  举报