文章目录
1.6 线程池原理与实战
Java线程的创建非常昂贵,需要JVM和OS(操作系统)配合完成大量的工作:
(1)必须为线程堆栈分配和初始化大量内存块,其中包含至少1MB的栈内存。
(2)需要进行系统调用,以便在OS(操作系统)中创建和注册本地线程。
Java高并发应用频繁创建和销毁线程的操作是非常低效的,而且是不被编程规范所允许的。如何降低Java线程的创建成本?必须使用到线程池。线程池主要解决了以下两个问题:
(1)提升性能:线程池能独立负责线程的创建、维护和分配。在执行大量异步任务时,可以不需要自己创建线程,而是将任务交给线程池去调度。线程池能尽可能使用空闲的线程去执行异步任务,最大限度地对已经创建的线程进行复用,使得性能提升明显。
(2)线程管理:每个Java线程池会保持一些基本的线程统计信息,例如完成的任务数量、空闲时间等,以便对线程进行有效管理,使得能对所接收到的异步任务进行高效调度。
说明
在主要大厂的编程规范中,不允许在应用中自行显式地创建线程,线程必须通过线程池提供。由于创建和销毁线程需要时间以及系统资源开销,使用线程池的好处是减少这些开销,解决资源不足的问题。
1.6.1 JUC的线程池架构
在JUC中有关线程池的类与接口的架构图大致如图1-15所示。
说明
JUC就是java.util.concurrent工具包的简称,该工具包是从JDK 1.5开始加入JDK的,是用于完成高并发、处理多线程的一个工具包。
1.Executor
Executor是Java异步目标任务的“执行者”接口,其目标是执行目标任务。“执行者”Executor提供了execute()接口来执行已提交的Runnable执行目标实例。Executor作为执行者的角色,其目的是提供一种将“任务提交者”与“任务执行者”分离开来的机制。它只包含一个函数式方法:
void execute(Runnable command)
2.ExecutorService
ExecutorService继承于Executor。它是Java异步目标任务的“执行者服务接”口,对外提供异步任务的接收服务。ExecutorService提供了“接收异步任务并转交给执行者”的方法,如submit系列方法、invoke系列方法等,具体如下:
//向线程池提交单个异步任务
<T> Future<T> submit(Callable<T> task);
//向线程池提交批量异步任务
<T> List<Future<T>> invokeAll(Collection<? extends Callable<T>> tasks)
throws InterruptedException;
3.AbstractExecutorService
AbstractExecutorService是一个抽象类,它实现了ExecutorService接口。AbstractExecutorService存在的目的是为ExecutorService中的接口提供默认实现。
4.ThreadPoolExecutor
ThreadPoolExecutor就是大名鼎鼎的“线程池”实现类,它继承于AbstractExecutorService抽象类。
ThreadPoolExecutor是JUC线程池的核心实现类。线程的创建和终止需要很大的开销,线程池中预先提供了指定数量的可重用线程,所以使用线程池会节省系统资源,并且每个线程池都维护了一些基础的数据统计,方便线程的管理和监控。
5.ScheduledExecutorService
ScheduledExecutorService是一个接口,它继承于ExecutorService。它是一个可以完成“延时”和“周期性”任务的调度线程池接口,其功能和Timer/TimerTask类似。
6.ScheduledThreadPoolExecutor
ScheduledThreadPoolExecutor继承于ThreadPoolExecutor,它提供了ScheduledExecutorService线程池接口中“延时执行”和“周期执行”等抽象调度方法的具体实现。
ScheduledThreadPoolExecutor类似于Timer,但是在高并发程序中,ScheduledThreadPoolExecutor的性能要优于Timer。
7.Executors
Executors是一个静态工厂类,它通过静态工厂方法返回ExecutorService、ScheduledExecutorService等线程池示例对象,这些静态工厂方法可以理解为一些快捷的创建线程池的方法。
1.6.2 Executors的4种快捷创建线程池的方法
Java通过Executors工厂类提供了4种快捷创建线程池的方法,具体如表1-1所示。
表1-1 Executors工厂类提供的4种快捷创建线程池的方法
1.newSingleThreadExecutor创建“单线程化线程池”
该方法用于创建一个“单线程化线程池”,也就是只有一个线程的线程池,所创建的线程池用唯一的工作线程来执行任务,使用此方法创建的线程池能保证所有任务按照指定顺序(如FIFO)执行。
(1)单线程化的线程池中的任务是按照提交的次序顺序执行的。
(2)池中的唯一线程的存活时间是无限的。
(3)当池中的唯一线程正繁忙时,新提交的任务实例会进入内部的阻塞队列中,并且其阻塞队列是无界的。
总体来说,单线程化的线程池所适用的场景是:任务按照提交次序,一个任务一个任务地逐个执行的场景。
以上用例在最后调用shutdown()方法来关闭线程池。执行shutdown()方法后,线程池状态变为SHUTDOWN,此时线程池将拒绝新任务,不能再往线程池中添加新任务,否则会抛出RejectedExecutionException异常。此时,线程池不会立刻退出,直到添加到线程池中的任务都已经处理完成才会退出。还有一个与shutdown()类似的方法,叫作shutdownNow(),执行shutdownNow()方法后,线程池状态会立刻变成STOP,并试图停止所有正在执行的线程,并且不再处理还在阻塞队列中等待的任务,会返回那些未执行的任务。
2.newFixedThreadPool创建“固定数量的线程池”
该方法用于创建一个“固定数量的线程池”,其唯一的参数用于设置池中线程的“固定数量”。
“固定数量的线程池”的特点大致如下:
(1)如果线程数没有达到“固定数量”,每次提交一个任务线程池内就创建一个新线程,直到线程达到线程池固定的数量。
(2)线程池的大小一旦达到“固定数量”就会保持不变,如果某个线程因为执行异常而结束,那么线程池会补充一个新线程。
(3)在接收异步任务的执行目标实例时,如果池中的所有线程均在繁忙状态,新任务会进入阻塞队列中(无界的阻塞队列)。
“固定数量的线程池”的适用场景:需要任务长期执行的场景。“固定数量的线程池”的线程数能够比较稳定地保证一个数,能够避免频繁回收线程和创建线程,故适用于处理CPU密集型的任务,在CPU被工作线程长时间占用的情况下,能确保尽可能少地分配线程。
“固定数量的线程池”的弊端:内部使用无界队列来存放排队任务,当大量任务超过线程池最大容量需要处理时,队列无限增大,使服务器资源迅速耗尽。
3.newCachedThreadPool创建“可缓存线程池”
该方法用于创建一个“可缓存线程池”,如果线程池内的某些线程无事可干成为空闲线程,“可缓存线程池”可灵活回收这些空闲线程。
“可缓存线程池”的特点大致如下:
(1)在接收新的异步任务target执行目标实例时,如果池内所有线程繁忙,此线程池就会添加新线程来处理任务。
(2)此线程池不会对线程池大小进行限制,线程池大小完全依赖于操作系统(或者说JVM)能够创建的最大线程大小。
(3)如果部分线程空闲,也就是存量线程的数量超过了处理任务数量,就会回收空闲(60秒不执行任务)线程。
“可缓存线程池”的适用场景:需要快速处理突发性强、耗时较短的任务场景,如Netty的NIO处理场景、REST API接口的瞬时削峰场景。“可缓存线程池”的线程数量不固定,只要有空闲线程就会被回收;接收到的新异步任务执行目标,查看是否有线程处于空闲状态,如果没有就直接创建新的线程。
“可缓存线程池”的弊端:线程池没有最大线程数量限制,如果大量的异步任务执行目标实例同时提交,可能会因创建线程过多而导致资源耗尽。
4.newScheduledThreadPool创建“可调度线程池”
该方法用于创建一个“可调度线程池”,即一个提供“延时”和“周期性”任务调度功能的ScheduledExecutorService类型的线程池。Executors提供了多个创建“可调度线程池”的工厂方法,部分如下:
//方法一:创建一个可调度线程池,池内仅含有一个线程
public static ScheduledExecutorService newSingleThreadScheduledExecutor();
//方法二:创建一个可调度线程池,池内含有N个线程,N的值为输入参数corePoolSize
public static ScheduledExecutorService newScheduledThreadPool(int corePoolSize) ;
newSingleThreadScheduledExecutor工厂方法所创建的仅含有一个线程的可调度线程池适用于调度串行化任务,也就是一个任务一个任务地串行化调度执行。
newScheduledThreadPool工厂方法可以创建一个执行“延时”和“周期性”任务的可调度线程池,所创建的线程池为ScheduleExecutorService类型的实例。ScheduleExecutorService接口中有多个重要的接收被调目标任务的方法,其中scheduleAtFixedRate和scheduleWithFixedDelay使用得比较多。
ScheduleExecutorService中接收被调目标任务的方法之一scheduleAtFixedRate的定义如下:
public ScheduledFuture<?> scheduleAtFixedRate(
Runnable command, //异步任务target执行目标实例
long initialDelay, //首次执行延时
long period, //两次开始执行最小间隔时间
TimeUnit unit //所设置的时间的计时单位,如TimeUnit.SECONDS常量
);
ScheduleExecutorService中接收被调目标任务的方法之二scheduleWithFixedDelay的定义如下:
public ScheduledFuture<?> scheduleWithFixedDelay(
Runnable command, //异步任务target执行目标实例
long initialDelay, //首次执行延时
long delay, //前一次执行结束到下一次执行开始的间隔时间(间隔执行延迟时间)
TimeUnit unit //所设置的时间的计时单位,如TimeUnit.SECONDS常量
);
当被调任务的执行时间大于指定的间隔时间时,ScheduleExecutorService并不会创建一个新的线程去并发执行这个任务,而是等待前一次调度执行完毕。
“可调度线程池”的适用场景:周期性地执行任务的场景。Spring Boot中的任务调度器,底层借助了JUC的ScheduleExecutorService“可调度线程池”实现,并且可以通过@Configuration配置类型的Bean。
以上为Executors中4个主要的快捷创建线程池的方法。为何JUC要提供工厂方法呢?原因是使用ThreadPoolExecutor、ScheduledThreadPoolExecutor构造器创建普通线程池、可调度线程池比较复杂,这些构造器会涉及大量的复杂参数。尽管Executors的工厂方法使用方便,但是在生产场景中被很多企业(尤其是大厂)的开发规范所禁用。
1.6.3 线程池的标准创建方式
大部分企业的开发规范都会禁止使用快捷线程池(具体原因稍后介绍),要求通过标准构造器ThreadPoolExecutor去构造工作线程池。Executors工厂类中创建线程池的快捷工厂方法实际上是调用ThreadPoolExecutor(定时任务使用ScheduledThreadPoolExecutor)线程池的构造方法完成的。ThreadPoolExecutor构造方法有多个重载版本,其中一个比较重要的构造器如下:
// 使用标准构造器构造一个普通的线程池
public ThreadPoolExecutor(
int corePoolSize, // 核心线程数,即使线程空闲(Idle),也不会回收
int maximumPoolSize, // 线程数的上限
long keepAliveTime, TimeUnit unit, // 线程最大空闲(Idle)时长
BlockingQueue<Runnable> workQueue, // 任务的排队队列
ThreadFactory threadFactory, // 新线程的产生方式
RejectedExecutionHandler handler) // 拒绝策略
很无奈,构造一个线程池竟然有7个参数,但是确实需要这么多参数。接下来对这些参数进行具体介绍。
1.核心和最大线程数量
参数corePoolSize用于设置核心(Core)线程池数量,参数maximumPoolSize用于设置最大线程数量。线程池执行器将会根据corePoolSize和maximumPoolSize自动维护线程池中的工作线程,大致规则为:
(1)当在线程池接收到新任务,并且当前工作线程数少于corePoolSize时,即使其他工作线程处于空闲状态,也会创建一个新线程来处理该请求,直到线程数达到corePoolSize。
(2)如果当前工作线程数多于corePoolSize数量,但小于maximumPoolSize数量,那么仅当任务排队队列已满时才会创建新线程。通过设置corePoolSize和maximumPoolSize相同,可以创建一个固定大小的线程池。
(3)当maximumPoolSize被设置为无界值(如Integer.MAX_VALUE)时,线程池可以接收任意数量的并发任务。
(4)corePoolSize和maximumPoolSize不仅能在线程池构造时设置,也可以调用setCorePoolSize()和setMaximumPoolSize()两个方法进行动态更改。
2.BlockingQueue
BlockingQueue(阻塞队列)的实例用于暂时接收到的异步任务,如果线程池的核心线程都在忙,那么所接收到的目标任务缓存在阻塞队列中。
3.keepAliveTime
线程构造器的keepAliveTime(空闲线程存活时间)参数用于设置池内线程最大Idle(空闲)时长(或者说保活时长),如果超过这个时间,默认情况下Idle、非Core线程会被回收。
如果池在使用过程中提交任务的频率变高,也可以调用方法setKeepAliveTime(long,TimeUnit)进行线程存活时间的动态调整,可以将时长延长。如果需要防止Idle线程被终止,可以将Idle时间设置为无限大,具体如下:
setKeepAliveTime(Long.MAX_VALUE,TimeUnit.NANOSECONDS);
默认情况下,Idle超时策略仅适用于存在超过corePoolSize线程的情况。但若调用了allowCoreThreadTimeOut(boolean)方法,并且传入了参数true,则keepAliveTime参数所设置的Idle超时策略也将被应用于核心线程。
1.6.4 向线程池提交任务的两种方式
向线程池提交任务的两种方式大致如下:
方式一:调用execute()方法,例如:
//Executor 接口中的方法
void execute(Runnable command);
方式二:调用submit()方法,例如:
//ExecutorService 接口中的方法
<T> Future<T> submit(Callable<T> task);
<T> Future<T> submit(Runnable task, T result);
Future<?> submit(Runnable task);
以上的submit()和execute()两类方法的区别在哪里呢?大致有以下三点:
(1)二者所接收的参数不一样
Execute()方法只能接收Runnable类型的参数,而submit()方法可以接收Callable、Runnable两种类型的参数。Callable类型的任务是可以返回执行结果的,而Runnable类型的任务不可以返回执行结果。
Callable是JDK 1.5加入的执行目标接口,作为Runnable的一种补充,允许有返回值,允许抛出异常。Runnable和Callable的主要区别为:Callable允许有返回值,Runnable不允许有返回值;Runnable不允许抛出异常,Callable允许抛出异常。
(2)submit()提交任务后会有返回值,而execute()没有
execute()方法主要用于启动任务的执行,而任务的执行结果和可能的异常调用者并不关心。submit()方法也用于启动任务的执行,但是启动之后会返回Future对象,代表一个异步执行实例,可以通过该异步执行实例去获取结果。
(3)submit()方便Exception处理
execute()方法在启动任务执行后,任务执行过程中可能发生的异常调用者并不关心。而通过submit()方法返回的Future对象(异步执行实例),可以进行异步执行过程中的异常捕获。
1.通过submit()返回的Future对象获取结果
submit()方法自身并不会传递结果,而是返回一个Future异步执行实例,处理过程的结果被包装到Future实例中,调用者可以通过Future.get()方法获取异步执行的结果。
2.通过submit()返回的Future对象捕获异常
submit()方法自身并不会传递异常,处理过程中的异常都被包装到Future实例中,调用者在调用Future.get()方法获取执行结果时,可以捕获异步执行过程中抛出的受检异常和运行时异常,并进行对应的业务处理。
在ThreadPoolExecutor类的实现中,内部核心的任务提交方法是execute()方法,虽然用户程序通过submit()也可以提交任务,但是实际上submit()方法中最终调用的还是execute()方法。
1.6.5 线程池的任务调度流程
线程池的任务调度流程(包含接收新任务和执行下一个任务)大致如下:
(1)如果当前工作线程数量小于核心线程数量,执行器总是优先创建一个任务线程,而不是从线程队列中获取一个空闲线程。
(2)如果线程池中总的任务数量大于核心线程池数量,新接收的任务将被加入阻塞队列中,一直到阻塞队列已满。在核心线程池数量已经用完、阻塞队列没有满的场景下,线程池不会为新任务创建一个新线程。
(3)当完成一个任务的执行时,执行器总是优先从阻塞队