数据共享通道:BlockingQueue

思考这样一个问题:在多线程的开发过程中,如何进行多个线程间的数据共享呢?比如:线程A希望给线程B发一个消息,用什么样的方式告知线程B比较合适呢?

解析:一般来说,我们希望整个系统是松散耦合的。就是说我们希望线程A能够通知线程B,又希望线程A不知道线程B的存在。这样,如果将来进行重构或者升级,我们可以完全不修改线程A,而直接把线程B升级为线程C,保证了系统的平滑过渡。这时,我们就可以使用BlockingQueue来实现。

  与之前文章讲述的CopyOnWriteArrayList高效读写的队列:ConcurrentLinkedQueue不同,BlockingQueue是一个接口,并非一个具体的实现。

  之所以BlockingQueue适合作为数据共享的通道,不仅它是FIFO(先进先出)队列,其关键还在Blocking上。Blocking是阻塞的意思,当服务线程(服务线程是指不断获取队列中的消息,进行处理的线程)处理完队列中所有的消息后,它会让服务线程进行等待,当有新的消息进入队列后,自动唤醒服务线程,BlockingQueue的工作模式如下图所示:

 BlockingQueue的核心方法

  boolean offer(E e):表示将元素压入队列末尾,如果当前队列没有满,则执行正常的入队操作返回true,如果已经满了,它就会立即返回false。

  boolean offer(E e, long timeout, TimeUnit unit) throws InterruptedException:设定时间单位为unit的时间长度timeout,如果在时间内能把元素加入到队列末尾,则返回true,否则返回false;

  void put(E e) throws InterruptedException:将元素压入队列末尾,但是如果队列满了,那么它会一直等待,直到队列中有空闲的位置。

  E poll(long timeout, TimeUnit unit) throws InterruptedException:在时间单位为unit长度为timeout的时间段内,若取走队列的头部的第一个元素,则返回首元素,否则返回null;

  E take() throws InterruptedException:取走队列头部的第一个元素,如果队列为空,则等待直到队列有可用的元素;

常见的BlockingQueue:

BlockingQueue的家庭成员有:  

主要介绍ArrayBlockingQueue和LinkedBlockingQueue

 ArrayBlockingQueue:

  ArrayBlockingQueue从名字可知,底层是基于数组实现的,适合做有界队列,因为队列中可容纳的最大元素需要在队列创建的时候指定(毕竟数组的动态扩展比较消耗性能)。下面通过源码来更加深入的了解ArrayBlockingQueue:

  ArrayBlockingQueue类内部定义的变量

1     /** Main lock guarding all access */
2     final ReentrantLock lock;
3 
4     /** Condition for waiting takes */
5     private final Condition notEmpty;
6 
7     /** Condition for waiting puts */
8     private final Condition notFull;

从上述代码的注释可以看出,ArrayBlockingQueue是通过重入锁ReentrantLock来保证线程安全的,当执行take()操作时,如果队列为空,则让线程等待在notEmpty上,如果队列满了,则让线程等待在noFtull上,来看看take()的源码:

 1 public E take() throws InterruptedException {
 2         final ReentrantLock lock = this.lock;
 3         lock.lockInterruptibly();
 4         try {
 5             while (count == 0)
 6                 notEmpty.await();
 7             return dequeue();
 8         } finally {
 9             lock.unlock();
10         }
11     }

代码的第5,6行,当队列元素为空时,则让线程在notEmpty上等待,那么它是怎样唤醒的呢?下面看看元素入队的代码:

 1 public void put(E e) throws InterruptedException {
 2         Objects.requireNonNull(e);
 3         final ReentrantLock lock = this.lock;
 4         lock.lockInterruptibly();
 5         try {
 6             while (count == items.length)
 7                 notFull.await();
 8             enqueue(e);
 9         } finally {
10             lock.unlock();
11         }
12     }
 1 private void enqueue(E e) {
 2         // assert lock.isHeldByCurrentThread();
 3         // assert lock.getHoldCount() == 1;
 4         // assert items[putIndex] == null;
 5         final Object[] items = this.items;
 6         items[putIndex] = e;
 7         if (++putIndex == items.length) putIndex = 0;
 8         count++;
 9         notEmpty.signal();
10     }

第一个方法是向队列末尾加入元素,如果队列满了,则让线程在notFull上进行等待,否则执行enqueue(E e) 方法,在enqueue(E e)的地9行,在新元素加入到队列后,对等待在notEmpty上的线程进行的通知,让它能继续工作。

从上面的源码可以看出:

  ArrayBlockingQueue向队列中放入元素和从队列中取走元素,都是采用的同一个锁对象(类变量定义的:final ReentrantLock lock),由此意味着ArrayBlockingQueue不是真正意义上的并行,之所以这样做,有可能是因为在引入独立锁后,除了增加代码的复杂度之外,在其性能上提高很不明显,这样也说明了ArrayBlockingQueue的写入和获取操作已经足够轻巧,性能已经足够好了。这点不同于LinkedBlockingQueue,在创建ArrayBlockingQueue时,我们还可以控制队列的内部锁是否采用公平锁,默认是非公平锁。

LinkedBlockingQueue:

  LinkedBlockingQueue底层是基于链表实现的,适合做无界队列。

注意:

  如果构造一个LinkedBlockingQueue,没有指定其容量大小(LinkedBlockingQueue可以通过构造函数指定最大值),LinkedBlockingQueue会默认一个大小为Integer.MAX_VALUE的队列,这样如果put()操作大于take()操作,这样就有可能会导致系统内存消耗殆尽,这样对于系统来说是致命的。

LinkedBlockingQueue类的变量定义:

    transient Node<E> head;

    private transient Node<E> last;

    /** Lock held by take, poll, etc */
    private final ReentrantLock takeLock = new ReentrantLock();

    /** Wait queue for waiting takes */
    private final Condition notEmpty = takeLock.newCondition();

    /** Lock held by put, offer, etc */
    private final ReentrantLock putLock = new ReentrantLock();

    /** Wait queue for waiting puts */
    private final Condition notFull = putLock.newCondition();

可以看出有队列头head和队列尾last,takeLock是获取锁,putLock是写入锁。LinkedBlockingQueue对于写入和获取操作分别采用了独立的锁来可控制数据同步,削弱了锁竞争的可能性,这也意味着在高并发的情况下写入和获取可以真正的并行操作队列中的元素,这样来提高整个队列的并发性能。

take()方法:

 1 public E take() throws InterruptedException {
 2         E x;
 3         int c = -1;
 4         final AtomicInteger count = this.count;
 5         final ReentrantLock takeLock = this.takeLock;
 6         takeLock.lockInterruptibly();
 7         try {
 8             while (count.get() == 0) {//如果当前没有可用的数据,一直等待
 9                 notEmpty.await();  //等待put()方法的通知
10             }
11             x = dequeue();      //获取第一个元素
12             c = count.getAndDecrement();  //变量c是count减1前的值
13             if (c > 1)
14                 notEmpty.signal(); //通知其他take()操作
15         } finally {
16             takeLock.unlock();//释放锁
17         }
18         if (c == capacity)
19             signalNotFull();//通知put()操作,已有空余空间
20         return x;
21     }

代码的第6行,采用的是lockInterruptibly()方法获取锁,这样如果锁资源一直没有获取到可以中断该线程,对系统的极端情况提供了处理方案。

put()方法:

 1 public void put(E e) throws InterruptedException {
 2         if (e == null) throw new NullPointerException();
 3         int c = -1;
 4         Node<E> node = new Node<E>(e);
 5         final ReentrantLock putLock = this.putLock;
 6         final AtomicInteger count = this.count;
 7         putLock.lockInterruptibly();
 8         try {
 9             while (count.get() == capacity) { //如果队列满了
10                 notFull.await();         //等待
11             }
12             enqueue(node);        //插入数据
13             c = count.getAndIncrement();//更新总数,变量c是count加1前的值
14             if (c + 1 < capacity)
15                 notFull.signal();      //通知线程,队列未满
16         } finally {
17             putLock.unlock();        //释放锁
18         }
19         if (c == 0)
20             signalNotEmpty();      //插入成功后,通知take()取元素
21     }

总结:

  BlockingQueue不仅实现了一个完整队列所需要的基本功能,同时在多线程的环境下,还自动管理了多线程间的唤醒和阻塞,从而使得程序员可以忽略这些比较容易出错的细节,更加关注功能开发。

posted on 2018-10-14 17:14  AoTuDeMan  阅读(280)  评论(0编辑  收藏  举报

导航