18-LinkedBlockingQueue 源码解析(队列)

注:源码系列文章主要是对某付费专栏的总结记录。如有侵权,请联系删除。

1 整体架构

LinkedBlockingQueue 中文叫做链表阻塞队列,从命名上就知道其底层数据结构是链表,并且队列是可阻塞的。

1.1 类图

LinkedBlockingQueue 类结构图

从类图我们大概可以看到两条路径:

  1. AbstractQueue -> AbstractCollection -> Collection -> Iterable 这条路径依赖,主要是想复用 Collection 和 迭代器的一些操作;
  2. BlockingQueue -> Queue -> Collection,BlockingQueue 和 Queue 是新出来的两个队列接口。

Queue 是最基础的接口,几乎所有的队列实现类都会实现这个接口,该接口定义出了队列的三大类操作:

新增操作:

  1. add 队列满的时候抛出异常;
  2. offer 队列满的时候返回 false。

查看并删除操作:

  1. remove 队列空的时候抛出异常;
  2. poll 队列空的时候返回 null。

只查看不删除的操作:

  1. element 队列空的时候抛出异常;
  2. peek 队列空的时候返回 null。

一共 6 种方法,除了以上分类,也可以分成两类:

  1. 遇到队列满或空的时候,抛出异常,如:add、remove、element
  2. 遇到队列满或空的时候,返回特殊值,如:offer、poll、peek

BlockingQueue 在 Queue 的基础上加上了阻塞的概念,比如一直阻塞,还是阻塞一段时间。如图:

抛异常返回特殊值一直阻塞阻塞一段时间
新增操作(队列满)addoffer 返回 falseputoffer 过超时时间返回 false
查看并删除操作(队列空)remobepoll 返回 nulltakepoll 过超时时间返回 null
只查看不删除操作(队列空)elementpeek 返回 null暂无暂无

PS: remove 方法,BlockingQueue 类注释中定义的是抛异常,但 LinkedBlockingQueue 中 remove 方法实际返回的是 false。

从表格中可以看到,在新增和查看并删除两大类操作上,BlockingQueue 增加了阻塞的功能,而且可以选择一直阻塞,或者阻塞一段时间后返回特殊值。

1.2 类注释

  1. 基于链表的阻塞队列,其底层的数据结构是链表;
  2. 链表维护先入先出队列,新元素被放在队尾,获取元素从队列头部拿;
  3. 链表大小在初始化的时候可以设置,默认是 Integer 的最大值;
  4. 可以使用 Collection 和 Iterator 两个接口的所有操作,因为实现了这两个接口。

1.3 内部构成

LinkedBlockingQueue 内部构成简单,分成三个部分:链表存储 + 锁 + 迭代器。

// 链表结构的元素
static class Node<E> {
    E item;

    /**
     * One of:
     * - the real successor Node
     * - this Node, meaning the successor is head.next
     * - null, meaning there is no successor (this is the last node)
     */
    Node<E> next;

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

// 链表容量,默认 Integer.MAX_VALUE
private final int capacity;

// 链表已有元素大小,使用 AtomicInteger,所以是线程安全的
private final AtomicInteger count = new AtomicInteger();

// 链表头
transient Node<E> head;

// 链表尾
private transient Node<E> last;

// take 时的锁
private final ReentrantLock takeLock = new ReentrantLock();

// take 的条件锁
private final Condition notEmpty = takeLock.newCondition();

// put 时的锁,设计两把锁的目的,主要是为了 take 和 put 可以同时进行
private final ReentrantLock putLock = new ReentrantLock();

// put 的条件锁
private final Condition notFull = putLock.newCondition();

// 迭代器
private class Itr implements Iterator<E> {
	// ......
}

从代码上来看,结构是非常清晰的,三种结构各司其职:

  1. 链表的作用是为了保存当前节点,节点中的数据可以是任意东西,是一个泛型,比如说队列被应用到线程池时,节点就是线程,比如队列被应用到消息队列中,节点就是消息,节点的含义主要看队列被使用的场景;
  2. 锁有 take 锁和 put 锁,是为了保证队列操作时的线程安全,设计两种锁,是为了 take 和 put 两种操作可以同时进行,互不影响。

1.4 初始化

初始化有三种:

  1. 空参不指定容量大小,默认 Integer.MAX_VALUE;
  2. 指定链表容量大小;
  3. 对已有集合数据进行初始化。

源码:

// 不指定容量,默认 Integer 的最大值
public LinkedBlockingQueue() {
    this(Integer.MAX_VALUE);
}

// 指定链表容量大小,链表头尾相等,节点值(item)都是 null
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;
    putLock.lock(); // Never contended, but necessary for visibility
    try {
        int n = 0;
        for (E e : c) {
        	// 集合内的元素为空抛出异常
            if (e == null)
                throw new NullPointerException();
            // capacity 代表链表的大小,在这里是 Integer 的最大值
            // 如果集合类的大小大于 Integer 的最大值,就会报错
            // 其实这个判断完成可以方在 for 循环外面,这样可以减少 Integer 的最大值次循环(最坏情况)
            if (n == capacity)
                throw new IllegalStateException("Queue full");
            enqueue(new Node<E>(e));
            ++n;
        }
        count.set(n);
    } finally {
        putLock.unlock();
    }
}

对于初始化源码,说明两点:

  1. 初始化时,容量大小是不会影响性能的,只影响在后面的使用,因为初始化队列太小,容易导致没有放多少数据就报队列已满的错误;
  2. 在对给定集合初始化时,源码给了一个不优雅的示范,我们不反对在每次 for 循环的时候,都去检查当前链表的大小是否超过容量,但我们希望在 for 循环开始之前就做这一步工作。举个例子,给定集合大小是 1w,链表大小是 9k,按照现在的实现,只能在循环 9k 次时才发现,原来给定集合的大小已经大于链表大小了,导致 9k 次循环都是在浪费资源,还不如在 for 循环之前就 check 一次,如果 1w > 9k,直接报错即可。

2 阻塞新增

新增有很多方法,如:add、put、offer。以 put 为例,put 方法在碰到队列满的时候,会一直阻塞下去,直到队列不满时,并且自己被唤醒时,才会继续去执行,如下:

// 新增元素 e 到队列的尾部
// 如果有可以新增的空间的话,直接新增成功,否则当前线程陷入等待
public void put(E e) throws InterruptedException {
    if (e == null) throw new NullPointerException();
    // 预先设置 c 为 -1,约定负数为新增失败
    int c = -1;
    Node<E> node = new Node<E>(e);
    final ReentrantLock putLock = this.putLock;
    final AtomicInteger count = this.count;
    // 设置可中断锁
    putLock.lockInterruptibly();
    try {
        // 如果队列已满,当前线程阻塞,等待其他线程的唤醒(其他线程 take 成功后就会唤醒此处被阻塞的线程)
        while (count.get() == capacity) {
        	// await 无限等待
            notFull.await();
        }
        // 队列没满,直接新增到队尾
        enqueue(node);

        // 队列长度新增赋值给 c,这里返回的是旧的值
        // 这里的 c 比真实的 count 小 1
        c = count.getAndIncrement();
        // 如果链表现在的大小小于链表容量,说明队列未满
        // 唤醒一个 put 的等待线程
        if (c + 1 < capacity)
            notFull.signal();
    } finally {
    	// 释放 put 锁
        putLock.unlock();
    }
    // 当 c==0,代表队列里面只有一个元素
    // 尝试唤醒一个 take 的等待线程
    if (c == 0)
        signalNotEmpty();
}

private void enqueue(Node<E> node) {
    // assert putLock.isHeldByCurrentThread();
    // assert last.next == null;
    last = last.next = node;
}

从源码中我们可以得到以下几点:

  1. 往队列中新增数据,第一步是上锁,所以新增数据是线程安全的;
  2. 队列新增数据,简单的追加到链表的尾部即可;
  3. 新增时,如果队列满了,当前线程是会被阻塞的,阻塞的底层使用是锁的能力;
  4. 新增数据成功后,在适当时机,会唤醒 put 的等待线程(队列不满时 c+1 < capacity),或者 take 的等待线程(队列不为空时),这样保证队列一旦满足 put 或者 take 条件时,立马就能唤起阻塞线程,继续运行,保障了唤起的时机不被浪费。

offer 方法阻塞超过一段时间后,仍未成功,就会直接返回默认值的实现,如:

// ....
notFull.awaitNanos(nanos);
// ....

3 阻塞删除

删除的方法也很多,我们主要看两个关键问题:

  1. 删除的原理是怎样的;
  2. 查看并删除和只查看不删除两种的区别是如何实现的;

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 大 1
        // put 时得到的 c 比真实 count 小 1
        c = count.getAndDecrement();
        // 如果队列里面有值,从 take 的等待线程里面唤醒一个
        // 意思是队列里面有值了,唤醒之前被阻塞的线程
        if (c > 1)
            notEmpty.signal();
    } finally {
    	// 解锁
        takeLock.unlock();
    }
    // 如果队列空闲还剩下一个(c 比真实 count 大 1,所以当 c == capacity 时,真实 count == capacity - 1),尝试从 put 的等待线程中唤醒一个
    if (c == capacity)
        signalNotFull();
    return x;
}

// 从队头中取数据
private E dequeue() {
    // assert takeLock.isHeldByCurrentThread();
    // assert head.item == null;
    Node<E> h = head;
    Node<E> first = h.next;
    h.next = h; // help GC
    head = first;
    E x = first.item;
    // 头节点数据指向 null,删除
    first.item = null;
    return x;
}

整体流程和 put 很相似,都是先上锁,然后从队列的头部拿出数据,如果队列为空,会一直阻塞到队列有值为止。

peek() 阻塞拿但是不删除数据底层源码:

public E peek() {
	// 如果队列为空则返回 null
    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();
    }
}

可以看出,查看并删除,和查看不删除两者从队头拿数据的逻辑不太一致,从而导致一个会删除,一个不会删除队头数据。

------------------------------------- END -------------------------------------

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值