延时队列DalayQueue的源码分析
一、前言
DalayQueue
是Java并发包中的一个队列,是优先级队列实现的无界阻塞队列,它提供了一种先进先出(FIFO)的数据结构,并且允许在元素达到期望的时间后获取或删除它们。下图展示了DalayQueue
的继承、接口及属性关系:
下面是DelayQueue
类的主要成员:
private final transient ReentrantLock lock = new ReentrantLock();
private final PriorityQueue<E> q = new PriorityQueue<E>();
private Thread leader = null;
private final Condition available = lock.newCondition();
如上图所示DelayQueue
实现了阻塞队列BlockingQueue
,DelayQueue
中内部使用的是PriorityQueue
存放数据,其元素实现了Delayed
接口,而Delayed
继承了Comparable
接口,compareTo
方法用于元素比较; getDelay
方法用于获取元素剩余延时时间,以实现PriorityQueue
内元素排序。使用ReentrantLock
实现线程同步,使用Condition
实现延时等待。使用变量leader
来判断线程占有情况。
public interface Delayed extends Comparable<Delayed> {
long getDelay(TimeUnit unit);
}
public interface Comparable<T> {
public int compareTo(T o);
}
二、源码分析
2.1 新增元素
public boolean add(E e) {
return offer(e);
}
public boolean offer(E e) {
final ReentrantLock lock = this.lock;
lock.lock();
try {
q.offer(e);
if (q.peek() == e) {
leader = null;
available.signal();
}
return true;
} finally {
lock.unlock();
}
}
public void put(E e) {
offer(e);
}
public boolean offer(E e, long timeout, TimeUnit unit) {
return offer(e);
}
可以看出不管是add
还是put
都是调用offer
来进行添加元素的,这里通过ReentrantLock
来获取锁,如果队列首部为当前元素,则设置leader
为null
,并唤醒等待线程。
2.2 获取元素
2.2.1 poll函数
只取过期的元素,当队列中没有过期元素时,会返回null
。
public E poll() {
final ReentrantLock lock = this.lock;
lock.lock();
try {
E first = q.peek();
if (first == null || first.getDelay(NANOSECONDS) > 0)
return null;
else
return q.poll();
} finally {
lock.unlock();
}
}
2.2.2 take函数
在获取元素时,只能一个线程获取,如果队首元素已过期,则取出;如果队首元素没有过期,看leader是否为null,不为null则说明是其他线程也在执行take()则把当前线程放入条件队列,否则就是只有当前线程在执行take()方法,则当前线程await直到剩余过期时间到,这期间该线程会释放锁,所以其他线程可以offer()添加元素,也可以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);
if (delay <= 0)
// 元素过期时取出元素
return q.poll();
first = null;
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();
lock.unlock();
}
}
2.2.3 drainTo函数
将超时的元素排放到集合中,返回获取数量。
public int drainTo(Collection<? super E> c) {
if (c == null)
throw new NullPointerException();
if (c == this)
throw new IllegalArgumentException();
final ReentrantLock lock = this.lock;
lock.lock();
try {
int n = 0;
// 获取超时的元素
for (E e; (e = peekExpired()) != null;) {
c.add(e);
q.poll();
++n;
}
return n;
} finally {
lock.unlock();
}
}
三、优缺点
- 优点
- 简单易用
- 自动排序
- 线程安全
- 内部元素有”延迟”特性:只有延迟到期的元素才允许被获取
- 缺点
- 如果队列中的元素数量较多,那么获取元素时的效率会较低,因为每次出队操作都需要对队列进行排序。
- 它的容量是无界的,如果队列中的元素数量非常大,可能会消耗大量内存,甚至导致内存溢出。
- 数据无持久化机制,系统宕机时会出现数据丢失。
四、改造思路
- 可以考虑使用
redis
来进行持久化,使用zset
来进行时间排序。优先队列的peek
可用zset
的zrange
替代,poll
可用zpopmin
替代。可解决缺点1/3问题。当然除了使用redis也可以用其他的数据库来进行替代,这里只是提出一种方案。 - 可以考虑在
offer
时进行队列大小判断,在超出一定大小时抛出异常。可解决缺点2问题。