ArrayBlockingQueue 源码分析
[阻塞队列简介](阻塞队列简介) [阻塞队列的历史](阻塞队列的历史) Java 阻塞队列的历史可以追溯到 JDK1.5 版本,当时 Java 平台增加了 `java.util.concurre
阻塞队列简介 阻塞队列的历史 Java 阻塞队列的历史可以追溯到 JDK1.5 版本,当时 Java 平台增加了 ,即我们常说的 JUC 包,其中包含了各种并发流程控制工具、并发容器、原子类等。这其中自然也包含了我们这篇文章所讨论的阻塞队列。 为了解决高并发场景下多线程之间数据共享的问题,JDK1.5 版本中出现了 和 ,它们是带有生产者 消费者模式实现的并发容器。其中, 是有界队列,即添加的元素达到上限之后,再次添加就会被阻塞或者抛出异常。而 则由链表构成的队列,正是因为链表的特性,所以 在添加元素上并不会向 那样有着较多的约束,所以 设置队列是否有界是可选的(注意这里的无界并不是指可以添加任意数量的元素,而是说队列的大小默认为 ,近乎于无限大)。 随着 Java 的不断发展,JDK 后续的几个版本又对阻塞队列进行了不少的更新和完善: 1. JDK1.6 版本:增加 ,一个不存储元素的阻塞队列。 2. JDK1.7 版本:增加 ,一个支持更多操作的阻塞队列。 3. JDK1.8 版本:增加 ,一个支持延迟获取元素的阻塞队列。 阻塞队列的思想 阻塞队列就是典型的生产者 消费者模型,它可以做到以下几点: 1. 当阻塞队列数据为空时,所有的消费者线程都会被阻塞,等待队列非空。 2. 当生产者往队列里填充数据后,队列就会通知消费者队列非空,消费者此时就可以进来消费。 3. 当阻塞队列因为消费者消费过慢或者生产者存放元素过快导致队列填满时无法容纳新元素时,生产者就会被阻塞,等待队列非满时继续存放元素。 4. 当消费者从队列中消费一个元素之后,队列就会通知生产者队列非满,生产者可以继续填充数据了。 总结一下:阻塞队列就说基于非空和非满两个条件实现生产者和消费者之间的交互,尽管这些交互流程和等待通知的机制实现非常复杂,好在 Doug Lea 的操刀之下已将阻塞队列的细节屏蔽,我们只需调用 、 、 、 等 API 即可实现多线程之间的生产和消费。 这也使得阻塞队列在多线程开发中有着广泛的运用,最常见的例子无非是我们的线程池,从源码中我们就能看出当核心线程无法及时处理任务时,这些任务都会扔到 中。 ArrayBlockingQueue 常见方法及测试 简单了解了阻塞队列的历史之后,我们就开始重点讨论本篇文章所要介绍的并发容器—— 。为了后续更加深入的了解 ,我们不妨基于下面几个实例了解以下 的使用。 先看看第一个例子,我们这里会用两个线程分别模拟生产者和消费者,生产者生产完会使用 方法生产 10 个元素给消费者进行消费,当队列元素达到我们设置的上限 5 时, 方法就会阻塞。 同理消费者也会通过 方法消费元素,当队列为空时, 方法就会阻塞消费者线程。这里笔者为了保证消费者能够在消费完 10 个元素后及时退出。便通过倒计时门闩,来控制消费者结束,生产者在这里只会生产 10 个元素。当消费者将 10 个元素消费完成之后,按下倒计时门闩,所有线程都会停止。 代码输出结果如下,可以看到只有生产者往队列中投放元素之后消费者才能消费,这也就意味着当队列中没有数据的时消费者就会阻塞,等待队列非空再继续消费。 了解了 、 这两个会阻塞的存和取方法之后,我我们再来看看阻塞队列中非阻塞的入队和出队方法 和 。 如下所示,我们设置了一个大小为 3 的阻塞队列,我们会尝试在队列用 offer 方法存放 4 个元素,然后再从队列中用 尝试取 4 次。 最终代码的输出结果如下,可以看到因为队列的大小为 3 的缘故,我们前 3 次存放到队列的结果为 true,第 4 次存放时,由于队列已满,所以存放结果返回 false。这也是为什么我们后续的 方法只得到了 3 个元素的值。 了解了阻塞存取和非阻塞存取,我们再来看看阻塞队列的一个比较特殊的操作,某些场景下,我们希望能够一次性将阻塞队列的结果存到列表中再进行批量操作,我们就可以使用阻塞队列的 方法,这个方法会一次性将队列中所有元素存放到列表,如果队列中有元素,且成功存到 list 中则 会返回本次转移到 list 中的元素数,反之若队列为空, 则直接返回 0。 代码输出结果如下 ArrayBlockingQueue 源码分析 自此我们对阻塞队列的使用有了基本的印象,接下来我们就可以进一步了解一下 的工作机制了。 整体设计 在了解 的具体细节之前,我们先来看看 的类图。 从图中我们可以看出, 实现了阻塞队列 这个接口,不难猜出通过实现 这个接口之后, 就拥有了阻塞队列那些常见的操作行为。 同时, 还继承了 这个抽象类,这个继承了 和 的抽象类,从抽象类的特定和语义我们也可以猜出,这个继承关系使得 拥有了队列的常见操作。 所以我们是否可以得出这样一个结论,通过继承 获得队列所有的操作模板,其实现的入队和出队操作的整体框架。然后 通过实现 获取到阻塞队列的常见操作并将这些操作实现,填充到 模板方法的细节中,由此 成为一个完整的阻塞队列。 为了印证这一点,我们到源码中一探究竟。首先我们先来看看 ,从类的继承关系我们可以大致得出,它通过 获得了集合的常见操作方法,然后通过 接口获得了队列的特性。 对于集合的操作无非是增删改查,所以我们不妨从添加方法入手,从源码中我们可以看到,它实现了 的 方法,其内部逻辑如下: 1. 调用继承 接口得来的 方法,如果 成功则返回 。 2. 如果 失败,即代表当前元素入队失败直接抛异常。 而 中并没有对 的 的实现,很明显这样做的目的是定义好了 的核心逻辑,将 的细节交由其子类即我们的 实现。 到此,我们对于抽象类 的分析就结束了,我们继续看看 中实现的另一个重要接口 。 点开 之后,我们可以看到这个接口同样继承了 接口,这就意味着它也具备了队列所拥有的所有行为。同时,它还定义了自己所需要实现的方法。 了解了 的常见操作后,我们就知道了 通过实现 的方法并重写后,填充到 的方法上,由此我们便知道了上文中 的 方法的 方法是哪里是实现的了。 初始化 了解 的细节前,我们不妨先看看其构造函数,了解一下其初始化过程。从源码中我们可以看出 有 3 个构造方法,而最核心的构造方法就是下方这一个。 这个构造方法里面有两个比较核心的成员变量 (非空) 和 (非满),需要我们格外留意,它们是实现生产者和消费者有序工作的关键所在,这一点笔者会在后续的源码解析中详细说明,这里我们只需初步了解一下阻塞队列的构造即可。 另外两个构造方法都是基于上述的构造方法,默认情况下,我们会使用下面这个构造方法,该构造方法就意味着 用的是非公平锁,即各个生产者或者消费者线程收到通知后,对于锁的争抢是随机的。 还有一个不怎么常用的构造方法,在初始化容量和锁的非公平性之后,它还提供了一个 参数,从源码中不难看出这个构造方法是将外部传入的集合的元素在初始化时直接存放到阻塞队列中。 阻塞式获取和新增元素 阻塞式获取和新增元素对应的就是生产者 消费者模型,虽然它也支持非阻塞式获取和新增元素(例如 和 方法,后文会介绍到),但一般不会使用。 阻塞式获取和新增元素的方法为: :将元素插入队列中,如果队列已满,则该方法会一直阻塞,直到队列有空间可用或者线程被中断。 :获取并移除队列头部的元素,如果队列为空,则该方法会一直阻塞,直到队列非空或者线程被中断。 这两个方法实现的关键就是在于两个条件对象 (非空) 和 (非满),这个我们在上文的构造方法中有提到。 接下来笔者就通过两张图让大家了解一下这两个条件是如何在阻塞队列中运用的。 假设我们的代码消费者先启动,当它发现队列中没有数据,那么非空条件就会将这个线程挂起,即等待条件非空时挂起。然后 CPU 执行权到达生产者,生产者发现队列中可以存放数据,于是将数据存放进去,通知此时条件非空,此时消费者就会被唤醒到队列中使用 等方法获取值了。 随后的执行中,生产者生产速度远远大于消费者消费速度,于是生产者将队列塞满后再次尝试将数据存入队列,发现队列已满,于是阻塞队列就将当前线程挂起,等待非满。然后消费者拿着 CPU 执行权进行消费,于是队列可以存放新数据了,发出一个非满的通知,此时挂起的生产者就会等待 CPU 执行权到来时再次尝试将数据存到队列中。 简单了解阻塞队列的基于两个条件的交互流程之后,我们不妨看看 和 方法的源码。 方法内部调用了 方法来实现元素入队,我们继续深入查看一下 方法的实现细节: 从源码中可以看到入队操作的逻辑就是在数组中追加一个新元素,整体执行步骤为: 1. 获取 底层的数组 。 2. 将元素存到 位置。 3. 更新 到下一个位置,如果 等于队列长度,则说明 已经到达数组末尾了,下一次插入则需要 0 开始。( 用到了循环队列的思想,即从头到尾循环复用一个数组) 4. 更新 的值,表示当前队列长度+1。 5. 调用 通知队列非空,消费者可以从队列中获取值了。 自此我们了解了 方法的流程,为了更加完整的了解 关于生产者 消费者模型的设计,我们继续看看阻塞获取队列元素的 方法。 理解了 方法再看 方法就很简单了,其核心逻辑和 方法正好是相反的,比如 方法在队列满的时候等待队列非满时插入元素(非满条件),而 方法等待队列非空时获取并移除元素(非空条件)。 方法内部调用了 方法来实现元素出队,其核心逻辑和 方法也是相反的。 由于 方法(出队)和上面介绍的 方法(入队)的步骤大致类似,这里就不重复介绍了。 为了帮助理解,我专门画了一张图来展示 (非空) 和 (非满)这两个条件对象是如何控制 的存和取的。 消费者 :当消费者从队列中 或者 等操作取出一个元素之后,就会通知队列非满,此时那些等待非满的生产者就会被唤醒等待获取 CPU 时间片进行入队操作。 生产者 :当生产者将元素存到队列中后,就会触发通知队列非空,此时消费者就会被唤醒等待 CPU 时间片尝试获取元素。如此往复,两个条件对象就构成一个环路,控制着多线程之间的存和取。 非阻塞式获取和新增元素 非阻塞式获取和新增元素的方法为: :将元素插入队列尾部。如果队列已满,则该方法会直接返回 false,不会等待并阻塞线程。 :获取并移除队列头部的元素,如果队列为空,则该方法会直接返回 null,不会等待并阻塞线程。 :将元素插入队列尾部。如果队列已满则会抛出 异常,底层基于 方法。 :移除队列头部的元素,如果队列为空则会抛出 异常,底层基于 。 :获取但不移除队列头部的元素,如果队列为空,则该方法会直接返回 null,不会等待并阻塞线程。 先来看看 方法,逻辑和 差不多,唯一的区别就是入队失败时不会阻塞当前线程,而是直接返回 。 方法同理,获取元素失败也是直接返回空,并不会阻塞获取元素的线程。 方法其实就是对于 做了一层封装,如下代码所示,可以看到 会调用没有规定时间的 ,如果入队失败则直接抛异常。 方法同理,调用 ,如果返回 则说明队列没有元素,直接抛出异常。 方法的逻辑也很简单,内部调用了 方法。 指定超时时间内阻塞式获取和新增元素 在 和 非阻塞获取和新增元素的基础上,设计者提供了带有等待时间的 和 ,用于在指定的超时时间内阻塞式地添加和获取元素。 可以看到,带有超时时间的 方法在队列已满的情况下,会等待用户所传的时间段,如果规定时间内还不能存放元素则直接返回 。 同理,带有超时时间的 也一样,队列为空则在规定时间内等待,若时间到了还是空的,则直接返回 null。 判断元素是否存在 提供了 来判断指定元素是否存在于队列中。 ArrayBlockingQueue 获取和新增元素的方法对比 为了帮助理解 ,我们再来对比一下上面提到的这些获取和新增元素的方法。 新增元素: | 方法 | 队列满时处理方式 | 方法返回值 | | | | | | | 线程阻塞,直到中断或被唤醒 | void | | | 直接返回 false | boolean | | | 指定超时时间内阻塞,超过规定时间还未添加成功则返回 false | boolean | | | 直接抛出 异常 | boolean | 获取/移除元素: | 方法 | 队列空时处理方式 | 方法返回值 | | | | | | | 线程阻塞,直到中断或被唤醒 | E | | | 返回 null | E | | | 指定超时时间内阻塞,超过规定时间还是空的则返回 null | E | | | 返回 null | E | | | 直接抛出 异常 | boolean | ArrayBlockingQueue 相关面试题 ArrayBlockingQueue 是什么?它的特点是什么? 是 接口的有界队列实现类,常用于多线程之间的数据共享,底层采用数组实现,从其名字就能看出来了。 的容量有限,一旦创建,容量不能改变。 为了保证线程安全, 的并发控制采用可重入锁 ,不管是插入操作还是读取操作,都需要获取到锁才能进行操作。并且,它还支持公平和非公平两种方式的锁访问机制,默认是非公平锁。 虽名为阻塞队列,但也支持非阻塞获取和新增元素(例如 和 方法),只是队列满时添加元素会抛出异常,队列为空时获取的元素为 null,一般不会使用。 ArrayBlockingQueue 和 LinkedBlockingQueue 有什么区别? 和 是 Java 并发包中常用的两种阻塞队列实现,它们都是线程安全的。不过,不过它们之间也存在下面这些区别: 底层实现: 基于数组实现,而 基于链表实现。 是否有界: 是有界队列,必须在创建时指定容量大小。 创建时可以不指定容量大小,默认是 ,也就是无界的。但也可以指定队列大小,从而成为有界的。 锁是否分离: 中的锁是没有分离的,即生产和消费用的是同一个锁; 中的锁是分离的,即生产用的是 ,消费是 ,这样可以防止生产者和消费者线程之间的锁争夺。 内存占用: 需要提前分配数组内存,而 则是动态分配链表节点内存。这意味着, 在创建时就会占用一定的内存空间,且往往申请的内存比实际所用的内存更大,而 则是根据元素的增加而逐渐占用内存空间。 ArrayBlockingQueue 和 ConcurrentLinkedQueue 有什么区别? 和 是 Java 并发包中常用的两种队列实现,它们都是线程安全的。不过,不过它们之间也存在下面这些区别: 底层实现: 基于数组实现,而 基于链表实现。 是否有界: 是有界队列,必须在创建时指定容量大小,而 是无界队列,可以动态地增加容量。 是否阻塞: 支持阻塞和非阻塞两种获取和新增元素的方式(一般只会使用前者), 是无界的,仅支持非阻塞式获取和新增元素。 ArrayBlockingQueue 的实现原理是什么? 的实现原理主要分为以下几点(这里以阻塞式获取和新增元素为例介绍): 内部维护一个定长的数组用于存储元素。 通过使用 锁对象对读写操作进行同步,即通过锁机制来实现线程安全。 通过 实现线程间的等待和唤醒操作。 这里再详细介绍一下线程间的等待和唤醒具体的实现(不需要记具体的方法,面试中回答要点即可): 当队列已满时,生产者线程会调用 方法让生产者进行等待,等待队列非满时插入(非满条件)。 当队列为空时,消费者线程会调用 方法让消费者进行等待,等待队列非空时消费(非空条件)。 当有新的元素被添加时,生产者线程会调用 方法唤醒正在等待消费的消费者线程。 当队列中有元素被取出时,消费者线程会调用 方法唤醒正在等待插入元素的生产者线程。 关于 接口的补充: 是 JDK1.5 之后才有的,它具有很好的灵活性,比如可以实现多路通知功能也就是在一个 对象中可以创建多个 实例(即对象监视器), 线程对象可以注册在指定的 中,从而可以有选择性的进行线程通知,在调度线程上更加灵活。 在使用 方法进行通知时,被通知的线程是由 JVM 选择的,用 类结合 实例可以实现“选择性通知” ,这个功能非常重要,而且是 接口默认提供的。而 关键字就相当于整个 对象中只有一个 实例,所有的线程都注册在它一个身上。如果执行 方法的话就会通知所有处于等待状态的线程,这样会造成很大的效率问题。而 实例的 方法,只会唤醒注册在该 实例中的所有等待线程。 参考文献 深入理解 Java 系列 | BlockingQueue 用法详解:<https://juejin.cn/post/6999798721269465102 深入浅出阻塞队列 BlockingQueue 及其典型实现 ArrayBlockingQueue:<https://zhuanlan.zhihu.com/p/539619957 并发编程大扫盲:ArrayBlockingQueue 底层原理和实战:<https://zhuanlan.zhihu.com/p/339662987 写在最后 如果内容对你有帮助的话,欢迎顺手给 JavaGuide 点一个免费的 Star 支持一下:GitHub | Gitee。 JavaGuide 已持续维护近七年,累计 6100+ 次提交,来自 620+ 位贡献者共同完善。你的 Star、反馈和 PR,都是这个项目继续更新的动力。 如果你正在准备后端/AI 应用开发面试,也可以了解一下我的知识星球,里面包括后端和 AI 实战项目、简历优化、一对一提问和高频考点资料,已经持续维护六年。