彻底弄懂ArrayBlockingQueue —— 超详细的原码分析

ArrayBlockingQueue 是常见的有界阻塞队列,用过线程池的,对它肯定不陌生。

它的实现原理,相信多数人也能说出个大概。可具体实现细节,估计会难住多数人。

我在网上查了一圈,大多是讲ArrayBlockingQueue怎么用的。它是怎么阻塞的,却少有人提及。

这里,由浅入深,我来剖析下ArrayBlockingQueue的源码,彻底弄懂它是怎么阻塞的。

很可能,这是你能看到的,最详细的源码解析,不信的话,耐心看完,再和网上的比较下。

当然本人水平有限,若有论述不当之处,请大神指正。

ArrayBlockingQueue能干啥

ArrayBlockingQueue 底层是数组,容量是固定的。特别适用于,生产者-消费者模型。

也就是说,有线程往队列中放元素,有线程从队列中取元素,可保证线程安全

用一句话概括:保证同一时刻,只有一个线程可操作队列

  • 第一层理解:ArrayBlockingQueue 底层加锁,持有锁才能入队或出队操作,所以能保证线程安全。
  • 第二层理解:入队操作,发现队满,会阻塞,当有出队操作时,队列中有了空位置,入队操作继续

当你看了本文的分析后,你会有更深入的理解:


入队、出队所竞争的是同一把锁。

从入队角度来说

当A线程持有锁,执行入队操作时,队满,A线程会到一个排队的地方,同时会释放锁。 

释放的锁,假设被出队操作的线程B拿到。

在B线程执行完出队操作后,会释放锁。

但B线程释放锁之前,会把A线程从排队的地方给拽出来,放到抢锁的地方,让A有机会去抢锁。

当A抢到锁之后,此时一定有空位,则会继续执行入队操作。

入队结束后,A线程也会释放锁,但在释放之前,也会到排队的地方,找一个出队的线程,派去抢锁

这是我从源码中琢磨出来的,大概的流程,先有个印象。

一、ArrayBlockingQueue 的初始化

    public static void main(String[] args) throws InterruptedException {
        ArrayBlockingQueue<String> queue = new ArrayBlockingQueue<>(3);
        int len = 10;
        Thread t0 = new Thread(new Runnable() {
            @SneakyThrows
            @Override
            public void run() {
                for(int i = 0; i < len; i++){
                    queue.put("money");
                    log.info("线程:{},开始往队列里放钱了,这是第{}次放钱", Thread.currentThread().getName(), i);
                }

            }
        },"t0");
        Thread t1 = new Thread(new Runnable() {
            @SneakyThrows
            @Override
            public void run() {
                for(int i = 0; i < len; i++){
                    queue.take();
                    log.info("线程:{},取钱,取钱……这是第{}次取钱", Thread.currentThread().getName(), i);
                }

            }
        },"t1");
        t0.start();
        Thread.sleep(3000);
        t1.start();
    }

本例中,我给了两个线程,一个往队列里放,一个从队列中取。

第1个线程启动后,sleep三秒,瞬间队满,而t1还未启动,t0会被阻塞。

当t1启动后,从队列中取,之后程序很快就执行完了。

这个可以自己跑一下,这里贴一个结果的图片。

在这里插入图片描述


    /** The queued items */
    final Object[] items;

    /** items index for next take, poll, peek or remove */
    int takeIndex;

    /** items index for next put, offer, or add */
    int putIndex;

    /** Number of elements in the queue */
    int count;

    /*
     * Concurrency control uses the classic two-condition algorithm
     * found in any textbook.
     */

    /** Main lock guarding all access */
    final ReentrantLock lock;

    /** Condition for waiting takes */
    private final Condition notEmpty;

    /** Condition for waiting puts */
    private final Condition notFull;

    public ArrayBlockingQueue(int capacity, boolean fair) {
        if (capacity <= 0)
            throw new IllegalArgumentException();
        this.items = new Object[capacity];
        lock = new ReentrantLock(fair);
        notEmpty = lock.newCondition();
        notFull =  lock.newCondition();
    }
ArrayBlockingQueue<String> queue = new ArrayBlockingQueue<>(3);

测试用例中,执行这句代码时,源码层面,会初始化四个参数

  • Object[] items — 队列底层的数组,此时大小固定了,不会再变
  • ReentrantLock lock — 全局锁,操作队列需要先拿到这把锁
  • Condition notFull — 队满了,还有入队操作,都在这儿等
  • Condition notEmpty — 队空了,还有出队操作,都在这儿等

lock 底层是一个AQS对象, AQS里有个内部类ConditionObject。

notEmpty 和 notFull 就是ConditionObject的实例,用一张图来形象说明下。

在这里插入图片描述

二、入队源码浅析

    public void put(E e) throws InterruptedException {
        checkNotNull(e);
        final ReentrantLock lock = this.lock;
        lock.lockInterruptibly(); // 获取锁,没拿到锁的阻塞在这行
        try {
            while (count == items.length)
                notFull.await(); // 队满时操作
            enqueue(e); // 入队
        } finally {
            lock.unlock(); // 释放锁
        }
    }

代码极其精简优雅,但里面的细节,极其复杂。

关于ReentrantLock,获取锁与释放锁,本文不讲。

但其涉及的源码的细节,必需要懂,要不这篇文章看越看越迷糊。

ReentrantLock 和 ArrayBlockingQueue 都是AQS框架的应用,两者密切相关。

请先理解ReentrantLock,可以我之前的文章——ReentrantLock 源码详解

本文重点解析 await() 和 enqueue(e)

先看入队的代码,很是精简


    private void enqueue(E x) {
        // assert lock.getHoldCount() == 1;
        // assert items[putIndex] == null;
        final Object[] items = this.items;
        items[putIndex] = x;
        if (++putIndex == items.length)
            putIndex = 0; // putIndex 指下次入队时,应放位置的下标
        count++; // 队列中元素的个数
        notEmpty.signal(); // 给出队的线程一个信号,队列中有元素了,可以取了。
    }

enqueue() 这个方法很好理解,代码也很清晰,这里就不多说了,

signal() 这个方法后面再细说,这里先有个印象——发信号


        public final void await() throws InterruptedException {
            if (Thread.interrupted())
                throw new InterruptedException();
            Node node = addConditionWaiter();
            int savedState = fullyRelease(node);
            int interruptMode = 0;
            while (!isOnSyncQueue(node)) {
                LockSupport.park(this);
                if ((interruptMode = checkInterruptWhileWaiting(node)) != 0)
                    break;
            }
            if (acquireQueued(node, savedState) && interruptMode != THROW_IE)
                interruptMode = REINTERRUPT;
            if (node.nextWaiter != null) // clean up if cancelled
                unlinkCancelledWaiters();
            if (interruptMode != 0)
                reportInterruptAfterWait(interruptMode);
        }

这个方法极其复杂,其中的逻辑不太好理解,先分步说明用意

1 addConditionWaiter() :生成一个Node,并与线程绑定,放在notFull的等待队列

2 fullyRelease() :释放锁资源,即解锁

3 LockSupport.park(this)阻塞入队的线程,也就是在唤醒前,就停在这儿了

4 checkInterruptWhileWaiting() : 若线程被中断,直接跳出 while 循环

5 acquireQueued(node, savedState)抢锁,抢不到就阻塞,直到抢到锁为止

6 unlinkCancelledWaiters():等待队列中,清除无效节点

7 reportInterruptAfterWait(int interruptMode):中断唤醒的节点抛出异常

看完是不很懵,其实我也很懵,之后想了好久,当想通那一刻,特高兴!

Doug Lea 真是大牛、大神,这么精巧的代码,怎么想出来的?

三、notFull.signal() 具体干了啥?

你想不明白notFull.await()的代码逻辑,是因为notFull.signal() 具体做的啥,你不清楚。

别急,咱们用一个具体例子来说明这个问题,就用开头的示例代码。

假设刚开始,有五个线程 t1、t2、t3、t4、t5,顺次调用 put()方法,那前三个线程执行成功了。

t4拿到锁后,队满了,调用 notFull.await(),执行前3步后,阻塞了。

t5拿到锁后,同样是队满,调用 notFull.await(),执行前3步后,也阻塞了。

在这里插入图片描述

这时,假设线程 t6 来取钱,执行 take() 方法

    public E take() throws InterruptedException {
        final ReentrantLock lock = this.lock;
        lock.lockInterruptibly();
        try {
            while (count == 0)
                notEmpty.await();
            return dequeue();
        } finally {
            lock.unlock();
        }
    }

此时count是3,直接走dequeue()方法。

    private E dequeue() {
        // assert lock.getHoldCount() == 1;
        // assert items[takeIndex] != null;
        final Object[] items = this.items;
        @SuppressWarnings("unchecked")
        E x = (E) items[takeIndex];
        items[takeIndex] = null;
        if (++takeIndex == items.length)
            takeIndex = 0;
        count--;
        if (itrs != null)
            itrs.elementDequeued();
        notFull.signal();
        return x;
    }

代码不复杂,直接看 notFull.signal()

        public final void signal() {
            if (!isHeldExclusively()) // 当前线程与持有锁的线程,不是同一个,抛出异常
                throw new IllegalMonitorStateException();
            Node first = firstWaiter; // 从 notFull 的等待队列中取第一个节点
            if (first != null)
                doSignal(first); // 结合上图,取到 线程t4那个结点
        }


        private void doSignal(Node first) {
            do {
                if ( (firstWaiter = first.nextWaiter) == null)
                    lastWaiter = null; // 如果原本的等待队列中只有一个线程,那取出一个线程后,将等待队列置空
                first.nextWaiter = null;
            } while (!transferForSignal(first) && 
                     (first = firstWaiter) != null);
        }

transferForSignal(first) 这个方法,是将 first 放到抢锁的队列中,

即把 t4 放到sync队列中,若没有成功,就尝试放 t5。

在这里插入图片描述

插一句,生成等待队列中的note节点时,waitStatus 都是 -2,

    final boolean transferForSignal(Node node) {
        /*
         * If cannot change waitStatus, the node has been cancelled.
         */
        if (!compareAndSetWaitStatus(node, Node.CONDITION, 0)) // ws由-2 设置为 0 
            return false; // ws设置为0失败,直接返回,外层进行下一个循环

        /*
         * Splice onto queue and try to set waitStatus of predecessor to
         * indicate that thread is (probably) waiting. If cancelled or
         * attempt to set waitStatus fails, wake up to resync (in which
         * case the waitStatus can be transiently and harmlessly wrong).
         */
        Node p = enq(node); // 自旋方式将节点加到CLH队列尾部,并返回前驱节点
        int ws = p.waitStatus;
        if (ws > 0 || !compareAndSetWaitStatus(p, ws, Node.SIGNAL))
            LockSupport.unpark(node.thread); // 前驱节点异常,直接唤醒当前节点
        return true;
    }

enq(node) 这个方法,在讲ReentrantLock 源码剖析时详细说过,这里就不再赘述。

t4 前驱节点,ws = 0,直接返回true, doSignal() 方法 do while 循环结束。 t6 线程释放锁。

如果你看过我之前写的 ReentrantLock 源码剖析,应该会知道,

锁资源释放时,会到CLH队列中,会唤醒头节点的后继节点,即唤醒 t4 。

至此 notFull.signal() 方法结束,总结——从等待队列中,捞出一个节点,放到CLH队列。

四、notFull.await() 源码详解

讲到这里,我们在大流程上,知道入队操作的原理,现在终于可以一行一行讲入队的源码了。先看notFull.await()

        public final void await() throws InterruptedException {
            if (Thread.interrupted())
                throw new InterruptedException();
            Node node = addConditionWaiter();
            int savedState = fullyRelease(node);
            int interruptMode = 0;
            while (!isOnSyncQueue(node)) {
                LockSupport.park(this);
                if ((interruptMode = checkInterruptWhileWaiting(node)) != 0)
                    break;
            }
            if (acquireQueued(node, savedState) && interruptMode != THROW_IE)
                interruptMode = REINTERRUPT;
            if (node.nextWaiter != null) // clean up if cancelled
                unlinkCancelledWaiters();
            if (interruptMode != 0)
                reportInterruptAfterWait(interruptMode);
        }

1 addConditionWaiter() :生成一个Node,并与线程绑定,放在notFull的等待队列

        private Node addConditionWaiter() {
            Node t = lastWaiter;
            // If lastWaiter is cancelled, clean out.
            if (t != null && t.waitStatus != Node.CONDITION) {
                unlinkCancelledWaiters(); // 清除无效节点,代码有点复杂,在纸上画画就明白了
                t = lastWaiter;
            }
            Node node = new Node(Thread.currentThread(), Node.CONDITION);
            if (t == null)
                firstWaiter = node;
            else
                t.nextWaiter = node;
            lastWaiter = node;
            return node;
        }

还用刚刚的t4 t5 例子,前面也画了图,入队操作,代码不复杂。

2 fullyRelease() :释放锁资源,即解锁

ReentrantLock 源码剖析里讲过这个方法,此处不再赘述。

3 LockSupport.park(this)阻塞入队的线程,也就是在唤醒前,就停在这儿了

这一步是在 while 循环中,看循环条件,节点是否在 CLH 队列中。

若在就跳出循环。否则就进入循环,随即被阻塞。


前面讲 t6 线程时,说的很清楚, notFull.signal() 就是将节点放到CLH队列中。

也就是说,队满时,会阻塞在这一步,但凡有出队操作,就会将一个节点放到CLH队列中,

此时,阻塞的线程在被唤醒后,就可以跳出while循环。

    final boolean isOnSyncQueue(Node node) {
        if (node.waitStatus == Node.CONDITION || node.prev == null)
            return false;
        if (node.next != null) // If has successor, it must be on queue
            return true;
        /*
         * node.prev can be non-null, but not yet on queue because
         * the CAS to place it on queue can fail. So we have to
         * traverse from tail to make sure it actually made it.  It
         * will always be near the tail in calls to this method, and
         * unless the CAS failed (which is unlikely), it will be
         * there, so we hardly ever traverse much.
         */
        return findNodeFromTail(node);
    }

    /**
     * Returns true if node is on sync queue by searching backwards from tail.
     * Called only when needed by isOnSyncQueue.
     * @return true if present
     */
    private boolean findNodeFromTail(Node node) {
        Node t = tail;
        for (;;) {
            if (t == node)
                return true;
            if (t == null)
                return false;
            t = t.prev;
        }
    }

4 checkInterruptWhileWaiting() : 若线程被中断,直接跳出 while 循环

这个方法是处理中断的,不考虑中断,代码会容易理解。

初次接触,可假定没有中断,方便理解,若想深究,看源码。

这块代码的分析可以不用细看

        private int checkInterruptWhileWaiting(Node node) {
        // 如果线程有中断,park()方法就失效了,会进入这个方法
            return Thread.interrupted() ?
                (transferAfterCancelledWait(node) ? THROW_IE : REINTERRUPT) :
                0;
             // 结合外层代码,只要被中断了,返回外层就不是0,一定会break,跳出while循环,最终被第7步处理
        }

    final boolean transferAfterCancelledWait(Node node) {
        if (compareAndSetWaitStatus(node, Node.CONDITION, 0)) {
            enq(node); // 入队成功,有机会去抢锁了。
            return true;
        }
        /*
         * If we lost out to a signal(), then we can't proceed
         * until it finishes its enq().  Cancelling during an
         * incomplete transfer is both rare and transient, so just
         * spin.
         */
        while (!isOnSyncQueue(node))
            Thread.yield(); // 入队失败,没在CLH队列中,歇下。
        return false;
    }

5 acquireQueued(node, savedState)抢锁,抢不到就阻塞,直到抢到锁为止

抢锁的源码,在ReentrantLock 源码剖析时详细说过。这里再简单说一下。

自旋抢锁,抢不到会被阻塞,等待唤醒之后再去抢。

什么时候抢到,什么时候结束这个方法。即这行代码之后的代码,都是持有锁资源

6 unlinkCancelledWaiters():等待队列中,清除无效节点

这个前面提到过,源码不讲,自己在纸上画画。

7 reportInterruptAfterWait(int interruptMode):中断唤醒的节点抛出异常

        private void reportInterruptAfterWait(int interruptMode)
            throws InterruptedException {
            if (interruptMode == THROW_IE)
                throw new InterruptedException();
            else if (interruptMode == REINTERRUPT)
                selfInterrupt();
        }

这里中对中断的响应,进入CLH队列,是由于中断唤醒,非notFull.signal()唤醒,直接抛出异常。

至此 notFull.await() 方法讲解结束。总结下:


持有锁的线程,由于队满,无法执行入队,先释放锁,进入等待队列。

 当有出队线程,执行出队操作时,会将等待队列中的节点,转移到CLH队列中
 
 在CLH队列中的结点,阻塞的结点被唤醒,接着去抢锁,抢到锁后,执行后续操作

入队方法总结

    public void put(E e) throws InterruptedException {
        checkNotNull(e);
        final ReentrantLock lock = this.lock;
        lock.lockInterruptibly(); // 抢到锁
        try {
            while (count == items.length) // 是否可入队
                notFull.await(); // 释放锁,等到条件合适时,再抢到锁
            enqueue(e); // 持有锁的情况下,执行入队操作
        } finally {
            lock.unlock(); // 释放锁资源
        }
    }

入队的方法很仔细的说完了,出队的方法,跟这个差不多的逻辑,这个懂了,那个就不用说了。

ArrayBlockingQueue 除了 put()take() 方法之外,还有其它的方法,

比如入队的 offer(), add()、出队的 poll(), peek(), remove(),等等。都差不多的套路。

好了,说到这个份儿上,为会么ArrayBlockingQueue 是线程安全的, 有界阻塞队列?

这个还是个事儿么?

相关文章:

PriorityBlockingQueue 源码解析

LinkedBlockingQueue源码解析

SynchronousQueue 源码解析

  • 4
    点赞
  • 12
    收藏
    觉得还不错? 一键收藏
  • 7
    评论
评论 7
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值