LinkedBlockingQueue 源码解析

LinkedBlockingQueue 源码解析

说到队列,大家的反应可能是我从来都没有用过,应该不是重要的 API 吧。其实,我们平时使用到的线程池、读写锁、消息队列等等技术和框架,底层都是队列。队列是很多高级 API 的基础,学好队列,对自己深入学习 Java 非常重要。

本文主要以 LinkedBlockingQueue 队列为例,详细描述一下底层具体的实现。

整体架构

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

类图

image-20210629110843667

从类图中,我们大概可以看出两条路径:

  1. AbstractQueue -> AbstractCollection -> Collection -> Iterable 这条路径依赖,主要是想复用 Collection 和迭代器的一些操作。
  2. BlockingQueue -> Queue -> Collection。

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

  • 新增操作:
    • add:队列满的时候抛异常
    • offer:队列满的时候返回 false
  • 查看并删除操作:
    • remove:队列为空的时候抛异常
    • poll:队列空的时候返回 null
  • 只查看不删除操作:
    • element:队列空的时候抛异常
    • peek:队列空的时候返回 null

一共 6 中方法,除了以上分类方法,也可以这样分:

  1. 遇到队列满或空的时候,抛异常:add、remove、element
  2. 遇到队列满或空的时候,返回 null 或 false:offer、poll、peek

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

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

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

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

类注释

我们看看从 LinkedBlockingQueue 的类注释中能得到哪些信息:

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

源码

内部构成

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

public class LinkedBlockingQueue<E> extends AbstractQueue<E>
        implements BlockingQueue<E>, java.io.Serializable {
    
    // 单向链表
    static class Node<E> {
        E item;
        
        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的条件队列,condition可以简单理解为基于AQS同步机制建立的条件队列
    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. 指定链表容量大小
  2. 不指定链表容量大小,默认是 Integer 的最大值
  3. 对已有集合数据进行初始化
// 指定容量初始化
public LinkedBlockingQueue(int capacity) {
    if (capacity <= 0) throw new IllegalArgumentException();
    this.capacity = capacity;
    // 初始化,链表头节点和尾节点都为null
    last = head = new Node<E>(null);
}

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

// 对已有集合数据进行初始化
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();
            // 如果容量等于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 循环开始之前就做一步这样的工作。举个列子,给定集合大小是 1 w,链表大小是 9k,按照现在代码实现,只能在 for 循环 9k 次时才能发现,原来给定集合的大小已经大于链表大小了,导致 9k 次循环都是在浪费资源,还不如在 for 循环之前就 check 一次,如果 1w > 9k,直接报错即可。

阻塞新增

新增有多个方法,add、put、offer。

我们拿 put 方法为例,put 方法在碰到队列满的时候,会一直阻塞下去,直到队列有位置时,并且自己被唤醒,才会继续执行,源码如下

// 把元素新增到队列的尾部
// 如果有可以新增的空间的话,直接新增成功,否则当前线程陷入等待
public void put(E e) throws InterruptedException {
    // 队列元素不能为null
    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 {
        // 队列满了
        while (count.get() == capacity) {
            // 当前线程阻塞,等待其他线程的唤醒
            // 其他线程take成功之后会唤醒此处被阻塞的线程
            // await无限等待
            notFull.await();
        }
        // 队列没有满,直接新增到队列的尾部
        enqueue(node);
        // 新增计数赋值,getAndIncrement()返回的是旧值
        // 这里的c比入队后的count小1
        c = count.getAndIncrement();
        // 如果链表现在的大小 小于链表的容量,说明队列未满
        if (c + 1 < capacity)
            // 尝试唤醒一个put的等待线程
            notFull.signal();
    } finally {
        // 释放锁
        putLock.unlock();
    }
    // c==0 代表队列里面有一个元素
    if (c == 0)
        // 尝试唤醒一个take的等待线程
        signalNotEmpty();
}

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

从源码中我们可以总结以下几点:

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

以上就是 put 方法的原理,至于 offer 阻塞超过一段时间后,仍未成功,就会直接返回默认值 false

阻塞删除

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

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

以 take 为例,我们看下查看并删除的底层源码:

// 阻塞出队
public E take() throws InterruptedException {
    E x;
    // 负数为失败
    int c = -1;
    // count代表当前链表的真实长度
    final AtomicInteger count = this.count;
    final ReentrantLock takeLock = this.takeLock;
    // 加上可中断锁
    takeLock.lockInterruptibly();
    try {
        // 空队列时,阻塞,等待其他线程唤醒
        while (count.get() == 0) {
            notEmpty.await();
        }
        // 非空队列,直接从队列头部拿一个元素出来
        x = dequeue();
        // 数量减1,并拿到旧值
        c = count.getAndDecrement();
        // 队列有值,唤醒其他被阻塞的take线程
        if (c > 1)
            notEmpty.signal();
    } finally {
        takeLock.unlock();
    }
    // 如果队列还剩一个空闲位置,尝试唤醒一个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() {
   	// count=实际队列大小
    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();
    }
}
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值