java并发编程之阻塞队列详解

63 阅读8分钟

前言

本文主要讲解LinkedBlockingQueue、ArrayBlockingQucue、ConcurrentLinkedQueue PriorityBlockingQueue、DelayQueue等阻塞队列的主要方法和区别。

LinkedBlockingQueue

重要属性

// 队列最大的长度限制
private final int capacity;
// 原子变量,记录队列元素的个数
// 这里用原子变量是因为LinkedBlockingQueue添加和移除是可以并行进行的,必须通过原子变量才可以保证数目的正确性
private final AtomicInteger count = new AtomicInteger();
// 队首元素
transient Node<E> head;
// 队尾元素
transient Node<E> last;
// 在移除元素的时候,所有线程都要来竞争这个锁
private final ReentrantLock takeLock = new ReentrantLock();
// 这是一个takeLock的条件队列,在队列为空的时候,如果使用的是take方法是会阻塞的,此时就会通过这个Condition进行阻塞,在队列不为空的时候再通过这个Condition唤醒线程。
private final Condition notEmpty = takeLock.newCondition();
// 在添加元素的时候,所有线程都要来竞争这个锁
private final ReentrantLock putLock = new ReentrantLock();
// 这是一个putLock的条件队列,在队列满的时候,如果使用的是put方法插入元素是会阻塞的,此时就会通过这个Condition进行阻塞,在队列不满的时候再通过这个Condition唤醒线程。
private final Condition notFull = putLock.newCondition();

put,add和offer 的区别

都是往队列尾部添加一个元素。既然都是同样的功能,为啥要有有三个方法呢?

三个都是队列尾部添加元素,这三个方法的区别在于:

  • put方法添加元素,如果队列已满,会阻塞直到有空间可以放。 put 会响应中断
  • add方法在添加元素的时候,若超出了度列的长度会直接抛出异常。add不会响应中断
  • offer方法添加元素,如果队列已满,直接返回false, offer不会响应中断

add

//  add 底层调用就是offer
public boolean add(E e) {
    if (offer(e))
        return true;
    else
        throw new IllegalStateException("Queue full");
}

offer

// offer 如果队列满了,会等待一会,超过等待时间,就返回false
// 同时还有个offer(E e)方法 队列满了,直接返回
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;
    }

put

// put 如果队列满了,会一直阻塞等待
public void put(E e) throws InterruptedException {
        if (e == null) throw new NullPointerException();
        // Note: convention in all put/take/etc is to preset local var
        // holding count negative to indicate failure unless set.
        int c = -1;
        Node<E> node = new Node<E>(e);
        final ReentrantLock putLock = this.putLock;
        final AtomicInteger count = this.count;
        putLock.lockInterruptibly();
        try {
            /*
             * Note that count is used in wait guard even though it is
             * not protected by lock. This works because count can
             * only decrease at this point (all other puts are shut
             * out by lock), and we (or some other waiting put) are
             * signalled if it ever changes from capacity. Similarly
             * for all other uses of count in other wait guards.
             */
            while (count.get() == capacity) {
                notFull.await();
            }
            enqueue(node);
            c = count.getAndIncrement();
            if (c + 1 < capacity)
                notFull.signal();
        } finally {
            putLock.unlock();
        }
        if (c == 0)
            signalNotEmpty();
    }

take、poll、 peek 的区别

take和poll 都是从队列头部获取并移除一个元素,区别在于:

  • take方法取元素,如果队列为空,会阻塞直到有数据。 take会响应中断
  • poll方法取元素,如果队列为空,直接返回null。 poll不会响应中断
  • peek 获取但不移除此队列的头元素;如果此队列为空,则返回 null。

take

public E take() throws InterruptedException {
    E x;
    int c = -1;
    final AtomicInteger count = this.count;
    final ReentrantLock takeLock = this.takeLock;
    // 竞争锁,响应中断
    takeLock.lockInterruptibly();
    try {
        // 如果队列是空的,就阻塞,等到队列有元素的时候在唤醒
        while (count.get() == 0) {
            notEmpty.await();
        }
        x = dequeue();
        c = count.getAndDecrement();
        if (c > 1)
            notEmpty.signal();
    } finally {
        takeLock.unlock();
    }
    if (c == capacity)
        signalNotFull();
    return x;
}

poll

public E poll() {
    final AtomicInteger count = this.count;
    // 如果当前队列为空,直接返回null
    if (count.get() == 0)
        return null;
    E x = null;
    int c = -1;
    final ReentrantLock takeLock = this.takeLock;
    // 竞争takeLock,保证同一个时刻只有一个线程可以从队列中获取元素,不响应中断
    takeLock.lock();
    try {
        // 再一次判断是否有元素,因为在上次判断和获得锁之间可能还有其他线程进行可出队操作
        if (count.get() > 0) {
            // 出队
            x = dequeue();
            // 数量减一
            c = count.getAndDecrement();
            if (c > 1)
                // 如果此时还有元素,唤醒其他线程take阻塞的线程
                notEmpty.signal();
        }
    } finally {
        takeLock.unlock();
    }
    if (c == capacity)
        // 唤醒put阻塞的线程
        signalNotFull();
    return x;
}

remove

移除指定的元素,putLock和takeLock都需要锁住。

public boolean remove(Object o) {
    if (o == null) return false;
    // putLock和takeLock加锁
    fullyLock();
    try {
        // 找到对应的元素,并移除
        for (Node<E> trail = head, p = trail.next;
             p != null;
             trail = p, p = p.next) {
            if (o.equals(p.item)) {
                unlink(p, trail);
                return true;
            }
        }
        return false;
    } finally {
        fullyUnlock();
    }
}

总结

LinkedBlockingQueue 的内部是通过单向链表实现的,使用头、尾节点来进行入队和出队操作,也就是入队操作都是对尾节点进行操作,出队操作都是对头节点进行操作。对头、尾节点的操作分别使用了单独的独占锁(头takeLock,尾putLock)从而保证了原子性,所以出队和入队操作是可以同时进行的。另外对头、尾节点的独占锁都配备了一个条件队列,用来存放被阻塞的线程,并结合入队、出队操作实现了一个生产消费模型。

ArrayBlockingQueue

重要属性

// 存放具体的元素
final Object[] items;
// 获取元素的下标  take、peek、poll、remove使用
int takeIndex;
// 插入元素的下标  put add offer 使用
int putIndex;
// 队列中元素的数量
int count;
// 独占锁,队列发生操作时都要获得该锁
final ReentrantLock lock;
// 在队列为空的时候,如果使用的是take方法是会阻塞的,此时就会通过这个Condition进行阻塞,在队列不为空的时候再通过这个Condition唤醒线程。
private final Condition notEmpty;
// 在队列满的时候,如果使用的是put方法插入元素是会阻塞的,此时就会通过这个Condition进行阻塞,在队列不满的时候再通过这个Condition唤醒线程。
private final Condition notFull;

主要方法和LinkedBlockingQueue一样:

  • offer 往队列中插入一个元素,如果队列满了,不会阻塞,会直接返回,不会响应中断。
  • put往队列中插入一个元素,如果队列满了,就阻塞,直到队列不满被唤醒,会响应中断。
  • poll在队列是空的时候,会直接返回null,不会阻塞,加锁不会响应中断。
  • take在队列为空的时候会阻塞,并且加锁会响应中断。

我们只看put的一个源码

public void put(E e) throws InterruptedException {
    checkNotNull(e);
    final ReentrantLock lock = this.lock;
    // 获得锁,可以响应中断
    lock.lockInterruptibly();
    try {
        // 如果队列满了,就阻塞,这里循环判断条件是防止虚假唤醒
        while (count == items.length)
            notFull.await();
        enqueue(e);
    } finally {
        lock.unlock();
    }
}

总结

ArrayBlockingQucue通过使用全局独占锁实现了同时只能有一个线程进行入队或者出队操作,这个锁的粒度比较大,有点类似于在方法上添加synchronized的意思。其中 offer 和 poll操作通过简单的加锁进行入队、出队操作,而put、take 操作则使用条件变量实现了,如果队列满则等待,如果队列空则等待,然后分别在出队和入队操作中发送信号激活等待线程实现同步。另外,ArrayBlockingQueue的size操作的结果是精确的,因为计算前加了全局锁。

其他的阻塞队列

ConcurrentLinkedQueue

ConcurrentLinkedQueue是线程安全的无界非阻塞队列,其底层数据结构使用单向链表实现,对于入队和出队操作使用CAS来实现线程安全。ConcurrentLinkedQueue无论是插入还是获取都是通过CAS保证的,所以是非阻塞的,这样可以减少上下文切换,但是大量CAS自旋操作会占用CPU的资源。

在获取ConcurrentLinkedQueue中元素的个数时,也就是size方法,会去遍历链表,得到一个统计值,因为CAS没有加锁,所以从调用size函数到返回结果期间有可能增删元素,导致统计的元素个数不精确。

PriorityBlockingQueue

PriorityBlockingQueue是带优先级的无界阻塞队列,每次出队都返回优先级最高或者最低的元素。其内部是使用平衡二叉树堆实现的,所以直接遍历队列元素不保证有序。默认使用对象的compareTo方法提供比较规则,如果需要自定义比较规则则可以自定义comparators。

PriorityBlockingQueue内部有一个数组queue,用来存放队列元素,size用来存放队列元素个数。allocationSpinLock是个自旋锁,其使用CAS操作来保证同时只有一个线程可以扩容队列,状态为0或者1,其中0表示当前没有进行扩容,1表示当前正在扩容。

由于PriorityBlockingQueue是一个优先级队列,所以有一个比较器comparator 用来比较元素大小。lock 独占锁对象用来控制同时只能有一个线程可以进行入队、出队操作。notEmpty 条件变量用来实现 take方法阻塞模式。这里没有notFull条件变量是因为这里的put操作是非阻塞的,为啥要设计为非阻塞的,是因为这是无界队列。

PriorityBlockingQueue内部数组默认大小为11,默认比较器为null,也就是使用元素的compareTo方法进行比较来确定元素的优先级,这意味着队列元素必须实现了Comparable接口。

DelayQueue

DelayQueue并发队列是一个无界阻塞延迟队列,队列中的每个元素都有个过期时间,当从队列获取元素时,只有过期元素才会出队列。队列头元素是最快要过期的元素。

DelayQueue内部使用PriorityQueue存放数据,使用ReentrantLock实现线程同步。另外,队列里面的元素要实现 Delayed接口,由于每个元素都有一个过期时间,所以要实现获知当前元素还剩下多少时间就过期了的接口,由于内部使用优先级队列来实现,所以要实现元素之间相互比较的接口。

DelayQueue内部的优先队列通过过期时间进行排序,因此可以保证队列头元素是最快要过期的元素。