在后台服务或中间件开发中,经常需要把一批待处理任务按照优先级高低进行组织,既能随时插入新任务,又能立刻拿到当前最该执行的那个。Java里的TreeMap因为底层是红黑树,会持续保持键的顺序,天然适合做这种带排序能力的映射结构。只要我们选好合适的优先级键,就能用很少的代码实现一个轻量级的任务调度映射。
为什么选择TreeMap而不是其他结构
HashMap虽然读写接近常量时间,但完全不保证顺序,想要按优先级取任务就必须额外遍历,成本很高。PriorityQueue也能按优先级出队,可它不支持根据优先级区间做范围查找,也不方便修改已经入队元素的优先级。TreeMap在插入、删除、查找的时间复杂度都是O(log n),并且提供了firstKey、lastKey、subMap等有序视图方法,对调度系统非常友好。
更重要的是,TreeMap允许我们传入自定义Comparator。这意味着优先级不一定非得是数字,也可以是包含等级、时间戳、来源权重的复合对象。只要比较逻辑写清楚,映射就会按照我们预期的维度保持有序,后续取任务不需要任何手动排序动作。
设计优先级键与任务值
最简单的做法是用整数表示优先级,数值越小优先级越高,值部分放任务对象或任务ID列表。当同一优先级可能有多个任务时,建议把值设成List,避免后面覆盖。下面给出一个基础定义:
import java.util.*;
// 任务实体
class Task {
String id;
String payload;
Task(String id, String payload) {
this.id = id;
this.payload = payload;
}
}
// 优先级->任务列表 的映射
public class PriorityTaskMap {
// 自然序:键越小排越前
private final TreeMap<Integer, List<Task>> map = new TreeMap<>();
public void addTask(int priority, Task task) {
map.computeIfAbsent(priority, k -> new ArrayList<>()).add(task);
}
}
上面的computeIfAbsent会在对应优先级不存在时新建一个ArrayList,然后把任务加进去。这样即便同一优先级并发或连续提交多个任务,也不会丢失。如果希望数值越大优先级越高,只要在构造TreeMap时传入反向比较器即可。
当优先级不只是数字,比如由“等级+创建时间”组成,可以定义一个PriorityKey类,实现Comparable或者传Comparator。注意比较逻辑必须覆盖所有字段且保持一致性,否则红黑树会出现找不到节点的诡异问题。
取出并执行最高优先级任务
TreeMap的firstEntry方法能直接拿到最小键对应的映射项,也就是当前最高优先级的那一组任务。我们从中弹出一个具体任务交给线程池即可。示例代码如下:
public Task pollHighest() {
while (!map.isEmpty()) {
Map.Entry<Integer, List<Task>> entry = map.firstEntry();
List<Task> tasks = entry.getValue();
if (tasks.isEmpty()) {
// 防御性清理空列表
map.remove(entry.getKey());
continue;
}
Task t = tasks.remove(0);
if (tasks.isEmpty()) {
map.remove(entry.getKey());
}
return t;
}
return null;
}
这段代码先取firstEntry,从列表头部拿任务,如果某个优先级列表空了就顺手删掉该键,防止TreeMap里堆积空列表。相比遍历所有键找最小,这种写法时间复杂度低,而且语义清晰。
如果业务要求同优先级内部遵循先来先服务,用ArrayList并在头部取就满足;若想避免移除时的数组拷贝,也可换成LinkedList。不过对于一般调度量,ArrayList的局部拷贝开销可以忽略。
使用自定义比较器处理复合优先级
假设优先级由level和timestamp组成,level越小越优先,level相同则timestamp越小越优先。可以这么写:
class PriorityKey implements Comparable<PriorityKey> {
int level;
long timestamp;
PriorityKey(int level, long timestamp) {
this.level = level;
this.timestamp = timestamp;
}
public int compareTo(PriorityKey o) {
if (this.level != o.level) {
return Integer.compare(this.level, o.level);
}
return Long.compare(this.timestamp, o.timestamp);
}
}
TreeMap<PriorityKey, List<Task>> tree = new TreeMap<>();
实现Comparable后,TreeMap无需额外比较器就能正确排序。需要特别留意,如果之后用new PriorityKey(相同字段)去get或remove,必须保证compareTo判定相等的两个对象equals也一致,否则会出现“明明有却取不到”的情况。稳妥做法是同时重写equals和hashCode。
在真实调度器里,还可以把PriorityKey扩展成包含来源权重,然后在compareTo里先做权重比较,再做等级比较,从而灵活调整策略而不动整体存取逻辑。
避免常见误区
第一个误区是拿任务内容当键。TreeMap要求键唯一且可比较,如果把整个Task当键,一方面要写复杂比较,另一方面相同优先级的任务会因为对象不同而被拆成多个键,失去聚合意义。正确做法始终是把“优先级描述”作为键,任务作为值容器。
第二个误区是在比较器里返回随机或不一致的顺序。红黑树依赖比较结果稳定,如果两次比较同一对键得到不同结果,树结构会损坏,后续操作可能死循环或抛异常。写Comparator时务必保证全序关系:自反、对称、传递。
第三个误区是忽略线程安全。TreeMap本身不是并发安全的,如果在多线程调度器中直接共享,需要用Collections.synchronizedSortedMap包装,或者改用ConcurrentSkipListMap。后者同样有序且支持并发,但语义和性能特征略有差别,选取时要结合读写比。
小结与扩展思路
用TreeMap承载优先级到任务列表的映射,可以用极少量代码获得自动排序、范围查询和高效取首的能力。它在中等规模调度、延迟敏感但又不需要分布式协调的场景里非常实用。若系统进一步演进,可以把值换成阻塞队列,或者结合ScheduledExecutorService做定时捞取,但核心的“优先级即键、TreeMap保序”的思路不变。
当任务量突破单机内存限制,就要考虑把TreeMap仅作为本地缓存层,后端接数据库或Redis的ZSet。此时本地TreeMap负责热数据快速决策,远程结构负责持久与共享,两者通过优先级区间同步,既保留TreeMap的轻量优势,也具备横向扩展能力。