1. 什么是队列?
队列(Queue)是一种操作受限的线性数据结构,遵循**先进先出(FIFO, First In First Out)**原则。它只允许在一端(队尾)进行插入操作,在另一端(队头)进行删除操作。队列就像日常生活中的排队,先进入队列的元素会先被处理。
1.1 队列的常见应用场景
- 任务调度:线程池中的任务队列,CPU 的任务调度。
- 消息队列:异步处理、系统解耦(Kafka、RabbitMQ 底层也用到了队列思想)。
- 广度优先搜索(BFS):图的遍历、树的层序遍历。
- 缓存系统:如 LRU Cache 的实现(常与链表或队列结合)。
- 打印机任务队列:多个打印任务按顺序打印。
2. Java 中的队列接口体系
Java 的队列相关接口主要定义在 java.util 包下,它们之间存在清晰的继承关系:
| 接口 | 描述 |
|---|---|
Collection |
所有集合类的根接口。 |
Iterable |
支持迭代。 |
Queue<E> |
队列接口,定义了基本的添加、删除、检查操作。 |
Deque<E> |
双端队列,两端都可插入和删除。 |
BlockingQueue<E> |
阻塞队列接口,提供了阻塞的插入和移除操作。 |
BlockingDeque<E> |
阻塞双端队列。 |
TransferQueue<E> |
特殊的阻塞队列,生产者会等待消费者接收元素。 |
主要关注 Queue、Deque 和 BlockingQueue 这三个核心接口。
3. 常用队列实现类
3.1 LinkedList(链表实现的队列)
LinkedList 同时实现了 List 和 Deque 接口,因此它既可以当作列表,也可以当作普通队列和双端队列使用。底层使用双向链表,插入删除效率高,无容量限制。
java
Queue<Integer> queue = new LinkedList<>();
queue.offer(1);
queue.offer(2);
System.out.println(queue.poll()); // 1,出队
System.out.println(queue.peek()); // 2,查看队头
3.2 PriorityQueue(优先队列)
PriorityQueue 基于优先级堆(默认小顶堆)实现,元素按照自然顺序或自定义比较器排序。出队时总是返回优先级最高的元素(最小值或最大值)。
java
// 默认小顶堆
PriorityQueue<Integer> pq = new PriorityQueue<>();
pq.offer(3);
pq.offer(1);
pq.offer(2);
System.out.println(pq.poll()); // 1,按优先级出队
注意:PriorityQueue 不是线程安全的,也不允许插入 null 元素。
3.3 ArrayDeque(数组双端队列)
ArrayDeque 是基于循环数组实现的双端队列,可作为栈或队列使用。它比 LinkedList 性能更好(数组缓存友好,无额外节点开销),因此官方推荐用 ArrayDeque 替代 Stack 和大多数 LinkedList 的队列场景。
java
Deque<Integer> deque = new ArrayDeque<>();
deque.offerFirst(1); // 队头入队
deque.offerLast(2); // 队尾入队
System.out.println(deque.pollFirst()); // 1
3.4 并发队列:ConcurrentLinkedQueue
ConcurrentLinkedQueue 是基于链表实现的无界线程安全队列,使用 CAS(Compare-And-Swap)无锁算法保证并发安全,适用于高并发场景。
java
ConcurrentLinkedQueue<Integer> queue = new ConcurrentLinkedQueue<>();
queue.offer(1);
queue.poll();
3.5 阻塞队列家族
阻塞队列是 JUC(java.util.concurrent)包中的重要组件,常用于生产者-消费者模型。
- ArrayBlockingQueue:有界阻塞队列,基于数组实现,使用一把锁控制入队和出队。
- LinkedBlockingQueue:可选有界(默认无界,最大 Integer.MAX_VALUE),基于链表实现,使用两把锁分离入队和出队,吞吐量更高。
- PriorityBlockingQueue:无界的优先级阻塞队列。
- DelayQueue:延迟队列,元素只有在其指定的延迟时间到达后才能被取出。
- SynchronousQueue:不存储元素的阻塞队列,每个插入操作必须等待一个移除操作,常用于线程池的任务交接。
java
// 生产者消费者简单示例
BlockingQueue<Integer> queue = new ArrayBlockingQueue<>(10);
new Thread(() -> {
for (int i = 0; i < 100; i++) {
try {
queue.put(i);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}).start();
4. 队列常用方法对比
队列操作主要分为三类:添加元素、移除元素、查看队头元素。每种操作又分"出错时抛异常"和"返回特殊值"两种风格。
| 操作 | 抛异常方法 | 返回特殊值方法 | 说明 |
|---|---|---|---|
| 添加 | add(e) |
offer(e) |
offer 在容量满时返回 false,add 抛异常 |
| 移除 | remove() |
poll() |
poll 在队列空时返回 null,remove 抛异常 |
| 检查 | element() |
peek() |
peek 在队列空时返回 null,element 抛异常 |
双端队列额外方法:
| 操作 | 抛异常方法 | 返回特殊值方法 |
|---|---|---|
| 队头添加 | addFirst(e) |
offerFirst(e) |
| 队尾添加 | addLast(e) |
offerLast(e) |
| 队头移除 | removeFirst() |
pollFirst() |
| 队尾移除 | removeLast() |
pollLast() |
| 队头检查 | getFirst() |
peekFirst() |
| 队尾检查 | getLast() |
peekLast() |
5. 优先队列原理与实现
优先队列通常使用堆(Heap) 实现。Java 中的 PriorityQueue 默认采用小顶堆,即在完全二叉树中,每个父节点的值都小于等于子节点的值,因此堆顶总是最小值。
入队(offer)时,新元素被放到数组末尾,然后执行上浮(siftUp) 操作,不断与父节点比较交换,保证堆的性质。出队(poll)时,移除堆顶元素,将数组最后一个元素移到堆顶,再执行**下沉(siftDown)**操作,与较小的子节点交换,直到满足堆条件。
时间复杂度:入队 O(log n),出队 O(log n),查看堆顶 O(1)。
java
// 自定义比较器实现大顶堆
PriorityQueue<Integer> maxHeap = new PriorityQueue<>(Collections.reverseOrder());
maxHeap.offer(3);
maxHeap.offer(5);
System.out.println(maxHeap.poll()); // 5,最大值先出队
6. 阻塞队列原理与生产者消费者模式
BlockingQueue 通过锁(ReentrantLock)和条件(Condition)实现线程间的等待/通知机制。put(e) 方法在队列满时,会在 notFull 条件上等待;take() 方法在队列空时,在 notEmpty 条件上等待。当有元素被取出或加入时,会唤醒相应的等待线程。
在经典的生产者消费者模式中,生产者线程调用 put 生产数据,消费者线程调用 take 消费数据,阻塞队列负责缓冲和同步,极大地简化了并发编程。
java
class Producer implements Runnable {
private BlockingQueue<Integer> queue;
public Producer(BlockingQueue<Integer> q) { this.queue = q; }
public void run() {
for (int i = 0; i < 100; i++) {
try { queue.put(i); } catch (InterruptedException e) { }
}
}
}
class Consumer implements Runnable {
private BlockingQueue<Integer> queue;
public Consumer(BlockingQueue<Integer> q) { this.queue = q; }
public void run() {
while (true) {
try {
Integer val = queue.take();
System.out.println("consume " + val);
} catch (InterruptedException e) { }
}
}
}
7. 双端队列 Deque 详解
Deque 接口扩展了 Queue,允许在头尾进行快速插入和移除。主要实现类有:
- ArrayDeque:基于循环数组,性能优秀,建议优先使用。
- LinkedList:基于链表,适合频繁增删的场景。
Deque 可以模拟栈:push() 对应 addFirst(),pop() 对应 removeFirst()。因此官方建议使用 Deque<Integer> stack = new ArrayDeque<>(); 来替代古老的 Stack 类。
java
Deque<Integer> deque = new ArrayDeque<>();
deque.push(1); // 入栈
deque.push(2);
System.out.println(deque.pop()); // 2,栈顶元素
8. 队列的线程安全与选择策略
在多线程环境下需要根据场景选择合适的队列:
- 简单的并发场景 :
ConcurrentLinkedQueue,无界、无锁,性能好。 - 需要阻塞等待的生产者消费者 :使用
ArrayBlockingQueue或LinkedBlockingQueue。如果有明确的容量限制,优先用ArrayBlockingQueue;若需要更高吞吐量且可接受较大内存占用,用LinkedBlockingQueue。 - 延迟处理任务 :
DelayQueue。 - 任务交接无缓冲 :
SynchronousQueue。 - 需要两端操作的并发 :
LinkedBlockingDeque。
注意:Collections.synchronizedList(new LinkedList()); 包装的队列虽然线程安全,但性能远不如并发专用类,且不支持阻塞操作。
9. 经典面试题解析
9.1 用两个栈实现队列
思路:栈是先进后出,两个栈倒一下就能实现先进先出。一个栈用于入队,另一个用于出队。当出队栈为空时,将入队栈的所有元素倒入出队栈。
java
class MyQueue {
private Stack<Integer> inStack, outStack;
public MyQueue() { inStack = new Stack<>(); outStack = new Stack<>(); }
public void push(int x) { inStack.push(x); }
public int pop() {
if (outStack.isEmpty())
while (!inStack.isEmpty()) outStack.push(inStack.pop());
return outStack.pop();
}
public int peek() {
if (outStack.isEmpty())
while (!inStack.isEmpty()) outStack.push(inStack.pop());
return outStack.peek();
}
public boolean empty() { return inStack.isEmpty() &&& outStack.isEmpty(); }
}
9.2 用队列实现栈
思路:使用两个队列,或一个队列。入栈时直接入队;出栈时将队列前 n-1 个元素出队再入队,此时队头即为栈顶。
java
class MyStack {
Queue<Integer> queue = new LinkedList<>();
public void push(int x) {
queue.offer(x);
for (int i = 0; i < queue.size() - 1; i++)
queue.offer(queue.poll());
}
public int pop() { return queue.poll(); }
public int top() { return queue.peek(); }
public boolean empty() { return queue.isEmpty(); }
}
9.3 优先队列解决 TopK 问题
求数组中第 K 大的元素或前 K 个最大的元素:用一个大小为 K 的小顶堆,遍历数组,如果当前元素大于堆顶,则移除堆顶并加入当前元素。最终堆顶就是第 K 大的元素。
java
public int findKthLargest(int[] nums, int k) {
PriorityQueue<Integer> pq = new PriorityQueue<>();
for (int num : nums) {
pq.offer(num);
if (pq.size() > k) pq.poll();
}
return pq.peek();
}
9.4 滑动窗口最大值
使用双端队列(Deque)维护一个递减的窗口。队列存储数组的下标,队头始终是窗口内的最大值。当窗口滑动时,移除过期的下标,保持队头合法。
java
public int[] maxSlidingWindow(int[] nums, int k) {
if (nums == null || nums.length == 0) return new int[0];
int[] res = new int[nums.length - k + 1];
Deque<Integer> deque = new ArrayDeque<>();
for (int i = 0; i < nums.length; i++) {
if (!deque.isEmpty() && deque.peekFirst() <= i - k) deque.pollFirst();
while (!deque.isEmpty() && nums[deque.peekLast()] < nums[i]) deque.pollLast();
deque.offerLast(i);
if (i >= k - 1) res[i - k + 1] = nums[deque.peekFirst()];
}
return res;
}
9.5 设计一个延迟任务调度器
利用 DelayQueue 实现。每个任务实现 Delayed 接口,定义延迟时间。消费者线程从队列中 take() 任务,只有到达指定时间才会被取出执行。
java
class Task implements Delayed {
private long time;
private String name;
public Task(long delay, String name) {
this.time = System.currentTimeMillis() + delay;
this.name = name;
}
@Override public long getDelay(TimeUnit unit) {
return unit.convert(time - System.currentTimeMillis(), TimeUnit.MILLISECONDS);
}
@Override public int compareTo(Delayed o) {
return Long.compare(this.time, ((Task)o).time);
}
public void execute() { System.out.println("Run " + name); }
}
public class Scheduler {
public static void main(String[] args) throws InterruptedException {
DelayQueue<Task> queue = new DelayQueue<>();
queue.put(new Task(3000, "task1"));
queue.put(new Task(1000, "task2"));
while (!queue.isEmpty()) {
Task t = queue.take(); // 阻塞直到任务到期
t.execute();
}
}
}
9.6 常见面试简答题
- ArrayBlockingQueue 和 LinkedBlockingQueue 的区别? 前者基于数组,容量固定,一把锁控制生产和消费;后者基于链表,默认无界,使用两把锁分离读写,吞吐量更高。
- ConcurrentLinkedQueue 与 BlockingQueue 的区别? 前者无锁非阻塞,使用 CAS;后者阻塞,使用锁和条件。
- PriorityQueue 的底层数据结构是什么? 二叉堆(数组实现的完全二叉树)。
- 队列和栈的区别? 队列 FIFO,栈 LIFO;队列用于缓冲、调度,栈用于递归、撤销操作等。
- 如何实现一个线程安全的队列? 可直接使用 JUC 下的队列类,或者用
Collections.synchronizedList包装,但性能不如前者。
10. 总结
本文从 Java 队列的接口体系出发,详细介绍了普通队列、优先队列、双端队列以及阻塞队列的实现原理和使用场景。重点掌握了:
- 队列的 FIFO 特性及其典型应用。
- Queue、Deque、BlockingQueue 三大接口及其核心方法。
- PriorityQueue 的堆实现和 TopK 面试题。
- 阻塞队列在生产者消费者模式中的应用。
- ConcurrentLinkedQueue 等并发队列的选择策略。
- 常见的队列相关算法面试题。
理解这些内容,能够帮助你在实际开发中合理选择队列实现,并轻松应对大部分 Java 队列相关的面试。建议结合源码进一步深入,比如分析 ArrayBlockingQueue 的 put 和 take 方法的锁实现,将理论与实践结合。