Java多线程第十四篇--盘一盘延迟队列DelayQueue的原理

756 阅读5分钟

我正在参与掘金技术社区创作者签约计划招募活动,点击链接报名投稿

Hello,大家好!会飞的鱼来了~

在之前的篇章中,我们一起学习过阻塞队列BlockingQueue及其典型的实现类ArrayBlockingQueue,本篇我们来一起学点简单点的阻塞实现类 - 延迟队列DelayQueue

简单介绍

我们平时在网上刷一些博客文章的时候,经常看到这样的标题“如何实现延迟队列”,里面会讲很多实现延迟队列的方案,其中就有利用原生JDK工具类DelayQueue的方案介绍。

DelayQueue

  • 首先它是一个无界的阻塞队列
  • 其内部维护了一个优先队列PriorityQueue,用来存放即将要到期的元素(就是延迟任务
  • 该队列不允许存放空的元素
  • 队列中的元素只有在其延迟时间到了后才能被消费者消费
  • 队列中队首位置的元素就是优先级最小(即将过期)的元素
  • 取队列中的元素是从头部开始,如果取出的元素还没到期,则返回空或者阻塞等待直到元素到期
  • 队列中维护了一个领导者线程,用来等待头部元素到期的,以减少不必要的等待时间,即时间一到,这个领导者线程就会获取到头部任务,从而唤醒其他等待的消费者线程
  • 延迟队列采取了Leader-Follower的原理模式来进行取元素的

源码结构介绍

image.png 其源码实现其实很简单,和之前提到的阻塞队列一样

  • 继承了AbstractQueue和实现了BlockingQueue
  • 利用ReentrantLock来控制操作同步性,保证了线程安全
  • 用available = lock.newCondition()等待队列,存放等待延迟到期任务的消费者线程
  • 同样的实现了存offer和取take方法

DelayQueue的内部元素需要实现Delayed接口,如下: image.png 其还继承了Comparable接口,主要用于排序优先级的实现

源码看过来

offer方法

public boolean offer(E e) {
        final ReentrantLock lock = this.lock;
        lock.lock();
        try {
            //调用优先队列的offer方法,存入元素
            q.offer(e);
            //判断队头是否就是当前元素,是的话,则设置领导者为空,并唤醒一个等待的消费者
            if (q.peek() == e) {
                leader = null;
                available.signal();
            }
            return true;
        } finally {
            lock.unlock();
        }
}

很简单,没什么可以讲的

take方法

     /**
     * Retrieves and removes the head of this queue, waiting if necessary
     * until an element with an expired delay is available on this queue.
     *从对首获取元素,如果元素还没到期,则等待,直到延迟到期
     * @return the head of this queue
     * @throws InterruptedException {@inheritDoc}
     */
    public E take() throws InterruptedException {
        final ReentrantLock lock = this.lock;
        lock.lockInterruptibly();
        try {
            /**
            注意这里是for死循环,又是个自旋的操作,老套路了
            */
            for (;;) {
                //取出队首,但是这边不移除
                E first = q.peek();
                //如果取不到,则等待
                if (first == null)
                    //A
                    available.await();
                else {
                    //否则,看取出的头部元素是否已经到期
                    long delay = first.getDelay(NANOSECONDS);
                    //如果到期,则取出元素任务,返回这个延迟任务对象
                    if (delay <= 0)
                        return q.poll();
                    //没到期的话,将first再次置为空
                    first = null; // don't retain ref while waiting
                    /**
                    判断领导者是否为空,如果不为空,说明什么?
                    说明在这个线程之前,就已经有线程成为了领导者,且执行了下面else分支的代码
                    如果领导者不为空,则当前消费者加入等待队列
                    如果为空,则把当前消费者线程作为领导者,并且等待剩下的延迟时长,到时间了,会进入
                    到下面代码1处的finally代码块,如果领导者还是当前线程的话,则释放,然后
                    进入到下一次的循环,这时就会走到上面的代码,然后取到元素返回
                    最后再走2处的finally代码块,通知下一个消费者(如果当前领导者为空且队列里
                    还有元素的话)
                    */
                    if (leader != null)
                        //B
                        available.await();
                    else {
                        Thread thisThread = Thread.currentThread();
                        leader = thisThread;
                        try {
                            available.awaitNanos(delay);
                        } finally {
                            //1
                            if (leader == thisThread)
                                leader = null;
                        }
                    }
                }
            }
        } finally {
            //2
            if (leader == null && q.peek() != null)
                available.signal();
            lock.unlock();
        }
    }

这段代码流程总结如下:

  • 消费者来取延迟任务,如果取不到则加入等待队列等待
  • 如果取到了,再看取出的元素还有没有到期,如果还没到期,就看当前领导者是否为空
    • 如果不为空,则说明当前的头部元素已经有线程再等待了,则直接将当前线程加入到等待队列等待
    • 如果为空,则将当前线程置为领导者,并进行延迟等待头部对象剩下的时间,主动唤醒后,领导者主动将自己置为空,当前线程进入下一次的循环再次取元素,这时肯定是能够取到的,并且最后唤醒等待队列的下一个消费者。
  • 领导者Leader,有一种将队头元素“包圆”的一种感觉,减少了一定的竞争,如果取到任务了,则唤醒下一个消费者,下一个消费者被唤醒后(即上面代码A,B两处),进入下一次的循环,有可能被提拔为下一任领导者
  • 在和延迟队列打交道的消费者线程中,一共分为三种:
    • 第一个直接等待,即Leader-Follower模式中的Follower,等待领导者唤醒
    • 第二个成为领导者Leader,即将要执行任务
    • 第三种,就是取到任务,正在执行的消费者

分析到这里,其实延迟队列的源码分析也就差不多了。口说无凭,下面我将举个场景例子,写个demo,来具体看看延迟队列到底是如何使用的吧。

示例

场景描述:有一个生成订单的流水线服务,一直创建并生成订单,每个订单都延迟一定的时间(随机)才会执行,以减少服务器的压力。

public class DelayQueueDemo {

    public static void main(String[] args) throws InterruptedException {
	DelayQueue<MyDelay<String>> queue = new DelayQueue<>();

	//new 10个线程,放延迟任务至队列中
	for (int i = 0; i < 10; i++) {
            int j = i;
            new Thread(new Runnable() {

		@Override
		public void run() {
                    Random random = new Random();
                    int time = (random.nextInt(3) + 5);
                    MyDelay<String> task = new MyDelay<String>(time * 1000, String.valueOf(j));
                    queue.offer(task);
		}
            }).start();
	}
	//任务通过线程池来执行
	ExecutorService executorService = Executors.newSingleThreadExecutor();
	// 睡眠2秒,确保上面的延迟任务加入队列中
	Thread.sleep(2000);
	//主线程一直循环取任务
	for (;;) {
            System.out.println("取任务中...");
            MyDelay<String> task = queue.take();
            if (task != null) {
		executorService.execute(task);
            }
	}
    }

    static class MyDelay<T> implements Delayed, Runnable {
	// 延迟时间
	private long delayTime;
	// 到期时间
	private long expire;
	// 执行的任务参数
	private T t;

	public MyDelay(long delayTime, T t) {
            this.delayTime = delayTime;
            this.t = t;
            // 初始化到期时间
            this.expire = System.currentTimeMillis() + this.delayTime;
	}

        // 获取剩余的延迟时间,用到期时间减去当前时间
	@Override
	public long getDelay(TimeUnit unit) {
            return unit.convert(this.expire - System.currentTimeMillis(), TimeUnit.MILLISECONDS);
	}

	// 比较时间,小的排前面
	@Override
	public int compareTo(Delayed o) {
            return (int) (this.getDelay(TimeUnit.MILLISECONDS) - o.getDelay(TimeUnit.MILLISECONDS));
	}

	@Override
	public void run() {
            System.out.println("开始执行任务" + t.toString());
            Random random = new Random();
            int time = (random.nextInt(3) + 1);
            for (int i = 0; i < time; i++) {
                try {
                    Thread.sleep(1000);
		} catch (InterruptedException e) {
                    e.printStackTrace();
		}
		System.out.println("执行任务" + t.toString() + "中...");
            }
	}

    }
}

效果如下:

取任务中...
取任务中...
取任务中...
取任务中...
取任务中...
开始执行任务3
取任务中...
执行任务3中...
取任务中...
取任务中...
取任务中...
取任务中...
取任务中...
执行任务3中...
执行任务3中...
开始执行任务1
执行任务1中...
开始执行任务9
执行任务9中...
开始执行任务8
执行任务8中...
执行任务8中...
执行任务8中...
开始执行任务5
执行任务5中...
执行任务5中...
执行任务5中...
开始执行任务7
执行任务7中...
开始执行任务4
执行任务4中...
执行任务4中...
执行任务4中...
开始执行任务2
执行任务2中...
开始执行任务6
执行任务6中...
执行任务6中...
开始执行任务0
执行任务0中...
执行任务0中...
执行任务0中...

使用场景介绍

  • 延迟任务,比如周期性的定时任务延迟一定时间执行等
  • 超时订单,订单支付超时失效
  • 重试机制(每次时间都一样/时间递减等形式)
  • 自定义缓存失效机制
  • 消息延迟发送
  • ...还有其他很多,小伙伴们可以举例吗

本篇到此就结束了,你学会了吗?~ 文字偏多,感谢阅读,认可的话,请点个赞吧~ 哈哈

我正在参与掘金技术社区创作者签约计划招募活动,点击链接报名投稿