JAVA并发编程--7 延时队列DelayQueue

前言:在编程过程中,如果需要在过去一定时间之后,消费数据完成业务的处理,此时又不想大动干戈的使用中间件或者其他工具时可以试试延时队列;

1 延时队列使用:

1.1 定义延时队列中的元素和延时的时长:

import java.util.concurrent.Delayed;
import java.util.concurrent.TimeUnit;

/**
 * @Description TODO
 * @Date 2023/2/8 10:18
 * @Author lgx
 * @Version 1.0
 */
@Data
public class AddWxFriendVo<T> implements Delayed {
    /**
     * 到期时间 单位:秒
     */
    private long activeTime;
    /**
     * 订单实体(使用泛型是因为后续扩展其他业务共用此业务类)
     */
    private T data;
	// 构造方法完成 实体数据和到期时间的传入
    public AddWxFriendVo(long activeTime, T data) {
        // 将传入的时间转换为超时的时刻
        this.activeTime = TimeUnit.NANOSECONDS.convert(activeTime, TimeUnit.SECONDS)
                + System.nanoTime();
        this.data = data;
    }
	 /**
     * 时间获取
     */
    @Override
    public long getDelay(TimeUnit unit) {
        // 剩余时间= 到期时间-当前系统时间,系统一般是纳秒级的,所以这里做一次转换
        long d = unit.convert(activeTime - System.nanoTime(), TimeUnit.NANOSECONDS);
        return d;
    }
	 /**
     * 时间比较
     */
    @Override
    public int compareTo(Delayed o) {
        // 订单剩余时间-当前传入的时间= 实际剩余时间(单位纳秒)
        long d = getDelay(TimeUnit.NANOSECONDS) - o.getDelay(TimeUnit.NANOSECONDS);
        // 根据剩余时间判断等于0 返回0 不等于0
        // 有可能大于0 有可能小于0  大于0返回1  小于返回-1
        return (d == 0) ? 0 : ((d > 0) ? 1 : -1);
    }
}

1.2 延时队列构建:

DelayQueue<AddWxFriendVo<实体类>> delayQueue = new DelayQueue<AddWxFriendVo<实体类>>();
// 消费元素间隔时长
int intervalSecond =10;
AtomicInteger enumi = new AtomicInteger(0);
List.stream().forEach(e -> {
     // 创建队列中的业务类,参数一是延迟时间,参数二是实体
     AddWxFriendVo<FriendBaseDto> itemVoTb = null;
     if (1 == enumi.incrementAndGet()) {
         itemVoTb = new AddWxFriendVo<FriendBaseDto>(3, e);
     } else {
         itemVoTb = new AddWxFriendVo<FriendBaseDto>(enumi.getAndIncrement() * intervalSecond, e);
     }
     // 将业务类放入延迟队列
     if (null != itemVoTb) {
         delayQueue.offer(itemVoTb);
     }
 });

1.3 消费元素:

while (delayQueue.size() > 0) {
   实体类 friendBaseDto = null;
   try {
       AddWxFriendVo<实体类> take = delayQueue.take();
       实体类= take.getData();
       if (null != friendBaseDto) {
           // do something
       }
   } catch (Exception ex) {
       log.error("异常原因:{}",  ex.getMessage());
   }
}

2 延时队列源码:
源码对应版本jdk8
2.1 对于要使用的DelayQueue,先看下DelayQueue的 类图:
JAVA并发编程--7 延时队列DelayQueue_第1张图片
可见DelayQueue 是有阻塞队列 和delay 延时的特性;

2.2 DelayQueue的几个类属性:

private final transient ReentrantLock lock = new ReentrantLock();
	// 存放队列元素
    private final PriorityQueue<E> q = new PriorityQueue<E>();

    /**
     * 标识是否有线程抢占
     */
    private Thread leader = null;

    /**
     * 达到一定条件进入 condition 双向链表
     */
    private final Condition available = lock.newCondition();

2.3 向延时队列放入元素:
DelayQueue offer(E e) 方法:

public boolean offer(E e) {
    // 首先获取lock 锁,获取到则继续向下执行,
    // 如果没有获取到则进入AQS队列等待下一次被唤醒进行锁的抢占
    final ReentrantLock lock = this.lock;
    lock.lock();
    try {
    	// 放入队列元素
        q.offer(e);
        // 如果此时e 的过期时间是最小的或者改队列还没有元素 则返回放入的e元素
        if (q.peek() == e) {
        	// 证明e元素 此时是需要被优先进行检查并且出队列完成消费的,将leader 置空
        	// 允许线程可以优先消费到e 元素
            leader = null;
            // 使用signal 将condition 队列中的一个线程转移到AQS的队列尾部(如果condition 有元素的话)
            available.signal();
        }
        return true;
    } finally {
    	// 放入元素最后释放lock锁,唤醒AQS 的队列头部元素所在线程,去抢占锁
        lock.unlock();
    }
}

q.offer(e) 具体放入队列的方法:
PriorityQueue 类的几个属性:


	// 初始化的队列长度
	 private static final int DEFAULT_INITIAL_CAPACITY = 11;

    /** 由数组实现的队列
     * Priority queue represented as a balanced binary heap: the two
     * children of queue[n] are queue[2*n+1] and queue[2*(n+1)].  The
     * priority queue is ordered by comparator, or by the elements'
     * natural ordering, if comparator is null: For each node n in the
     * heap and each descendant d of n, n <= d.  The element with the
     * lowest value is in queue[0], assuming the queue is nonempty.
     */
    transient Object[] queue; // non-private to simplify nested class access

    /** 队列的元素个数
     * The number of elements in the priority queue.
     */
    private int size = 0;

    /** 队列中元素的比较器
     * The comparator, or null if priority queue uses elements'
     * natural ordering.
     */
    private final Comparator<? super E> comparator;

    /** 队列被修改的次数
     * The number of times this priority queue has been
     * structurally modified.  See AbstractList for gory details.
     */
    transient int modCount = 0; // non-private to simplify nested class access

PriorityQueue 类offer(E e)

 public boolean offer(E e) {
 //  放入元素时null 直接抛出异常
 if (e == null)
        throw new NullPointerException();
    // 记录队列改变的次数
    modCount++;
    // 队列的长度
    int i = size;
    // 如果队列长度不够进行扩容
    if (i >= queue.length)
        grow(i + 1);
    // 队列的长度+1
    size = i + 1;
   // 放入的第一个元素不需要进行过期时间的比较直接放入到最前面
    if (i == 0)
        queue[0] = e;
    else
    // 不是第一个元素需要通过比较判定元素存入的位置
        siftUp(i, e);
    return true;
}

siftUp 方法:

// k 当前数组长度的下标,x 本次要放入的元素
private void siftUp(int k, E x) {
     if (comparator != null)
         siftUpUsingComparator(k, x);
     else
     //  因为比较器是null(使用自己类中覆盖的 比较器) 进入此方法
         siftUpComparable(k, x);
 }

siftUpComparable(k, x);
将项x插入到位置k,通过将x向数组之前位置进行迁移,直到它大于或等于它的父结点下标元素,或者是下标0的元素, 从而保持队列的相对有序性。

 private void siftUpComparable(int k, E x) {
   // 将当前要存放的x元素赋值给key
   Comparable<? super E> key = (Comparable<? super E>) x;
    while (k > 0) {
    	// 先找到父的位置
        int parent = (k - 1) >>> 1;
        // 获取父位置下标的元素
        Object e = queue[parent];
        // 如果其过期时间比父下标元素长则直接跳出循环
        if (key.compareTo((E) e) >= 0)
            break;
        // 如果其过期时间比父元素下标要小,则进行元素的迁移
        // 先将父下标元素向后迁移,直到要放入的元素,比其父下标元素
        // 的过期时间要长;或者一直找到数组的第一个元素
        queue[k] = e;
        k = parent;
    }
    // 最终根据key 位置完成插入,此种插法,保证了每次放入元素,如果改元素过期时间较短
    // 可以被迁移到队列靠前位置,保证顺利消费
    queue[k] = key;
}

siftUpComparable 放入元素:

  • 放入第一个元素(如过期时间100) 队列长度为0 ,则可以直接放入此时queue【0】 = 100;
  • 放入第二元素 (如 过期时间 300) 队列此时长度为1 ,则放入时需要与(1-1)/2 =0 位置的父元素queue【0】 = 100 进行过期时间比较,显然300.>100 则直接跳出循环,将300 放入到 k(k=1) 位置,此时queue【0】 = 100;queue【1】 = 300;
  • 放入第三个元素 (如 过期时间 50) 队列此时长度为2 ,则放入时需要与(2-1)/2 =0 位置的父元素 queue【0】 = 100进行过期时间比较,显然50 <100 ,将100 放入到 k(k=2) 位置queue【2】 = 100,然后将parent =0 赋值给k,然后继续循环,再次循环发现k= 0 循环结束,将 queue[0] 放入 50, 此时queue【0】 =,50;queue【1】 = 300;queue【2】 = 100;
  • 依次放入元素时分别与 (当前数组长度-1)/2 的父类位置元素进行比较,如果比父类元素大则直接放入,否则需要将父类元素位置先后迁移后,继续依次迁移 ;最终保证了 在某个位置的元素其 下标*2 +1 位置元素的过期时间都要比父位置的过期时间要长,这样就保证了一定的顺序性;
  • siftUpComparable 虽然按照过期时间保证了一定的顺序,但是处理队列中的第一个元素确保是最小过期时间外 ,其他后续位置的过期时间只是比其父元素的过期时间要长,并没有保证相邻元素过期时间的顺序性;所以如果按照过期时间分别为 100 500,200 的元素放入,队列此时的元素依次就是 100 ,500,200 虽然200明显要比500 小但是并没有放入的队列的前端。

既然队列中只有第一个元素是过期时间最短的,那么在消费的时候怎么保证,按照过期时间的长短完成消费:

2.4 从队列中获取元素:
DelayQueue类中的take 方法:消费并移除队列头部节点

public E take() throws InterruptedException {
	// 可以响应中断的获取锁
   final ReentrantLock lock = this.lock;
    lock.lockInterruptibly();
    try {
        for (;;) {
        	// 死循环获取队列元素
        	// 取的队列的第一个元素
            E first = q.peek();
            // 队列为空则 将当前线程加入到condition 队列中
            if (first == null)
                available.await();
            else {
            	// 队首元素的过期时间获取
                long delay = first.getDelay(NANOSECONDS);
                if (delay <= 0)
                	// 队首元素已经达到了过期时间则 弹出元素,跳出循环
                    return q.poll();
                 // 队首元素还米有到过期时间,将该引用置空,准备进行当前线程进入condition的准备
                first = null; // don't retain ref while waiting
                if (leader != null)
                	// 当前队列有线程竞争,则直接将当前线程加入到condition 队列中
                    available.await();
                else {
                	// 当前没有线程竞争
                    Thread thisThread = Thread.currentThread();
                    // 现将主导标识设置为当前线程
                    leader = thisThread;
                    try {
                    	// awaitNanos 等待,加入到能够抢占锁的AQS队列中
                    	// 在等待加入到AQS 队列期间,释放掉持有的lock 锁
                        available.awaitNanos(delay);
                    } finally {
                    	// 当线程获取到锁,发现当前没有线程竞争 队列资源,
                    	// 当前线程等待的任务已经完成,则将leader 置空
                    	// 方便其他线程竞争 ,然后进行下次循环从队列中 获取元素进行消费
                        if (leader == thisThread)
                            leader = null;
                    }
                }
            }
        }
    } finally {
        if (leader == null && q.peek() != null)
        	// 当前线程没有竞争,并且队列还有元素 signal() 将condition线程转移至AQS队列
        	// 让其抓紧时间干活去抢占锁,消费队列元素,不要闲着
            available.signal();
         // 获取元素然后释放锁
        lock.unlock();
    }
}

available.await() 方法可以参考:JAVA并发编程–4.2理解Condition
这里跟下 available.awaitNanos(delay); 方法:
AbstractQueuedSynchronizer 中的awaitNanos(long nanosTimeout)

 public final long awaitNanos(long nanosTimeout)
                throws InterruptedException {
  if (Thread.interrupted())
        throw new InterruptedException();
     //  构建Condition单向链表,将当前节点加入到此单向链表中
    Node node = addConditionWaiter();
    // 完全释放持有的锁
    int savedState = fullyRelease(node);
    final long deadline = System.nanoTime() + nanosTimeout;
    int interruptMode = 0;
    while (!isOnSyncQueue(node)) {
       // 如果当前node 节点只在Condition单向链表 不在AQS 同步阻塞队列中,则返回false,进入此while 循环
        if (nanosTimeout <= 0L) {
        // 如果该元素已经到达过期时间,则直接加入到AQS 队列
            transferAfterCancelledWait(node);
            break;
        }
        // 如果发现过期时间大于1s 则直接park 然后cpu 的资源
        //  static final long spinForTimeoutThreshold = 1000L;
        if (nanosTimeout >= spinForTimeoutThreshold)
            LockSupport.parkNanos(this, nanosTimeout);
        if ((interruptMode = checkInterruptWhileWaiting(node)) != 0)
            break;// 当前线程中断则跳出循环
        // 每次都重新计算该元素的过期时间
        nanosTimeout = deadline - System.nanoTime();
    }
    //   在AQS 同步队列中唤醒的node 节点去抢占锁
    if (acquireQueued(node, savedState) && interruptMode != THROW_IE)
        interruptMode = REINTERRUPT;
    if (node.nextWaiter != null)
        unlinkCancelledWaiters();//  将Condition单向链表中年已经是取消状态的线程从队列中剔除
    if (interruptMode != 0)
        reportInterruptAfterWait(interruptMode);// // 线程中断标记
     // 返回过期的剩余时间
    return deadline - System.nanoTime();
}

前面说过放入队列的元素除了第一个保证其是过期时间最短的,队列后面的元素的过期时间并不是从小到大完成排列的,那么怎么保证消费的有序性,答案在每次取出元素的poll() 方法:
PriorityQueue 类中的poll() 方法:

public E poll() {
     if (size == 0)
         return null;
     // 将队列长度-1
     int s = --size;
     // 修改次数+1
     modCount++;
     // 得到队列的头部元素
     E result = (E) queue[0];
     // 得到队列的最后一个元素
     E x = (E) queue[s];
     // 将队列的最后一个元素置null
     queue[s] = null;
     if (s != 0)// 如果队列还有元素,则进行元素迁移确保其消费的有序性
         siftDown(0, x);
     // 返回本次要消费的队列头部元素
     return result;
 }

siftDown:
通过不断向前查找父下标元素,进行大小比较和交换,保证队列要消费的下一个元素是队列中过期时间最小的元素;

// k =0 ;x 队列尾部最后一个元素
private void siftDown(int k, E x) {
    if (comparator != null)
        siftDownUsingComparator(k, x);
    else
    	// comparator  为null 直接进入该方法
        siftDownComparable(k, x);
}

siftDownComparable:

// 初始时 k =0 ;x 队列尾部最后一个元素
 private void siftDownComparable(int k, E x) {
 	// 初始时将队列的最后一个元素赋值给key
    Comparable<? super E> key = (Comparable<? super E>)x;
    // 得到当前队列 父下标元素位置
    int half = size >>> 1;        // loop while a non-leaf
    while (k < half) {// 最多迁移的元素次数为k < half
    	// 当前k 下标做运算:k*2 +1  获取其子元素的第一个下标
        int child = (k << 1) + 1; // assume left child is least
        // c 获取到第一个子元素
        Object c = queue[child];
        // right  标识紧挨子元素的下一个元素
        int right = child + 1;
        // 如果right 下标还没有达到数组的末尾,在对孩子下标的两个位置元素进行过期时间比较
        // c = 队列中本次对应的两个子元素过期时间小的元素
        // 如果两个子元素,前一个过期时间大于后一个,则将后一个的元素赋值给C 否则赋值前一个元素
        if (right < size &&
            ((Comparable<? super E>) c).compareTo((E) queue[right]) > 0)
            c = queue[child = right];
         // 额外比较下 末尾的元素和本次获取到的过期时间最小的元素,进行过期时间比较
         // 如果末尾元素比改元素还有早过期,则直接跳出循环
        if (key.compareTo((E) c) <= 0)
            break;
        // 将较元素迁移至父下标元素(因为原始的父下标元素已经进行了向前迁移,则需要
        // 后面对应的元素依次先前迁移占据其父下边元素的位置)
        queue[k] = c;
        // 每次都将较小过期时间元素的下标赋值给k ,进行进行下一次循环完成元素迁移
        k = child;
    }
    // 最终将最后一个key元素放入队列中
    queue[k] = key;
}

siftDownComparable 数据迁移方法:

  • 在第一次循环中已经将最小过期时间的元素迁移至 queue【0】下标的位置,后续的迁移工作都是为了将之前迁移到 queue【0】,原有的下标位置元素完成覆盖;
  • 每次在take 获取到头部元素后,因为队列元素放入的特性queue【1】,queue【2】的元素的过期时间都要比队列后面的过期时间要短,所以每次只要对比,队里下标1,2 的元素,进行过期时间最小的获取,实际上就获得了队列中所有元素过期时间最小的元素,并将其放入到 队列的头部,便于后续线程的元素消费;

3 总结:

  • 在放入和消费队列元素时,每次都使用lock 获取锁之后进行操作;
  • 放入队列中的元素只保证了队列头部元素的过期时间是最小的,和后续每个下标在 ,父下标2+1 和 父下标2+2 两个位置下标的元素一定大于其父下标元素的过期时间,并没有保证两个相邻元素过期时间的大小排序,也没有关注队列的整体过期时间的大小排序;
  • 为了消费到的元素都是按照过期时间从小到大进行的,就需要在每次消费掉队列头部元素之后,都要对下标位置为1,和2的元素,通过比较获取到过期时间最小的并将其放入到队列的头部位置,而移走后原有位置的元素 就需要 队列长度/2 次迁移将移走后原有位置的元素 完成覆盖;

你可能感兴趣的:(java基础篇,java工具篇,java,开发语言)