并发编程从零开始(五)-BlockingQueue

并发编程从零开始(五)-BlockingQueue

第二部分:JUC

5 并发容器

5.1 BlockingQueue

在所有的并发容器中,BlockingQueue是最常见的一种。BlockingQueue是一个带阻塞功能的队列,当入队列时,若队列已满,则阻塞调用者;当出队列时,若队列为空,则阻塞调用者。

在Concurrent包中,BlockingQueue是一个接口,有许多个不同的实现类,如图所示。

image-20211027101019601

该接口的定义如下:

image-20211027101049589

该接口和JDK集合包中的Queue接口是兼容的,同时在其基础上增加了阻塞功能。

add(…)和offer(…)的返回值是布尔类型,而put无返回值,还会抛出中断异常,所以add(…)和offer(…)是无阻塞的,也是Queue本身定义的接口,而put(…)是阻塞的。add(…)和offer(…)的区别不大,当队列为满的时候,前者会抛出异常,后者则直接返回false。

出队列与之类似,提供了remove()、poll()、take()等方法,remove()是非阻塞式的,take()和poll()是阻塞式的。

核心方法:

  • offer(anObject):表示如果可能的话,将anObject加到BlockingQueue里,即如果BlockingQueue可以容纳,则返回true,否则返回false.(本方法不阻塞当前执行方法的线程)。
  • offer(E o, long timeout, TimeUnit unit),可以设定等待的时间,如果在指定的时间内,还不能往队列中加入BlockingQueue,则返回失败。
  • put(anObject):把anObject加到BlockingQueue里,如果BlockQueue没有空间,则调用此方法的线程被阻断直到BlockingQueue里面有空间再继续.获取数据。
  • poll(time):取走BlockingQueue里排在首位的对象,若不能立即取出,则可以等time参数规定的时间,取不到时返回null。
  • poll(long timeout, TimeUnit unit):从BlockingQueue取出一个队首的对象,如果在指定时间内,队列一旦有数据可取,则立即返回队列中的数据。否则知道时间超时还没有数据可取,返回失败。
  • take():取走BlockingQueue里排在首位的对象,若BlockingQueue为空,阻断进入等待状态直到BlockingQueue有新的数据被加入。
  • drainTo():将队列中值,全部移除,并发设置到给定的集合中。可以添加移除的个数
5.1.1 ArrayBlockingQueue

ArrayBlockingQueue是一个用数组实现的环形队列,在构造方法中,会要求传入数组的容量。

image-20211027102125816

其核心数据结构如下:

image-20211027102146382

其put/take方法也很简单:

**put方法:**底层使用了核心的ReentrantLock,使用了lock.lockInterruptibly();也就是如果获得到了锁那么就继续,如果没有获得到锁就终端等待过程并抛出InterruptedException异常。如果获取到了锁,就while循环判断是否下标达到了该队列设定的最大大小,如果达到了就执行notFull.await();方法,阻塞队列。否则就执行enqueue开始加入值。在队尾下标插入元素,并将下标加一与队列设定大小进行比对,如果相等则代表达到了最大值,将队尾下标设置为0,以达到循环队列的效果。再将统计个数的count自增加一,并使用notEmpty.signal();通知唤醒等待线程。signal和notify一样都是唤醒某个正在wait的线程,与notify不一样的是只有在当前condition上等待的线程才有可能被唤醒,提高了并发的性能,防止活锁。

**take方法:**底层使用了核心的ReentrantLock,使用了lock.lockInterruptibly();如果获取到锁,就循环判断队列是否为空,如果为空就调用notEmpty.await();阻塞队列,不为空就执行dequeue。将队头下标处的元素赋给临时变量,然后将队列此处置空,自增下标判断是否需要置零,统计个数的count减一。take结束调用notFull.signal()唤醒线程,最后返回临时变量。

5.1.2 LinkedBlockingQueue

LinkedBlockingQueue是一种基于单向链表的阻塞队列。因为队头和队尾是2个指针分开操作的,所以用了2把锁+2个条件,同时有1个AtomicInteger的原子变量记录count数。

image-20211027104434790

在其构造方法中,也可以指定队列的总容量。如果不指定,默认为Integer.MAX_VALUE。

**put方法:**如果判断传入元素为空就抛出NullPointerException异常。调用putLock.interruptibly()方法。如果个数等于大小就调用notFull.await进行阻塞,否则就进行插入操作。接着判断队列中是否还有剩余空间,如果有就调用notFull.signal()唤醒线程。如果现在队列大小为0,则调用signalNotEmpty();

**take方法:**调用takeLock.interruptibly()方法。如果队列为空就调用notEmpty.await();进行阻塞。否则就个数自增并获取值保存在变量中,接着判断队列中是否还有元素,如果有就调用notEmpty.signal();唤醒其他take线程。最后判断如果队列满了就调用singalNotFull();最后返回保存的变量值。

LinkedBlockingQueue和ArrayBlockingQueue的差异:

  1. 为了提高并发度,用2把锁,分别控制队头、队尾的操作。意味着在put(…)和put(…)之间、take()与take()之间是互斥的,put(…)和take()之间并不互斥。但对于count变量,双方都需要操作,所以必须是原子类型。

  2. 因为各自拿了一把锁,所以当需要调用对方的condition的signal时,还必须再加上对方的锁,就是signalNotEmpty()和signalNotFull()方法。

    image-20211027111742443

  3. 不仅put会通知 take,take 也会通知 put。当put 发现非满的时候,也会通知其他 put线程;当take发现非空的时候,也会通知其他take线程。

5.1.3 PriorityBlockingQueue

队列通常是先进先出的,而PriorityQueue是按照元素的优先级从小到大出队列的。正因为如此,PriorityQueue中的2个元素之间需要可以比较大小,并实现Comparable接口。

其核心数据结构如下:

image-20211027111834050

如果不指定初始大小,内部会设定一个默认值11,当元素个数超过这个大小之后,会自动扩容。如果旧容量小于64,则增加一倍,否则增加一半大小。

**put方法:**其实调用的是offer方法,添加操作没有阻塞,获取到锁之后,判断如果是否元素超过了数组长度,就进行扩容。接着开始比较,如果没有定义比较操作,那么就使用元素自带的比较功能。元素入堆则进行shiftUp操作。个数加一,最后解锁。

**take方法:**调用lock.interruptibly()方法。调用dequeue方法出队列,如果取出为空就调用notEmpty.await()进行阻塞。否则就进行解锁,返回值。

在dequeue中,因为是最小二叉堆,堆顶就是要出队的元素。取出后,调用comparator进行比较,如果没有定义比较操作,那么就使用元素自带的比较功能。执行shifDown操作进行调整堆,最后返回取出的元素。

从上面可以看到,在阻塞的实现方面,和ArrayBlockingQueue的机制相似,主要区别是用数组实现了一个二叉堆,从而实现按优先级从小到大出队列。另一个区别是没有notFull条件,当元素个数超出数组长度时,执行扩容操作。

5.1.4 DelayQueue

DelayQueue即延迟队列,也就是一个按延迟时间从小到大出队的PriorityQueue。所谓延迟时间,就是“未来将要执行的时间”减去“当前时间”。为此,放入DelayQueue中的元素,必须实现Delayed接口。该接口中存在一个方法:

long getDelay(@NotNull() TimeUnit unit);

关于该接口:

  1. 如果getDelay的返回值小于或等于0,则说明该元素到期,需要从队列中拿出来执行。
  2. 该接口首先继承了 Comparable 接口,所以要实现该接口,必须实现 Comparable 接口。具体来说,就是基于getDelay()的返回值比较两个元素的大小。

DelayQueue的核心数据结构:

image-20211027131600633

take方法:

image-20211027131723540

关于take()方法:

  1. 不同于一般的阻塞队列,只在队列为空的时候,才阻塞。如果堆顶元素的延迟时间没到,也会阻塞。

  2. 在上面的代码中使用了一个优化技术,用一个Thread leader变量记录了等待堆顶元素的第1个线程。为什么这样做呢?通过 getDelay(…)可以知道堆顶元素何时到期,不必无限期等待,可以使用condition.awaitNanos()等待一个有限的时间;只有当发现还有其他线程也在等待堆顶元素(leader!=NULL)时,才需要无限期等待。

**put方法:**底层调用了offer方法。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-DFCpfk5F-1635315928816)(…/…/…/…/…/个人图片/笔记图片/image-20211027133222818.png)]

注意:不是每放入一个元素,都需要通知等待的线程。放入的元素,如果其延迟时间大于当前堆顶的元素延迟时间,就没必要通知等待的线程;只有当延迟时间是最小的,在堆顶时,才有必要通知等待的线程,也就是上面代码中的 if(q.peek()==e)部分。

5.1.5 SynchronousQueue

SynchronousQueue是一种特殊的BlockingQueue,它本身没有容量。先调put(…),线程会阻塞;直到另外一个线程调用了take(),两个线程才同时解锁,反之亦然。对于多个线程而言,例如3个线程,调用3次put(…),3个线程都会阻塞;直到另外的线程调用3次take(),6个线程才同时解锁,反之亦然。

接下来看SynchronousQueue的实现:

image-20211027135744372

和锁一样,也有公平和非公平模式。如果是公平模式,则用TransferQueue实现;如果是非公平模式,则用TransferStack实现。

image-20211027135830279

可以看到,put/take都调用了transfer(…)接口。而TransferQueue和TransferStack分别实现了这个接口。该接口在SynchronousQueue内部,如下所示。如果是put(…),则第1个参数就是对应的元素;如果是take(),则第1个参数为null。后2个参数分别为是否设置超时和对应的超时时间。对应的transfer:

//nanos纳秒
abstract E transfer(E e,boolean timed,long nanos);

接下来看一下什么是公平模式和非公平模式。假设3个线程分别调用了put(…),3个线程会进入阻塞状态,直到其他线程调用3次take(),和3个put(…)一一配对。

如果是公平模式(队列模式),则第1个调用put(…)的线程1会在队列头部,第1个到来的take()线程和它进行配对,遵循先到先配对的原则,所以是公平的;如果是非公平模式(栈模式),则第3个调用put(…)的线程3会在栈顶,第1个到来的take()线程和它进行配对,遵循的是后到先配对的原则,所以是非公平的。

image-20211027140158640

1. TransferQueue

image-20211027140220236

TransferQueue是一个基于单向链表而实现的队列,通过head和tail 2个指针记录头部和尾部。初始的时候,head和tail会指向一个空节点。

image-20211027140256008

阶段(a):队列中是一个空的节点,head/tail都指向这个空节点。

阶段(b):3个线程分别调用put,生成3个QNode,进入队列。

阶段(c):来了一个线程调用take,会和队列头部的第1个QNode进行配对。

阶段(d):第1个QNode出队列。

image-20211027140326805

这里有一个关键点:put节点和take节点一旦相遇,就会配对出队列,所以在队列中不可能同时存在put节点和take节点,要么所有节点都是put节点,要么所有节点都是take节点。

TransferQueue的代码实现:

image-20211027140746097

image-20211027140807448

image-20211027140824884

整个 for 循环有两个大的 if-else 分支,如果当前线程和队列中的元素是同一种模式(都是put节点或者take节点),则与当前线程对应的节点被加入队列尾部并且阻塞;如果不是同一种模式,则选取队列头部的第1个元素进行配对。

这里的配对就是m.casItem(x,e),把自己的item x换成对方的item e,如果CAS操作成功,则配对成功。如果是put节点,则isData=true,item!=null;如果是take节点,则isData=false,item=null。如果CAS操作不成功,则isData和item之间将不一致,也就是isData!=(x!=null),通过这个条件可以判断节点是否已经被匹配过了。

2. TransferStack

TransferStack的定义如下所示,首先,它也是一个单向链表。不同于队列,只需要head指针就能实现入栈和出栈操作。

image-20211027141745824

链表中的节点有三种状态,REQUEST对应take节点,DATA对应put节点,二者配对之后,会生成一个FULFILLING节点,入栈,然后FULLING节点和被配对的节点一起出栈。

阶段(a):head指向NULL。不同于TransferQueue,这里没有空的头节点。

阶段(b):3个线程调用3次put,依次入栈。

阶段(c):线程4调用take,和栈顶的第1个元素配对,生成FULLFILLING节点,入栈。

阶段(d):栈顶的2个元素同时出栈

image-20211027142005578

具体的代码实现:

image-20211027142027238

image-20211027142047953

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值