阿里面试官:你了解过延迟队列DelayQueue的底层实现原理吗?

欢迎学习《解读Java源码专栏》,在这个系列中,我将手把手带着大家剖析Java核心组件的源码,内容包含集合、线程、线程池、并发、队列等,深入了解其背后的设计思想和实现细节,轻松应对工作面试。

这是解读Java源码系列的第13篇,将跟大家一起学习Java中的阻塞队列 - DelayQueue。

引言

DelayQueue是一种本地延迟队列,比如希望我们的任务在5秒后执行,就可以使用DelayQueue实现。常见的使用场景有:

  • 订单10分钟内未支付,就取消。
  • 缓存过期后,就删除。
  • 消息的延迟发送等。

但是DelayQueue是怎么使用的?底层原理是什么样的?如果有多个任务是怎么排队的? 看完这篇文章,可以轻松解答这些问题。

由于DelayQueue实现了BlockingQueue接口,而BlockingQueue接口中定义了几组放数据和取数据的方法,来满足不同的场景。

操作 抛出异常 返回特定值 一直阻塞 阻塞指定时间
放数据 add() offer() put() offer(e, time, unit)
取数据(同时删除数据) remove() poll() take() poll(time, unit)
取数据(不删除) element() peek() 不支持 不支持

这四组方法的区别是:

  1. 当队列满的时候,再次添加数据,add()会抛出异常,offer()会返回false,put()会一直阻塞,offer(e, time, unit)会阻塞指定时间,然后返回false。
  2. 当队列为空的时候,再次取数据,remove()会抛出异常,poll()会返回null,take()会一直阻塞,poll(time, unit)会阻塞指定时间,然后返回null。

类结构

先看一下DelayQueue类里面有哪些属性:

java 复制代码
public class DelayQueue<E extends Delayed>
        extends AbstractQueue<E>
        implements BlockingQueue<E> {

    /**
     * 排它锁,用于保证线程安全
     */
    private final transient ReentrantLock lock = new ReentrantLock();

    /**
     * 底层是基于PriorityQueue实现
     */
    private final PriorityQueue<E> q = new PriorityQueue<E>();

    /**
     * 当前线程
     */
    private Thread leader = null;

    /**
     * 条件队列
     */
    private final Condition available = lock.newCondition();

}

DelayQueue实现了BlockingQueue接口,是一个阻塞队列。并且DelayQueue里面的元素需要实现Delayed接口。使用了ReentrantLock保证线程安全,使用了Condition作条件队列,当队列中没有过期元素的时候,取数据的线程需要在条件队列中等待。

java 复制代码
public interface Delayed extends Comparable<Delayed> {

    /**
     * 返回剩余过期时间
     */
    long getDelay(TimeUnit unit);
}

初始化

DelayQueue常用的初始化方法有两个,无参构造方法和指定元素集合的有参构造方法。

java 复制代码
/**
 * 无参构造方法
 */
public DelayQueue() {
}

/**
 * 指定元素集合
 */
public DelayQueue(Collection<? extends E> c) {
    this.addAll(c);
}

使用示例

先定义一个延迟任务,需要实现Delayed接口,并重写getDelay()和compareTo()方法。

java 复制代码
/**
 * @author 一灯架构
 * @apiNote 自定义延迟任务
 **/
public class DelayedTask implements Delayed {

    /**
     * 任务到期时间
     */
    private long expirationTime;
    
    /**
     * 任务
     */
    private Runnable task;

    public void execute() {
        task.run();
    }

    public DelayedTask(long delay, Runnable task) {
        // 到期时间 = 当前时间 + 延迟时间
        this.expirationTime = System.currentTimeMillis() + delay;
        this.task = task;
    }

    /**
     * 返回延迟时间
     */
    @Override
    public long getDelay(@NotNull TimeUnit unit) {
        return unit.convert(expirationTime - System.currentTimeMillis(), TimeUnit.MILLISECONDS);
    }

    /**
     * 任务列表按照到期时间排序
     */
    @Override
    public int compareTo(@NotNull Delayed o) {
        return Long.compare(this.expirationTime, ((DelayedTask) o).expirationTime);
    }
}

测试运行延迟任务

java 复制代码
/**
 * @author 一灯架构
 * @apiNote DelayQueue测试类
 **/
@Slf4j
public class DelayQueueTest {

    public static void main(String[] args) throws InterruptedException {
        // 初始化延迟队列
        DelayQueue<DelayedTask> delayQueue = new DelayQueue<>();

        // 添加3个任务,延迟时间分别是3秒、1秒、5秒
        delayQueue.add(new DelayedTask(3000, () -> log.info("任务2开始运行")));
        delayQueue.add(new DelayedTask(1000, () -> log.info("任务1开始运行")));
        delayQueue.add(new DelayedTask(5000, () -> log.info("任务3开始运行")));

        // 运行任务
        log.info("开始运行任务");
        while (!delayQueue.isEmpty()) {
            //阻塞获取最先到期的任务
            DelayedTask task = delayQueue.take();
            task.execute();
        }
    }
}

输出结果:

java 复制代码
10:30:10.000 [main] INFO com.yideng.DelayQueueTest - 开始运行任务
10:30:11.000 [main] INFO com.yideng.DelayQueueTest - 任务1开始运行
10:30:13.000 [main] INFO com.yideng.DelayQueueTest - 任务2开始运行
10:30:15.000 [main] INFO com.yideng.DelayQueueTest - 任务3开始运行

可以看出,运行任务的时候,会按照任务的到期时间进行排序,先到期的任务先运行。如果没有到期的任务,调用take()方法的时候,会一直阻塞。 然后再看一下源码实现,先看放数据的几组方法。

放数据源码

放数据的方法有四个:

操作 抛出异常 返回特定值 阻塞 阻塞一段时间
放数据 add() offer() put() offer(e, time, unit)

offer方法源码

先看一下offer()方法源码,其他放数据方法逻辑也是大同小异。 offer()方法在队列满的时候,会直接返回false,表示插入失败。

java 复制代码
/**
 * offer方法入口
 *
 * @param e 元素
 * @return 是否插入成功
 */
public boolean offer(E e) {
    // 1. 获取锁
    final ReentrantLock lock = this.lock;
    lock.lock();
    try {
        // 2. 直接调用PriorityQueue的offer方法
        q.offer(e);
        // 3. 如果是第一次放数据,需要唤醒调用take方法阻塞的线程
        if (q.peek() == e) {
            leader = null;
            available.signal();
        }
        return true;
    } finally {
        // 4. 释放锁
        lock.unlock();
    }
}

DelayQueue的offer()方法底层是基于PriorityQueue的offer()方法实现的,而PriorityQueue内部实现了排序任务的功能,详细源码可以翻一下前面的文章。 由于PriorityQueue的offer()方法实现了队列自动扩容,所以正常情况都会添加数据成功。

再看一下另外三个添加元素方法源码:

add方法源码

add()方法底层基于offer()实现,逻辑相同。

java 复制代码
/**
 * add方法入口
 *
 * @param e 元素
 * @return 是否添加成功
 */
public boolean add(E e) {
    return offer(e);
}

put方法源码

put()方法底层也是基于offer()实现,逻辑相同。

java 复制代码
/**
 * put方法入口
 *
 * @param e 元素
 */
public void put(E e) {
    offer(e);
}

offer(e, time, unit)源码

offer(e, time, unit)方法底层也是基于offer()实现,逻辑相同,并没有实现阻塞指定时间的功能,设计源码的这帮人也留了一堆坑「尴尬」。

java 复制代码
/**
 * offer方法入口
 *
 * @param e       元素
 * @param timeout 超时时间
 * @param unit    时间单位
 * @return 是否添加成功
 */
public boolean offer(E e, long timeout, TimeUnit unit) {
    return offer(e);
}

弹出数据源码

弹出数据(取出数据并删除)的方法有四个:

操作 抛出异常 返回特定值 阻塞 阻塞一段时间
取数据(同时删除数据) remove() poll() take() poll(time, unit)

poll方法源码

看一下poll()方法源码,其他方取数据法逻辑大同小异,都是从链表头部弹出元素。 poll()方法在弹出元素的时候,如果队列为空,直接返回null,表示弹出失败。

java 复制代码
/**
 * poll方法入口
 */
public E poll() {
    // 1. 获取锁
    final ReentrantLock lock = this.lock;
    lock.lock();
    try {
        // 2. 获取队头元素
        E first = q.peek();
        // 3. 如果队头元素为空,或者还没有过期,则返回null
        if (first == null || first.getDelay(NANOSECONDS) > 0) {
            return null;
        } else {
            // 5. 否则返回队头元素
            return q.poll();
        }
    } finally {
        // 4. 释放锁
        lock.unlock();
    }
}

remove方法源码

再看一下remove()方法源码,如果队列为空,remove()会抛出异常。

java 复制代码
/**
 * remove方法入口
 */
public E remove() {
    // 1. 直接调用poll方法
    E x = poll();
    // 2. 如果取到数据,直接返回,否则抛出异常
    if (x != null) {
        return x;
    } else {
        throw new NoSuchElementException();
    }
}

take方法源码

再看一下take()方法源码,如果队列为空,take()方法就一直阻塞,直到被唤醒。

java 复制代码
/**
 * take方法入口
 */
public E take() throws InterruptedException {
    // 1. 加锁,加可中断的锁
    final ReentrantLock lock = this.lock;
    lock.lockInterruptibly();
    try {
        for (;;) {
            // 2. 获取队头元素
            E first = q.peek();
            // 3. 如果队头元素为null,则阻塞等待
            if (first == null) {
                available.await();
            } else {
                // 4. 如果队头元素不为null,则获取队头元素延迟时间
                long delay = first.getDelay(NANOSECONDS);
                // 5. 如果延迟时间小于等于0,表示已到期,则返回队头元素
                if (delay <= 0) {
                    return q.poll();
                }
                first = null;
                // 6. 如果队头元素未到期,则阻塞等待
                if (leader != null) {
                    available.await();
                } else {
                    Thread thisThread = Thread.currentThread();
                    leader = thisThread;
                    try {
                        available.awaitNanos(delay);
                    } finally {
                        if (leader == thisThread)
                            leader = null;
                    }
                }
            }
        }
    } finally {
        if (leader == null && q.peek() != null) {
            available.signal();
        }
        // 7. 释放锁
        lock.unlock();
    }
}

当队列为空的时候,take()方法会一直阻塞,所以需要加可中断的锁。take()方法逻辑也很简单,就是判断队头元素,如果队头元素存在并且已到期,直接返回,否则就阻塞等待。

poll(time, unit)源码

再看一下poll(time, unit)方法源码,在队列满的时候, poll(time, unit)方法会阻塞指定时间,然后然后null。

java 复制代码
/**
 * poll方法入口
 *
 * @param timeout 超时时间
 * @param unit    时间单位
 * @return 元素
 */
public E poll(long timeout, TimeUnit unit) throws InterruptedException {
    long nanos = unit.toNanos(timeout);
    // 1. 加锁,加可中断的锁
    final ReentrantLock lock = this.lock;
    lock.lockInterruptibly();
    try {
        for (; ; ) {
            // 2. 获取队头元素
            E first = q.peek();
            // 3. 如果队头元素为null,则阻塞等待
            if (first == null) {
                if (nanos <= 0) {
                    return null;
                } else {
                    nanos = available.awaitNanos(nanos);
                }
            } else {
                // 4. 如果队头元素不为null,则获取队头元素延迟时间
                long delay = first.getDelay(NANOSECONDS);
                // 5. 如果延迟时间小于等于0,表示已到期,则返回队头元素
                if (delay <= 0) {
                    return q.poll();
                }
                if (nanos <= 0) {
                    return null;
                }
                first = null;
                // 6. 如果队头元素未到期,则阻塞等待
                if (nanos < delay || leader != null) {
                    nanos = available.awaitNanos(nanos);
                } else {
                    Thread thisThread = Thread.currentThread();
                    leader = thisThread;
                    try {
                        long timeLeft = available.awaitNanos(delay);
                        nanos -= delay - timeLeft;
                    } finally {
                        if (leader == thisThread)
                            leader = null;
                    }
                }
            }
        }
    } finally {
        if (leader == null && q.peek() != null)
            available.signal();
        // 7. 释放锁
        lock.unlock();
    }
}

poll(time, unit)与上面的take()方法逻辑类似,区别是,当队列为空的时候,take()方法会一直阻塞,poll(time, unit)方法只会阻塞指定时间。

查看数据源码

再看一下查看数据源码,查看数据,并不删除数据。

操作 抛出异常 返回特定值 阻塞 阻塞一段时间
取数据(不删除) element() peek() 不支持 不支持

peek方法源码

先看一下peek()方法源码,如果数组为空,直接返回null,底层基于PriorityQueue的peek()方法实现。

java 复制代码
/**
 * peek方法入口
 */
public E peek() {
    final ReentrantLock lock = this.lock;
    lock.lock();
    try {
        return q.peek();
    } finally {
        lock.unlock();
    }
}

element方法源码

再看一下element()方法源码,如果队列为空,则抛出异常,底层直接使用的peek()方法。

java 复制代码
/**
 * element方法入口
 */
public E element() {
    // 1. 调用peek方法查询数据
    E x = peek();
    // 2. 如果查到数据,直接返回
    if (x != null) {
        return x;
    } else {
        // 3. 如果没找到,则抛出异常
        throw new NoSuchElementException();
    }
}

总结

这篇文章讲解了DelayQueue阻塞队列的核心源码,了解到DelayQueue队列具有以下特点:

  1. DelayQueue实现了BlockingQueue接口,提供了四组放数据和读数据的方法,来满足不同的场景。
  2. DelayQueue底层采用组合的方式,复用PriorityQueue的按照延迟时间排序任务的功能,实现了延迟队列。
  3. DelayQueue是线程安全的,内部使用ReentrantLock加锁。

今天一起分析了DelayQueue队列的源码,可以看到DelayQueue的源码非常简单,没有什么神秘复杂的东西,下篇文章再一起接着分析其他的阻塞队列源码。

相关推荐
天行健的回响几秒前
枚举在实际开发中的使用小Tips
后端
on the way 1233 分钟前
行为型设计模式之Mediator(中介者)
java·设计模式·中介者模式
保持学习ing5 分钟前
Spring注解开发
java·深度学习·spring·框架
wuhunyu6 分钟前
基于 langchain4j 的简易 RAG
后端
techzhi6 分钟前
SeaweedFS S3 Spring Boot Starter
java·spring boot·后端
异常君31 分钟前
Spring 中的 FactoryBean 与 BeanFactory:核心概念深度解析
java·spring·面试
weixin_4612594144 分钟前
[C]C语言日志系统宏技巧解析
java·服务器·c语言
cacyiol_Z1 小时前
在SpringBoot中使用AWS SDK实现邮箱验证码服务
java·spring boot·spring
竹言笙熙1 小时前
Polarctf2025夏季赛 web java ez_check
java·学习·web安全
写bug写bug1 小时前
手把手教你使用JConsole
java·后端·程序员