PriorityQueue:for-each 遍历和 poll 取出,顺序一样吗?
摘要:往 PriorityQueue 里放数据时指定了比较器,很多人就默认遍历出来是有序的。实际上它的迭代器按内部堆数组的存放位置返回元素,JDK 文档写得很直白:"does not return the elements in any particular order"。这篇讲清楚遍历为什么是无序的、怎么验证,以及想按顺序取数据该怎么写。
适合读者:使用 PriorityQueue / PriorityBlockingQueue 的 Java 开发;写过"遍历队列批量处理"代码的更该看看。
一、踩坑的场景
凡是"元素带优先级、要按优先级取出来处理"的代码,都可能撞上。比如任务按到期时间放进优先队列,某个时刻想把队列里所有已到期的任务按时间顺序取出来批量执行;或者积压的消息按优先级导出来排查。写法出奇一致------既然队列是"有序"的,那直接遍历:
java
for (Task task : priorityQueue) { // 想按优先级处理全部元素
execute(task);
}
也有人转成 List 再用:
java
List<Task> all = new ArrayList<>(priorityQueue); // 想拿一份有序快照
两种写法拿到的都是乱序。元素少的时候乱得不起眼,一多就藏不住了。
我们在一个真实项目里踩过:延迟落库的队列按落地时间排序,停服时全量落地写的是 for-each 遍历。平时 peek/poll 单取都没问题,唯独全量遍历这条路是乱的------有先后依赖的数据乱了次序,停服后零星丢失,而且每次丢的都不一样(队列内部的排列跟入队历史有关,没法稳定复现),查了很久才定位到这行遍历。
二、为什么遍历是无序的
PriorityQueue 和 PriorityBlockingQueue 内部都是数组实现的二叉堆,不是排序数组。它只维护一条性质:父节点不大于子节点(小顶堆)。这条性质管的是父子关系,不管数组前后的顺序。比如 [1, 3, 2] 是个完全合法的小顶堆------1 是堆顶,3 和 2 是它的两个孩子------但按位置遍历就是 1、3、2,3 跑到了 2 前面。
堆里唯一能兑现"最小"的位置是堆顶。取走根节点后堆自己调整,下一个最小值浮上来,这正是 poll() 做的事。所以顺序是"取"出来的,不是"存"出来的,元素在数组里的位置只是堆调整的副产品。
JDK 文档在 iterator() 的说明里写得明明白白:
Returns an iterator over the elements in this queue. The iterator does not return the elements in any particular order.
PriorityBlockingQueue 一样,而且它的迭代器还是弱一致的,遍历期间的增删不保证反映出来。另外要注意,所有遍历整个队列的方式都继承了这个行为:new ArrayList<>(queue)(走 toArray() 直接复制堆数组)、queue.stream()、queue.toString(),拿到的都是堆里的存放顺序。
三、跑一段代码看看
java
PriorityBlockingQueue<Integer> q = new PriorityBlockingQueue<>();
q.addAll(Arrays.asList(7, 2, 9, 1, 5, 3, 8, 0, 6, 4));
List<Integer> forEachOrder = new ArrayList<>();
for (Integer i : q) forEachOrder.add(i); // for-each 遍历
List<Integer> pollOrder = new ArrayList<>();
while (!q.isEmpty()) pollOrder.add(q.poll()); // poll 逐个取出
System.out.println("for-each : " + forEachOrder);
System.out.println("poll : " + pollOrder);
输出:
text
for-each : [0, 1, 3, 2, 4, 9, 8, 7, 6, 5]
poll : [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
poll 是升序;for-each 打出来的 [0, 1, 3, 2, 4, 9, 8, 7, 6, 5] 就是堆数组的物理顺序,0 在堆顶没错,后面全乱。对固定输入,这个排列是确定的,本地跑出来一字不差(前提是同一实现;迭代顺序本身不在规范里,换 JDK 实现不保证一致)。
四、想按顺序取数据,怎么写
| 需求 | 写法 | 说明 |
|---|---|---|
| 消费掉队列,处理完不再保留 | while (!q.isEmpty()) { Task e = q.poll(); ... } |
取堆顶的语义和 remove/take 相同,空队列时行为不同:remove 抛异常、take 阻塞、poll 返回 null |
| 要有序快照,原队列保留 | new ArrayList<>(q) 之后 sort(comparator) |
光转 List 不排序,拿到的还是乱的 |
| 频繁按序遍历、少量增删 | 换 TreeSet / TreeMap |
注意排序字段别在集合里改 |
| 偶尔要一次有序 | 副本排序 | 同第二条 |
五、遍历优先队列的另外两个坑
这两个也是从那次事故的代码里带出来的。
遍历的时候改排序字段。 字段还在堆里参与结构调整,遍历中改了它,后续的 poll 顺序、插入位置都说不准。先 poll 出来再改------元素离开堆之后随便改。TreeSet 那个"改了排序字段 remove 不掉"的坑跟它是同族问题:有序结构里的元素,参与排序的字段要么不动,要么取出来再动。
遍历完 clear()。 PriorityBlockingQueue 的迭代器是快照,遍历开始后别的线程加进来的元素不在快照里,clear 却是无差别清空------这些元素既没被处理、也不在队列里,无声消失。(同样的写法放到 java.util.PriorityQueue 上,迭代器是 fail-fast 的,迭代期间队列被改会抛 ConcurrentModificationException,不会静默丢失。)要清空就用 poll 循环排空,每个元素要么被取走、要么还在队列里,不存在两边都落空的情况。
六、小结
回头看,这个坑的根源是把"入队时指定了比较器"和"遍历时有序"划了等号。前者是存储时的约定,后者 JDK 从来没承诺过。优先队列的正确用法就是 poll------它是唯一把"堆顶最小"这个性质兑现出来的操作;迭代器可以用来数个数、看看里面有什么,别用来按序取数据。
一句话总结:优先队列是取的时候才排序的结构,for-each 拿到的是元素在堆里的站位顺序,不是优先级顺序;要有序,要么一个个 poll,要么自己 sort。