Java线程池的设计与使用
多线程情景引入
情景分析
- 请求积压的情况
- 系统资源受限: 当大量用户请求同时到来时,服务器受限于内存、CPU、和网络带宽等资源,导致用户长时间等待。
- 后端处理能力限制: 如频率限制措施(每秒或每几秒的访问限制)在高并发情况下会增加服务器压力,导致请求处理延迟。
- 线程数限制: 例如,Tomcat服务器在高并发下(如200用户/秒)可能因线程数限制而导致请求积压,延迟数据处理。
- 流量突刺情况
- 第三方服务能力限制: 如使用AI服务每三秒只能处理一个请求,在高并发下突然到来上百个请求,可能会导致AI过载,甚至服务拒绝。
- AI处理能力限制: AI服务在面对大量同时请求时可能误判为攻击,导致服务限制或拒绝。
解决方法
- 异步化策略: 识别并处理那些可能导致服务处理能力受限或处理时间过长的场景,如数据量大或第三方服务响应慢的情况。
- 减少等待时间: 通过异步处理避免用户长时间等待,如在处理繁重任务时采用异步方式。
系统问题分析总结
- 面对的问题:
- 用户等待时间过长: 主要由于AI结果生成的延迟。
- 服务器资源紧张: 高并发请求可能导致系统资源紧张,极端情况下可能造成服务器宕机。
- 第三方服务能力限制: 如AI服务的处理能力限制(例如每3秒处理1个请求)可能导致处理不及时。
- 综合对策: 面对上述问题,采用异步化解决方案,优化系统处理能力和用户体验。
异步化
- 介绍
- 同步与异步的对比:
- 同步: 完成一件事情后才能开始另一件(如烧水后才能开始工作)。
- 异步: 在处理一件事情的同时,可以进行另一件事情。一旦第一件事完成,系统会通过通知告知,从而进行后续处理(如在烧水的同时处理其他工作,水壶的蜂鸣器通知水烧好,可进行下一步)。
- 通知机制: 异步化的关键在于知道何时任务已完成,需要一个有效的通知机制。
- 同步与异步的对比:
- 异步业务流程分析
- 在异步流程中,用户提交请求后无需在界面等待,可直接返回主界面或继续其他操作。提交完成后,可以在主页上看到图表生成状态。
- 消息通知功能: 用于告知用户任务完成情况,比如在界面右上角提供消息通知。
- 标准异步化业务流程
- 流程实施:
- 用户长时间操作时,提交请求后无需等待,系统先将请求保存至数据库。
- 将用户任务加入任务队列,由程序或线程按顺序执行。
- 任务队列类似备忘录,记录待处理事项,按资源可用性处理。
- 处理策略:
- 如果任务队列满或线程忙碌,可以选择直接拒绝任务,或记录下来待后续处理。
- 无论成功与否,应将任务保存到数据库以供后期查阅。
- 对于提交失败的任务,可在程序空闲时从数据库中提取并执行。
- 任务状态更新:
- 程序执行任务后,更新数据库中的任务状态。
- 为用户提供查询任务状态的功能,以减少无尽等待。
- 用户体验优化:
- 异步执行适用于复杂分析,用户可提交新任务或实时查看状态,而非长时间等待。
- 进度条等可视化工具可用于显示任务进度,提高透明度和用户体验。
- 流程实施:
- 标准异步化流程总结
- 提交长时间操作请求后,任务保存至数据库,无需用户在界面等待。
- 根据系统资源状况,任务可能立即执行、进入等待队列,或在失败时被记录待后续处理。
- 系统从队列中取任务执行,并更新状态。
- 用户可查询任务状态或接收完成通知(如邮件、系统消息)。
注意点
- 选择异步化: 并非所有操作都需要异步化。只有在执行时间长的场景中考虑异步化,以避免增加代码复杂度和潜在问题。
- 异步处理的复杂性: 异步执行中,开发者可能不清楚程序执行到哪一步,因此需要记录每个小任务的状态或进度。
- 用户体验: 对于复杂任务,提供进度条等可视化工具,以便用户了解任务执行情况,优化体验。
异步化流程与线程池概念
-
异步化流程详解
- 线程角色分配: 设想我们的程序有一个工作者,即线程,例如称之为线程A。此外,存在一个待处理任务的队列。
- 任务处理: 用户提交任务(如智能分析任务1),任务加入队列。线程小季负责从队列中取出任务执行,但由于线程处理能力有限,一次只能处理一个任务。
- 多任务情况: 若有第二个任务(任务2)提交,而线程A正在处理任务1,则需考虑调用另一个线程(比如线程B)来处理。如果有更多任务,而可用线程已满,新任务将排队等待处理。
- 队列溢出处理: 当任务队列满时,为维护系统稳定性,新任务会被记录到数据库中,但暂时不加入队列,待有空闲线程时再加入处理。
- 任务分配策略: 如何分配任务至线程(比如线程ABCD-Z)需根据实际情况和处理速度来决定。例如,线程A处理速度快,可连续处理多个任务,而线程B处理较慢。、
线程与任务队列的可视化图解,如下图所示:
包含以下步骤:
- 用户提交任务: 用户的任务请求首先被提交。
- 任务加入队列: 提交的任务会被加入到待处理的任务队列中。
- 线程处理任务: 根据线程的可用性(例如线程A和线程B),从队列中取出任务进行处理。
- 返回结果: 线程完成任务后,结果会返回给用户。
- 队列溢出处理: 当任务队列满时,新提交的任务会被记录到数据库中,而不是直接加入队列。
- 任务再分配: 当有线程变为空闲状态时,可以从数据库中取出之前记录的任务,重新加入队列处理。
-
线程池总结
- 线程管理的复杂性: 管理线程(如何新增或减少线程)和任务处理(何时接收或拒绝任务)是复杂的。
- 线程池作用: 线程池协助管理线程,调整任务执行流程,确保高效协调。
- 线程池的灵活性: 可根据需求设定线程池的最大线程数,线程池会根据任务紧急程度或线程空闲状态来分配任务。
-
线程池的实现和应用
- 实现挑战: 自行实现线程池涉及多方面的考虑,例如何时增加或减少线程,如何防止多个线程抢占同一任务等。
- 协调与任务窃取: 在Linux环境中,存在一种任务窃取的机制,例如线程A效率高可接手线程B的任务,这需要线程间的有效协调。
- 数据结构的选择: 实现线程池时,所选用的数据结构(如阻塞队列)对线程管理策略和任务分配具有重要影响。
-
注意事项
- 任务队列的设计: 要考虑任务队列的最大容量和处理策略,以防溢出时影响系统性能。
- 线程池大小的选择: 依据系统负载和任务特性调整线程池大小,避免资源浪费或处理瓶颈。
- 任务分配策略: 合理分配任务至线程,考虑任务的紧急程度和线程的处理能力,以提高效率。
线程池的选用与学习
- 在Spring框架中实现线程池
- 使用
ThreadPoolTaskExecutor
和@Async
注解: 在Spring框架中,可以通过ThreadPoolTaskExecutor
结合@Async
注解来实现线程池。这种方法虽然可行,但有一些局限。 - 局限性: Spring作为一个全面的框架,对线程池进行了封装,这可能隐藏了一些底层细节。对于初学者来说,这种封装可能不利于深入理解线程池的工作原理。
- 使用
- 直接使用Java并发编程包
- 推荐使用
ThreadPoolExecutor
: 直接使用Java中的并发编程包,特别是ThreadPoolExecutor
,可以实现更灵活和详细的线程池定制。这种方法更适合于需要深入了解和控制线程行为的场景。 - Java并发编程包的学习建议: 建议在学完Spring Boot并能够实现一个项目,以及在学习完Redis之后,再系统地学习Java并发编程包(Java Concurrency Utilities, JUC)。这样的顺序可以帮助避免过早的学习压力,让学习者在有了一定的实践基础后,更好地理解并发编程的概念和应用。
- 推荐使用
实践建议
- 逐步学习: 并发编程是一个复杂且强大的工具,适合在掌握了基础的编程技能和框架使用后进一步学习。
- 理论与实践相结合: 在学习并发编程的过程中,结合具体的项目实践,可以更加深刻地理解并发控制的细节和挑战。
- 了解底层实现: 尽管框架如Spring提供了便利的封装,但理解其底层实现(如直接使用Java的并发工具)有助于深入理解并发机制。
Java-JUC并发编程-线程池学习与使用
线程池参数详解
ThreadPoolExecutor 主要有以下几个参数:
public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler)
Java线程池参数的详细讲解:
corePoolSize
(核心线程数)- 描述:核心线程数类似于公司的正式员工,随时准备处理任务。
- 作用:保证即使在负载较低时,也有一定数量的线程活跃以应对突发任务。
- 设定原则:根据允许同时进行的任务数来设定。例如,如果AI服务允许4个任务同时执行,核心线程数应设为4。
maximumPoolSize
(最大线程数)- 描述:最大线程数定义了线程池在极限情况下的线程数量上限。
- 作用:确保在高负载情况下,线程池不会超过一定的资源限制。当线程数 = maximumPoolSize, 且任务队列已满,此时添加任务时会触发RejectedExecutionHandler进行处理
- 设定原则:考虑成本和资源限制。例如,如服务最多允许4个任务同时执行,则最大线程数应设为4。
keepAliveTime
(空闲线程存活时间)- 描述:这个参数决定了非核心线程(即临时线程)在空闲时会等待多久才被终止。
- 作用:管理线程池的规模,释放不再需要的线程资源。
- 设定原则:根据任务频率和资源管理需求来设定,以避免过多占用资源。
TimeUnit
(时间单位)- 描述:用于设定
keepAliveTime
参数的时间单位,例如分钟、秒等。 - 作用:提供灵活性,以便根据具体场景选择合适的时间单位。
- 描述:用于设定
workQueue
(任务队列)- 描述:存储待执行任务的队列,也称为阻塞/工作队列。
- 作用:缓存待处理任务,确保按顺序执行。
- 设定原则:需要设定队列长度,因为无限长度的队列可能会消耗过多系统资源。
threadFactory
(线程工厂)- 描述:负责生成新线程的工厂,类似于公司的人力资源部门。
- 作用:允许自定义线程的创建,如设定线程名称、优先级等。
RejectedExecutionHandler
(拒绝策略)- 描述:定义当任务队列满时如何处理新来的任务,例如抛出异常或采用其他策略。
- 应用:可以设定任务优先级,或者使用资源隔离策略(例如,分别为VIP任务和普通任务设立不同的线程池)。
参数设置参考建议
任务类型考虑
- 计算密集型任务: 对于CPU密集型任务(如视频处理、图像处理等),
corePoolSize
建议设置为CPU核数+1,以最大化CPU利用率,减少线程切换。 - I/O密集型任务: 对于带宽、内存或硬盘读写密集的任务,
corePoolSize
可以设置得相对较大,一般经验值为2倍CPU核数,但应以实际I/O能力为准。
工作队列(workQueue
)的详细描述
在Java线程池中,workQueue
是用于存储等待执行的任务的队列。它是线程池的一个重要组成部分,影响着线程池的任务处理策略。以下是几种常见的工作队列类型及其特点:
-
ArrayBlockingQueue
- 有界队列- 特点: 这是一个基于数组结构的有界阻塞队列,需要在创建时指定队列的大小。
- 适用场景: 当希望线程池处理任务的数量有一个明确的上限时,以避免资源耗尽。
- 有界队列(如
ArrayBlockingQueue
)可以防止资源耗尽,但可能导致新任务在队列满时被拒绝。
-
LinkedBlockingQueue
- 链表队列- 有界与无界:
- 如果在创建时指定了大小,它表现得与
ArrayBlockingQueue
相似。 - 如果未指定大小,它将变成一个实际上的无界队列,其最大容量为
Integer.MAX_VALUE
。
- 如果在创建时指定了大小,它表现得与
- 适用场景: 适用于任务处理不需要严格的数量限制,或者当希望队列能够自动扩容以处理更多的任务时。
- 无界队列(如未指定大小的
LinkedBlockingQueue
)可以减少任务拒绝的风险,但可能会导致系统资源耗尽,尤其是在任务提交速度远大于处理速度的情况下。
- 有界与无界:
-
SynchronousQueue
- 同步阻塞队列- 特点: 这个队列没有实际的容量。每一个插入操作必须等待一个相应的删除操作,反之亦然。
- 适用场景: 适用于任务处理需要即时响应的场景。例如,
newCachedThreadPool
就使用这种队列。 - 同步队列(如
SynchronousQueue
)适用于任务执行需要立即处理的场景,但可能会对线程池的工作效率产生影响。
ThreadFactory
- 创建线程的工厂
ThreadFactory
是一个接口,允许你在Java线程池中自定义线程的创建方式。通过实现ThreadFactory
,可以在创建线程时执行一些特定操作,例如自定义线程的名称、优先级、是否为守护线程等。以下是ThreadFactory
的一个自定义实现示例:
private static class CustomThreadFactory implements ThreadFactory {
private final AtomicInteger POOL_NUMBER = new AtomicInteger(1);
private final ThreadGroup group;
private final AtomicInteger threadNumber = new AtomicInteger(1);
private final String namePrefix;
CustomThreadFactory() {
SecurityManager s = System.getSecurityManager();
group = (s != null) ? s.getThreadGroup() : Thread.currentThread().getThreadGroup();
namePrefix = "ume-pool-" + POOL_NUMBER.getAndIncrement() + "-thread-";
}
@Override
public Thread newThread(@NonNull Runnable runnable) {
Thread thread = new Thread(group, runnable, namePrefix + threadNumber.getAndIncrement(), 0);
if (thread.isDaemon()) {
thread.setDaemon(false);
}
if (thread.getPriority() != Thread.NORM_PRIORITY) {
thread.setPriority(Thread.NORM_PRIORITY);
}
return thread;
}
}
RejectedExecutionHandler
- 拒绝策略
当线程池中的线程数达到最大值且工作队列已满时,RejectedExecutionHandler
定义了线程池如何处理无法执行的新提交任务。RejectedExecutionHandler
是一个接口,定义了线程池如何处理这些不能执行的任务。以下是一些标准的实现:
AbortPolicy
- 实现: 这是默认的拒绝策略。当任务被拒绝时,它会抛出
RejectedExecutionException
异常。 - 应用场景: 适用于那些希望在任务超出处理能力时立即得知的场合。这种策略可以迅速反馈系统过载的问题。
- 实现: 这是默认的拒绝策略。当任务被拒绝时,它会抛出
CallerRunsPolicy
- 实现: 此策略并不抛弃任务,也不抛出异常。相反,它会将任务回退到调用者,从而在调用者的线程中运行任务。
- 应用场景: 适用于在任务被拒绝时仍希望任务得以执行的场合。这种方式减少了新任务的丢弃,但增加了调用者线程的负载。
DiscardPolicy
- 实现: 任务被拒绝时,该策略将直接丢弃任务,而不会有任何动作。
- 应用场景: 当任务可以被安全丢弃时使用。这种策略适用于那些对丢弃任务不敏感的场合。
DiscardOldestPolicy
- 实现: 该策略将丢弃最早的未处理任务(即队列中最长时间的任务),然后尝试重新提交新的任务。
- 应用场景: 适用于希望牺牲部分旧任务以获取新任务处理机会的场景。这种方式试图通过替换旧任务来为新任务腾出空间。
选择合适的策略
在选择合适的RejectedExecutionHandler
实现时,需要考虑以下因素:
- 任务的重要性: 如果每个任务都非常重要,不能被丢弃,那么
CallerRunsPolicy
可能是一个更好的选择。 - 资源限制: 如果系统资源(如内存和CPU)非常有限,可能需要选择
DiscardPolicy
或DiscardOldestPolicy
来避免资源过载。 - 性能要求: 如果系统性能是关键考虑因素,
AbortPolicy
可以迅速反馈系统过载的问题,但可能需要额外的错误处理逻辑。
策略的实现
以下是Java线程池中几种标准拒绝策略的参考代码实现:
-
AbortPolicy
这是默认的拒绝策略,抛出
RejectedExecutionException
异常。RejectedExecutionHandler abortPolicyHandler = new ThreadPoolExecutor.AbortPolicy();
-
CallerRunsPolicy
这种策略将任务回退到调用者的线程中运行。
RejectedExecutionHandler callerRunsPolicyHandler = new ThreadPoolExecutor.CallerRunsPolicy();
-
DiscardPolicy
这种策略将直接丢弃任务,而不会有任何动作。
RejectedExecutionHandler discardPolicyHandler = new ThreadPoolExecutor.DiscardPolicy();
-
DiscardOldestPolicy
这种策略将丢弃队列中最早的未处理任务,然后尝试重新提交新的任务。
RejectedExecutionHandler discardOldestPolicyHandler = new ThreadPoolExecutor.DiscardOldestPolicy();
-
自定义
RejectedExecutionHandler
自定义的拒绝策略可以根据具体需要进行实现,例如下面这个示例记录了被拒绝的任务。
private static class CustomRejectedExecutionHandler implements RejectedExecutionHandler { private CustomRejectedExecutionHandler() {} @Override public void rejectedExecution(Runnable r, ThreadPoolExecutor e) { Log.e("umeweb", "Task " + r.toString() + " rejected from " + e.toString()); } }
在使用这些策略时,可以将它们作为参数传递给ThreadPoolExecutor
的构造函数,以定义线程池如何处理超出其容量和队列限制的任务。
线程池工作原理
假设我们的设置的参数:
corePoolSize: 2
maximumPoolSize: 4
workQueue.size = 2
- 最初开始的时候,没有任何线程也没有任何任务添加进任务队列里
- 来了一人任务,发现我们的员工还没有达到正式员工数(corePoolSize = 2),来一个员工直接处理这个任务.
又来了一个任务,发现我们的员工还没有达到正式员工数(corePoolSize = 2),再来一个员工直接处理这个任务.
又来了一个任务,但是我们正式员工数已经满了(当前线程数 = corePolSize = 2),任务放到队列(最大长度workQueue.size 是 2) 里等待,而不是再加新员工。
又来了一个任务,但是我们的任务队列已经满了(当前线程数> corePoolSize = 2,已有任务数 = 最大长度workQueue.size = 2) ,新增线程 (maximumPoolSize = 4)来处理新任务,而不是丢弃任务
已经到了任务7,但是我们的任务队列已经满了、临时工也招满了(当前线程数 = maximumPoolSize = 4,已有任务数 = 最大长度,workQueue.size = 2),调用 RejectedExecutionHandler 拒绝策略来处理多余的任务
如果当前线程数超过 corePoolSize (正式员工数),又没有新的任务给他,那么等 keepAliveTime 时间达到后就可以把这个线程释放。
线程池使用方法
此类中定义了线程池的核心参数,包括核心线程数、最大线程数、空闲线程的存活时间、时间单位和任务队列。threadPoolExecutor方法创建并返回一个配置好的ThreadPoolExecutor实例。自定义线程工厂threadFactory用于创建具有特定名称模式的线程,而AbortPolicy作为拒绝策略在任务被拒绝时抛出异常。这些配置确保了线程池的高效和合理管理,同时提供了足够的灵活性来处理各种任务。
package com.caixy.backend.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import javax.validation.constraints.NotNull;
import java.util.concurrent.*;
/**
* 线程池配置
*
* @name: com.caixy.backend.config.ThreadPoolExecutorConfig
* @author: CAIXYPROMISE
* @since: 2024-01-09 19:36
**/
@Configuration
public class ThreadPoolExecutorConfig
{
// 核心线程数 - 线程池保持活跃的线程数
private final int corePoolSize = 10;
// 最大线程数 - 线程池最大能创建的线程数
private final int maximumPoolSize = 20;
// 空闲线程存活时间 - 当线程数大于核心线程数时,这是多余空闲线程在终止前的最大存活时间
private final long keepAliveTime = 60;
// 时间单位 - 上述存活时间的时间单位
private final TimeUnit unit = TimeUnit.SECONDS;
// 队列容量 - 存放待执行任务的队列容量
private final int queueCapacity = 50;
// 工作队列 - 用于存放待执行的任务
private final ArrayBlockingQueue<Runnable> workQueue = new ArrayBlockingQueue<>(queueCapacity);
/**
* 配置并返回一个ThreadPoolExecutor线程池。
* 此方法创建一个新的ThreadPoolExecutor并配置其参数
*
* @return 配置好的ThreadPoolExecutor实例
*/
@Bean
public ThreadPoolExecutor threadPoolExecutor()
{
// 自定义线程工厂 - 用于创建新线程
ThreadFactory threadFactory = new ThreadFactory()
{
private int count = 0;
@Override
public Thread newThread(@NotNull Runnable r)
{
Thread thread = new Thread(r);
// 设置线程名称
thread.setName("thread-" + count++);
return thread;
}
};
// 创建并返回ThreadPoolExecutor实例
return new ThreadPoolExecutor(
corePoolSize,
maximumPoolSize,
keepAliveTime,
unit,
workQueue,
threadFactory,
new ThreadPoolExecutor.AbortPolicy() // 拒绝策略 - 当线程池无法接受任务时抛出异常
);
}
}
创建线程池异步任务
@RestController
@RequestMapping("/thread")
@AllArgsConstructor
public class ThreadController
{
// 注入线程池
private final ThreadPoolExecutor threadPoolExecutor;
@GetMapping("/put")
public String putThread()
{
CompletableFuture.runAsync(() -> {
log.info("{} to do something!!", Thread.currentThread().getName());
try
{
Thread.sleep(1000);
}
catch (InterruptedException e)
{
e.printStackTrace();
}
log.info("{} finished!!", Thread.currentThread().getName());
}, threadPoolExecutor);
return "Task submitted successfully";
}
}
CompletableFuture.runAsync(Runnable runnable, Executor executor)
是Java 8引入的CompletableFuture
类的一个方法。它用于异步执行一个任务,这个任务不返回任何值(即Runnable
接口的一个实例)。该方法提供了一种在将来某个时间点完成任务执行的方式,而不会阻塞当前线程。以下是该方法的关键特征:
- 异步执行:
runAsync
方法会在一个单独的线程(从指定的Executor
中获取)上异步执行给定的Runnable
任务。这意味着方法调用会立即返回,而实际的任务执行会在另一个线程中进行。 - 任务类型: 作为参数的
Runnable
接口代表没有返回值的任务。如果您有返回值的异步任务,应该考虑使用CompletableFuture.supplyAsync(Supplier<U>, Executor)
。 - 使用指定的
Executor
: 您可以指定一个Executor
来执行任务,这提供了对执行环境更多的控制。例如,可以指定一个线程池来管理任务的执行。如果不指定Executor
,则会使用ForkJoinPool.commonPool()
作为默认执行器。 - 返回值: 方法返回一个
CompletableFuture<Void>
对象。这个CompletableFuture
可以用来检查任务是否完成(成功或失败),并允许您在任务完成后添加进一步的操作,如使用thenAccept
、thenRun
或exceptionally
方法。
结语
如果您喜欢我们的文章,请不要忘记点击关注。我们将继续推出更多关于计算机视觉、人工智能、以及C++、Python、Java等技术领域的精彩内容。您的支持是我们不断前进、分享更多知识和见解的最大动力。我们期待与您一起探索这些激动人心的技术领域,共同成长。感谢您的阅读和支持,敬请期待我们的后续文章!