Java PriorityQueue 源码剖析:二叉堆的实现原理与应用

261 阅读10分钟

优先队列在 Java 中是如何实现的?PriorityQueue 作为 Java 集合框架中的重要成员,其底层结构与普通队列完全不同。本文深入分析其核心机制。

1. PriorityQueue 基础概念

PriorityQueue 是一个基于优先级的队列,它会自动扩容直到达到 JVM 对数组大小的限制(理论上限是 Integer.MAX_VALUE - 8)。队列元素按照自然顺序或指定比较器排序,总是优先获取优先级最高的元素。

PriorityQueue.png

2. 底层数据结构:二叉堆

PriorityQueue 的核心是二叉堆,这是一种特殊的完全二叉树,在 Java 中通过数组实现。

二叉堆有两个关键特性:

  • 结构性:除最后一层外都是完全填满的,最后一层从左到右填充
  • 堆序性:每个节点都大于等于(大顶堆)或小于等于(小顶堆)其子节点

PriorityQueue 默认实现小顶堆,即根节点是最小元素。

小顶堆.png

3. 数组表示法

二叉堆虽然是树形结构,但 Java 中使用数组高效存储,通过索引计算父子关系:

  • 对于索引 i 的节点:
    • 父节点索引:(i-1) >>> 1 (无符号右移,等同于(i-1)/2 但性能更优)
    • 左子节点索引:2*i+1
    • 右子节点索引:2*i+2
public class PriorityQueue<E> extends AbstractQueue<E> {
    // 存储元素的数组
    transient Object[] queue;

    // 队列中的元素数量
    private int size = 0;

    // 比较器
    private final Comparator<? super E> comparator;

    // 默认初始容量
    private static final int DEFAULT_INITIAL_CAPACITY = 11;

    // 构造函数...
}

注意,内部数组是Object[]类型,而非泛型数组。这是 Java 集合类的常见实现模式,源于泛型引入前的历史原因。虽然 PriorityQueue 的公共方法确保类型安全,但内部实现中需要频繁进行类型转换(E),这是为了兼容性和性能考虑。

4. 核心操作实现

入队操作(offer/add)

当添加元素时,将其放在数组末尾,然后向上调整堆结构:

public boolean offer(E e) {
    if (e == null)
        throw new NullPointerException();
    int i = size;
    // 扩容检查
    if (i >= queue.length)
        grow(i + 1);
    size = i + 1;
    // 如果堆为空,直接插入第一个元素
    if (i == 0)
        queue[0] = e;
    else
        // 向上调整堆
        siftUp(i, e);
    return true;
}

// 向上调整堆的核心逻辑
private void siftUp(int k, E x) {
    if (comparator != null)
        siftUpUsingComparator(k, x);
    else
        siftUpComparable(k, x);
}

private void siftUpComparable(int k, E x) {
    Comparable<? super E> key = (Comparable<? super E>) x;
    while (k > 0) {
        int parent = (k - 1) >>> 1; // 计算父节点位置,使用无符号右移而非除法提升性能
        Object e = queue[parent];
        if (key.compareTo((E) e) >= 0) // 如果当前元素大于等于父节点,停止调整
            break;
        queue[k] = e; // 父节点下移
        k = parent;
    }
    queue[k] = key; // 放置元素到最终位置
}

当数组容量不足时,grow()方法会触发扩容。它实现了一个智能增长策略:当原容量小于 64 时,新容量翻倍;否则增加 50%。这种策略平衡了内存利用率和重分配频率,避免了频繁的昂贵扩容操作,同时也不会过度浪费内存。

出队操作(poll)

移除堆顶元素(最小值),将最后一个元素放到堆顶,然后向下调整堆结构:

public E poll() {
    if (size == 0)
        return null;
    int s = --size;
    E result = (E) queue[0]; // 取出堆顶元素
    E x = (E) queue[s];      // 最后一个元素
    queue[s] = null;         // 清空最后位置
    if (s != 0)
        siftDown(0, x);      // 从顶部开始向下调整
    return result;
}

private void siftDown(int k, E x) {
    if (comparator != null)
        siftDownUsingComparator(k, x);
    else
        siftDownComparable(k, x);
}

private void siftDownComparable(int k, E x) {
    Comparable<? super E> key = (Comparable<? super E>)x;
    int half = size >>> 1;    // 堆的一半位置(只需检查到非叶子节点)
    while (k < half) {        // 优化:索引>=half的节点都是叶子节点,无需下沉
        int child = (k << 1) + 1; // 左子节点
        Object c = queue[child];
        int right = child + 1;
        // 如果右子节点存在且小于左子节点,选择右子节点
        if (right < size &&
            ((Comparable<? super E>) c).compareTo((E) queue[right]) > 0)
            c = queue[child = right];
        // 如果当前元素小于等于最小的子节点,停止调整
        if (key.compareTo((E) c) <= 0)
            break;
        queue[k] = c; // 子节点上移
        k = child;
    }
    queue[k] = key; // 放置元素到最终位置
}

5. 其他关键操作

批量构造与 heapify 过程

当从集合批量构造 PriorityQueue 时,并不是逐个调用 offer 方法(这将是 O(n log n)的复杂度),而是采用了一个更高效的 O(n)建堆算法:

private void heapify() {
    // 从最后一个非叶子节点开始,自下而上进行调整
    for (int i = (size >>> 1) - 1; i >= 0; i--)
        siftDown(i, (E) queue[i]);
}

这个过程从倒数第一个非叶子节点开始,依次向上对每个节点执行 siftDown 操作。这种方法比逐个插入要高效得多,是一种巧妙的算法优化。

批量构造PriorityQueue.png

remove(Object o)方法

从队列中删除指定元素,这是一个 O(n)操作:

public boolean remove(Object o) {
    int i = indexOf(o); // O(n)线性查找元素位置
    if (i == -1)
        return false;
    else {
        removeAt(i);   // 从指定位置移除元素
        return true;
    }
}

private E removeAt(int i) {
    // 最后一个元素作为"洞"
    int s = --size;
    if (s == i) // 如果移除的是最后一个元素
        queue[i] = null;
    else {
        E moved = (E) queue[s];
        queue[s] = null;
        siftDown(i, moved); // 尝试下沉
        if (queue[i] == moved) // 如果位置没变(说明不需要下沉)
            siftUp(i, moved);  // 则尝试上浮
        return moved;
    }
    return null;
}

这里有一个微妙但重要的逻辑:if (queue[i] == moved) siftUp(i, moved);。为什么在siftDown之后还需要siftUp?这是因为siftDown操作会将合适的子节点上移来填充位置i处的空洞。但如果替换元素moved没有子节点,或者比它的所有子节点都小,那么siftDown不会执行任何操作,此时queue[i]仍然等于moved

在这种情况下,可能moved元素比它的新父节点还小(特别是在删除了一个较大的中间元素后),因此需要通过siftUp向上调整来恢复堆的性质。这个双向调整机制保证了在任何情况下移除元素后堆的正确性。

迭代器行为

PriorityQueue 的迭代器不保证按优先级顺序遍历元素,它只是按照底层数组的顺序返回元素:

public Iterator<E> iterator() {
    return new Itr();
}

private final class Itr implements Iterator<E> {
    // 简单遍历数组,不保证按优先级顺序
    // 如需按优先级处理所有元素,应使用poll()方法
}

6. 实际应用案例

案例一:任务调度系统

假设我们需要实现一个基于优先级的任务调度系统:

import java.util.PriorityQueue;
import java.util.Comparator;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class TaskScheduler {
    private static final Logger logger = LoggerFactory.getLogger(TaskScheduler.class);
    private PriorityQueue<Task> taskQueue;

    public TaskScheduler() {
        // 创建优先队列,使用Comparator.comparingInt避免整数溢出风险
        taskQueue = new PriorityQueue<>(Comparator.comparingInt(Task::getPriority));
    }

    public void addTask(Task task) {
        taskQueue.offer(task);
        logger.info("任务已添加: {}, 优先级: {}", task.getName(), task.getPriority());
    }

    public Task getNextTask() {
        return taskQueue.poll();
    }

    public static void main(String[] args) {
        try {
            TaskScheduler scheduler = new TaskScheduler();

            scheduler.addTask(new Task("数据备份", 3));
            scheduler.addTask(new Task("系统更新", 1));
            scheduler.addTask(new Task("日志清理", 2));

            // 按优先级获取任务
            Task task;
            while ((task = scheduler.getNextTask()) != null) {
                logger.info("执行任务: {}, 优先级: {}", task.getName(), task.getPriority());
            }

            // 模拟异常情况,演示异常日志处理
            scheduler.addTask(null);  // 会抛出NPE
        } catch (Exception e) {
            // 正确记录异常,包含完整堆栈信息,这对排查生产问题至关重要
            logger.error("调度器执行过程中发生意外错误", e);
        }
    }

    static class Task {
        private String name;
        private int priority; // 数字越小优先级越高

        public Task(String name, int priority) {
            this.name = name;
            this.priority = priority;
        }

        public String getName() {
            return name;
        }

        public int getPriority() {
            return priority;
        }
    }
}

注意,我们使用Comparator.comparingInt作为比较器,这是 Java 8 引入的函数式接口,它实现了策略模式(Strategy Pattern)。这种设计将比较逻辑("策略")与数据结构解耦,允许灵活配置排序行为(小顶堆、大顶堆、自定义对象排序)而无需修改 PriorityQueue 的核心实现。

案例二:Dijkstra 最短路径算法

PriorityQueue 在图算法中的应用,将算法复杂度从 O(V²)优化到 O(E log V):

import java.util.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class DijkstraAlgorithm {
    private static final Logger logger = LoggerFactory.getLogger(DijkstraAlgorithm.class);

    public static void findShortestPaths(int[][] graph, int start) {
        int n = graph.length;
        int[] distance = new int[n];
        boolean[] visited = new boolean[n];

        Arrays.fill(distance, Integer.MAX_VALUE);
        distance[start] = 0;

        // 使用PriorityQueue优化选择最小距离节点的过程
        PriorityQueue<Node> pq = new PriorityQueue<>(Comparator.comparingInt(node -> node.distance));
        pq.offer(new Node(start, 0));

        while (!pq.isEmpty()) {
            Node current = pq.poll();
            int u = current.vertex;

            // 如果已访问过,跳过
            if (visited[u]) continue;
            visited[u] = true;

            // 更新相邻节点距离
            for (int v = 0; v < n; v++) {
                if (graph[u][v] > 0 && !visited[v]) {
                    int newDist = distance[u] + graph[u][v];
                    if (newDist < distance[v]) {
                        distance[v] = newDist;
                        pq.offer(new Node(v, newDist));
                    }
                }
            }
        }

        // 输出结果
        for (int i = 0; i < n; i++) {
            logger.info("从节点 {} 到节点 {} 的最短距离是: {}", start, i, distance[i]);
        }
    }

    static class Node {
        int vertex;
        int distance;

        Node(int vertex, int distance) {
            this.vertex = vertex;
            this.distance = distance;
        }
    }

    public static void main(String[] args) {
        try {
            int[][] graph = {
                {0, 4, 0, 0, 0, 0, 0, 8, 0},
                {4, 0, 8, 0, 0, 0, 0, 11, 0},
                {0, 8, 0, 7, 0, 4, 0, 0, 2},
                {0, 0, 7, 0, 9, 14, 0, 0, 0},
                {0, 0, 0, 9, 0, 10, 0, 0, 0},
                {0, 0, 4, 14, 10, 0, 2, 0, 0},
                {0, 0, 0, 0, 0, 2, 0, 1, 6},
                {8, 11, 0, 0, 0, 0, 1, 0, 7},
                {0, 0, 2, 0, 0, 0, 6, 7, 0}
            };

            findShortestPaths(graph, 0);
        } catch (Exception e) {
            logger.error("执行Dijkstra算法时发生错误", e);
        }
    }
}

7. 大顶堆与小顶堆

PriorityQueue 默认实现小顶堆,但通过自定义比较器可以轻松实现大顶堆:

// 方法一:使用Collections.reverseOrder()
PriorityQueue<Integer> maxHeap = new PriorityQueue<>(Collections.reverseOrder());

// 方法二:使用Lambda比较器(安全写法,避免整数溢出)
PriorityQueue<Integer> maxHeap2 = new PriorityQueue<>((a, b) -> Integer.compare(b, a));

// 方法三:使用Comparator工厂方法
PriorityQueue<Integer> maxHeap3 = new PriorityQueue<>(Comparator.reverseOrder());

8. 常见陷阱与最佳实践

  1. 迭代器顺序陷阱:PriorityQueue 的迭代器不保证按优先级顺序遍历元素。如需按优先级处理,应循环调用 poll()方法。

    // 错误:这不会按优先级顺序处理元素
    for (Task task : taskQueue) {
        processTask(task);
    }
    
    // 正确:按优先级顺序处理所有元素
    Task task;
    while ((task = taskQueue.poll()) != null) {
        processTask(task);
    }
    
  2. 元素修改陷阱:修改已在队列中对象的排序关键属性,会破坏堆结构。

    // 错误:直接修改队列中任务的优先级
    Task task = findTaskInQueue();
    task.setPriority(newPriority);  // 堆结构已被破坏!
    
    // 正确:移除后修改再重新入队
    Task task = findTaskInQueue();
    taskQueue.remove(task);  // 先移除
    task.setPriority(newPriority);  // 修改
    taskQueue.offer(task);  // 重新入队
    
  3. 线程安全性:PriorityQueue 不是线程安全的。多线程环境下应使用 PriorityBlockingQueue。

    // 多线程环境中使用
    import java.util.concurrent.PriorityBlockingQueue;
    
    PriorityBlockingQueue<Task> safeTaskQueue =
        new PriorityBlockingQueue<>(11, Comparator.comparingInt(Task::getPriority));
    
  4. 比较器陷阱:避免使用减法实现比较器,防止整数溢出。

    // 危险:可能导致整数溢出
    new PriorityQueue<>((a, b) -> a.getValue() - b.getValue());
    
    // 安全:使用Integer.compare或Comparator工厂方法
    new PriorityQueue<>(Comparator.comparingInt(Task::getValue));
    

9. 性能分析

PriorityQueue 的主要操作性能表现:

操作性能表现.png

10. 总结

特性说明
底层结构二叉堆(通过数组实现的完全二叉树)
默认排序小顶堆(元素自然顺序)
排序定制可通过自定义 Comparator 实现大顶堆或其他排序
元素要求不允许 null,元素必须可比较或提供比较器
线程安全否(多线程环境应使用 PriorityBlockingQueue)
构造时间复杂度批量构造 O(n),逐个添加 O(n log n)
插入时间复杂度O(log n)
删除时间复杂度O(log n)
查找时间复杂度O(n)
迭代器顺序不保证按优先级顺序
主要应用任务调度、图算法、事件处理、优先级处理

通过对 PriorityQueue 底层结构与源码的深入理解,我们可以更高效地利用它解决各种问题,同时避开常见陷阱。

究其本质,PriorityQueue 体现了经典的工程权衡:它牺牲了 O(1)的入队/出队操作和 O(n)的查找,以换取对优先级最高元素的高效 O(log n)访问,使其成为算法和系统中不可或缺的工具,尤其是在"下一步"由重要性而非到达时间决定的场景中。