剖析Java并发设计模式:生产者-消费者模式、读写锁模式与线程池模式!| 多线程篇(九)在现代软件开发中,多线程应用无处
环境说明:Windows 10 + IntelliJ IDEA 2021.3.2 + Jdk 1.8
一、前言
- 并发集合-基本概念:了解并发集合的工作机制和它们如何保证在多线程环境下的线程安全性。
- 并发集合-使用场景:探讨在何种情况下使用并发集合比传统集合更加合适。
- 并发集合-源码解析:深入分析
ConcurrentHashMap
、ConcurrentLinkedQueue
和ConcurrentSkipListSet
等类的内部实现。 - 并发集合-案例分析:通过实际的编程案例,展示并发集合在多线程程序中的应用。
- 并发集合-优点与局限:评估并发集合在不同场景下的性能表现和潜在的使用限制。
并发集合通过提供线程安全的存储和访问机制能够简化了多线程数据共享问题,现在我们将继续学习使用其他方式来解决更广泛的并发问题,例如设计模式,大致有如下:
- 生产者-消费者模式:我们可以了解如何在线程间协调数据的生产和消费。
- 读写锁模式:将展示如何在保持高性能的同时,安全地进行资源的读写操作。
- 线程池模式:将指导我们如何有效地管理线程资源,提高任务执行的效率。
二、摘要
在如今的项目开发中,多线程的使用无处不在,提供其并发功能,但同时也带来了复杂的设计挑战。为了解决这些使用弊端,Java 就提供了一系列并发设计模式,这些模式可以帮助开发者以一种高效、结构化的方式来构建多线程应用,使其避免并发所带来的陷阱,如死锁、竞争条件和资源争用等。在本章中,我们将深入探讨这三种并发设计模式:生产者-消费者模式、读写锁模式和线程池模式,通过实际案例、源码分析、内部原理等剖析,我们将能更深入了解这些模式,以便于我们能够恰如其分的使用好它们。
三、正文
3.1 何为并发设计模式?
所谓并发设计模式(Concurrent Design Patterns),它是一类专门用于处理多线程或并发编程中常见问题的设计模式。这些模式提供了可靠、可扩展且高效的解决方案,用于管理线程之间的协作、同步、数据共享和任务调度,帮助开发者们避免常见的并发编程陷阱,如死锁、竞争条件和资源争用等。以下是本文将要讨论的三种并发设计模式:
- 生产者-消费者模式:
这是一种非常经典的并发模式,用于解决多个线程之间协调生产和消费数据的问题。在Java中,我们通常使用阻塞队列(如ArrayBlockingQueue
、LinkedBlockingQueue
等)来实现这种模式。阻塞队列在数据未满时允许生产者插入数据,数据未空时允许消费者取出数据,从而实现线程间的同步。
- 读写锁模式:
当一个资源同时被多个线程读或偶尔被一个线程写时,读写锁(ReentrantReadWriteLock
)提供了一种更细粒度的锁策略。它允许多个线程同时读取资源,但写入时需要独占访问。这种模式适用于读操作远多于写操作的场景,可以显著提高性能。
- 线程池模式:
线程池是一种用于管理线程执行的模式,它可以有效地控制任务的执行。通过重用线程资源,线程池避免了频繁创建和销毁线程的开销。Java中的ExecutorService
和相关类(如ThreadPoolExecutor
)提供了线程池的实现。
3.2 生产者-消费者模式
3.2.1 概述
何为生产者-消费者模式?这个问题大家可以思索片刻,3秒过后我来统一公布正确答案。何为生产者-消费者模式?它是一种经典的并发设计模式,用于解决在多线程环境中多个线程之间就某一资源的生产和消费的同步问题。在这个模式中,生产者线程负责生成数据,而消费者线程则负责处理(消费)这些数据。
3.2.2 核心组成
如下是其模式的核心组成部分:
- 生产者(Producer):生成数据的线程。
- 消费者(Consumer):处理数据的线程。
- 缓冲区(Buffer):存储生产者生成的数据,供消费者使用。在Java中,这通常是一个阻塞队列。
3.2.3 工作原理
针对该模式,先来梳理一下它究竟是如何工作的?概述如下:
- 生产者将生成的数据放入缓冲区。
- 消费者从缓冲区取出数据进行处理。
- 当缓冲区满时,生产者会等待,直到消费者从缓冲区取出数据。
- 当缓冲区空时,消费者会等待,直到生产者生成新数据放入缓冲区。
3.2.4 使用场景
针对该模式,我们可以主动去探索,比如说该模式可以运用于何场景,又那些场景不适合,而适合用其余两种,目前我先来抛砖引玉,列举了如下两种:
- 任务队列管理:在Web服务器或消息队列中,任务或消息的生产和处理。
- 数据流处理:在数据流应用中,如日志处理或实时数据分析,数据的生成和消费。
3.2.5 案例演示
接着我给大家通过一个案例,演示一下如何使用该生产者-消费者模式,理论与实操相结合,辅助大家深度理解。
3.2.5.1 案例代码
如下是通过代码实现【生产者-消费者模式】,仅供参考。
public class ProducerConsumerExample {
private final BlockingQueue<Integer> queue = new ArrayBlockingQueue<>(10);
public static void main(String[] args) {
ProducerConsumerExample example = new ProducerConsumerExample();
Thread producer = new Thread(() -> {
for (int i = 0; i < 20; i++) {
try {
example.produce(i);
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
Thread consumer = new Thread(() -> {
for (int i = 0; i < 20; i++) {
try {
example.consume();
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
producer.start();
consumer.start();
}
public void produce(Integer value) throws InterruptedException {
queue.put(value);
System.out.println("Produced: " + value);
}
public void consume() throws InterruptedException {
Integer value = queue.take();
System.out.println("Consumed: " + value);
}
}
3.2.5.2 案例代码执行结果
根据如上的案例,我在本地进行测试结果如下,仅供参考,你们也可以自行修改测试用例或者添加其他的测试数据或测试方法,以便于进行熟练学习以此加深知识点的理解。
3.2.5.3 案例代码解析
根据如上测试用例,在此我给大家进行深入详细的解读一下测试代码,以便于更多的同学能够理解并加深印象。
如上代码是一个生产者-消费者模式的演示案例,我分享的目的注释为了展示如何使用ArrayBlockingQueue
作为线程安全的阻塞队列来实现生产者和消费者之间的协调。下面是对代码的详细解析:
-
BlockingQueue
:BlockingQueue
接口在这里由ArrayBlockingQueue
实现,提供了线程安全的元素添加(put
)和移除(take
)操作。ArrayBlockingQueue
是一个基于数组的有界阻塞队列,容量设为10,意味着队列最多可以容纳10个元素,超过这个数量时生产者线程将会被阻塞,直到队列中有空闲位置。
-
生产者线程:
- 生产者线程生成从0到19的整数,并通过
produce
方法将这些整数放入队列中。 - 使用了
BlockingQueue
的put
方法,该方法会在队列满时阻塞生产者线程,直到队列中有空间。 - 生产者线程每次放入一个整数后,休眠100毫秒,这使得生产速度低于消费速度,从而能够观察到消费者线程的行为。
- 生产者线程生成从0到19的整数,并通过
-
消费者线程:
- 消费者线程从队列中取出整数并通过
consume
方法处理这些整数。 - 使用了
BlockingQueue
的take
方法,该方法会在队列为空时阻塞消费者线程,直到队列中有元素。 - 消费者线程每次取出一个整数后,也休眠100毫秒,模拟消费操作的延迟。
- 消费者线程从队列中取出整数并通过
代码解析:
queue.put(value)
:将元素添加到队列中。如果队列已满,生产者线程会被阻塞,直到队列中有空闲位置。queue.take()
:从队列中移除并返回一个元素。如果队列为空,消费者线程会被阻塞,直到队列中有元素。Thread.sleep(100)
:线程休眠100毫秒,模拟生产和消费的延迟。
3.2.6 优缺点分析
我们不能说一味的夸赞它的好就看不见它的弊端,我们更应该看到它的不足与短板,才能激发更多技术人员对技术上的追求与热爱,激发更多人拥有科研精神及打破精神,如果说它已经都是众人眼中天花板了,那只能说境界太窄,唯有接受它的不足,才能精益求精,更上一层楼!技术永远都没有终点,唯有不断挑战,它的高度才能无限被拉高!如下是我对它的一些优点与缺点总结,请同学们参考:
-
优点:
- 解耦生产者和消费者:两者独立运行,互不干扰。
- 提高效率:生产者和消费者可以并行工作,提高整体处理速度。
- 灵活性:缓冲区的大小可以根据需要调整。
-
缺点:
- 复杂性:增加了程序的复杂度,特别是在处理多个生产者和消费者时。
- 资源管理:不当的缓冲区大小可能导致资源浪费或死锁。
3.2.7 小结
对于生产者-消费者模式,它是处理多线程间数据生产和消费问题的有效解决方案。通过使用阻塞队列作为缓冲区,可以协调生产者和消费者之间的工作,实现数据交换。合理应用此模式,可以显著提升多线程应用的性能和响应能力,这点还是值得深究的。
3.3 读写锁模式
3.3.1 概述
对于读写锁模式(Reader-Writer Lock Pattern),它是一种用于控制对共享资源的并发访问的设计模式。它允许多个读操作同时进行,但写操作是排他的,即在写操作时不允许其他读或写操作。
3.3.2 核心概念
- 读者(Readers):执行读操作的线程,可以同时访问资源。
- 写者(Writer):执行写操作的线程,需要独占访问资源。
- 锁管理器(Lock Manager):控制对资源的访问,确保同步。
3.3.3 工作原理
- 当一个线程需要读取资源时,它请求一个读锁。
- 如果没有其他线程持有写锁,读锁会被授予,线程可以开始读取。
- 如果一个线程需要写入资源,它请求一个写锁。
- 在授予写锁之前,所有现有的读锁必须被释放,新的读锁请求必须被阻塞,直到写操作完成。
3.3.4 使用场景
- 数据库系统:在数据库系统中,读写锁可以提高查询(读操作)的并发性,同时确保更新(写操作)的一致性。
- 配置管理:在应用程序中,配置信息可能频繁地被读取,但很少被修改,读写锁可以提高性能。
3.3.5 案例演示
Java中的ReentrantReadWriteLock
类提供了读写锁的实现:
3.3.5.1 案例代码
如下是通过代码实现【读写锁模式】,仅供参考。
public class ReadWriteLockExample {
private final ReadWriteLock lock = new ReentrantReadWriteLock();
private final StringBuffer buffer = new StringBuffer();
public void readOperation() {
lock.readLock().lock();
try {
String content = buffer.toString();
// 处理读取到的内容
} finally {
lock.readLock().unlock();
}
}
public void writeOperation(String content) {
lock.writeLock().lock();
try {
buffer.append(content);
// 写入内容到缓冲区
} finally {
lock.writeLock().unlock();
}
}
public static void main(String[] args) {
ReadWriteLockExample example = new ReadWriteLockExample();
// 启动多个线程执行读操作和写操作
}
}
3.3.5.2 运行结果展示
根据如上的测试用例,作者在本地进行测试结果如下,仅供参考,你们也可以自行修改测试用例或者添加其他的测试数据或测试方法,以便于进行熟练学习以此加深知识点的理解。
3.3.5.3 案例代码解析
接着我将对上述代码进行详细的一个逐句解读,希望能够帮助到同学们,能以更快的速度对其知识点掌握学习,这也是我写此文的初衷,授人以鱼不如授人以渔,只有将其原理摸透,日后应对场景使用,才能得心应手,所以如果有基础的同学,可以略过如下代码分析步骤,然而没基础的同学,还是需要加强对代码的理解,方便你深入理解并掌握其常规使用。
如上使用示例展示了如何使用ReadWriteLock
来实现对共享资源的读写锁定。ReadWriteLock
允许多个线程同时读取共享资源,但在写入操作时则会排他性地锁定,确保数据的一致性。以下是代码的详细解析:
关键组件:
-
ReadWriteLock
:ReadWriteLock
接口允许更细粒度的锁定控制,它分为读锁(readLock
)和写锁(writeLock
)。ReentrantReadWriteLock
是ReadWriteLock
接口的一个实现,提供了可重入的读写锁功能。
-
StringBuffer
:StringBuffer
是一个可变的字符序列,线程安全的,因为它的所有方法都使用了同步机制。- 在这个例子中,它用于存储要读写的内容。
方法解析:
-
readOperation
方法:- 获取读锁:调用
lock.readLock().lock()
来获取读锁。 - 读取内容:在持有读锁的情况下,读取
buffer
的内容。 - 释放读锁:在
finally
块中调用lock.readLock().unlock()
以确保读锁在操作完成后被释放。
- 获取读锁:调用
-
writeOperation
方法:- 获取写锁:调用
lock.writeLock().lock()
来获取写锁。 - 写入内容:在持有写锁的情况下,将内容追加到
buffer
中。 - 释放写锁:在
finally
块中调用lock.writeLock().unlock()
以确保写锁在操作完成后被释放。
- 获取写锁:调用
-
main
方法:
- 线程启动:在
main
方法中,实例化ReadWriteLockExample
类并启动多个线程来执行读和写操作(具体的线程创建和启动代码未提供)。
代码分析:
- 读操作:多个线程可以同时进行读操作,因为
ReadWriteLock
允许多个读锁同时存在,但写锁会阻止任何读锁和写锁的存在。这使得读操作不会相互阻塞。 - 写操作:写操作是排他性的,只有一个线程可以持有写锁,写锁会阻止其他线程获取读锁或写锁。写操作完成后,所有读操作才能继续进行。
3.3.6 优缺点分析
-
优点:
- 提高读操作的并发性:允许多个线程同时读取,提高性能。
- 写操作的安全性:写操作时保证独占访问,避免数据不一致。
-
缺点:
- 写操作的瓶颈:写操作可能会阻塞后续的读操作,成为性能瓶颈。
- 复杂性:增加了锁管理的复杂性,需要仔细处理锁的获取和释放。
3.3.7 小结
读写锁模式为多线程环境下的资源访问提供了一种灵活的同步机制。通过区分读锁和写锁,它允许在不冲突的情况下提高读操作的并发性,同时确保写操作的安全性。合理应用读写锁模式可以在保证数据一致性的同时,提升系统的整体性能。
3.4 线程池模式
3.4.1 简介
何为线程池模式?线程池模式它是一种在多线程编程中常用的设计模式,用于管理和优化线程的创建和销毁过程。线程池提供了一个线程队列,复用已创建的线程来执行多个任务,从而提高了应用程序的效率和响应速度。
3.4.2 核心概念
- 任务(Task):需要执行的工作,通常实现
Runnable
接口或Callable
接口。 - 线程池(ExecutorService):管理线程和任务的执行,复用线程资源。
- 工作线程(Worker Thread):线程池中的线程,用于执行任务。
3.4.3 工作原理
- 线程池创建并初始化一定数量的工作线程。
- 当一个任务提交给线程池时,线程池会将任务放入任务队列。
- 工作线程从任务队列中取出任务并执行。
- 任务执行完成后,工作线程返回线程池,等待执行下一个任务。
3.4.4 使用场景
- 批量数据处理:需要并行处理大量数据时,如图像处理或数据分析。
- 异步任务执行:需要异步执行任务而不阻塞主线程,如网络请求或日志记录。
3.4.5 案例演示
Java中的ExecutorService
接口和ThreadPoolExecutor
类提供了线程池的实现:
3.4.5.1 示例代码
如下是通过代码实现【线程池模式】,仅供参考。
public class ThreadPoolExample {
private final ExecutorService executorService = Executors.newFixedThreadPool(4);
public void submitTasks() {
for (int i = 0; i < 10; i++) {
int finalI = i;
executorService.submit(() -> {
// 模拟任务执行
System.out.println("Task " + finalI + " executed by " + Thread.currentThread().getName());
});
}
}
public static void main(String[] args) {
ThreadPoolExample example = new ThreadPoolExample();
example.submitTasks();
// 合理地关闭线程池
example.executorService.shutdown();
try {
if (!example.executorService.awaitTermination(60, TimeUnit.SECONDS)) {
example.executorService.shutdownNow();
}
} catch (InterruptedException e) {
example.executorService.shutdownNow();
}
}
}
3.4.5.2 运行结果展示
根据如上的测试用例,作者在本地进行测试结果如下,仅供参考,你们也可以自行修改测试用例或者添加其他的测试数据或测试方法,以便于进行熟练学习以此加深知识点的理解。
3.4.5.3 案例代码解析
接着我将对上述代码进行详细的一个逐句解读,希望能够帮助到同学们,能以更快的速度对其知识点掌握学习,这也是我写此文的初衷,授人以鱼不如授人以渔,只有将其原理摸透,日后应对场景使用,才能得心应手,所以如果有基础的同学,可以略过如下代码分析步骤,然而没基础的同学,还是需要加强对代码的理解,方便你深入理解并掌握其常规使用。
这个示例代码展示了如何使用Java的线程池(ExecutorService
)来并发地执行任务,并在完成后适当地关闭线程池。以下是代码的详细解析:
关键组件:
-
ExecutorService接口:
ExecutorService
是Java并发框架中的一个接口,提供了管理线程池和任务执行的功能。Executors.newFixedThreadPool(int nThreads)
创建一个固定大小的线程池,线程池中的线程数量为nThreads
。
-
线程池:
- 线程池管理多个线程,允许我们将多个任务提交给线程池,线程池会负责管理这些线程的生命周期和任务的调度。
方法解析:
-
submitTasks() 方法:
- 这个方法创建了10个任务,并将它们提交给线程池执行。
- 使用
executorService.submit()
方法提交任务。每个任务会在线程池中的一个线程上执行。 finalI
用于确保每个任务能够正确地访问循环变量i
,因为 lambda 表达式中的局部变量必须是final
或有效的final
。
-
main() 方法:
- 在
main
方法中,首先创建ThreadPoolExample
实例并调用submitTasks
提交任务。 - 使用
executorService.shutdown()
方法来关闭线程池,这将停止接受新任务,并在所有任务完成后关闭线程池。 awaitTermination(60, TimeUnit.SECONDS)
用于等待线程池中的所有任务在60秒内完成。如果线程池在60秒内没有完成所有任务,则调用shutdownNow()
强制停止线程池。- 在
catch
块中,处理InterruptedException
异常,并调用shutdownNow()
确保线程池被强制关闭。
- 在
代码分析:
-
线程池的创建:
Executors.newFixedThreadPool(4)
创建一个包含4个线程的固定线程池。线程池中的线程数目是固定的,当线程池中的线程忙碌时,新的任务会被排队,直到有线程可用。 -
任务提交:通过
submit
方法提交的任务会被线程池中的线程执行。submit
方法允许我们提交Runnable
或Callable
任务,并返回一个Future
对象,我们可以使用这个对象来检查任务的执行状态或获取任务的结果(如果有的话)。 -
关闭线程池:
shutdown()
方法会关闭线程池,不再接受新任务,但会继续执行已提交的任务。awaitTermination
方法会等待线程池中的所有任务完成,最多等待指定的时间。如果在指定时间内线程池没有完成所有任务,shutdownNow()
方法会被调用,强制停止线程池中的所有任务并关闭线程池。
3.4.6 小结
这个示例我目的是为了展示了如何有效地使用线程池来并发地执行任务,并在所有任务完成后正确地关闭线程池。合理地管理线程池的生命周期和任务执行对于确保程序的性能和资源管理至关重要。
四、小结
以上三种并发设计模式理论及实践演示就已经全部讲解完了,在结束本期内容之前,我们先来小结一下本期的主要内容要点。生产者-消费者模式教会我们如何协调线程间的数据生产和消费;读写锁模式展示了在保持高性能的同时,如何安全地进行资源的读写操作;线程池模式指导我们如何有效地管理线程资源,提高任务执行的效率。
通过本章的学习,我们不仅能掌握Java并发设计模式的使用,还提升了解决并发问题的能力。这些模式是Java并发编程中的重要组成部分,它们以一种高效且易于管理的方式来保证多线程环境下数据的一致性和完整性。
五、总结
在本章中,我们深入探讨了Java中的三种并发设计模式:生产者-消费者模式、读写锁模式和线程池模式。通过理论+实践教学模式,希望能够帮助大家早日掌握并发设计模式,特别是如上提及的这三种,将其灵活运用到日常开发中去,辅助大家能够开发出更高质量的系统平台。
六、结语
最后,我想说,多线程编程是Java核心模块之一。我们只需要通过不断学习和实践,就能够精通Java这门开发语言。希望本章的内容能够帮助大家更好地理解和运用Java中的并发设计模式,编写出更高效、更健壮的并发程序。记住,唯有不断学习和实践,才能掌握精通Java语言。
ok,以上就是我这期的全部内容啦,若想学习更多,你可以持续关注我,我会把这个多线程篇系统性的更新,保证每篇都是实打实的项目实战经验所撰。只要你每天学习一个奇淫小知识,日积月累下去,你一定能成为别人眼中的大佬的!功不唐捐,久久为功!
「学无止境,探索无界」,期待在技术的道路上与你再次相遇。咱们下期拜拜~~
七、往期推荐
转载自:https://juejin.cn/post/7405526698937384987