【阻塞队列】- LinkedBlockingQueue 的原理

文章目录

  • [1. 前言](#1. 前言)
  • [2. 提供的 API](#2. 提供的 API)
  • [3. 参数](#3. 参数)
  • [4. 生产者](#4. 生产者)
    • [4.1 public void put(E e)](#4.1 public void put(E e))
    • [4.2 public boolean offer(E e, long timeout, TimeUnit unit)](#4.2 public boolean offer(E e, long timeout, TimeUnit unit))
    • [4.3 public boolean offer(E e)](#4.3 public boolean offer(E e))
  • [5. 消费者](#5. 消费者)
    • [5.1 public E take()](#5.1 public E take())
    • [5.2 public E poll(long timeout, TimeUnit unit)](#5.2 public E poll(long timeout, TimeUnit unit))
    • [5.3 public E poll()](#5.3 public E poll())
    • [5.4 public E peek()](#5.4 public E peek())
  • [6. 其他方法](#6. 其他方法)
    • [6.1 remove 方法](#6.1 remove 方法)
    • [6.2 contains 方法](#6.2 contains 方法)
    • [6.2 clear 方法](#6.2 clear 方法)
    • [6.3 构造函数](#6.3 构造函数)
  • [7. 小结](#7. 小结)

1. 前言

上一篇文章已经介绍了 ArrayBlockingQueue 的迭代器原理,那么现在这篇文章就要介绍 LinkedBlockingQueue 了,这个阻塞队列和 ArrayBlockingQueue 的唯一区别就是 ArrayBlockingQueue 是数组存储,LinkedBlockingQueue 是链表存储。

系列文章:

2. 提供的 API

LinkedBlockingQueue 作为链表阻塞队列,提供了一些给生产者和消费者调用的方法,下面就来看下。

用法和区别如下:

生产者 消费者
offer(E e):不阻塞的添加元素 poll():不阻塞的获取元素
offer(E e, long timeout, TimeUnit unit):带超时时间的阻塞添加元素 poll(long timeout, TimeUnit unit):带超时时间的阻塞获取元素
put(E e):一直阻塞的添加元素 take():一直阻塞的添加元素

和 ArrayBlockingQueue 一样,上面三个方法是生产者和消费者会调用的。下面源码其实也是重点解析这几个方法,看看是怎么通信的。

3. 参数

首先看下里面的参数:

java 复制代码
/**
 * 链表节点
 * @param <E>
 */
static class Node<E> {
    // 节点的值
    E item;

    Node<E> next;

    Node(E x) { item = x; 
}

/**
 * 最大容量上限
 */
private final int capacity;

/**
 * 队列节点数
 */
private final AtomicInteger count = new AtomicInteger();

/**
 * 头部元素,非序列化
 */
transient Node<E> head;

/**
 * 尾部节点
 */
private transient Node<E> last;

/**
 * 当使用 take,poll 的时候,就加这个锁
 */
private final ReentrantLock takeLock = new ReentrantLock();

/**
 * 等待队列
 */
private final Condition notEmpty = takeLock.newCondition();

/**
 * 调用 put,offer 的时候,加这个锁
 */
private final ReentrantLock putLock = new ReentrantLock();

/**
 * 队列满了,等待队列元素被消耗
 */
private final Condition notFull = putLock.newCondition();

上面的元素注释都解释的很清楚了,要说明的是,LinkedBlockingQueue 里面使用链表来存储节点,并且这个链表是一个单向链表。虽然队列里面是使用的单向链表,但是这个链表容量上限是 capacity,也不是无限往里面添加元素的。

然后注意下里面的两个 Condition,notEmptynotFull 是专门用来实现消费者和生产者的线程阻塞唤醒控制,notEmpty 作用于消费者线程,notFull 作用于生产者线程。

4. 生产者

4.1 public void put(E e)

这个方法是往队列中添加节点,如果队列满了,就无限阻塞等待。

java 复制代码
/**
 * 添加一个元素,如果发生线程冲突,那么就 await 阻塞等待,没有超时时间
 * @param e
 * @throws InterruptedException
 */
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;
    // 可打断的锁putLock
    putLock.lockInterruptibly();
    try {
        // 队列满了,那么等待
        // while 循环是因为唤醒的线程要和外层新的线程竞争添加节点,如果竞争添加节点失败,这里就还会进入循环
        while (count.get() == capacity) {
            // 生产者阻塞等待消费者消费
            notFull.await();
        }
        // 加入链表尾结点
        enqueue(node);
        // 获取现在链表中有多少个节点
        c = count.getAndIncrement();
        // c + 1 < capacity, 那么容量没满
        if (c + 1 < capacity)
            // 唤醒生产者去继续生产数据
            notFull.signal();
    } finally {
        // 解锁
        putLock.unlock();
    }
    if (c == 0)
        // 如果原来队列是空的,那么添加元素之后需要唤醒消费者去消费
        signalNotEmpty();
}

同样的,添加的锁也是可中断锁,并且里面使用 while (count.get() == capacity) 来判断,之所以使用 while,是因为就算这里被唤醒了,也有可能会跟其他外层的方法去争夺 lock 锁,这里的外层方法指的是调用这个 put 方法的线程,而不是其他的生产者线程,因为 signal 只会唤醒一个生产者线程。

当生产者被唤醒之后,并且容量也没有到达容量上限,这时候就可以往链表中添加节点了,也就是下面的 enqueue 方法,这个方法就是把节点添加到链表结尾,也就是说新增的节点会添加到链表的尾部。

java 复制代码
/**
 * 插入一个节点到链表尾部
 * @param node
 */
private void enqueue(Node<E> node) {
    // assert putLock.isHeldByCurrentThread();
    // assert last.next == null;
    last = last.next = node;
}

然后获取链表中有多少个节点,注意这里是用的 getAndIncrement,也就是获取的是原来的节点个数,还没有 + 1 的,所以下面判断需要 + 1。if (c + 1 < capacity),如果节点数没有到容量上限,就唤醒生产者去继续生产数据。

最后当解锁之后,如果原来队列是空的,那么添加完元素之后队列就有节点了,这时候会唤醒消费者去消费。

java 复制代码
/**
 * 唤醒 take、poll 的线程,就是消费者线程
 */
private void signalNotEmpty() {
    final ReentrantLock takeLock = this.takeLock;
    takeLock.lock();
    try {
        // 唤醒消费者去消费阻塞队列里面的元素
        notEmpty.signal();
    } finally {
        takeLock.unlock();
    }
}

在上面整个过程中,加锁是堆 putLock 加锁,这个 putLock 就是生产者线程竞争来添加节点的,和消费者区分开,实现精确的加锁解锁,同时提高并发,避免生产者和消费者都用一个线程。其实这里就算生产者用 putLock,消费者用 takeLock,在这个方法里面 while 循环也会一直阻塞,所以并不会线程不安全。

4.2 public boolean offer(E e, long timeout, TimeUnit unit)

上面的 put 方法是不阻塞的添加节点,这个方法就是阻塞的添加节点,也就是生产者会阻塞,只不过不同于 put,这个方法最多只会阻塞 timeout 时间。

java 复制代码
/**
 * 添加元素,逻辑和上面一样,这是这里多了超时时间
 * @param e 添加元素
 * @param timeout 超时时间
 * @param unit 单位
 * @return
 * @throws InterruptedException
 */
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) {
            // 里面判断如果超时了,返回 false,添加不成功
            if (nanos <= 0)
                return false;
            // 阻塞等待 nanos 时间,被唤醒的时候有可能是提前被唤醒,也有可能是超时唤醒
            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 方法是差不多的,只不过 while 循环里面会判断 while (count.get() == capacity),当队列节点到达容量上限了,这时候会去判断超时时间,如果超时时间 <= 0,就直接返回 false。否则就会继续阻塞等待,nanos = notFull.awaitNanos(nanos) 这个方法返回的结果是剩下的超时时间,在里面其实是返回的 deadline - 当前时间,deadline 是调用 offer 方法的时候设置的,也就是 deadline = timeout + 当前时间,当然了这个 timeout 是经过 unit 进行转换过的。

4.3 public boolean offer(E e)

不阻塞的添加方法,如果队列元素满了,直接返回 false,并不会阻塞

java 复制代码
/**
 * 添加一个元素,如果满了直接返回,不阻塞等待
 * @param e
 * @return
 */
public boolean offer(E e) {
    // 不能为空
    if (e == null) throw new NullPointerException();
    // 数量
    final AtomicInteger count = this.count;
    // 如果队列满了直接返回 false,不阻塞
    if (count.get() == capacity)
        return false;
    int c = -1;
    Node<E> node = new Node<E>(e);
    // 加锁 putLock
    final ReentrantLock putLock = this.putLock;
    putLock.lock();
    try {
        // 如果没满那么就插入
        if (count.get() < capacity) {
            enqueue(node);
            // 然后节点数 + 1
            c = count.getAndIncrement();
            if (c + 1 < capacity)
                // 如果节点数还没满,那么继续唤醒生产者继续生产
                notFull.signal();
        }
    } finally {
        // 解锁
        putLock.unlock();
    }
    // 如果原来队列是空的,那么添加元素之后需要唤醒消费者去消费
    if (c == 0)
        signalNotEmpty();
    // 是否添加成功
    return c >= 0;
}

这个方法和上面的也一样了,就不多说,直接看注释就可以了。

5. 消费者

5.1 public E take()

take 方法和上面的 put 方法对应,take 是无限阻塞的消费者方法,当队列里面元素为空的时候,就会无限阻塞住,下面来看下具体的方法。

java 复制代码
/**
 * 获取队头元素,里面没有元素就阻塞
 * @return
 * @throws InterruptedException
 */
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 是节点数量,这里是 -1 前的数量
        c = count.getAndDecrement();
        // 如果 c > 1,就说明还有数据可以消费
        if (c > 1)
            // 继续唤醒消费者去消费
            notEmpty.signal();
    } finally {
        // 解锁
        takeLock.unlock();
    }
    // 如果原来队列是满的,那么消费之后需要唤醒生产者去生产
    if (c == capacity)
        signalNotFull();
    return x;
}

首先加可中断锁,加锁加的是 takeLock。同理 while (count.get() == 0),当队列没有元素的时候,消费者会阻塞等待,注意这里是用的 notEmpty,这个就是我们说的精确唤醒。

当消费者被唤醒之后,会调用 dequeue() 获取队列节点,获取节点的方法在下面。

java 复制代码
/**
 * 删除队头元素
 * @return
 */
private E dequeue() {
    // assert takeLock.isHeldByCurrentThread();
    // assert head.item == null;
    Node<E> h = head;
    Node<E> first = h.next;
    h.next = h; // 自引用,方便后续 GC
    head = first;
    E x = first.item;
    first.item = null;
    return x;
}

可以看到上面获取的是队列的头节点,也就是说 LinkedBlockingQueue 是先进先出的队列。

获取头结点之后,再获取下之前的节点数量,如果节点数 > 1,就代表队列头结点被获取之后剩下还有节点可以被其他消费者消费,所以就调用 notEmpty.signal() 唤醒其他消费者去消费。

最后调用 takeLock 解锁,然后判断如果原来队列是满的,那么消费之后需要唤醒生产者去生产,因为队列有位置可以放元素了,也就是 signalNotFull() 方法。

java 复制代码
/**
 * 唤醒 put,offer 线程,就是生产者线程去生产
 */
private void signalNotFull() {
    final ReentrantLock putLock = this.putLock;
    putLock.lock();
    try {
        notFull.signal();
    } finally {
        putLock.unlock();
    }
}

5.2 public E poll(long timeout, TimeUnit unit)

不同于上面的 take,这个 poll 方法是带超时时间的阻塞,下面具体看注释,就不多说了,流程和上面的 take 基本一模一样。

java 复制代码
/**
* 获取队头元素,超时时间是 timeout
  * @param timeout 过期时间
  * @param unit 单位
  * @return
  * @throws InterruptedException
  */
 public E poll(long timeout, TimeUnit unit) throws InterruptedException {
     E x = null;
     int c = -1;
     // 超时时间
     long nanos = unit.toNanos(timeout);
     final AtomicInteger count = this.count;
     final ReentrantLock takeLock = this.takeLock;
     // 加可中断锁
     takeLock.lockInterruptibly();
     try {
         while (count.get() == 0) {
             // nanos <= 0,到期了返回 null
             if (nanos <= 0)
                 return null;
             // 消费者阻塞等待 nanos 超时时间
             nanos = notEmpty.awaitNanos(nanos);
         }
         // 队头元素
         x = dequeue();
         // 原来有多少个节点
         c = count.getAndDecrement();
         // 如果 c > 1,就说明还有数据可以消费
         if (c > 1)
             // 唤醒消费者继续消费
             notEmpty.signal();
     } finally {
         // 解锁
         takeLock.unlock();
     }
     // 如果原来就是满的,消费之后唤醒生产者去生产数据
     if (c == capacity)
         signalNotFull();
     return x;
 }

5.3 public E poll()

这里的 poll 对应生产者的 offer,如果队列是空的,那么消费者会立刻返回。

java 复制代码
/**
 * 获取队头元素,如果为空直接返回 null,不等待
 * @return
 */
public E poll() {
    final AtomicInteger count = this.count;
    if (count.get() == 0)
        return null;
    E x = null;
    int c = -1;
    final ReentrantLock takeLock = this.takeLock;
    takeLock.lock();
    try {
        if (count.get() > 0) {
            x = dequeue();
            c = count.getAndDecrement();
            if (c > 1)
                notEmpty.signal();
        }
    } finally {
        takeLock.unlock();
    }
    if (c == capacity)
        signalNotFull();
    return x;
}

5.4 public E peek()

peek 方法是获取队头元素,不需要删除头结点,其实看源码就能看到这里返回的就是 head 的数据。不过注意一下由于链表是有头单向链表,所以返回的是 head.next 的数据。

java 复制代码
/**
 * 获取队头元素,不需要删除队头元素
 * @return
 */
public E peek() {
    if (count.get() == 0)
        return null;
    final ReentrantLock takeLock = this.takeLock;
    takeLock.lock();
    try {
        // 返回头结点
        Node<E> first = head.next;
        if (first == null)
            return null;
        else
            return first.item;
    } finally {
        takeLock.unlock();
    }
}

6. 其他方法

上面是生产者和消费者的核心方法,但是除了这些方法之外,集合的一些基本方法也是具备的,比如删除节点,是否包含某个节点,清空队列...下面就来看下这些方法。

6.1 remove 方法

java 复制代码
/**
 * 从链表中移除元素 o
 * @param o 元素
 * @return
 */
public boolean remove(Object o) {
    // 如果元素 == null, 返回false, 移除失败
    if (o == null) return false;
    // 从链表中删除元素的时候生产者和消费者都要停下来,所以这里是加生产者和消费者锁
    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();
    }
}

remove 方法其实不属于生产者和消费者的固定方法,所以这里面会和所有生产者和消费者都进行锁竞争。

java 复制代码
/**
  * 加锁
  */
 void fullyLock() {
     putLock.lock();
     takeLock.lock();
 }

 /**
  * 解锁
  */
 void fullyUnlock() {
     takeLock.unlock();
     putLock.unlock();
 }

加锁之后,就是常规的查找节点了,从头结点开始遍历,找到之后使用 unlink 删掉,注意因为是单向链表,所以调用 unlink 的时候会把删除的节点和删除节点的前一个节点传进来。

java 复制代码
/**
 * 用于解除链表中某个节点 p 与其前驱节点 trail 之间的链接关系。
 * @param p 节点 p
 * @param trail 前驱节点 trail
 */
void unlink(Node<E> p, Node<E> trail) {
    p.item = null;
    // 链表删除节点的逻辑
    trail.next = p.next;
    // 如果 p 是最后一个节点
    if (last == p)
        // 把前驱节点设置为尾节点
        last = trail;
    // 如果原来已经到达容量上限了
    if (count.getAndDecrement() == capacity)
        // 唤醒生产者去生产
        notFull.signal();
}

里面就是删除链表的逻辑了,trail.next = p.next,然后设置下 last 节点,如果要删除的节点是 last 节点,那么就把 last 设置成前驱节点。

删除完了之后要判断下如果原来已经到达容量上限了,删除节点之后说明队列里面就有空闲位置可以生产节点了,这时候唤醒生产者去生产 notFull.signal()

6.2 contains 方法

java 复制代码
/**
 * 是否包含某个元素
 * @param o
 * @return
 */
public boolean contains(Object o) {
    if (o == null) return false;
    fullyLock();
    try {
        // 遍历链表判断
        for (Node<E> p = head.next; p != null; p = p.next)
            if (o.equals(p.item))
                return true;
        return false;
    } finally {
        fullyUnlock();
    }
}

这个方法也是要对生产者和消费者的锁去加锁,然后遍历链表判断。

6.2 clear 方法

java 复制代码
/**
 * 清除队列
 */
public void clear() {
    fullyLock();
    try {
        // 遍历所有节点,把所有节点从链表中删掉
        for (Node<E> p, h = head; (p = h.next) != null; h = p) {
            h.next = h;
            p.item = null;
        }
        head = last;
        // 如果原本没有清除链表之前容量是满的,清除之后就会唤醒
        if (count.getAndSet(0) == capacity)
            notFull.signal();
    } finally {
        fullyUnlock();
    }
}

清空队列方法,遍历所有节点,把所有节点从队列中删掉,同时清空完队列之后如果原本没有清除链表之前容量是满的,也就是 count.getAndSet(0) == capacity,说明有生产者阻塞了,所以这里需要唤醒生产者,也就是调用 notFull.signal() 方法。

6.3 构造函数

java 复制代码
public LinkedBlockingQueue() {
    // LinkedBlockingQueue容量: Integer.MAX_VALUE,默认
    this(Integer.MAX_VALUE);
}

/**
 * 也可以自己定义容量大小
 * @param capacity
 */
public LinkedBlockingQueue(int capacity) {
    if (capacity <= 0) throw new IllegalArgumentException();
    this.capacity = capacity;
    last = head = new Node<E>(null);
}

/**
 * 传入一个集合去初始化LinkedBlockingQueue
 * @param c
 */
public LinkedBlockingQueue(Collection<? extends E> c) {
    // 注意,这里即使传入了集合,但是默认还是最大容量上限为Integer.MAX_VALUE
    this(Integer.MAX_VALUE);
    // 加锁
    final ReentrantLock putLock = this.putLock;
    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所以一般都不会调用默认构造器,这样在极端情况下任务过多会导致内存溢出。

一般我们都自己定义容量大小,在构造器里面初始化 lasthead 节点。然后再看下最后一个,就是传入一个集合来创建队列,其实就是遍历这个集合然后将集合里面的元素一个一个添加到队列里面。至于为什么不需要唤醒消费者线程,当然是因为这里是初始化方法了,队列都没初始化好呢。

7. 小结

好了,LinkedBlockingQueue 就介绍到这了,里面的方法和 ArrayBlockingQueue 很类似,甚至说就是结构不一样,所以其实看明白 ArrayBlockingQueue,LinkedBlockingQueue 也能很容易就看懂了。

如有错误,欢迎指出!!!

相关推荐
C1829818257515 分钟前
BeanFactory与factoryBean 区别,请用源码分析,及spring中涉及的点,及应用场景
java·spring
xmh-sxh-13141 小时前
熔断器模式如何进入半开状态的
java
阿芯爱编程2 小时前
清除数字栈
java·服务器·前端
小大力2 小时前
简单的jmeter数据请求学习
java·学习·jmeter
孑么2 小时前
力扣 二叉树的最大深度
java·算法·leetcode·职场和发展·深度优先·广度优先
mikey棒棒棒2 小时前
SSM-Spring-IOC/DI注解开发
java·后端·spring·ssm·ioc·di
xweiran2 小时前
Spring源码分析之事件机制——观察者模式(二)
java·开发语言·spring·观察者模式·底层源码
深鱼~2 小时前
【多线程初阶篇¹】线程理解| 线程和进程的区别
java·开发语言·人工智能·深度学习·计算机视觉
Q_19284999063 小时前
基于Spring Boot的前后端分离的外卖点餐系统
java·spring boot·后端
xmh-sxh-13143 小时前
Redis中字符串和列表的区别
java