线程池就是管理一系列线程的资源池。当有任务要处理时,直接从线程池中获取线程来处理,处理完之后线程并不会立即被销毁,而是等待下一个任务。
使用线程池的好处:
- 降低资源消耗。通过重复利用已创建的线程降低线程创建和销毁造成的消耗。
- 提高响应速度。当任务到达时,任务可以不需要等到线程创建就能立即执行。
- 提高线程的可管理性。线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控。
如何创建线程池?
方式一:通过ThreadPoolExecutor
构造函数来创建
方式二:通过 Executor
框架的工具类 Executors
来创建。
Executors
工具类可以创建多种类型的线程池,包括:
FixedThreadPool
:固定线程数量的线程池。该线程池中的线程数量始终不变。当有一个新的任务提交时,线程池中若有空闲线程,则立即执行。若没有,则新的任务会被暂存在一个任务队列中,待有线程空闲时,便处理在任务队列中的任务。SingleThreadExecutor
: 只有一个线程的线程池。若多余一个任务被提交到该线程池,任务会被保存在一个任务队列中,待线程空闲,按先入先出的顺序执行队列中的任务。CachedThreadPool
: 可根据实际情况调整线程数量的线程池。线程池的线程数量不确定,但若有空闲线程可以复用,则会优先使用可复用的线程。若所有线程均在工作,又有新的任务提交,则会创建新的线程处理任务。所有线程在当前任务执行完毕后,将返回线程池进行复用。ScheduledThreadPool
:给定的延迟后运行任务或者定期执行任务的线程池。
Executors
返回线程池对象的弊端如下:
FixedThreadPool
和SingleThreadExecutor
:使用的是有界阻塞队列是LinkedBlockingQueue
,其任务队列的最大长度为Integer.MAX_VALUE
,可能堆积大量的请求,从而导致 OOM。CachedThreadPool
:使用的是同步队列SynchronousQueue
, 允许创建的线程数量为Integer.MAX_VALUE
,如果任务数量过多且执行速度较慢,可能会创建大量的线程,从而导致 OOM。ScheduledThreadPool
和SingleThreadScheduledExecutor
:使用的无界的延迟阻塞队列DelayedWorkQueue
,任务队列最大长度为Integer.MAX_VALUE
,可能堆积大量的请求,从而导致 OOM。
既然Exexutors工具类提供的线程池都有内存耗尽的风险,我们何不自己设计线程池,选用合适的任务队列,来避免出现内存泄漏的现象呢?
ThreadPoolExecutor
核心参数
ThreadPoolExecutor
3 个最重要的参数:
corePoolSize
: 任务队列未达到队列容量时,最大可以同时运行的线程数量。maximumPoolSize
: 任务队列中存放的任务达到队列容量的时候,当前可以同时运行的线程数量变为最大线程数。workQueue
: 新任务来的时候会先判断当前运行的线程数量是否达到核心线程数,如果达到的话,新任务就会被存放在队列中。
ThreadPoolExecutor
其他常见参数 :
keepAliveTime
:线程池中的线程数量大于corePoolSize
的时候,如果这时没有新的任务提交,核心线程外的线程不会立即销毁,而是会等待,直到等待的时间超过了keepAliveTime
才会被回收销毁。unit
:keepAliveTime
参数的时间单位。threadFactory
:executor 创建新线程的时候会用到。handler
:拒绝策略(后面会单独详细介绍一下)。
ThreadPoolExecutor
默认不会回收核心线程,即使它们已经空闲了。这是为了减少创建线程的开销,因为核心线程通常是要长期保持活跃的。但是,如果线程池是被用于周期性使用的场景,且频率不高(周期之间有明显的空闲时间),可以考虑将allowCoreThreadTimeOut(boolean value)
方法的参数设置为true
,这样就会回收空闲(时间间隔由keepAliveTime
指定)的核心线程了。
ThreadPoolExecutor
定义一些策略:
ThreadPoolExecutor.AbortPolicy
:抛出RejectedExecutionException
来拒绝新任务的处理。ThreadPoolExecutor.CallerRunsPolicy
:调用执行自己的线程运行任务,也就是直接在调用execute
方法的线程中运行(run
)被拒绝的任务,如果执行程序已关闭,则会丢弃该任务。因此这种策略会降低对于新任务提交速度,影响程序的整体性能。如果你的应用程序可以承受此延迟并且你要求任何一个任务请求都要被执行的话,你可以选择这个策略。ThreadPoolExecutor.DiscardPolicy
:不处理新任务,直接丢弃掉。ThreadPoolExecutor.DiscardOldestPolicy
:此策略将丢弃最早的未处理的任务请求。
举个例子:Spring 通过 ThreadPoolTaskExecutor
或者我们直接通过 ThreadPoolExecutor
的构造函数创建线程池的时候,当我们不指定 RejectedExecutionHandler
拒绝策略来配置线程池的时候,默认使用的是 AbortPolicy
。在这种拒绝策略下,如果队列满了,ThreadPoolExecutor
将抛出 RejectedExecutionException
异常来拒绝新来的任务 ,这代表你将丢失对这个任务的处理。如果不想丢弃任务的话,可以使用CallerRunsPolicy
。CallerRunsPolicy
和其他的几个策略不同,它既不会抛弃任务,也不会抛出异常,而是将任务回退给调用者,使用调用者的线程来执行任务。
CallerRunsPolicy 拒绝策略有什么风险?如何解决?
如果想要保证任何一个任务请求都要被执行的话,那选择 CallerRunsPolicy
拒绝策略更合适一些。
不过,如果走到CallerRunsPolicy
的任务是个非常耗时的任务,且处理提交任务的线程是主线程,可能会导致主线程阻塞,影响程序的正常运行。
解决方案:
从问题的本质入手,调用者采用CallerRunsPolicy
是希望所有的任务都能够被执行,暂时无法处理的任务又被保存在阻塞队列BlockingQueue
中。这样的话
1.在内存允许的情况下,我们可以增加阻塞队列BlockingQueue
的大小并调整堆内存以容纳更多的任务,确保任务能够被准确执行。
2.为了充分利用 CPU,我们还可以调整线程池的maximumPoolSize
(最大线程数)参数,这样可以提高任务处理速度,避免累计在 BlockingQueue
的任务过多导致内存用完。
如果服务器资源以达到可利用的极限,这就意味我们要在设计策略上改变线程池的调度了,我们都知道,导致主线程卡死的本质就是因为我们不希望任何一个任务被丢弃。换个思路,有没有办法既能保证任务不被丢弃且在服务器有余力时及时处理呢?
3.设计一张任务表将任务存储到 MySQL 数据库中。
4.Redis 缓存任务。
5.将任务提交到消息队列中。
以方案三为例,简单介绍一下实现逻辑:
- 实现
RejectedExecutionHandler
接口自定义拒绝策略,自定义拒绝策略负责将线程池暂时无法处理(此时阻塞队列已满)的任务入库(保存到 MySQL 中)。注意:线程池暂时无法处理的任务会先被放在阻塞队列中,阻塞队列满了才会触发拒绝策略。 - 继承
BlockingQueue
实现一个混合式阻塞队列,该队列包含 JDK 自带的ArrayBlockingQueue
。另外,该混合式阻塞队列需要修改取任务处理的逻辑,也就是重写take()
方法,取任务时优先从数据库中读取最早的任务,数据库中无任务时再从ArrayBlockingQueue
中去取任务。
线程池常用的阻塞队列有哪些?
- 容量为
Integer.MAX_VALUE
的LinkedBlockingQueue
(有界阻塞队列):FixedThreadPool
和SingleThreadExecutor
。FixedThreadPool
最多只能创建核心线程数的线程(核心线程数和最大线程数相等),SingleThreadExecutor
只能创建一个线程(核心线程数和最大线程数都是 1),二者的任务队列永远不会被放满。 SynchronousQueue
(同步队列):CachedThreadPool
。SynchronousQueue
没有容量,不存储元素,目的是保证对于提交的任务,如果有空闲线程,则使用空闲线程来处理;否则新建一个线程来处理任务。也就是说,CachedThreadPool
的最大线程数是Integer.MAX_VALUE
,可以理解为线程数是可以无限扩展的,可能会创建大量线程,从而导致 OOM。DelayedWorkQueue
(延迟队列):ScheduledThreadPool
和SingleThreadScheduledExecutor
。DelayedWorkQueue
的内部元素并不是按照放入的时间排序,而是会按照延迟的时间长短对任务进行排序,内部采用的是“堆”的数据结构,可以保证每次出队的任务都是当前队列中执行时间最靠前的。DelayedWorkQueue
添加元素满了之后会自动扩容,增加原来容量的 50%,即永远不会阻塞,最大扩容可达Integer.MAX_VALUE
,所以最多只能创建核心线程数的线程。ArrayBlockingQueue
(有界阻塞队列):底层由数组实现,容量一旦创建,就不能修改
线程池处理任务的流程了解吗?
- 如果当前运行的线程数小于核心线程数,那么就会新建一个线程来执行任务。
- 如果当前运行的线程数等于或大于核心线程数,但是小于最大线程数,那么就把该任务放入到任务队列里等待执行。
- 如果向任务队列已经满了,但是当前运行的线程数是小于最大线程数的,就新建一个线程来执行任务。
- 如果当前运行的线程数已经等同于最大线程数了,新建线程将会使当前运行的线程超出最大线程数,那么当前任务会被拒绝,拒绝策略会调用
RejectedExecutionHandler.rejectedExecution()
方法。
线程池中线程异常后,销毁还是复用?
- 使用
execute()
提交任务:当任务通过execute()
提交到线程池并在执行过程中抛出异常时,如果这个异常没有在任务内被捕获,那么该异常会导致当前线程终止,会被打印到控制台或日志文件中。线程池会检测到这种线程终止,并创建一个新线程来替换它,从而保持配置的线程数不变。 - 使用
submit()
提交任务:对于通过submit()
提交的任务,如果在任务执行中发生异常,这个异常不会直接打印出来。相反,异常会被封装在由submit()
返回的Future
对象中。当调用Future.get()
方法时,可以捕获到一个ExecutionException
。在这种情况下,线程不会因为异常而终止,它会继续存在于线程池中,准备执行后续的任务。
使用execute()
时,未捕获异常导致线程终止,线程池创建新线程替代;使用submit()
时,异常被封装在Future
中,线程继续复用。
如何设计一个能够根据任务的优先级来执行的线程池?
1.任务队列使用优先级阻塞队列,入队的元素需要按照优先级来排序,可以实现Comparable接口的compareto方法来定义排列顺序。
2.重写priorityBlokingqueue的入队方法,如果任务积数量超过自定义的数量时,将优先级别高的元素入队,优先级低的任务由触发拒绝策略处理.
3.如果不想丢弃任务可继承RejectedExecutionHandler接口并重写方法完成自己的拒绝处理逻辑。
4.为了避免饥饿问题,等待时间过长的任务会被移除并重新添加到队列中,但是优先级会被提升。
Future
Future 类有什么用?
Future
类是异步思想的典型运用,主要用在一些需要执行耗时任务的场景,避免程序一直原地等待耗时任务执行完成,执行效率太低。具体来说是这样的:当我们执行某一耗时的任务时,可以将这个耗时任务交给一个子线程去异步执行,同时我们可以干点其他事情,不用傻傻等待耗时任务执行完成。等我们的事情干完后,我们再通过 Future
类获取到耗时任务的执行结果。
Future
类只是一个泛型接口,位于 java.util.concurrent
包下,其中定义了 5 个方法,主要包括下面这 4 个功能:
- 取消任务;
- 判断任务是否被取消;
- 判断任务是否已经执行完成;
- 获取任务执行结果。
Callable 和 Future 有什么关系?
FutureTask
提供了 Future
接口的基本实现,常用来封装 Callable
和 Runnable
,具有取消任务、查看任务是否执行完成以及获取任务执行结果的方法。ExecutorService.submit()
方法返回的其实就是 Future
的实现类 FutureTask
。
FutureTask
不光实现了 Future
接口,还实现了Runnable
接口,因此可以作为任务直接被线程执行
CompletableFuture 类有什么用?
Future
在实际使用过程中存在一些局限性比如不支持异步任务的编排组合、获取计算结果的 get()
方法为阻塞调用。
Java 8 才被引入CompletableFuture
类可以解决Future
的这些缺陷。CompletableFuture
除了提供了更为好用和强大的 Future
特性之外,还提供了函数式编程、异步任务编排组合(可以将多个异步任务串联起来,组成一个完整的链式调用)等能力。
参考链接:Java并发常见面试题总结(下) | JavaGuide