深度解析阻塞队列LinkedBlockingQueue

前言

关于阻塞队列的使用,其实之前的文章已经提到过:三种方式实现生产者-消费者模型,最后一种方式就是用阻塞队列实现的。仔细观察会发现,前两种也是在用wait/notifyReentrantLock/Condition模拟阻塞队列。
本篇主要从源码、核心方法、设计思想等方面全面解析LinkedBlockingQueue

有本篇涉及到单链表的操作以及ReentrantLockCondition的使用,所以提前了解一下可以做到事半功倍的效果。

阻塞队列

LinkedBlockingQueue是阻塞队列中的一种,见名知意,由链表实现的阻塞队列。类的继承结构图如下:
LinkedBlockingQueue继承结构图
可以看到LinkedBlockingQueue实现了BlockingQueue,相当于实现了Queue接口。因为阻塞队列也满足FIFO(先入先出)特性,能操作的只有队列的头部和尾部,所以相对来说,LinkedBlockingQueue接口对外提供的方法比较少,主要是入队和出队操作。

内部结构

要想知道LinkedBlockingQueue的内部结构,得先了解类的定义和成员变量/常量。

public class LinkedBlockingQueue<E> extends AbstractQueue<E>
        implements BlockingQueue<E>, java.io.Serializable {
    private static final long serialVersionUID = -6903933977591709194L;

    /**
     * Linked list node class
     * 链表内部节点定义
     * 根据节点的定义可以看出LinkedBlockingQueue由单链表实现的
     */
    static class Node<E> {
    	// 数据域
        E item;
		// 指针域
        Node<E> next;

        Node(E x) { item = x; }
    }

    /** The capacity bound, or Integer.MAX_VALUE if none */
    // 队列容量,不指定的话就是Integer.MAX_VALUE,也就是无界队列
    private final int capacity;

    /** Current number of elements */
    // 队列中元素的个数
    private final AtomicInteger count = new AtomicInteger();

    /**
     * Head of linked list.
     * Invariant: head.item == null
     * 队列(链表)头节点
     */
    transient Node<E> head;

    /**
     * Tail of linked list.
     * Invariant: last.next == null
     * 队列(链表)尾节点
     */
    private transient Node<E> last;

    /** Lock held by take, poll, etc */
    // 从队列中取元素的时候的锁
    private final ReentrantLock takeLock = new ReentrantLock();

    /** Wait queue for waiting takes */
    // 线程可以取元素
    private final Condition notEmpty = takeLock.newCondition();

    /** Lock held by put, offer, etc */
    // 往队列中放入元素时候的锁
    private final ReentrantLock putLock = new ReentrantLock();

    /** Wait queue for waiting puts */
    // 线程可以放元素
    private final Condition notFull = putLock.newCondition();
}    

从类的定义及成员变量来看,基本上可以猜测LinkedBlockingQueue底层数据结构是单链表,存/取元素通过ReentrantLock + Condition来保证线程安全以及实现线程阻塞。

构造方法

LinkedBlockingQueue构造方法有三个:

public LinkedBlockingQueue() {
	// Integer.MAX_VALUE
    this(Integer.MAX_VALUE);
}

public LinkedBlockingQueue(int capacity) {
    if (capacity <= 0) throw new IllegalArgumentException();
    this.capacity = capacity;
    // 初始化时,头指针和尾指针都指向同一个空节点
    last = head = new Node<E>(null);
}

public LinkedBlockingQueue(Collection<? extends E> c) {
    this(Integer.MAX_VALUE);
    final ReentrantLock putLock = this.putLock;
    // 加锁(put锁)
    putLock.lock(); // Never contended, but necessary for visibility
    try {
        int n = 0;
        // 遍历入队
        for (E e : c) {
            if (e == null)
                throw new NullPointerException();
            if (n == capacity)
                throw new IllegalStateException("Queue full");
            enqueue(new Node<E>(e));
            ++n;
        }
        // 初始化元素个数
        count.set(n);
    } finally {
    	// 解锁
        putLock.unlock();
    }
}

构造方法比较简单,只需要关注一点:如果不指定队列容量,默认就是Integer.MAX_VALUE,无界队列。

核心方法

队列的核心操作只有三个:入队、出队、查看队首元素。LinkedBlockingQueue的入队、出队操作对应实现了三组API。分别在队列满和队列空时有不同的表现。
三组API对应的表现如下:

(队列满或者空)抛异常 (队列满或者空)返回特殊值 (队列满或者空)阻塞
入队 add抛出异常 offer 返回false put
出队 remove抛出异常 poll返回null take
查看队首元素 element抛出异常 peek返回null

add/offer/put三个方法都不支持传入null,会抛出空指针异常。
对于三个抛出异常的方法add/remove/element都在父类AbstractQueue中实现,分别是offer/poll/peek方法的语法糖。

offer(E e)方法

offer(E e)方法用于入队操作,其源码如下

public boolean offer(E e) {
	// 不能放入null
    if (e == null) throw new NullPointerException();
    // 队列中元素个数
    final AtomicInteger count = this.count;
    if (count.get() == capacity)
    	// 元素个数等于队列容量,队列已满,返回特殊值false
        return false;
    int c = -1;
    Node<E> node = new Node<E>(e);
    final ReentrantLock putLock = this.putLock;
    // 加锁(put锁)
    putLock.lock();
    try {
        if (count.get() < capacity) {
        	// 双重校验,队列此时未满,入队
            enqueue(node);
            // count++
            c = count.getAndIncrement();
            if (c + 1 < capacity)
            	// 入队一个元素后,队列依然未满,唤醒因为调用put方法而被阻塞的线程
                notFull.signal();
        }
    } finally {
    	// 解锁
        putLock.unlock();
    }
    if (c == 0)
    	// 队列中有一个元素,唤醒因为调用take方法而被阻塞的线程
        signalNotEmpty();
    return c >= 0;
}

了解了整体执行逻辑,再具体看看入队方法enqueue,其源码如下:

扫描二维码关注公众号,回复: 8985955 查看本文章
private void enqueue(Node<E> node) {
    // assert putLock.isHeldByCurrentThread();
    // assert last.next == null;
    // 节点插在链表尾部,并把尾指针指向新插入的节点
    last = last.next = node;
}

因为有尾指针的存在,所以在单链表的尾部插入元素的时间复杂度是O(1),非常高效。
LinkedBlockingQueue还提供了一个重载的offer方法offer(E e, long timeout, TimeUnit unit),执行逻辑差不多,只是加了超时时间。

poll()方法

poll()方法用于出队操作,从队列头部取元素。其源码如下:

public E poll() {
	// 获取队列元素个数
    final AtomicInteger count = this.count;
    if (count.get() == 0)
    	// 队列为空(没有元素),返回特殊值null
        return null;
    // 要返回的结果
    E x = null;
    int c = -1;
    final ReentrantLock takeLock = this.takeLock;
    // 加锁(take锁,和put不是同一把锁)
    takeLock.lock();
    try {
        if (count.get() > 0) {
        	// 双重校验,队列中有元素,执行出队操作
            x = dequeue();
            // count--
            c = count.getAndDecrement();
            if (c > 1)
            	// 队列中还有元素,唤醒因为调用take方法而被阻塞的线程
                notEmpty.signal();
        }
    } finally {
    	// 解锁
        takeLock.unlock();
    }
    if (c == capacity)
    	// 队列还能入队一个元素,唤醒因为调用put方法而被阻塞的线程
        signalNotFull();
    // 返回出队的元素
    return x;
}

了解整体逻辑之后再来具体看下出队方法dequeue,其源代码定义如下:

private E dequeue() {
    // assert takeLock.isHeldByCurrentThread();
    // assert head.item == null;
    // head的数据域为null,相当于虚拟头节点,next指向的才是队列真正的第一个元素
    Node<E> h = head;
    Node<E> first = h.next;
    h.next = h; // help GC
    head = first;
    E x = first.item;
    first.item = null;
    return x;
}

出队操作是从单链表的头部删除一个节点,时间复杂度也是O(1),可以看出入队出队操作都是非常高效的。
LinkedBlockingQueue也提供了一个重载的poll方法E poll(long timeout, TimeUnit unit)
offer/poll只是现场安全的实现了队列的基本操作,光有这组操作的队列不能算是阻塞队列,所以再来看看入队、出队的阻塞式实现

put(E e)方法

put(E e)方法和offer(E e)方法一样,都是往队列尾部插入元素(入队)。不同的是,当队列满了的时候,put方法会阻塞当前线程,直到有线程从队列中取出元素,队列还有剩余空间的时候才会继续进行入队操作;而offer方法直接返回特殊值false。put方法的源代码如下:

public void put(E e) throws InterruptedException {
	// 不能放入空元素
    if (e == null) throw new NullPointerException();
    int c = -1;
    Node<E> node = new Node<E>(e);
    final ReentrantLock putLock = this.putLock;
    // 队列中元素个数
    final AtomicInteger count = this.count;
    // 加锁(put锁),lockInterruptibly是可中断锁
    putLock.lockInterruptibly();
    try {
        while (count.get() == capacity) {
        	// 队列已满,阻塞当前线程
            notFull.await();
        }
        // 队列未满,入队
        enqueue(node);
        // count++
        c = count.getAndIncrement();
        if (c + 1 < capacity)
        	// 入队一个元素后,队列依然未满,唤醒因调用put方法而阻塞的线程
            notFull.signal();
    } finally {
    	// 解锁
        putLock.unlock();
    }
    if (c == 0)
    	// 队列中还有一个元素,唤醒因调用take方法而被阻塞的线程
        signalNotEmpty();
}

可以看到整体逻辑是用ReentrantLock锁 + Condition实现:队列满时,阻塞当前线程,直到队列非满。

E take()方法

E take()方法和poll()方法一样,都是删除并返回队列头部元素(出队)。不同的是,当队列为空的时候,take方法会阻塞当前线程,直到有线程往队列中放入元素,才会继续进行出队操作;而poll方法直接返回特殊值null。take方法的源代码如下:

public E take() throws InterruptedException {
	// 要返回的元素
    E x;
    int c = -1;
    // 队列中元素的个数
    final AtomicInteger count = this.count;
    final ReentrantLock takeLock = this.takeLock;
    // 加锁(take锁,不同于put锁),lockInterruptibly是可中断锁
    takeLock.lockInterruptibly();
    try {
        while (count.get() == 0) {
        	// 队列为空,阻塞当前线程
            notEmpty.await();
        }
        // 队列非空,直接出队
        x = dequeue();
        // count--
        c = count.getAndDecrement();
        if (c > 1)
        	// 队列中还有元素,唤醒因为调用take方法而被阻塞的线程
            notEmpty.signal();
    } finally {
    	// 解锁
        takeLock.unlock();
    }
    if (c == capacity)
    	// 队列中还能入队一个元素,唤醒因为调用put方法而被阻塞的线程
        signalNotFull();
    // 返回出队的元素
    return x;
}

可以看到整体逻辑是用ReentrantLock锁 + Condition实现:队列为空时,阻塞当前线程,直到队列非空。

peek()方法

除了入队和出队,队列还有一个基本操作就是查看队首元素(和出队操作的区别是:是否删除队首元素),方法名是peek,源代码如下:

public E peek() {
    if (count.get() == 0)
    	// 队列为空,返回特殊值null
        return null;
    final ReentrantLock takeLock = this.takeLock;
    // 加锁
    takeLock.lock();
    try {
    	// 取队列头部元素
    	// head相当于虚拟头节点,head.next才是队列真正的第一个元素
        Node<E> first = head.next;
        if (first == null)
            return null;
        else
            return first.item;
    } finally {
    	// 解锁
        takeLock.unlock();
    }
}

基本是只是一个读操作,但是也加锁了。是为了防止在读的过程中,有线程执行了出队操作。

总结

总的来说LinkedBlockingQueue可以总结出以下特点:

  • 底层用带有头指针和尾指针的单链表实现,入队/出队都非常高效
  • ReentrantLock锁实现入队、出队、查看队首元素等操作的线程安全
  • 用两把ReentrantLock操作入队、出队线程,使出队和入队可以同时进行,并发度更高
  • Condition类实现有条件的分组唤醒
  • 入队操作是把元素插在链表尾部,出队操作是把链表头部元素删除并返回

LinkedBlockingQueue使用非常广泛,线程池中的缓冲队列,生产者/消费者模型的实现都离不开它。

发布了52 篇原创文章 · 获赞 107 · 访问量 1万+

猜你喜欢

转载自blog.csdn.net/Baisitao_/article/details/103396303