并发包中常用的队列

LinkedBlockingQueue

LinkedBlockingQueue是一个基于链表实现的阻塞队列,默认情况下,该阻塞队列的大小为Integer.MAX_VALUE,由于这个数值特别大,所以 LinkedBlockingQueue 也被称作无界队列,代表它几乎没有界限,队列可以随着元素的添加而动态增长,但是如果没有剩余内存,则队列将抛出OOM错误

所以为了避免队列过大造成机器负载或者内存爆满的情况出现,我们在使用的时候建议手动传一个队列的大小。

LinkedBlockingQueue内部由单链表实现,只能从head取元素,从tail添加元素。

LinkedBlockingQueue采用两把锁的锁分离技术实现入队出队互不阻塞,添加元素和获取元素都有独立的锁,也就是说LinkedBlockingQueue是读写分离的,读写操作可以并行执行。

一、单链表

1、结构定义

对存储结构的定义:

看一下对队列中的元素的定义

2、入队操作

入队操作:尾插法

3、出队操作

从头部出队

因为头结点是不保存数据的(方便操作),头结点的下一个结点才是真实的数据。

这里非常好奇的一个步骤是:h.next = h; // help GC

当前元素的下一个结点是自己,但是因为头结点向后移动一位,然后导致头结点指向的是之后的链表。

但是自己的下一个结点指向了自己,这个操作无法理解。GC会生效吗?

二、阻塞队列

1、结构

2、put操作

首先来分析一下put的操作:

1、队列如果是满的,那么等待有空的位置时再放入进去;

2、队列如果是空的,那么可以直接将其放进去;

3、但是存在着一边消费,一边生产的情况,这种情况是麻烦的。所以在放入的时候,需要使用到putLock锁;在获取得到的时候,需要使用到takeLock锁;

两把锁共同起到工作,提高效率。

那么具体看一下里面的判断:

对于LinkedBlockingQueue来说,在初始构造的时候,也是可以来设置这里的容量的。

The capacity bound, or Integer.MAX_VALUE if none

如果队列满了,那么就不能够继续往队列中来放了,而是只能够转移到条件队列中来存放。

而条件队列是先入先出的,所以有很好的组织性。

为什么会有

            if (c + 1 < capacity)
                notFull.signal();

这是因为如果当前线程在向队列中存任务的时候,发现当前队列中是可以继续放入任务的,那么可将条件队列的任务转移到同步队列中来。

这里利用容量来进行判断,尽可能的减少任务转移到同步队列中来。

这里需要考虑到消费者什么时候来通知生产者来进行生产?只有队列是满的时候,才会来进行通知。

还有一个判断:

        if (c == 0)
            signalNotEmpty();

第一个任务进来的时候,需要通知消费者来进行消费。需要将消费者唤醒。

等消费者消费完当前的任务之后,会继续从队列中来取任务,继续消费。

3、take操作

和上面类似:

仔细看这里的两个判断:

第一个判断:

            if (c > 1)
                notEmpty.signal();

表示队列中还有任务可以消费,通知消费者来进行消费。

第二个判断:

        if (c == capacity)
            signalNotFull();

因为达到了最大容量,但是当前消费者消费了一个,那么队列中就还有一个位置,通知消费者来进行消费。

说明什么?消费者通知生产者的条件是:只有当前消费任务前,队列是满的时候,消费者才会通知生产者继续来进行生产。

三、总结

LinkedBlockingQueue采用两把锁的锁分离技术实现入队出队互不阻塞,添加元素和获取元素都有独立的锁,也就是说LinkedBlockingQueue是读写分离的,读写操作可以并行执行。

ArrayBlockingQueue

ArrayBlockingQueue是最典型的有界阻塞队列,其内部是用数组存储元素的,初始化时需要指定容量大小,利用 ReentrantLock 实现线程安全。

在生产者-消费者模型中使用时,如果生产速度和消费速度基本匹配的情况下,使用ArrayBlockingQueue是个不错选择;当如果生产速度远远大于消费速度,则会导致队列填满,大量生产线程被阻塞。

使用独占锁ReentrantLock实现线程安全,入队和出队操作使用同一个锁对象,也就是只能有一个线程可以进行入队或者出队操作;这也就意味着生产者和消费者无法并行操作,在高并发场景下会成为性能瓶颈。

这也就是解释了为什么线程池中使用的是LinkedBlockingQueue,而不是ArrayBlockingQueue。

因为对于LinkedBlockingQueue来说,是可以进行并行的。

put操作

take操作

可以看到,这里加的是同一把锁。

这里也就是性能瓶颈所在的位置。

LinkedBlockingQueue与ArrayBlockingQueue对比

LinkedBlockingQueue是一个阻塞队列,内部由两个ReentrantLock来实现出入队列的线程安全,由各自的Condition对象的await和signal来实现等待和唤醒功能。它和ArrayBlockingQueue的不同点在于:

  • 队列大小有所不同,ArrayBlockingQueue是有界的初始化必须指定大小,而LinkedBlockingQueue可以是有界的也可以是无界的(Integer.MAX_VALUE),对于后者而言,当添加速度大于移除速度时,在无界的情况下,可能会造成内存溢出等问题。
  • 数据存储容器不同,ArrayBlockingQueue采用的是数组作为数据存储容器,而LinkedBlockingQueue采用的则是以Node节点作为连接对象的链表。
  • 由于ArrayBlockingQueue采用的是数组的存储容器,因此在插入或删除元素时不会产生或销毁任何额外的对象实例,而LinkedBlockingQueue则会生成一个额外的Node对象。这可能在长时间内需要高效并发地处理大批量数据的时,对于GC可能存在较大影响。
  • 两者的实现队列添加或移除的锁不一样,ArrayBlockingQueue实现的队列中的锁是没有分离的,即添加操作和移除操作采用的同一个ReenterLock锁,而LinkedBlockingQueue实现的队列中的锁是分离的,其添加采用的是putLock,移除采用的则是takeLock,这样能大大提高队列的吞吐量,也意味着在高并发的情况下生产者和消费者可以并行地操作队列中的数据,以此来提高整个队列的并发性能。

PriorityBlockingQueue

PriorityBlockingQueue是一个无界的基于数组的优先级阻塞队列,数组的默认长度是11,虽然指定了数组的长度,但是可以无限的扩充,直到资源消耗尽为止,每次出队都返回优先级别最高的或者最低的元素。默认情况下元素采用自然顺序升序排序,当然我们也可以通过构造函数来指定Comparator来对元素进行排序。需要注意的是PriorityBlockingQueue不能保证同优先级元素的顺序。

优先级队列PriorityQueue: 队列中每个元素都有一个优先级,出队的时候,优先级最高的先出。

应用场景

电商抢购活动,会员级别高的用户优先抢购到商品

银行办理业务,vip客户插队

PriorityBlockingQueue使用

//创建优先级阻塞队列  Comparator为null,自然排序
PriorityBlockingQueue<Integer> queue=new PriorityBlockingQueue<Integer>(5);
//自定义Comparator
PriorityBlockingQueue queue=new PriorityBlockingQueue<Integer>(
        5, new Comparator<Integer>() {
    @Override
    public int compare(Integer o1, Integer o2) {
        return o2-o1;
    }

思考:如何实现一个优先级队列?

如何构造优先级队列

使用普通线性数组(无序)来表示优先级队列

  • 执行插入操作时,直接将元素插入到数组末端,需要的成本为O(1),
  • 获取优先级最高元素,我们需要遍历整个线性队列,匹配出优先级最高元素,需要的成本为o(n)
  • 删除优先级最高元素,我们需要两个步骤,第一找出优先级最高元素,第二步删除优先级最高元素,然后将后面的元素依次迁移,填补空缺,需要的成本为O(n)+O(n)=O(n)

使用一个按顺序排列的有序向量实现优先级队列

  • 获取优先级最高元素,O(1)
  • 删除优先级最高元素,O(1)
  • 插入一个元素,需要两个步骤,第一步我们需要找出要插的位置,这里我们可以使用二分查找,成本为O(logn),第二步是插入元素之后,将其所有后继进行后移操作,成本为O(n),所有总成本为O(logn)+O(n)=O(n)

二叉堆

完全二叉树:除了最后一行,其他行都满的二叉树,而且最后一行所有叶子节点都从左向右开始排序。

二叉堆:完全二叉树的基础上,加以一定的条件约束的一种特殊的二叉树。根据约束条件的不同,二叉堆又可以分为两个类型:

大顶堆和小顶堆。

  • 大顶堆(最大堆):父结点的键值总是大于或等于任何一个子节点的键值;
  • 小顶堆(最小堆):父结点的键值总是小于或等于任何一个子节点的键值。

DelayQueue

DelayQueue 是一个支持延时获取元素的阻塞队列, 内部采用优先队列 PriorityQueue 存储元素,同时元素必须实现 Delayed 接口;在创建元素时可以指定多久才可以从队列中获取当前元素,只有在延迟期满时才能从队列中提取元素。延迟队列的特点是:不是先进先出,而是会按照延迟时间的长短来排序,下一个即将执行的任务会排到队列的最前面。

它是无界队列,放入的元素必须实现 Delayed 接口,而 Delayed 接口又继承了 Comparable 接口,所以自然就拥有了比较和排序的能力,代码如下:

public interface Delayed extends Comparable<Delayed> {
    //getDelay 方法返回的是“还剩下多长的延迟时间才会被执行”,
    //如果返回 0 或者负数则代表任务已过期。
    //元素会根据延迟时间的长短被放到队列的不同位置,越靠近队列头代表越早过期。
    long getDelay(TimeUnit unit);
}

DelayQueue使用

DelayQueue<OrderInfo> queue = new DelayQueue<OrderInfo>();

DelayQueue的原理

数据结构

//用于保证队列操作的线程安全
private final transient ReentrantLock lock = new ReentrantLock();
// 优先级队列,存储元素,用于保证延迟低的优先执行
private final PriorityQueue<E> q = new PriorityQueue<E>();
// 用于标记当前是否有线程在排队(仅用于取元素时) leader 指向的是第一个从队列获取元素阻塞的线程
private Thread leader = null;
// 条件,用于表示现在是否有可取的元素,当新元素到达,或新线程可能需要成为leader时被通知
private final Condition available = lock.newCondition();

public DelayQueue() {}
public DelayQueue(Collection<? extends E> c) {
    this.addAll(c);
}  

入队put方法

public void put(E e) {
    offer(e);
}
public boolean offer(E e) {
    final ReentrantLock lock = this.lock;
    lock.lock();
    try {
        // 入队
        q.offer(e);
        if (q.peek() == e) {
            // 若入队的元素位于队列头部,说明当前元素延迟最小
            // 将 leader 置空
            leader = null;
            // available条件队列转同步队列,准备唤醒阻塞在available上的线程
            available.signal();
        }
        return true;
    } finally {
        lock.unlock(); // 解锁,真正唤醒阻塞的线程
    }
}

出队take方法

public E take() throws InterruptedException {
  final ReentrantLock lock = this.lock;
  lock.lockInterruptibly();
  try {
    for (;;) {
      // 取出堆顶元素( 最早过期的元素,但是不弹出对象) 
      E first = q.peek();
      // 如果堆顶元素为空,说明队列中还没有元素,直接阻塞等待
      if (first == null)
        //当前线程无限期等待,直到被唤醒,并且释放锁。
        available.await();
      else {
        // 堆顶元素的到期时间  
        long delay = first.getDelay(NANOSECONDS); 
        // 如果小于0说明已到期,直接调用poll()方法弹出堆顶元素
        if (delay <= 0)
          return q.poll();

        // 如果delay大于0 ,则下面要阻塞了
        // 将first置为空方便gc
        first = null; 
        // 如果有线程争抢的Leader线程,则进行无限期等待。
        if (leader != null)
          available.await();
        else {
          // 如果leader为null,把当前线程赋值给它
          Thread thisThread = Thread.currentThread();
          leader = thisThread;
          try {
            // 等待剩余等待时间(会释放锁)
            available.awaitNanos(delay);
          } finally {
            // 如果leader还是当前线程就把它置为空,让其它线程有机会获取元素
            if (leader == thisThread)
              leader = null;
          }
        }
      }
    }
  } finally {
    // 成功出队后,如果leader为空且堆顶还有元素,就唤醒下一个等待的线程
    if (leader == null && q.peek() != null)
      // available条件队列转同步队列,准备唤醒阻塞在available上的线程
      available.signal();
    // 解锁,真正唤醒阻塞的线程
    lock.unlock();
  }

这里采用的是Leader-Follower模式,具体的可以参考:https://blog.csdn.net/piaoranyuji/article/details/124042408

来举个例子说明一下这里的情况:

有t1和t2两个线程,t1先来从队列中来获取得到元素,但是发现任务的到期时间还没有到,然后睡眠等待(释放了锁),但是因为代码执行时间都是有效的,不可能是那么及时。所以在t1睡眠期间,分为以下几种情况:

1、t2进入,发现当前任务是超时的,立刻将任务来进行执行;

2、t2进入时,发现当前任务是没有超时的,还差一点时间才能执行,然后发现当前的leader是有其他线程的,所以当前线程睡眠,让leader线程来执行这个任务。因为leader线程已经睡眠了一段时间了;

3、leader线程从休眠中出来了之后,才会将当前的leader置为null。然后继续下一轮循环,从队列中来获取得到任务执行。

总结下来就是:

  1. 当获取元素时,先获取到锁对象。
  2. 获取最早过期的元素,但是并不从队列中弹出元素。
  3. 最早过期元素是否为空,如果为空则直接让当前线程无限期等待状态,并且让出当前锁对象。
  4. 如果最早过期的元素不为空
  5. 获取最早过期元素的剩余过期时间,如果已经过期则直接返回当前元素
  6. 如果没有过期,也就是说剩余时间还存在,则先获取Leader对象,如果Leader已经有线程在处理,则当前线程进行无限期等待,如果Leader为空,则首先将Leader设置为当前线程,并且让当前线程等待剩余时间。
  7. 最后将Leader线程设置为空
  8. 如果Leader已经为空,并且队列有内容则唤醒一个等待的队列。
posted @ 2022-11-01 09:20  写的代码很烂  阅读(10)  评论(0编辑  收藏  举报