PriorityQueue 不能当排序列表:写一个支持更新和取消的优先任务队列
- 发布时间:2026-10-23 22:12:09
- 本文热度:浏览 3 赞 0 评论 0
- 文章标签: Java PriorityQueue 任务调度
- 全文共1字,阅读约需1分钟
把PriorityQueue打印出来,看见 [1, 3, 2, 5, 4],不代表比较器坏了。这不是一份排序列表,而是一棵以数组组织的堆。它需要保证队首是当前最优元素,不需要保证随后每个位置都排好。
真正按优先级取出,要反复poll;如果只是想得到一份排序展示,要复制后排序。两个动作不能混在一起:管理页面遍历队列看到的顺序,不应直接被当成接下来任务的准确执行顺序。
这次围绕一个更实际的问题写完整程序:任务入队后可以调整优先级、取消,优先级相同时按本次入队次序处理。实现是单线程内存调度结构,不启动工作线程,不持久化任务。先让队列语义准确,再决定由谁执行任务。
从“修改对象字段”走向不可变队列项
最诱人的写法是把可变Task放进堆,随后直接改priority。可惜PriorityQueue不知道对象变了,不会因为字段变化重新调整堆位置。除非移出再加入,否则poll可能不再给出业务期望的最优任务。
本例每次更新都创建新的不可变Job,旧项留在堆里,但有效索引只指向最新对象。取出时若不是索引中当前那一项,就丢弃继续poll。这叫惰性失效,避免更新时必须在堆里线性查找旧项。
同一个任务重新排期会得到新序号,因此相同优先级下,它按这次排期的时间重新排队,而不是永久保留第一次提交的位置。这是一项业务选择,代码和测试都据此解释。
可运行的最小调度结构
使用JDK 17,保存为 PriorityJobs.java,执行 java PriorityJobs.java。优先级允许0到9,数值越小越先处理。没有网络、数据库或时间等待,所有操作顺序都在main中明确写出。
import java.util.*;
public class PriorityJobs {
record Job(String id, int priority, long sequence) { }
static final Comparator<Job> ORDER = Comparator.comparingInt(Job::priority)
.thenComparingLong(Job::sequence);
static final class Queue {
private final PriorityQueue<Job> heap = new PriorityQueue<>(ORDER);
private final Map<String, Job> live = new HashMap<>();
private long sequence, discarded;
void schedule(String id, int priority) {
if (id == null || id.isBlank() || priority < 0 || priority > 9)
throw new IllegalArgumentException("id and priority 0..9 required");
sequence = Math.incrementExact(sequence);
Job job = new Job(id, priority, sequence); live.put(id, job); heap.add(job);
}
boolean cancel(String id) { return live.remove(id) != null; }
Job poll() {
while (!heap.isEmpty()) {
Job candidate = heap.poll();
if (live.get(candidate.id()) != candidate) { discarded++; continue; }
live.remove(candidate.id()); return candidate;
}
return null;
}
int queued() { return heap.size(); }
long discarded() { return discarded; }
void compact() { heap.clear(); heap.addAll(live.values()); }
}
public static void main(String[] args) {
PriorityQueue<Integer> numbers = new PriorityQueue<>();
numbers.addAll(List.of(1, 4, 2, 5, 3));
System.out.println("heap_view=" + numbers);
List<Integer> ordered = new ArrayList<>(); while (!numbers.isEmpty()) ordered.add(numbers.poll());
System.out.println("poll_order=" + ordered);
Queue tasks = new Queue();
tasks.schedule("a", 5); tasks.schedule("b", 1); tasks.schedule("a", 0);
tasks.cancel("b"); tasks.schedule("c", 0); tasks.schedule("d", 0);
List<String> actual = new ArrayList<>();
for (Job job; (job = tasks.poll()) != null; ) actual.add(job.id());
System.out.println("tasks=" + actual);
System.out.println("discarded=" + tasks.discarded());
for (int i = 0; i < 20; i++) tasks.schedule("x", i % 10);
System.out.println("before_compact=" + tasks.queued()); tasks.compact();
System.out.println("after_compact=" + tasks.queued());
System.out.println("current=" + tasks.poll().id());
if (!actual.equals(List.of("a", "c", "d"))) throw new AssertionError("priority order");
}
}
输出:
heap_view=[1, 3, 2, 5, 4]
poll_order=[1, 2, 3, 4, 5]
tasks=[a, c, d]
discarded=2
before_compact=20
after_compact=1
current=x
第一组输出对比堆的内部遍历与poll顺序;后面核对任务更新、取消、相同优先级平局和陈旧项压实。heap_view的具体排列只属于这组输入与实现样本,接口承诺的是不能依赖迭代器获得排序顺序,而不是永远输出这一个数组形状。
两份结构各自回答一个问题
堆回答“下一项候选是什么”,Map回答“这个任务现在是否还有效”。更新a时,Map指向新a,旧a暂时仍在堆中;取消b时只从Map删除b,堆里的旧b以后取到才被识别为失效。
poll不能见到失效项就返回null,那会错误宣布队列没有可执行任务。它必须继续跳过,直到找到有效项或堆真的耗尽。本例最终按a、c、d返回,取消的b与旧a都没有泄露到结果中,discarded计数为2。
返回有效项时还要从Map删除它,这代表从本结构领取成功。任务开始执行以后失败如何处理,不在这个Map里偷偷恢复。若业务要重试,应创建一条明确新排期,决定新优先级与平局位置;不能让执行异常改变已经完成的poll动作。
代码用对象身份判断当前项,而不依赖Job的字段相等。两个内容恰好相同的排期仍可能是不同版本;只要Map指向的是新对象,旧对象就不再有效。也可以用明确版本号实现相同判断,但必须确保版本增长和任务更新原子地完成。
平局规则与比较器都要稳定
PriorityQueue对比较为零的不同元素不承诺FIFO。任务优先级一样时,若产品要求先来先执行,就要像本例一样再比较递增sequence。业务若希望同优先级按截止时间、租户轮转或公平份额安排,则需要不同的第二层规则。
序号采用Math.incrementExact,溢出时明确失败。直接让long回绕,可能使新任务突然跑到很早的位置。虽然普通演示很难实际用完序号空间,但边界可以由代码规则而不是运行年限猜测来定义。
比较整数也不要直接返回 a.priority - b.priority,当允许范围很大时相减会溢出。这里用Comparator的比较方法表达顺序,并把priority限制为小范围。比较器在堆使用期间不能变更含义,否则已有结构仍按旧规则建立。
惰性失效的账要记在堆上
有效任务只有一个,不代表堆里也只有一个。程序连续更新x二十次,会留下二十条候选,其中十九条已经陈旧。若x一直没有被领取,旧项也就不会经过poll得到清理。
因此提供compact方法,用有效Map中的当前项重建堆。样本明确观察到queued从20降到1,而有效x仍能正确领取。这个动作代价与有效条目规模相关,不能在每次更新以后无条件重建,否则惰性策略的好处会被抹掉。
生产范围内可以按堆大小相对有效项数量的比例设置压实阈值,同时设置绝对容量上限。有效项为零时尤其要处理分母和保留问题。压实应由持有该结构的同一线程或同一互斥边界执行,不能一边遍历Map一边让其他线程更新它。
另外,旧项若直接持有大请求体,惰性保留的内存会明显放大。本例Job只有短标识和数字,业务载荷应由另一个有生命周期的存储持有。取消的任务是否立即释放载荷,应由索引删除与资源所有权决定,不能期待堆里的陈旧记录马上消失。
这套结构适合什么执行模型
最直接的使用方式是单线程事件循环:收到提交、更新、取消命令后修改结构,再按容量领取任务交给执行器。若允许多个线程并发调用,Map更新与堆变化必须统一同步;把PriorityQueue单独换成PriorityBlockingQueue,不会自动使两份结构之间保持一致。
内存领取也没有提供持久确认。进程退出后任务会丢失,领取后执行失败也不会自己归还;需要可靠任务系统时,应增加持久状态和确认协议,重新考虑哪些动作可以原子完成。这里不把一个数据结构包装成消息队列服务。
高优先级不断到达时,低优先级任务还可能长时间无法领取。FIFO平局只解决同优先级内的次序,不解决跨优先级饥饿。可以按业务采用等待时间提升、配额或多队列调度,但每种方案都会改变比较和更新策略,不应偷偷写进一个含糊的priority字段。
对外展示应给出明确快照
若管理端要看当前排队任务,应从有效Map复制,再按与调度一致的比较器排序;直接复制堆会把陈旧项也展示出来,直接遍历Map则没有调度顺序。展示时点与实际poll之间仍可能有更新,界面应把它当快照,而不是保证下一秒执行顺序的承诺。
测试也按这些协议分组:队首排序、平局FIFO、重复排期、取消不存在的ID、全部取消、压实前后结果一致。最初那个“打印顺序不对”的疑问,只涉及堆的一项契约;支持更新和取消以后,真正要保住的是候选、有效身份和领取动作之间的关系。