简要回答
线程池是一种池化技术,用于预先创建并管理一组线程,避免频繁创建和销毁线程的开销,提高性能和响应速度
它几个关键的配置包括:核心线程数、最大线程数、空闲存活时间、工作队列、拒绝策略
主要工作原理如下:
- 当线程池里存活的线程数小于核心线程数corePoolSize时,这时对于一个新提交的任务,线程池会创建一个线程去处理任务。当线程池里面存活的线程数小于等于核心线程数corePoolSize时,线程池里面的线程会一直存活着,就算空闲时间超过了keepAliveTime,线程也不会被销毁,而是一直阻塞在那里一直等待任务队列的任务来执行。
- 当线程池里面存活的线程数已经等于corePoolSize了,这是对于一个新提交的任务,会被放进任务队列workQueue排队等待执行。
- 当线程池里面存活的线程数已经等于corePoolSize了,并且任务队列也满了,假设maximumPoolSize>corePoolSize,这时如果再来新的任务,线程池就会继续创建新的线程来处理新的任务,知道线程数达到maximumPoolSize,就不会再创建了。
- 如果当前的线程数达到了maximumPoolSize,并且任务队列也满了,如果还有新的任务过来,那就直接采用拒绝策略进行处理。默认的拒绝策略是抛出一个RejectedExecutionException异常。
详细介绍
JDK线程池参数
ThreadPoolExecutor 的通用构造函数:- public ThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue<Runnable> workQueue, ThreadFactory threadFactory, RejectedExecutionHandler handler);
复制代码
- corePoolSize:当有新任务时,如果线程池中线程数没有达到核心线程池的大小corePoolSize,则会创建新的线程执行任务,否则将任务放入阻塞队列。当线程池中存活的线程数总是大于 corePoolSize 时,应该考虑调大 corePoolSize。
- maximumPoolSize:当阻塞队列填满时,如果线程池中线程数没有超过最大线程数maximumPoolSize,则会创建新的线程运行任务。如果线程池中线程数已经达到最大线程数maximumPoolSiz,则会根据拒绝策略处理新任务。非核心线程类似于临时借来的资源,这些线程在空闲时间超过 keepAliveTime 之后,就应该退出,避免资源浪费。
- BlockingQueue:阻塞队列,存储等待运行的任务。
- keepAliveTime:非核心线程空闲后,保持存活的时间,此参数只对非核心线程有效。设置为0,表示多余的空闲线程会被立即终止。
- TimeUnit:keepAliveTime的时间单位TimeUnit.DAYS
- TimeUnit.HOURS
- TimeUnit.MINUTES
- TimeUnit.SECONDS
- TimeUnit.MILLISECONDS
- TimeUnit.MICROSECONDS
- TimeUnit.NANOSECONDS
- ThreadFactory:每当线程池创建一个新的线程时,都是通过线程工厂方法来完成的。在 ThreadFactory 中只定义了一个方法 newThread,每当线程池需要创建新线程就会调用它。
- public class MyThreadFactory implements ThreadFactory {
- private final String poolName;
-
- public MyThreadFactory(String poolName) {
- this.poolName = poolName;
- }
-
- public Thread newThread(Runnable runnable) {
- return new MyAppThread(runnable, poolName);//将线程池名字传递给构造函数,用于区分不同线程池的线程
- }
- }
复制代码
- RejectedExecutionHandler:当队列和线程池都满了的时候,根据拒绝策略处理新任务。
- AbortPolicy:默认的策略,直接抛出RejectedExecutionException。如果是比较关键的业务,推荐使用此拒绝策略,这样子在系统不能承载更大的并发量的时候,能够及时的通过异常发现。
- DiscardPolicy:不处理,直接丢弃。建议是一些无关紧要的业务采用此策略。
- DiscardOldestPolicy:将等待队列队首的任务丢弃,并执行当前任务。得根据实际业务是否允许丢弃老任务来认真衡量。
- CallerRunsPolicy:由调用线程处理该任务CallerRunsPolicy:由调用线程处理该任务
线程池的核心组成
一个完整的线程池,应该包含以下几个核心部分:
- 任务提交:提供接口接收任务的提交;
- 任务管理:选择合适的队列对提交的任务进行管理,包括对拒绝策略的设置;
- 任务执行:由工作线程来执行提交的任务;
- 线程池管理:包括基本参数设置、任务监控、工作线程管理等
线程池生命周期
线程池状态状态释义RUNNING线程池被创建后的初始状态,能接受新提交的任务,并且也能处理阻塞队列中的任务SHUTDOWN关闭状态,不再接受新提交的任务,但仍可以继续处理已进入阻塞队列中的任务STOP会中断正在处理任务的线程,不能再接受新任务,也不继续处理队列中的任务TIDYING所有的任务都已终止,workerCount(有效工作线程数)为0TERMINATED线程池彻底终止运行Tips:千万不要把线程池的状态和线程的状态弄混了。补一张网上的线程状态图
当线程调用start(),线程在JVM中不一定立即执行,有可能要等待操作系统分配资源,此时为READY状态,当线程获得资源时进入RUNNING状态,才会真正开始执行。
线程池的预初始化机制
线程池的预初始化机制是指在线程池创建后,立即创建并启动一定数量的线程,即使这些线程暂时还没有任务要执行。这样做的目的是减少在实际接收到任务时创建线程所需的时间,从而提高响应速度。ThreadPoolExecutor提供了预初始化线程的功能。
预初始化方法(prestartCoreThread / prestartAllCoreThreads): ThreadPoolExecutor提供了两个方法来预初始化线程:
- prestartCoreThread():预初始化一个核心线程。如果核心线程数已经达到了设定的数量,则此方法不会有任何效果。
- public boolean prestartCoreThread() {
- return workerCountOf(ctl.get()) < corePoolSize &&
- addWorker(null, true);
- }
复制代码 - prestartAllCoreThreads():预初始化所有核心线程,即创建并启动等于核心线程数的线程
- public int prestartAllCoreThreads() {
- int n = 0;
- while (addWorker(null, true))
- ++n;
- return n;
- }
复制代码 拒绝策略
- CallerRunsPolicy:调用执行自己的线程运行任务,也就是直接在调用execute方法的线程中运行(run)被拒绝的任务,如果执行程序已关闭,则会丢弃该任务。因此这种策略会降低对于新任务提交速度,影响程序的整体性能。如果你的应用程序可以承受此延迟并且你要求任何一个任务请求都要被执行的话,你可以选择这个策略。
- AbortPolicy:抛出 RejectedExecutionException来拒绝新任务的处理。
- DiscardPolicy:不处理新任务,直接丢弃掉。
- DiscardOldestPolicy:此策略将丢弃最早的未处理的任务请求。
当没有显示指明拒绝策略时,默认使用AbortPolicy
CallerRunsPolicy
如果不允许丢弃任务,就应该选择CallerRunsPolicy。CallerRunsPolicy 和其他的几个策略不同,它既不会抛弃任务,也不会抛出异常,而是将任务回退给调用者,使用调用者的线程来执行任务。- public static class CallerRunsPolicy implements RejectedExecutionHandler {
- public CallerRunsPolicy() { }
- public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
- if (!e.isShutdown()) {
- // 直接主线程执行,而不是线程池中的线程执行
- r.run();
- }
- }
- }
复制代码 存在的问题:如果走到CallerRunsPolicy的任务是个非常耗时的任务,且处理提交任务的线程是主线程,可能会导致主线程阻塞,进而导致后续任务无法及时执行,严重的情况下很可能导致 OOM。
当然,采用CallerRunsPolicy其实就是希望所有的任务都能够被执行,暂时无法处理的任务又被保存在阻塞队列BlockingQueue中。这样的话,在内存允许的情况下,就可以增加阻塞队列BlockingQueue的大小并调整堆内存以容纳更多的任务,确保任务能够被准确执行。为了充分利用 CPU,还可以调整线程池的maximumPoolSize (最大线程数)参数,这样可以提高任务处理速度,避免累计在 BlockingQueue的任务过多导致内存用完。
但是,如果服务器资源达到可利用的极限了呢?导致主线程卡死的本质就是因为不希望任何一个任务被丢弃。换个思路,有没有办法既能保证任务不被丢弃且在服务器有余力时及时处理呢?
可以考虑任务持久化的思路,这里所谓的任务持久化,包括但不限于:
- 设计一张任务表间任务存储到 MySQL 数据库中。
- Redis缓存任务。
- 将任务提交到消息队列中。
这里以方案一为例,简单介绍一下实现逻辑:
- 实现RejectedExecutionHandler接口自定义拒绝策略,自定义拒绝策略负责将线程池暂时无法处理(此时阻塞队列已满)的任务入库(保存到 MySQL 中)。注意:线程池暂时无法处理的任务会先被放在阻塞队列中,阻塞队列满了才会触发拒绝策略。
- 继承BlockingQueue实现一个混合式阻塞队列,该队列包含JDK自带的ArrayBlockingQueue。另外,该混合式阻塞队列需要修改取任务处理的逻辑,也就是重写take()方法,取任务时优先从数据库中读取最早的任务,数据库中无任务时再从 ArrayBlockingQueue中去取任务。
也就是说,一旦线程池中线程达到满载时,就可以通过拒绝策略将最新任务持久化到 MySQL 数据库中,等到线程池有了有余力处理所有任务时,让其优先处理数据库中的任务以避免"饥饿"问题。
当然,对于这个问题,也可以参考其他主流框架的做法:
- 以 Netty 为例,它的拒绝策略则是直接创建一个线程池以外的线程处理这些任务,为了保证任务的实时处理,这种做法可能需要良好的硬件设备且临时创建的线程无法做到准确的监控:
- private static final class NewThreadRunsPolicy implements RejectedExecutionHandler {
- NewThreadRunsPolicy() {
- super();
- }
- public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) {
- try {
- //创建一个临时线程处理任务
- final Thread t = new Thread(r, "Temporary task executor");
- t.start();
- } catch (Throwable e) {
- throw new RejectedExecutionException(
- "Failed to start a new thread", e);
- }
- }
- }
复制代码
- ActiveMQ 则是尝试在指定的时效内尽可能的争取将任务入队,以保证最大交付:
- new RejectedExecutionHandler() {
- @Override
- public void rejectedExecution(final Runnable r, final ThreadPoolExecutor executor) {
- try {
- //限时阻塞等待,实现尽可能交付
- executor.getQueue().offer(r, 60, TimeUnit.SECONDS);
- } catch (InterruptedException e) {
- throw new RejectedExecutionException("Interrupted waiting for BrokerService.worker");
- }
- throw new RejectedExecutionException("Timed Out while attempting to enqueue Task.");
- }
- });
复制代码 线程池中线程异常后,销毁还是复用
先说结论,需要分两种情况:
- 使用execute()提交任务:当任务通过execute()提交到线程池并在执行过程中抛出异常时,如果这个异常没有在任务内被捕获,那么该异常会导致当前线程终止,并且异常会被打印到控制台或日志文件中。线程池会检测到这种线程终止,并创建一个新线程来替换它,从而保持配置的线程数不变。
- 使用submit()提交任务:对于通过submit()提交的任务,如果在任务执行中发生异常,这个异常不会直接打印出来。相反,异常会被封装在由submit()返回的Future对象中。当调用Future.get()方法时,可以捕获到一个ExecutionException。在这种情况下,线程不会因为异常而终止,它会继续存在于线程池中,准备执行后续的任务。
简单来说:使用execute()时,未捕获异常导致线程终止,线程池创建新线程替代;使用submit()时,异常被封装在Future中,线程继续复用。
这种设计允许submit()提供更灵活的错误处理机制,因为它允许调用者决定如何处理异常,而execute()则适用于那些不需要关注执行结果的场景。
execute()提交
查看execute方法的执行逻辑
可以发现,如果抛出异常,execute()提交的方式会移除抛出异常的线程,创建新的线程。
submit()提交
可以发现,submit也是调用了execute方法,但是在调用之前,包装了一层 RunnableFuture,那一定是在RunnableFuture的实现 FutureTask中有特殊处理了,我们查看源码可以发现。
但是,
也就是说,通过java.util.concurrent.FutureTask#get(),就可以获取对应的异常信息。
线程池是如何保活和回收的
线程池的作用就是提高线程的利用率,需要线程时,可以直接从线程池中获取线程直接使用,而不用创建线程,那线程池中的线程,在没有任务执行时,是如何保活的呢?
在runWorker方法里,线程会循环getTask()获取阻塞队列中的任务。
不断地的从阻塞队列中获取任务,主要调用的是workQueue.poll()方法或take(), 这两个方法都会阻塞式的从队列中获取元素,区别是poll()方法可以设置一个超时时间, take()不能设置超时时间,所以这也间接的使得线程池中的线程阻塞等待从而达到保活的效果。
当然并不是线程池中的所有线程都需要一直保活,比如只有核心线程需要保活,非核心线程就不需要保活,那非核心线程是怎么回收的呢?
底层是这样的,当一个线程处理完当前任务后,就会开始去阻塞队列中获取任务,只不过,在调用poll或take方法之前, 会判断当前线程池中有多少个线程,如果多余核心线程数(也就是wc > corePlloSize),那么timed为true,此时当前线程就会调用poll()并设置超时时间来获取阻塞队列中的任务,这样一旦时间到了还没有获取到任务,那么poll方法获取到的r就是null,返回给上一级,runWorker()里的getTask方法就获取到null了,此时while循环就会退出。那么就会调用processWorkerExit()方法,remove当前线程
这里其实可以看到timed还有一个参数,allowCoreThreadTimeOut,这个主要是用来控制核心线程是否可以回收,默认是false,上面是讨论默认值false的情况,即核心线程不会超时。如果为true,工作线程可以全部销毁
实际上,虽然有核心线程数,但线程并没有区分是核心还是非核心,并不是先创建的就是核心,超过核心线程数后创建的就是非核心,最终保留哪些线程,完全随机。
线程池的关闭
shutdown
shutdown方法会将线程池的状态设置为SHUTDOWN,线程池进入这个状态后,就拒绝再接受任务,然后会将剩余的任务全部执行完- public void shutdown() {
- final ReentrantLock mainLock = this.mainLock;
- mainLock.lock();
- try {
- //检查是否可以关闭线程
- checkShutdownAccess();
- //设置线程池状态
- advanceRunState(SHUTDOWN);
- //尝试中断worker
- interruptIdleWorkers();
- //预留方法,留给子类实现
- onShutdown(); // hook for ScheduledThreadPoolExecutor
- } finally {
- mainLock.unlock();
- }
- tryTerminate();
- }
- private void interruptIdleWorkers() {
- interruptIdleWorkers(false);
- }
- private void interruptIdleWorkers(boolean onlyOne) {
- final ReentrantLock mainLock = this.mainLock;
- mainLock.lock();
- try {
- //遍历所有的worker
- for (Worker w : workers) {
- Thread t = w.thread;
- //先尝试调用w.tryLock(),如果获取到锁,就说明worker是空闲的,就可以直接中断它
- //注意的是,worker自己本身实现了AQS同步框架,然后实现的类似锁的功能
- //它实现的锁是不可重入的,所以如果worker在执行任务的时候,会先进行加锁,这里tryLock()就会返回false
- if (!t.isInterrupted() && w.tryLock()) {
- try {
- t.interrupt();
- } catch (SecurityException ignore) {
- } finally {
- w.unlock();
- }
- }
- if (onlyOne)
- break;
- }
- } finally {
- mainLock.unlock();
- }
- }
复制代码 shutdownNow
shutdownNow做的比较绝,它先将线程池状态设置为STOP,然后拒绝所有提交的任务。最后中断左右正在运行中的worker,然后清空任务队列。- public List<Runnable> shutdownNow() {
- List<Runnable> tasks;
- final ReentrantLock mainLock = this.mainLock;
- mainLock.lock();
- try {
- checkShutdownAccess();
- //检测权限
- advanceRunState(STOP);
- //中断所有的worker
- interruptWorkers();
- //清空任务队列
- tasks = drainQueue();
- } finally {
- mainLock.unlock();
- }
- tryTerminate();
- return tasks;
- }
- private void interruptWorkers() {
- final ReentrantLock mainLock = this.mainLock;
- mainLock.lock();
- try {
- //遍历所有worker,然后调用中断方法
- for (Worker w : workers)
- w.interruptIfStarted();
- } finally {
- mainLock.unlock();
- }
- }
复制代码 优雅关闭
一般来说,线程池的优雅关闭需要结合 shutdown() 和 awaitTermination() 的正确使用,二者作用不同但需配合使用
shutdown() 的作用与局限:
- 功能: 调用 shutdown() 会将线程池状态设为 SHUTDOWN,不再接受新任务,但已提交的任务(包括正在执行和队列中的任务)会继续执行直到完成
- 局限: shutdown() 不会阻塞当前线程,主线程调用后会立即返回,无法直接确保所有任务已完成。例如:如果主线程在调用 shutdown() 后直接退出,可能导致程序终止时仍有任务未完成
awaitTermination() 的作用与必要性:
- 功能: awaitTermination(timeout, unit) 会阻塞当前线程,等待线程池中的任务完成或超时。返回 true 表示所有任务已完成,false 表示超时仍有任务未完成
- 必要性: 单独使用 shutdown() 无法保证任务完成,必须通过 awaitTermination() 的阻塞等待机制来确保任务执行完毕。
- executor.shutdown();
- if (!executor.awaitTermination(10, TimeUnit.SECONDS)) {
- executor.shutdownNow(); // 超时后强制关闭
- }
复制代码 为什么不能直接依赖 shutdown() 确保任务完成?
- 异步性: shutdown() 仅触发关闭流程,但主线程不会等待任务完成。若后续代码依赖任务结果,必须通过 awaitTermination() 或类似机制同步等待
- 潜在风险: 若任务执行时间过长或死锁,仅调用 shutdown() 会导致线程池无法关闭,资源无法释放
优雅关闭的最佳实践
- 调用 shutdown():停止接收新任务,允许已提交任务继续执行
- 使用 awaitTermination() 等待:设置合理超时时间(如 5-30 秒),等待任务自然完成
- 超时后强制关闭:若超时仍未完成,调用 shutdownNow() 中断任务并返回未执行任务列表
- 处理中断异常:在任务代码中响应 InterruptedException,确保中断信号能被正确处理
池化带来的问题
- 线程污染:如果线程池中的线程被用于执行不同类型的任务,而这些任务之间存在状态共享或依赖关系,可能会导致线程状态被污染,进而影响任务的正确执行。
- 内存泄漏:如果池化资源(如对象池中的对象)没有被正确地回收或重置,可能会导致内存泄漏。
当然,解决方法就是确保每次使用池化资源后,资源状态被正确重置,避免污染;正确回收,避免泄露。并且通过监控池化资源的使用情况,及时调优配置,以适应不同的负载和需求。
来源:程序园用户自行投稿发布,如果侵权,请联系站长删除
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作! |