Java阻塞队列
Java阻塞队列
一、阻塞队列
1. 为什么要用阻塞队列?
在多线程领域,所谓阻塞,是指在某些情况下会挂起线程(即阻塞),一旦条件满足,被挂起的线程又会自动被唤醒。
使用阻塞队列能够简化多线程编程,提升程序的性能和可靠性,适应各种并发场景,是实现生产者-消费者模型等常见并发模式的重要工具。它能够有效地衔接生产者和消费者之间的速度差异,提供一种协调和安全的数据交互方式。
2. 为什么需要 BlockingQueue ?
BlcokingQueue继承了Queue接口,是队列的一种,Queue和BlockingQueue都是在Java5中加入的,BlockingQueue是线程安全的,我们在很多场景下都可以利用线程安全的队列来优雅地解决我们业务自身的线程安全问题。
BlockingQueue代表了一个线程安全的队列,不仅可以由多个线程并发访问,还添加了等待/通知机制,以便在队列为空时阻塞获取元素的线程,直到队列变得可用,或者在队列满时阻塞插入元素的线程,直到队列变得可用。因此使用BlockingQueue的好处是我们不需要关心什么时候需要阻塞线程,什么时候需要唤醒线程,因为这些 BlockingQueue 都包办了。
在 juc 包发布以前,多线程环境下,我们每个程序员都必须自己去实现这些细节,尤其还要兼顾效率和线程安全,这会给我们的程序带来不小的复杂性。现在有了阻塞队列,我们的操作就从手动挡换成了自动挡。
3. Java中的阻塞队列
Java 中常用的阻塞队列主要在 java.util.concurrent 包中提供,这些阻塞队列用于在生产者-消费者模式或多线程环境中安全地进行数据交换和同步。
BlockingQueue常见的实现类如下图:
在阻塞队列中有很多方法,而且都非常相似,这里我把常用的8个方法总结了一下以添加、删除为主。主要分为三类:
- 抛出异常:add、remove、element
- 返回结果但是不抛出异常:offer、poll、peek
- 阻塞:take、put
阻塞队列主要方法及其作用:
# 插入元素方法 put(E e):用于向队尾插入元素,如果队列已满,则等待空位。 offer(E e):尝试向队尾插入元素,如果队列已满,则立即返回 false。 offer(E e, long timeout, TimeUnit unit):在指定时间内尝试向队尾插入元素,如果超时且队列仍满,则返回 false。 add(E e):立即插入元素,如果队列已满,则抛出 IllegalStateException。 # 移除元素方法 take():用于从队头移除元素,如果队列为空,则等待新元素加入。 poll():尝试从队头移除元素,如果队列为空,则返回 null。 poll(long timeout, TimeUnit unit):在指定时间内尝试从队头移除元素,如果超时且队列仍为空,则返回 null。 remove(Object o):从队列中移除指定的元素,成功移除返回 true,否则返回 false。 # 检查队列状态方法 peek():查看队头元素但不移除它,如果队列为空,则返回 null。
element():查看队头元素但不移除它,如果队列为空,则抛出 NoSuchElementException。
二、ArrayBlockingQueue
1. 基本介绍
ArrayBlockingQueue 是一个使用数组实现的有界阻塞队列。在创建时需要指定容量大小,并支持公平和非公平两种方式的锁访问机制。
ArrayBlockingQueue中的锁是没有分离的,即生产和消费用的是同一个锁。
适合在容量有限、入队和出队较为频繁的场景下使用。
2. 使用场景:生产者-消费者模式
1 public class ArrayBlockingQueueTest { 2 public static void main(String[] args) { 3 // 初始化一个容量为 10 的 ArrayBlockingQueue 4 ArrayBlockingQueue<Integer> queue = new ArrayBlockingQueue<>(10); 5 6 // 生产者线程 7 Thread producer = new Thread(() -> { 8 try { 9 for (int i = 0; i < 50; i++) { 10 queue.put(i); 11 System.out.println("Produced: " + i); 12 } 13 } catch (InterruptedException e) { 14 Thread.currentThread().interrupt(); 15 } 16 }); 17 18 // 消费者线程 19 Thread consumer = new Thread(() -> { 20 try { 21 while (true) { 22 Integer item = queue.take(); 23 System.out.println("Consumed: " + item); 24 } 25 } catch (InterruptedException e) { 26 Thread.currentThread().interrupt(); 27 } 28 }); 29 30 producer.start(); 31 consumer.start(); 32 33 } 34 }
线程池中的任务队列:
1 public class ArrayBlockingQueueTest { 2 public static void main(String[] args) { 3 // 创建一个容量为 5 的 ArrayBlockingQueue 4 ArrayBlockingQueue<Runnable> taskQueue = new ArrayBlockingQueue<>(5); 5 6 // 创建 ThreadPoolExecutor 7 ThreadPoolExecutor executor = new ThreadPoolExecutor(3, 5, 60L, TimeUnit.SECONDS, taskQueue); 8 9 // 提交任务到线程池 10 for (int i = 0; i < 20; i++) { 11 final int taskId = i; 12 13 try { 14 executor.submit(() -> { 15 System.out.println("Executing task " + taskId); 16 try { 17 // 模拟任务执行 18 System.out.println("Executing Task " + taskId); 19 TimeUnit.SECONDS.sleep(2); // 模拟耗时操作 20 } catch (InterruptedException e) { 21 // 处理中断异常 22 System.err.println("Task " + taskId + " was interrupted"); 23 Thread.currentThread().interrupt(); // 重新设置中断状态 24 } 25 }); 26 } catch (RejectedExecutionException e) { 27 System.out.println("task queue size:" + taskQueue.size()); 28 System.out.println("Task " + taskId + " rejected due to task queue overflow."); 29 } 30 } 31 32 33 // 关闭线程池 34 executor.shutdown(); 35 try { 36 // 等待所有任务完成 37 if (!executor.awaitTermination(60, TimeUnit.SECONDS)) { 38 // 超时则强制关闭 39 executor.shutdownNow(); 40 } 41 } catch (InterruptedException e) { 42 executor.shutdownNow(); 43 } 44 45 System.out.println("All tasks completed."); 46 } 47 }
任务数量固定且可预测的情况下,且希望获得更好的性能,可以考虑使用 ArrayBlockingQueue来实现工作队列。
3. 实现原理
ArrayBlockingQueue是最典型的有界队列,其内部是用数组存储元素的,利用ReentrantLock实现线程安全。部分源码如下:
1 public ArrayBlockingQueue(int capacity) { 2 this(capacity, false); 3 } 4 5 public ArrayBlockingQueue(int capacity, boolean fair) { 6 if (capacity <= 0) 7 throw new IllegalArgumentException(); 8 this.items = new Object[capacity]; 9 lock = new ReentrantLock(fair); 10 notEmpty = lock.newCondition(); 11 notFull = lock.newCondition(); 12 }
在创建时就需要指定容量,之后就不可以在扩容了。在构造函数中我们同样可以指定是否是公平的。如果ArrayBlockingQueue被设置为非公平的,那么就存在插队的可能;如果设置为公平的,那么等待了最长时间的线程会优先被处理,其它线程不允许插队。
三、LinkedBlockingQueue
1. 基本介绍
使用单向链表实现的可选有界/无界阻塞队列。在创建时可以指定容量大小,如果不指定则默认为Integer.MAX_VALUE(无界队列)。和ArrayBlockingQueue不同的是, 它仅支持非公平的锁访问机制。
LinkedBlockingQueue中的锁是分离的,即生产用的是putLock,消费是takeLock,这样可以防止生产者和消费者线程之间的锁争夺。因此生产与消费是可以同时进行的。
部分源码如下:
1 public class LinkedBlockingQueue<E> extends AbstractQueue<E> 2 implements BlockingQueue<E>, java.io.Serializable { 3 4 5 //确保出队操作的线程安全性 6 private final ReentrantLock takeLock = new ReentrantLock(); 7 8 //用于管理"队列非空"条件,控制消费者的等待和唤醒 9 private final Condition notEmpty = takeLock.newCondition(); 10 11 //确保入队操作的线程安全性 12 private final ReentrantLock putLock = new ReentrantLock(); 13 14 //用于管理"队列未满"条件,控制生产者的等待和唤醒 15 private final Condition notFull = putLock.newCondition(); 16 }
注意:不指队列容量大小也是会有风险的,一旦数据生产速度大于消费速度,系统内存将有可能被消耗殆尽,因此要谨慎操作。
2. 典型使用场景:线程池中的任务队列
线程池在执行任务时会用到 LinkedBlockingQueue 来存储待处理任务,队列为空时线程会阻塞等待新任务,队列满时新任务将阻塞等待空位。
1 public class LinkedBlockingQueueTest { 2 public static void main(String[] args) { 3 // 创建一个线程池,核心线程数为2,最大线程数为4,队列容量为10 4 BlockingQueue<Runnable> taskQueue = new LinkedBlockingQueue<>(10); 5 ThreadPoolExecutor executor = new ThreadPoolExecutor(2, 4, 60, TimeUnit.SECONDS, taskQueue); 6 7 // 提交任务到线程池 8 for (int i = 0; i < 20; i++) { 9 final int taskId = i; 10 try { 11 executor.submit(() -> { 12 System.out.println("Executing task " + taskId); 13 try { 14 Thread.sleep(1000); // 模拟任务执行时间 15 } catch (InterruptedException e) { 16 Thread.currentThread().interrupt(); 17 } 18 }); 19 } catch (RejectedExecutionException e) { 20 System.out.println("task queue size:" + taskQueue.size()); 21 System.out.println("Task " + taskId + " rejected due to task queue overflow."); 22 } 23 } 24 25 executor.shutdown(); // 关闭线程池 26 } 27 }
任务数量动态变化的情况下,且不易预测,可以考虑使用 LinkedBlockingQueue 来实现工作队列。
四、PriorityBlockingQueue
1. 基本介绍
支持优先级排序的无界阻塞队列,内部使用数组存储数据,达到容量时,会自动进行扩容。放入的元素必须实现Comparable接口或者在构造函数中传入Comparator对象,并且不能插入 null 元素。
2. 典型使用场景:任务调度系统
在任务调度系统中,PriorityBlockingQueue 可用于存储带有优先级的任务。优先级越高的任务会优先处理,保证关键任务不被延迟。举个简单例子:
1 public class PriorityBlockingQueueTest { 2 3 public static void main(String[] args) throws InterruptedException { 4 PriorityBlockingQueue<Task> queue = new PriorityBlockingQueue<>(); 5 6 // 添加不同优先级的任务 7 queue.put(new Task("Low priority task", 5)); 8 queue.put(new Task("Medium priority task", 3)); 9 queue.put(new Task("High priority task", 1)); 10 11 // 处理任务 12 while (!queue.isEmpty()) { 13 // 优先处理优先级高的任务 14 System.out.println("Processing " + queue.take()); 15 } 16 } 17 18 private static class Task implements Comparable<Task> { 19 private final String name; 20 private final int priority; // 数字越小优先级越高 21 22 public Task(String name, int priority) { 23 this.name = name; 24 this.priority = priority; 25 } 26 27 @Override 28 public int compareTo(Task other) { 29 //优先级数字越小的任务优先级越高 30 return Integer.compare(this.priority, other.priority); 31 } 32 33 @Override 34 public String toString() { 35 return "Task{name='" + name + "', priority=" + priority + "}"; 36 } 37 } 38 }
优先级队列放入元素的时候,会进行排序,所以我们需要指定排序规则,有2种方式:
1)创建PriorityBlockingQueue指定比较器Comparator
2)放入的元素需要实现Comparable接口
上面2种方式必须选一个,如果2个都有,则走第一个规则排序。
五、DelayQueue
1. 基本介绍
Delay这个队列比较特殊,具有延迟的功能,我们可以设定在队列中的任务延迟多久之后执行。它是无界队列,但是放入的元素必须实现Delayed接口,而Delayed接口又继承了Comparable接口,所以自然就拥有了比较和排序的能力。
2. 典型使用场景:定时任务调度
假设我们需要实现一个简单的定时任务调度系统,任务将在指定的延迟后执行。下面是一个使用 DelayQueue 的示例:
1 public class DelayQueueTest { 2 public static void main(String[] args) { 3 DelayQueue<DelayedTask> queue = new DelayQueue<>(); 4 5 // 添加任务,延迟5秒和10秒执行 6 queue.put(new DelayedTask("Task 1", 5, TimeUnit.SECONDS)); 7 queue.put(new DelayedTask("Task 2", 10, TimeUnit.SECONDS)); 8 9 10 // 处理任务 11 try { 12 while (!queue.isEmpty()) { 13 // 阻塞,直到有任务可以处理 14 DelayedTask task = queue.take(); 15 System.out.println("Executing: " + task.getTaskName()); 16 } 17 } catch (InterruptedException e) { 18 Thread.currentThread().interrupt(); // 重新设置线程的中断状态 19 System.out.println("Task execution was interrupted"); 20 } 21 } 22 23 static class DelayedTask implements Delayed { 24 private final String taskName; 25 private final long delayTime; 26 private final long triggerTime; 27 28 public DelayedTask(String taskName, long delay, TimeUnit unit) { 29 this.taskName = taskName; 30 this.delayTime = delay; 31 this.triggerTime = System.currentTimeMillis() + unit.toMillis(delay); 32 } 33 34 @Override 35 public long getDelay(TimeUnit unit) { 36 long diff = triggerTime - System.currentTimeMillis(); 37 return unit.convert(diff, TimeUnit.MILLISECONDS); 38 } 39 40 @Override 41 public int compareTo(Delayed o) { 42 if (this.getDelay(TimeUnit.MILLISECONDS) < o.getDelay(TimeUnit.MILLISECONDS)) { 43 return -1; 44 } 45 if (this.getDelay(TimeUnit.MILLISECONDS) > o.getDelay(TimeUnit.MILLISECONDS)) { 46 return 1; 47 } 48 return 0; 49 } 50 51 public String getTaskName() { 52 return taskName; 53 } 54 } 55 }
3. 实现原理
public interface Delayed extends Comparable<Delayed> { long getDelay(TimeUnit unit); }
可以看出这个Delayed接口继承自Comparable,里面需要实现getDelay方法。这里的getDelay()返回的是还剩下多长的延迟时间才会被执行。如果返回0或者负数则代表任务已过期。元素会根据延迟时间的长短被放到队列的不同位置,越靠近队列头代表越早过期。
六、SynchronousQueue
一种不存储元素的阻塞队列,每次插入操作必须等待另一个线程的移除操作,反之亦然。
七、LinkedBlockingDeque
LinkedBlockingDeque 是双端队列,允许在队列两端插入和移除元素。适合需要从两端操作的场景,如任务管理中从队头和队尾分别处理任务。
特点:双端,线程安全,有界或无界,适合双向处理需求的场景。
八、LinkedTransferQueue
基于链表的无界阻塞队列,允许生产者将元素直接传输给消费者。提供 transfer 方法,如果当前没有消费者,会阻塞直到有消费者消费。
九、总结
在任务数量较大、并发度较高的场景下,LinkedBlockingQueue 往往会比 ArrayBlockingQueue 更具优势,因为它的锁机制允许更高效的并行处理。而在任务数量可控且对内存占用有较高要求的场景下,ArrayBlockingQueue 由于其数组基础结构,可能会更为合适。因此,选择哪种阻塞队列需要根据具体的应用场景和需求进行评估。
参考链接:
https://juejin.cn/post/7149883540165885966