Java并发——任务执行(Executors、线程池)
本篇博文是Java并发编程实战的笔记。
直接构建线程的问题
无论在单处理器还是多处理器系统中,多线程都能够提高程序的整体性能,但是如果我们在程序中直接的构建线程,可能会出现一些问题:
public class DirectRunInNewThreadServer {
public void serve() throws IOException {
ServerSocket serverSocket = new ServerSocket();
serverSocket.bind(new InetSocketAddress(80));
while (true) {
Socket socket = serverSocket.accept();
new Thread(
() -> handleRequest(socket)
).start();
}
}
}
上面简单的服务器程序为每个请求连接的客户端都创建一个线程来处理它的请求,在高负载的情况下,它的性能不仅不会向预想中的高,而且还会造成很多风险,甚至造成服务器宕机。
该创建多少个线程
首先,无论你创建多少个线程,机器上的处理器资源是有限的,如果使用你的个人电脑,可能只有8个CPU(核)可用,也就是说最多同时只有8个线程在运行,就算加上超线程技术还有服务器上的CPU数量加成,那么最多也不会有很多个线程在同时运行。
那么只创建CPU个数个线程够吗?不一定,如果你处理用户请求时需要执行的任务都是计算任务,那么够了,因为CPU个数个线程足以让全部CPU在高负载情况下全部保持忙碌状态,如果你要执行的任务中包含IO任务呢(比如连接数据库,读写网络IO)?那就不够了!因为这类任务的特性是,它们通常由于要等待外部系统的返回所以不能立即执行,在等待时,这个线程只能空等,这时如果再加入一个或一批线程,就能够在它等待的同时继续处理其它任务。
所以该创建多少个线程没有一个万全的策略,这通常和你要处理的任务类型有关。只是如果线程创建过多,CPU资源不仅不能得到更多的利用,而且频繁创建切换线程的开销还可能会盖过它们所带来的收益,而如果线程创建的过少,CPU资源可能不能被充分利用而导致它们时常处于空闲状态,而实际上还有任务要处理
无限制创建线程的问题
- 如果
handleRequest
中要执行的内容很快就会返回(实际上在大部分事务型应用中是这样),那么创建并销毁线程的开销甚至要比执行任务的开销还大 - 如果线程数量超过了JVM或操作系统的最大线程数,程序可能会宕机
- 更多的线程占用更多的内存
线程池
通过上面的描述,我们也知道了线程的创建和销毁是有开销的,并且还不小,因为者涉及到系统调用,并且无限制的创建线程除了可能带来性能的负收益以外,还有可能引入风险。
那能不能有一种容器使得我们可以复用已经创建的线程,并且限制容器内最大的可用线程数?那就是线程池。
现在我们要修改我们以往代码中,任务的执行方式,要把下面的代码改成线程池版本
Runnable task = ...;
new Thread(task).start();
Runnable task = ...;
// 将任务提交到线程池中
将上面的代码修改会很麻烦,而且我们的代码中可能还有一些地方就是需要直接创建线程,或者使用不同的线程池,或者干脆让任务以单线程的方式执行,如果有一种同一一致的接口可以将轻松在这些种执行方式中替换就好了。
Executor
Executor是这样的接口,它是一个可以执行已提交的Runnable
任务的对象,它提供了一种将任务的提交和任务的执行方式解耦的手段,线程将如何被创建、使用和调度的细节将都由Executor来管控,你只需要提交任务对象即可。
你可以简单的理解为Executor是任务的执行器,它会执行你提交的任务,你不用关心其内部是如何执行你的任务的。
比如下面,我们对一种计算密集型的任务使用了能够容纳与当前CPU数量相同的线程的线程池来执行,以保证我们的程序能最大的利用CPU的处理能力并且不会创建出更多的无用线程。
private final static int NCPU =
Runtime.getRuntime().availableProcessors();
@Test
void testFixedExecutor() throws InterruptedException {
System.out.println("CPU COUNT : " + NCPU);
// 创建具有与CPU个数相同个线程的线程池来执行任务
Executor threadPool = Executors.newFixedThreadPool(NCPU);
Runnable computeTask = () -> {
int sum = 0;
for (int i=0; i<100; i++) sum += i;
System.out.println(Thread.currentThread() + " : " + sum);
};
// 执行计算任务100次
for (int i=0; i<100; i++) {
threadPool.execute(computeTask);
}
}
假设有一天,我突然希望这些计算任务能够串行被执行,只需要改动一行代码即可:
Executor threadPool = Executors.newSingleThreadExecutor();
Executors中提供了一些工厂方法来创建不同类型的Executor,你也可以实现自己的Executor,该类的注释中给出了一些例子:
第一个通过直接调用r
的run
方法来同步的执行每一个任务,它甚至没有引入线程,这相当于SingleThreadExecutor
,只不过比它更加简单。第二个为每个任务创建一个新线程,相当于最初我们的简单Web服务器的写法。
下面是Executor
接口的全部代码
public interface Executor {
/**
* 在未来的某一时间执行command,这个command可能
* 在一个新线程、一个被池化的线程或者调用者线程被执行
* 具体取决于不同的Executor实现
*
* Throws:
* RejectedExecutionException —— 如果command不能被接受(即Executor有权拒绝一个任务)
* NullPointerExecption —— 如果command == null */
void execute(Runnable command);
}
之前直接使用线程的时候,或者单线程的时候,我们总有一些办法来结束任务的执行,但是Executor
接口并没有给我们相关的方法来结束任务或者查看任务的相关状态。
ExecutorService
JVM提供的Executor
实际上都实现自ExecutorService
接口,这个接口提供了一些扩展功能。
ExecutorService
是一个提供了管理任务中断的方法和一些产生用于跟踪一个或多个异步任务进度的Future
对象的方法的Executor
。
ExecutorService
可以被结束,这将导致它将拒绝新来的所有任务。有两个方法可以用于结束ExecutorService
,shutdown
方法允许之前已提交的任务在ExecutorService
进入中断状态(isTerminated()==true
)之前执行,shutdownNow
方法会阻止那些之前已提交但尚在等待状态的任务被执行并尝试关闭当前正在执行的任务。
一旦ExecutorService
进入了中断状态,那么代表它当前没有正在执行中的任务,没有任务正在等待执行并且没有新任务可以提交进来。
submit
方法扩展了Executor.execute
方法,它返回一个Future
,这个Future
可以被用来取消执行或等待执行完成,invokeAny
和invokeAll
是批量执行任务的常用方式,执行一系列任务并且等待至少一个(Any)或是全部(All)执行完成。
ThreadPoolExecutor
一个使用多个被池化的线程中的一个来执行已提交任务的ExecutorService
,该类一般不会直接被创建,通常是通过Executors
中的工厂方法进行配置并创建。
该类提供了许多可调节的参数和扩展钩子,这让该类可以在很多情况下可以使用。尽管这样,程序员还是被推荐使用更便捷的Executors
工厂方法来创建该类的实例。
下面介绍该类的一些基础配置参数,内容截取自官方文档。
核心和最大池大小
一个线程池会自动的根据corePoolSize
和maximumPoolSize
自动调整池大小。当一个新的任务被提交,如果小于corePoolSize
个线程正在运行,一个新的线程将被创建用来处理这个请求,尽管其它工作线程处于空闲状态。否则,如果小于maximumPoolSize
个线程正在运行并且仅当队列已满时,一个新的线程将被创建用来处理这个请求。
当你设置一致的corePoolSize
和maximumPoolSize
时,你将得到一个固定大小的线程池。通过将线程池的maximumPoolSize
设为一个基本无界的值(如Integer.MAX_VALUE
),代表你允许线程池容纳任意数量的并发任务。一般来说,core和maximum pool大小在构造时就已经被创建,但是它们可以通过setCorePoolSize
和setMaximumPoolSize
被动态更改。
按需构造
默认情况下,每一个核心线程仅在新任务到达时被创建并start
,但是这个行为可以通过prestartCoreThread
和prestartAllCoreThreads
被动态的覆盖。如果你使用非空队列构造池,那么你可能想要prestart
线程。
创建新线程
新线程通过ThreadFactory
来创建,如果未指定则使用Executors.defaultThreadFactory
。它会将所有线程创建到一个ThreadGroup
中,并且它们都具有NORM_PRIORITY
优先级并且都是非守护线程。如果你想修改这个行为,提供自己的ThreadFactory
即可。
keep-alive time
如果池中具有多于corePoolSize
个线程,那么剩余的线程将在它们的空闲时间多于keepAliveTime
时被终结。这是为了当任务不重时减少资源的占用。
getKeepAliveTime
和setKeepAliveTime
可以获取和设置这个值,allowCoreThreadTimeout
用于设定是否允许这个动态缩减线程池中线程的操作可以在池中线程小于等于corePoolSize
时依旧缩减。
队列
任何BlockingQueue
都可以用来传输和保存提交的任务:
- 如果小于
corePoolSize
个线程正在运行,Executor
总是倾向于创建一个新线程而非让任务排队 - 如果大于
corePoolSize
或更多个线程正在运行,Executor
总是倾向于让任务排队,而非创建新线程 - 如果一个请求不能被排队,一个新线程将被创建除非超出
maximumPoolSize
。这种情况下,这个任务将被拒绝。
三种通用的排队策略:
- 直接交接:使用
SynchronousQueue
。如果没有可用线程运行一个任务,那么这个任务的排队尝试将失败,新线程将被创建。通常和无界的maximumPoolSize
一起设置以避免拒绝新任务,但一旦任务到达的速度大于任务处理的速度,这样做会耗尽系统资源。 - 无界队列:使用无界队列(
LinkedBlockingQueue
)将导致新任务在corePoolSize
个线程都忙时等待它们中的一个变空闲,所以,池中不会有多于corePoolSize
个线程。 - 有界队列:如
ArrayBlockingQueue
。与有限的maximumPoolSize
一起使用时,它可以防止系统资源被耗尽,但可能更难以调节和控制。队列大小和maximumPoolSize
的选择应该折衷。使用大的队列和小的池使得CPU和系统资源利用率,上下文切换开销变小,但会造成人为的吞吐量降低;而使用小队列和大的池则会遇到不可接受的调度开销,这也会降低吞吐量。
核心参数的总结
所以一个线程池在创建一个任务时,会经历如下阶段:
- 判断是否当前池中正在运行线程数小于
corePoolSize
,如果是就新建一个线程 - 否则,判断池中是否具有空闲的线程,如果有就让它执行
- 否则,判断当前任务排队队列是否未满,如果是则让任务排队
- 否则,判断是否当前池中正在运行的线程数小于
maximumPoolSize
,如果是就创建一个线程 - 否则,拒绝该任务
上面五条是我在阅读了官方文档后自己总结的结论,如果不对欢迎指正。
Executors
下面看看Executors
中的工厂方法。
newFixedThreadPool
该方法将corePoolSize
和maximumPoolSize
设成了一致的,说明池中最大具有nThreads
个线程,keepAliveTime
虽然被设置成了0毫秒,但是没用,因为在没有使用allowCoreThreadTimeout
之前,空闲线程销毁只对corePoolSize
之外的线程有效,而这里没有之外的线程。LinkedBlockingQueue
代表该线程池永远不会拒绝我们的任务。
newCachedThreadPool
corePoolSize
是0,maximumPoolSize
是Int最大值,这说明该线程池是一个无界线程池,keepAliveTime
为60秒,这意味着任何线程如果处于空闲状态60秒就会被销毁(因为核心池大小为0),SynchronousQueue
与无界线程池配合代表它不会拒绝用户任务,线程池将为任何无法立即得到线程执行的任务将直接创建新线程。