阻塞队列之ArrayBlockingQueue源码分析

组塞队列简单说明

阻塞队列是并发编程里面重要的一块,线程池中任务队列都会用到不同类型的阻塞队列。组塞队列BlockingQueue下面有多个不同的实现。主要包括下面7中

1. ArrayBlockingQueue :由数组结构组成的有界阻塞队列。

2. LinkedBlockingQueue :由链表结构组成的有界阻塞队列。

3. PriorityBlockingQueue :支持优先级排序的无界阻塞队列。

4. DelayQueue:使用优先级队列实现的无界阻塞队列。

5. SynchronousQueue:不存储元素的阻塞队列。

6. LinkedTransferQueue:由链表结构组成的无界阻塞队列。

7. LinkedBlockingDeque:由链表结构组成的双向阻塞队列

最常用的可能就是ArrayBlockingQueue,LinkedBlockingQueue,SynchronousQueue,DelayedWorkQueue这几种了。java内置的集中线程池中newFixedThreadPool()和newSingleThreadExecutor()使用的LinkedBlockingQueue作为任务队列,newCachedThreadPool()使用的是SynchronousQueue作为任务队列,newScheduledThreadPool()使用的就是DelayedWorkQueue作为任务队列。今天主要通过源码看一下ArrayBlockingQueue的底层实现。ArrayBlockingQueue的源码不算复杂,本文只是会针对几个主要的点做说明。

ArrayBlockingQueue类结构

 /** 底层用来保存数据的数组,arrayBlockingQueue是有界队列,就是这里用了数组,初始化的时候就确定了队列的容量 */
    final Object[] items;
    /** take(),poll(),peek(),remove()方法执行时候从队列中取数据时下一个操作数据的索引位置*/
    int takeIndex;
    /** put(),offer(),add()方法执行时候下一个操作的索引位置*/
    int putIndex;
    /** 队列中现在存入的元素个数 */
    int count;

    /*
     * 这里并发控制使用了一个锁,两个condition,的方式。就是生产者消费者模式
     * 
     */

    /** 同步操作用的锁 */
    final ReentrantLock lock;

    /** 消费者condition */
    private final Condition notEmpty;

    /** 生产者condition */
    private final Condition notFull;

    /**
     * Shared state for currently active iterators, or null if there
     * are known not to be any.  Allows queue operations to update
     * iterator state.
     */
    transient Itrs itrs = null;
上面是arrayBlockingQueue主要的类结构,一个存放数据的数组items,两个指针分别用于取数据和放数据,一个锁两个condition用于控制并发线程控制实现生产者消费者模式,还有一个count用来记录队列中数据总数,这参数很重要,他保证了插入数据时不会覆盖未被取走的数据,取数据时候也不会取到空数据。

重点代码分析

阻塞队列中主要的方法有

插入操作:add(e),offer(e),put(e),offer(e,time,unit)

删除操作:remove(),poll(),take(),poll(time,unit)

add()底层调用的就是offer(),能插入成则直接插入,不能插入直接返回false,如果返回offer()返回false的话add()方法就会抛出异常,表示队列已满

先看一下add(),offer()操作,下面如下:

/**往队列中添加数据*/
public boolean add(E e) {
        //这里会调用到java.util.AbstractQueue#add,即下面的add()
        return super.add(e);
    }
//java.util.AbstractQueue#add
public boolean add(E e) {
        //这里底层调用offer(),成功直接返回true,失败就抛出异常提示队列已满
        if (offer(e))
            return true;
        else
            throw new IllegalStateException("Queue full");
    }


public boolean offer(E e) {
        //队列中不允许插入null,如果为null会报空指针异常
        checkNotNull(e);
        //这里将全局的ReentrantLock对象赋值给方法内局部变量,在juc的各个组件中很多这种写法,后面单独解释这么为什么这么写
        final ReentrantLock lock = this.lock;
        lock.lock();
        try {
            //这里判断如果队列已满直接返回false,不会进行阻塞。这里就是与put()不同的地方
            if (count == items.length)
                return false;
            else { 
                //队列未满,数据入队,成功返回true
                enqueue(e);
                return true;
            }
        } finally {
            lock.unlock();
        }
    }
/**
数据入队
入队的逻辑很简单,首先将数据放大到putIndex位置,然后判断队列是否放满了,如果队列已满则putIndex再次指向数组第一个索引位置,如果未满则putIndex执行下次操作的索引位置。最后没插入一个数据元素总数+1,然后唤醒消费者取数据。
*/

private void enqueue(E x) {
        // assert lock.getHoldCount() == 1;
        // assert items[putIndex] == null;
        //这里同样是将全局的items赋值给方法内局部变量,这事jdk1.8以后做的优化,1.7以前还是直接用的items操作的
        final Object[] items = this.items;
        items[putIndex] = x;
        //这里指针如果数组已满,则从下次从第一个位置重新开始插入
        if (++putIndex == items.length)
            putIndex = 0;
        //队列中数据总数+1
        count++;
        //唤醒条件队列中的消费者,使之转移到同步队列竞争锁然后消费数据
        notEmpty.signal();
    }

 上面是add(),和offer()的操作逻辑,我们发现插入数据的时候是不会阻塞的,成功就返回true,失败一次也直接抛出异常或者返回false,put()方法是会线程阻塞的,下面看一下具体代码过程

/**
回阻塞的put操作
这里跟前面offer()逻辑还能像,主要不同就是这里插入数据的时候会判断如果队列已满则线程会阻塞直到有消费着消费了数据队列中有位置可以插入
*/
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();
        }
    }

上面就是add(),offer(),put(),的操作逻辑了,其实代码挺简单的,但是有没有小伙伴会有一个疑问,enqueue()入队操作的时候判断如果putIndex已经到数组length-1的时候也就是数组已满了,就会把putIndex从新设置为0,下次从数组开始位置插入,那会不会造成前面没有消费的数据被覆盖掉呢?答案当时是不会覆盖掉。前文我们说了有一个count是用来记录队列中数据总数的,就是他在发挥作用,每次插入数据的时候都会先判断count==items.length就是判断如果队列已满则不能插入,是有数据被消费者消费以后count<items.length了才可以执行后面的入队逻辑。而且每次入队成功都会通知消费者取消费。

下面看从队列中取数据的逻辑,可以通过poll()或者take(),同样前者是非阻塞的,如果第一次能取到者之间返回取到的数据,如果队列里面没有数据直接返回null。后者如果队列没有数据的情况下会阻塞,等待数据。

//非阻塞方式
public E poll() {
        final ReentrantLock lock = this.lock;
        lock.lock();
        try {
            //这里如果队列为空直接返回null,不为空则从队列中取数据
            return (count == 0) ? null : dequeue();
        } finally {
            lock.unlock();
        }
    }
/**
数据出队的处理逻辑跟入队很像
*/
private E dequeue() {
        // assert lock.getHoldCount() == 1;
        // assert items[takeIndex] != null;
        final Object[] items = this.items;
        @SuppressWarnings("unchecked")
        //数据出队用takeIndex,指向下一个需要出队的数据,arrayBlockingQueue的数据是先进先出的
        E x = (E) items[takeIndex];
        items[takeIndex] = null;
        //如果数据已经取到队列最后,则从数组第一位从新开始取数据
        if (++takeIndex == items.length)
            takeIndex = 0;
        count--; //队列总数-1
        if (itrs != null)
            itrs.elementDequeued();
        notFull.signal();//通知生产者可以生产数据了
        return x;
    }

下面看take()

/**会阻塞线程的take()操作*/
public E take() throws InterruptedException {
        final ReentrantLock lock = this.lock;
        lock.lockInterruptibly();
        try {
            //这里如果队列中没有数据,则当前线程进入条件队列,等待生产者放数据然后通知消费者消费
            //跟poll()操作就这里不一样了
            while (count == 0)
                notEmpty.await();
            return dequeue();//数据出队
        } finally {
            lock.unlock();
        }
    }

最后看一下从队列中删除数据remove(o)

/*
 从队列中删除数据
首先判断队列中是否有数据,没有这就返回false,如果有数据在判断
要删除的数据是不是下一个要取出的数据,如果是这就删除
*/
public boolean remove(Object o) {
        if (o == null) return false;
        final Object[] items = this.items;
        final ReentrantLock lock = this.lock;
        lock.lock();
        try {
            if (count > 0) {
                final int putIndex = this.putIndex;
                int i = takeIndex;
                do {
                    //遍历队列中元素,判断是否有需要删除的
                    if (o.equals(items[i])) {
                        //如果找到该对象,则根据索引删除
                        removeAt(i);
                        return true;
                    }
                    if (++i == items.length)
                        i = 0;
                } while (i != putIndex);
            }
            return false;
        } finally {
            lock.unlock();
        }
    }

//删除指定索引位置的数据
void removeAt(final int removeIndex) {
        // assert lock.getHoldCount() == 1;
        // assert items[removeIndex] != null;
        // assert removeIndex >= 0 && removeIndex < items.length;
        final Object[] items = this.items;
        //如果需要被删除的数据刚好是下一个需要被取出的数据则直接删除
        if (removeIndex == takeIndex) {
            // removing front item; just advance
            items[takeIndex] = null;
            if (++takeIndex == items.length)
                takeIndex = 0;
            count--;
            if (itrs != null)
                itrs.elementDequeued();
        } else {
            // an "interior" remove
            
            // slide over all others up through putIndex.
            //如果被删除数据不是下一个需要被取出的数据,则删除对应数据以后将后面所有为取出的数据前移一位,经被删除位置补上
            final int putIndex = this.putIndex;
            for (int i = removeIndex;;) {
                int next = i + 1;
                if (next == items.length)
                    next = 0;
                if (next != putIndex) {
                    items[i] = items[next];
                    i = next;
                } else {
                    items[i] = null;
                    this.putIndex = i;
                    break;
                }
            }
            count--;
            if (itrs != null)
                itrs.removedAt(removeIndex);
        }
        notFull.signal();
    }

从上面删除数据的逻辑看的从队列删除数据效率是很低的,首先需要遍历队列中数据跟需要被删除的对象是否相等来找到被删除对象在数组中索引位置,然后根据索引位置删除该对象。如果被删除对象不是下一个需要被取出的对象,则删除数据以后该索引我位置以后所有未被取出的数据前移一位。下面示例一下删除的逻辑:

                                putIndex                  takeIndex

 

abcnullnullfghij
0123456789

数据

索引

 

假如上面所示队列长度为10,当前putIndex = 3,takeIndex=5,需要被删除的数据刚好是f,则直接将该位置数据置为null,takeIndex后移一位即可

                               putIndex                              takeIndex

abcnullnullnullghij
0123456789

数据

索引

 

假如如果删除的数据不是f,而是g则删除以后数据是下面的样子,被删数据后面所有数据前移了一位

                     putIndex                              takeIndex

bcnullnullnullfhija
0123456789

数据

索引

 

好了,到此arrayBlockingQueue中主要代码已经说完了,最后还有一个遗留的问题,就是为什么代码中多处地方都会将全局变量赋值给局部变量,而不是直接使用全局变量?如这句话:

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();
    }
}

如果没有这句操作,后面直接使用this.lock一样没什么问题,那Doug Lea为什么要这么写呢?网上也有不少朋友给出了答案,给出了多种不同的原因,但是我更偏向其中的一个说法:

java虚拟机的内存模型与oop的原则不一致。java是面向对象的,但是jvm执行代码逻辑时候会为每个方法在线程栈中开辟一个栈帧,栈帧内数据是独享的。把全局变量赋值给局部变量以后,每次栈帧中使用该对象不需要先去堆上加载this到操作数栈,而直接使用本地变量表中的局部变量。我们也可以写一个例子比较一下有没有这句话的区别,最后会发现这种写法只会执行一次load0,后面都是使用的局部变量,如果循环操作的话这里会有一定的行呢个提升。不过个人觉得这具体会有多大的收益呢?但是大神就是大神,写的每一句代码都是做到最优,这是值得我们每个技术人学习的。

好了到此结束,如果哪里写的不对请留言指正。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值