JUC 阻塞队列

317 阅读9分钟

阻塞队列,也就是 BlockingQueue

BlockingQueue 是线程安全的,我们在很多场景下都可以利用线程安全的队列来优雅地解决我们业务自身的线程安全问题。

例如生产者消费者模式的核心就是阻塞队列,阻塞队列还有一个很重要的作用就是解耦。

1、关系图

image-20220212112724676

BlockingQueue 下面有 6 种最主要的实现,分别是 ArrayBlockingQueue、LinkedBlockingQueue、SynchronousQueue、DelayQueue、PriorityBlockingQueue 和 LinkedTransferQueue

非阻塞并发队列的典型例子是 ConcurrentLinkedQueue,这个类不会让线程阻塞,利用 CAS 保证了线程安全。

2、阻塞队列的特点

take 方法

take 方法的功能是获取并移除队列的头结点,通常在队列里有数据的时候是可以正常移除的。可是一旦执行 take 方法的时候,队列里无数据,则阻塞,直到队列里有数据。一旦队列里有数据了,就会立刻解除阻塞状态,并且取到数据。

put 方法

take 方法同理。

是否有界

无界队列意味着里面可以容纳非常多的元素,例如 LinkedBlockingQueue 的上限是 Integer.MAX_VALUE,约为 2 的 31 次方,是非常大的一个数,可以近似认为是无限容量,因为我们几乎无法把这个容量装满。

有界队列意味着可以容纳的元素有限,例如 ArrayBlockingQueue 如果容量满了,也不会扩容,所以一旦满了就无法再往里放数据了。

3、常用方法区别

  1. 抛出异常add、remove、element
  2. 返回结果但不抛出异常offer、poll、peek
  3. 阻塞put、take

image-20220212114707947

第一组:add、remove、element

add 方法

add 方法是往队列里添加一个元素,如果队列满了,就会抛出异常来提示队列已满。

java.lang.IllegalStateException: Queue full

remove 方法

remove 方法的作用是删除元素,如果我们删除的队列是空的,那么 remove 方法就会抛出异常。

java.util.NoSuchElementException

element 方法

element 方法是返回队列的头部节点,但是并不删除。如果队列为空,会抛出异常。

java.util.NoSuchElementException

第二组:offer、poll、peek

offer 方法

offer 方法用来插入一个元素,并用返回值来提示插入是否成功。

如果添加成功会返回 true,而如果队列已经满了,此时继续调用 offer 方法的话,它不会抛出异常,只会返回一个错误提示:false

poll 方法

poll 方法和第一组的 remove 方法是对应的,作用也是移除并返回队列的头节点。

但是如果当队列里面是空的,没有任何东西可以移除的时候,便会返回 null 作为提示。如果队列不为空,会把头节点删除并返回。

正因如此,我们是不允许往队列中插入 null 的,否则我们没有办法区分返回的 null 是一个提示还是一个真正的元素。

如果插入 null 会抛出 ava.lang.NullPointerException

peek 方法

peek 方法和第一组的 element 方法是对应的,意思是返回队列的头元素但并不删除。

如果队列里面是空的,它便会返回 null 作为提示。如果队列不为空,就返回队列的头元素。

带超时时间的 offer 和 poll

offer(E e, long timeout, TimeUnit unit)
poll(long timeout, TimeUnit unit)

有三个参数,分别是元素、超时时长和时间单位。

这里的超时时间是指等待时间。

第三组:put、take

生产者消费者模式就是使用这一组方法。

put 方法

put 方法的作用是插入元素。

在队列没满的时候是正常的插入,但是如果队列已满就无法继续插入,这时它既不会立刻返回 false 也不会抛出异常,而是让插入的线程陷入阻塞状态,直到队列里有了空闲空间,此时队列就会让之前的线程解除阻塞状态,并把刚才那个元素添加进去。

take 方法

take 方法的作用是获取并移除队列的头结点。

在队列里有数据的时候会正常取出数据并删除;但是如果执行 take 的时候队列里无数据,则阻塞,直到队列里有数据;一旦队列里有数据了,就会立刻解除阻塞状态,并且取到数据。

4、常见的阻塞队列

ArrayBlockingQueue

ArrayBlockingQueue 是最典型的有界队列,其内部是用数组存储元素的,利用 ReentrantLock 实现线程安全。

构造方法源码:

public ArrayBlockingQueue(int capacity) {
    this(capacity, false);
}

public ArrayBlockingQueue(int capacity, boolean fair) {
    if (capacity <= 0)
        throw new IllegalArgumentException();
    this.items = new Object[capacity];
    // ReentrantLock,并且可以指定是否安全
    lock = new ReentrantLock(fair);
    // 内部使用信号量
    notEmpty = lock.newCondition();
    notFull =  lock.newCondition();
}


LinkedBlockingQueue

内部用链表实现的。如果我们不指定它的初始容量,那么它容量默认就为整型的最大值 Integer.MAX_VALUE

LinkedBlockingQueue 也被称作无界队列,代表它几乎没有界限。

SynchronousQueue

SynchronousQueue 最大的不同之处在于,它的容量为 0,所以没有一个地方来暂存元素,导致每次取数据都要先阻塞,直到有数据被放入;同理,每次放数据的时候也会阻塞,直到有消费者来取。

因为 SynchronousQueue 不需要去持有元素,它所做的就是直接传递(direct handoff)。由于每当需要传递的时候,SynchronousQueue 会把元素直接从生产者传给消费者,在此期间并不需要做存储,所以如果运用得当,它的效率是很高的。

一些方法源码:

public int size() {
    return 0;
}
public boolean isEmpty() {
    return true;
}
public boolean remove(Object o) {
    return false;
}

PriorityBlockingQueue

PriorityBlockingQueue 是一个支持优先级的无界阻塞队列。支持自定义比较器。

核心构造方法源码:

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;
    this.queue = new Object[initialCapacity];
}

DelayQueue

DelayQueue 这个队列比较特殊,具有“延迟”的功能。我们可以设定让队列中的任务延迟多久之后执行,比如 10 秒钟之后执行,这在例如“30 分钟后未付款自动取消订单”等需要延迟执行的场景中被大量使用。

它是无界队列,放入的元素必须实现 Delayed 接口,而 Delayed 接口又继承了 Comparable 接口,所以自然就拥有了比较和排序的能力,代码如下:

public interface Delayed extends Comparable<Delayed> {
    /**
     * 还剩下多长的延迟时间才会被执行
     */
    long getDelay(TimeUnit unit);
}

元素会根据延迟时间的长短被放到队列的不同位置,越靠近队列头代表越早过期。

DelayQueue 内部使用了 PriorityQueue 的能力来进行排序,而不是自己从头编写。

5、阻塞和非阻塞队列的原理

ArrayBlockingQueue 源码分析

重要属性

// 存放元素的数组
final Object[] items;

// 下一次读取操作的位置
int takeIndex;

// 下一次写入操作的位置
int putIndex;

// 队列中的元素数量
int count;

// 这三个变量就是控制并发的
final ReentrantLock lock;

private final Condition notEmpty;

private final Condition notFull;

put 方法

public void put(E e) throws InterruptedException {
    // 检查 e 是否为 null,为 null 则抛出空指针异常
    checkNotNull(e);
    final ReentrantLock lock = this.lock;
    // 加锁,这个加锁方法是可以响应中断的
    lock.lockInterruptibly();
    try {
        // 如果元素个数等于队列容量,说明队列已满,则阻塞
        while (count == items.length)
            // 说明不满这个条件不满足
            notFull.await();
        enqueue(e);
    } finally {
        // 解锁
        lock.unlock();
    }
}

take 方法

public E take() throws InterruptedException {
    final ReentrantLock lock = this.lock;
    // 加锁,这个加锁方法是可以响应中断的
    lock.lockInterruptibly();
    try {
        // 队列中没有元素
        while (count == 0)
            // 说明不空这个条件不满足
            notEmpty.await();
        return dequeue();
    } finally {
        // 解锁
        lock.unlock();
    }
}

总结

ArrayBlocking 是使用 ReetrantLock + Condition 来保证线程安全。

ConcurrentLinkedQueue 源码分析

ConcurrentLinkedQueue 是使用链表作为其数据结构的,它是一个非阻塞的队列,使用 CAS 来保证线程安全。

offer 方法

public boolean offer(E var1) {
    // 检查是否为 null
    checkNotNull(var1);
    ConcurrentLinkedQueue.Node var2 = new ConcurrentLinkedQueue.Node(var1);
    ConcurrentLinkedQueue.Node var3 = this.tail;
    ConcurrentLinkedQueue.Node var4 = var3;

    do {
        while(true) {
            ConcurrentLinkedQueue.Node var5 = var4.next;
            if (var5 == null) {
                break;
            }

            if (var4 == var5) {
                var4 = var3 != (var3 = this.tail) ? var3 : this.head;
            } else {
                var4 = var4 != var3 && var3 != (var3 = this.tail) ? var3 : var5;
            }
        }
        // casNext(),很明显使用 CAS 来保证线程安全
    } while(!var4.casNext((ConcurrentLinkedQueue.Node)null, var2));

    if (var4 != var3) {
        this.casTail(var3, var2);
    }

    return true;
}

boolean casNext(ConcurrentLinkedQueue.Node<E> var1, ConcurrentLinkedQueue.Node<E> var2) {
    // 底层仍然是 unsaf,本地方法
    return UNSAFE.compareAndSwapObject(this, nextOffset, var1, var2);
}

poll 方法

public E poll() {
    restartFromHead:
    for (;;) {
        for (Node<E> h = head, p = h, q;;) {
            E item = p.item;

            if (item != null && p.casItem(item, null)) {
                // Successful CAS is the linearization point
                // for item to be removed from this queue.
                if (p != h) // hop two nodes at a time
                    updateHead(h, ((q = p.next) != null) ? q : p);
                return item;
            }
            else if ((q = p.next) == null) {
                updateHead(h, p);
                return null;
            }
            else if (p == q)
                continue restartFromHead;
            else
                p = q;
        }
    }
}

总结

非阻塞队列 ConcurrentLinkedQueue 使用 CAS 非阻塞算法 + 不停重试,来实现线程安全,适合用在不需要阻塞功能,且并发不是特别剧烈的场景。

6、阻塞队列如何选择

线程池对于阻塞队列的选择

image-20220212131016144

FixedThreadPool

FixedThreadPool(SingleThreadExecutor 同理)选取的是 LinkedBlockingQueue

由于 FixedThreadPool 的线程数是固定的,在任务激增的时候,它无法增加更多的线程来帮忙处理 Task,所以需要像 LinkedBlockingQueue 这样没有容量上限的 Queue 来存储那些还没处理的 Task

CachedThreadPool

CachedThreadPool 选取的是 SynchronousQueue

对于 CachedThreadPool 而言,为了避免新提交的任务被拒绝,它选择了无限制的 maximumPoolSize,所以既然它的线程的最大数量是无限的,也就意味着它的线程数不会受到限制,那么它就不需要一个额外的空间来存储那些 Task,因为每个任务都可以通过新建线程来处理。

ScheduledThreadPool

ScheduledThreadPool(SingleThreadScheduledExecutor同理)选取的是延迟队列

对于 ScheduledThreadPool 而言,它使用的是 DelayedWorkQueue。延迟队列的特点是:不是先进先出,而是会按照延迟时间的长短来排序,下一个即将执行的任务会排到队列的最前面。

ScheduledThreadPool 处理的是基于时间而执行的 Task,而延迟队列有能力把 Task 按照执行时间的先后进行排序,这正是我们所需要的功能。

ArrayBlockingQueue

常用的阻塞队列为 ArrayBlockingQueue,它经常被用于我们手动创建的线程池中。

这种阻塞队列内部是用数组实现的,在新建对象的时候要求传入容量值,且后期不能扩容,所以 ArrayBlockingQueue 的最大特点就是容量是有限且固定的。

这样一来,使用 ArrayBlockingQueue 且设置了合理大小的最大线程数的线程池,在任务队列放满了以后,如果线程数也已经达到了最大值,那么线程池根据规则就会拒绝新提交的任务,而不会无限增加任务或者线程数导致内存不足,可以非常有效地防止资源耗尽的情况发生。

选择策略

功能

第 1 个需要考虑的就是功能层面,比如是否需要阻塞队列帮我们排序,如优先级排序、延迟执行等。如果有这个需要,我们就必须选择类似于 PriorityBlockingQueue 之类的有排序能力的阻塞队列。

容量

第 2 个需要考虑的是容量,或者说是否有存储的要求,还是只需直接传递

有的是容量固定的,如 ArrayBlockingQueue;有的默认是容量无限的,如 LinkedBlockingQueue;而有的里面没有任何容量,如 SynchronousQueue;而对于 DelayQueue 而言,它的容量固定就是 Integer.MAX_VALUE

能否扩容

第 3 个需要考虑的是能否扩容。因为有时我们并不能在初始的时候很好的准确估计队列的大小,因为业务可能有高峰期、低谷期。

如果一开始就固定一个容量,可能无法应对所有的情况,也是不合适的,有可能需要动态扩容。如果我们需要动态扩容的话,那么就不能选择 ArrayBlockingQueue ,因为它的容量在创建时就确定了,无法扩容。相反,PriorityBlockingQueue 即使在指定了初始容量之后,后续如果有需要,也可以自动扩容。

底层数据结构

第 4 个需要考虑的点就是数据结构。

ArrayBlockingQueue 的数据结构是数组,LinkedBlockingQueue 的内部是用链表实现的,所以这里就需要我们考虑到,ArrayBlockingQueue 没有链表所需要的“节点”,空间利用率更高。所以如果我们对性能有要求可以从内存的结构角度去考虑这个问题。

性能

第 5 点就是从性能的角度去考虑。

比如 LinkedBlockingQueue 由于拥有两把锁,它的操作粒度更细,在并发程度高的时候,相对于只有一把锁的 ArrayBlockingQueue 性能会更好。

SynchronousQueue 性能往往优于其他实现,因为它只需要“直接传递”,而不需要存储的过程。如果我们的场景需要直接传递的话,可以优先考虑 SynchronousQueue