原创

DelayQueue 非空却取不到元素:到期资格与缓存续期分开判断

DelayQueue里有一百条记录,poll却返回null,这不一定是数据丢了。size包含尚未到期的元素,poll只交出已经取得取出资格的头部。到期之前,队列可以非空,却没有任何一项现在能被消费。

把它用于进程内缓存清理时,这种行为很合适:保存一份“将来可能需要检查”的提示,到时再核对当前状态。但把它直接当作可靠延迟任务系统,就少了持久保存、执行确认、故障恢复和重复处理等协议。一个内存队列不能仅靠名字解决这些问题。

不靠等待碰运气,先把时间变成受控输入

下面程序使用Java 17,自定义时钟只在测试里前进,非阻塞poll根据这个时钟判断到期。它不是对真实调度精度的测试,也没有让take使用虚拟时间等待。保存为ExpiryHints.java,执行java ExpiryHints.java。

import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicLong;
import java.util.function.LongSupplier;
public class ExpiryHints {
    static final class Token implements Delayed {
        final String key;
        final long version, deadline;
        final LongSupplier clock;
        Token(String key,long version,long deadline,LongSupplier clock) {
            this.key=key;this.version=version;this.deadline=deadline;this.clock=clock;
        }
        public long getDelay(TimeUnit unit) { return unit.convert(deadline-clock.getAsLong(),TimeUnit.NANOSECONDS); }
        public int compareTo(Delayed other) {
            Token token=(Token)other;
            return Long.compare(deadline-token.deadline,0);
        }
    }
    record Current(long version,String value) {}
    static boolean expire(Token token,Map<String,Current> active) {
        Current current=active.get(token.key);
        return current!=null && current.version()==token.version && active.remove(token.key,current);
    }
    public static void main(String[] args) {
        AtomicLong time=new AtomicLong();
        DelayQueue<Token> queue=new DelayQueue<>();
        queue.add(new Token("future",1,100,time::get));
        System.out.println("not empty="+!queue.isEmpty()+", size="+queue.size());
        System.out.println("peek="+queue.peek().key+", poll="+queue.poll());
        time.set(100);
        System.out.println("at deadline="+queue.poll().key);
        AtomicLong expiryClock=new AtomicLong();
        DelayQueue<Token> hints=new DelayQueue<>();
        Map<String,Current> active=new HashMap<>();
        active.put("A",new Current(1,"old"));
        hints.add(new Token("A",1,10,expiryClock::get));
        active.put("A",new Current(2,"renewed"));
        hints.add(new Token("A",2,30,expiryClock::get));
        expiryClock.set(20);
        System.out.println("old hint removed current="+expire(hints.poll(),active));
        System.out.println("current value="+active.get("A").value());
        expiryClock.set(40);
        System.out.println("new hint removed current="+expire(hints.poll(),active));
        System.out.println("active size="+active.size());
        System.out.println("new queue size="+new DelayQueue<Token>().size());
        if (!active.isEmpty()) throw new AssertionError("new expiry missing");
    }
}
not empty=true, size=1
peek=future, poll=null
at deadline=future
old hint removed current=false
current value=renewed
new hint removed current=true
active size=0
new queue size=0

首份队列尚未到期,size为一,peek可以观察future,poll却没有可交出的元素。把测试时钟推进到deadline,下一次poll才取到它。peek不删除,迭代器也不是按到期顺序提取任务的替代品;需要正常消费资格时,应使用poll或take的合同。

代码只给同一个队列放入使用同一时钟、同一期限模型的Token。比较器通过到期差值决定顺序,getDelay也依据相同deadline。若一个方法按业务优先级排序,另一个方法按到期时间判断,头部可能挡住更早到期元素,队列不会替不一致的Delayed实现纠错。

这个测试的时间范围很小。接入System.nanoTime时,应限制允许的延迟区间,并正确处理其环绕语义;不能把纳秒值当作可持久化的日期时间。若任务来自数据库中的绝对时刻,需要在进入进程调度时明确转换,并考虑恢复时怎样重新计算剩余时间。

续期以后,旧提示为什么不能直接删当前对象

第二份队列模拟一个键续期:A的版本一计划在10到期,随后更新为版本二,计划在30到期。旧Token仍然留在队列。时钟到20时,它确实到期,但当前Map里已经不是那份旧对象。

expire同时核对键和版本,旧提示不会删除版本二;到40时,版本二自己的提示才移除当前值。这个版本检查不是DelayQueue提供的功能,而是调用方为缓存续期建立的身份规则。不区分版本就直接remove(key),会让一次旧过期事件删掉刚续期的数据。

本例Map只在主线程访问,以便固定调度变量。真实并发缓存需要用原子条件删除或在既有锁范围内核对并移除;先get再无条件remove之间,可能再次发生更新。即使用了线程安全Map,也不能把两次独立调用自动理解成一个原子业务动作。

旧Token长期积累也有成本。频繁续期但不移除旧提示时,一个活跃键可以留下许多待到期对象。DelayQueue没有容量上限,put不会像有界阻塞队列那样帮生产者等待空位。应该控制键数、提示创建速率或采用合并更新方案,并评估删除队列中指定旧项的开销。

过期提示最好只携带核对所需的键与版本。若把整份大对象放进Token,即便缓存本体已经更新或移除,旧提示仍可能持有那份对象直到被消费;看起来只是若干待清理标识,实际却延长了数据寿命。队列元素数量与它们保留的引用大小,应一起纳入容量判断。

到期不承诺执行时刻,更不承诺执行成功

getDelay小于等于零,只表示元素现在可以被取走。没有消费者、工作线程忙、进程被暂停,都会让处理晚于deadline。不能把“设置了延迟”向用户承诺成毫秒精度的准时执行;需要度量的是到期到实际开始的滞后,而不只是队列元素数量。

消费者取走以后业务仍可能失败。队列默认不会在异常后把元素放回,不会保存重试次数,也不会记录外部副作用是否已经发生。若需要重试,应明确新deadline、最大次数和幂等标识;无限立即重入同一个失败元素,可能把错误变成空转和日志洪水。

程序最后创建新队列,里面没有任何历史元素。这只展示新实例的初始状态,没有假称实际做过重启恢复测试。真实进程结束后,内存提示也随之消失;可靠业务任务应有独立的持久来源,再在恢复时重新装载必要提示。

缓存清理可以把DelayQueue当作加速机制:数据本身保存有效期,访问时仍核对是否过期,提示丢失只影响主动清理时机。正式扣款、发送通知或发布任务则通常不能把“提示还在某个JVM里”当作唯一事实。应由已有任务存储及执行协议承担可靠性,队列只负责本地等待组织。

最后,修改Token的deadline不能当作重新排期。对象已在堆结构中,字段变了不代表内部顺序自动重建;更清晰的是删除旧项再放新项,或保留不可变旧提示并像本例一样核对版本。时间、身份和完成状态分别有自己的合同,只有把它们分开,这个小队列才不会背上它从未提供的保证。

正文到此结束
评论插件初始化中...
Loading...
本文目录