JUC是 java.util.concurrent 这个包的简写,其中存放了Java并发框架为协调并发任务所提供的一些工具。本文介绍其中的Executors、ThreadPoolExecutor类。
在Java中,xx池的概念是很常见的,比如之前遇到过的常量池、数据库连接池等等。线程池是一种常用的多线程处理方式,它可以重复利用已创建的线程,从而减少线程的创建和销毁开销,并提高程序的性能。
构造一个新的线程开销有些大,因为这涉及与操作系统的交互。如果你的程序中创建了大量的生命期很短的线程,那么不应该把每个任务映射到一个单独的线程,而应该使用线程池(thread pool)。线程池中包含许多准备运行的线程。为线程池提供一个 Runnable,就会有一个线程调用 run 方法。当run 方法退出时,这个线程不会死亡,而是留在池中准备为下一个请求提供服务。
通俗来说,线程池就是提前把线程准备好。当有任务要执行时,原本我们会直接创建一个线程(从系统申请一个线程的资源),然后让这个线程来执行这个任务。但有了线程池后,创建线程不是直接从系统申请,而是从池子里拿;线程不用了,也是还给池子。
池存在的目的是为了提高效率。线程的创建虽然比进程轻量,但是在频繁创建的情况下,开销也是不可忽略的。
线程池最大的好处就是减少了每次启动、销毁线程的损耗。
其实解决频繁创销线程带来的开销这个问题,可以用线程池,也可以用协程。协程是轻量级的线程,不过Java的标准库还不支持(Java只在第三方库的层面上支持协程)。
目录
一、为什么从线程池中获取线程更高效?
⭐线程池的优点
二、Java标准库中的线程池 ExecutorService
1、Executors执行器类 & 线程池的创建
(1)调用Executors类静态工厂方法创建线程池
(2)总结:在使用连接池时所做的工作
2、工厂模式
3、ThreadPoolExecutor类⭐
4、标准库提供的4种拒绝策略⭐
三、代码实现线程池
1、代码解析
2、实际开发中如何给线程池设置合适的线程数量
四、总结:线程池的执行流程
1. 任务提交
2. 队列处理
3. 线程调度
4. 任务执行
5. 线程回收
6. 线程池关闭
为什么从池子里拿线程要比从系统创建线程更高效?
这是因为,从人程池拿线程是纯粹的用户态操作;而从系统创建线程,就涉及到用户态和内核态之间的切换(真正的创建是要在内核态完成的)。
用户态、内核态是操作系统中的基本概念。
一个操作系统 = 内核 + 配套的应用程序
内核是操作系统最核心的功能模块的集合,如硬件管理,各种驱动,进程管理,内存管理,文件系统等等。这个内核要给上层的应用程序提供支持。比如在显示器上打印hello的操作: println("hello"),应用程序就要调用系统内核,告诉内核我要进行一个打印字符串操作,内核再通过驱动程序操作显示器,完成上述功能。
然而,同一时刻应用程序可能有很多,但内核始终只有一个。内核又要给这么多程序提供服务,这就导致了有的时候服务不一定那么及时。
想象一个银行的工作场景:
这里的柜台内部就相当于内核态,而大厅相当于用户态。
有个办业务的滑稽大哥和柜台的工作人员说,想办一张银行卡,但却发现没有带身份证复印件。这时,工作人员给了他两个方案:要么自己拿着身份证到大厅的复印机复印,要么把身份证交给工作人员,由工作人员在柜台内的复印机复印。
这两种方式在效率上是有差别的:
1、如果自己去复印,快去快回,复印完之后立即就回来了。
2、如果由工作人员复印,他可能去复印的同时还顺便干点别的,比如喝口水,上个厕所,完成一下上级给的别的任务,和同事唠唠嗑……最终确实也能给滑稽大哥复印,但可能就没那么及时了。
总结起来就是,纯用户态的操作,时间是可控的;但涉及到内核态操作,时间就不太可控了。
降低资源消耗:减少线程的创建和销毁带来的性能开销。
提高响应速度:当任务来时可以直接使用,不用等待线程创建。
可管理性: 进行统一的分配,监控,避免大量的线程间因互相抢占系统资源导致的阻塞现象。
ExecutorService是 Java 中用于管理和执行异步任务的接口,它是一个高级的线程池管理工具,继承自 Executor 接口。ExecutorService 接口定义了一组方法,可以用于提交任务、执行任务、关闭线程池等。
执行任务:
提交任务:可用下面的方法之一将Runnable或 Callable对象提交给ExecutorService,线程池会在方便的时候尽早执行提交的任务。调用submit 时,会得到一个 Future 对象,可用来得到结果或者取消任务:
使用完一个线程池时,调用:
通过使用 ExecutorService,我们可以将任务提交给线程池,由线程池自动分配和执行任务。线程池会管理线程的创建、复用和销毁,使多线程任务的执行更高效可控。
执行器(Executors)类有许多静态工厂方法,用来构造线程池。这些方法返回 ExecutorService 接口 或 ScheduledExecutorService 接口的实例,即线程池实例。ExecutorService 也提供了一些提交任务和管理线程池的方法,如submit()
、shutdown()
等。
使用 Executors执行器类 可以方便地创建和管理线程池,实现多线程编程以及控制线程的执行方式、数量和生命周期。
以下是创建线程池的代码示例:创建好线程池后,通过pool.submit()方法,向线程池中注册任务。
//创建线程池
ExecutorService pool = Executors.newFixedThreadPool(10);
//添加任务到线程池中
pool.submit(() -> {
System.out.println("hello");
});
Executors执行器
类提供的各种静态工厂方法可以用于创建不同类型的线程池,常用的方法有:
newFixedThreadPool(int nThreads)
:创建一个固定大小的线程池,该线程池中最多同时执行nThreads
个任务。如果提交的任务数多于空闲线程数,就把未得到服务的任务放到阻塞队列中。当其他任务完成以后再运行这些排队的任务。
newCachedThreadPool()
:构造一个线程池,会立即执行各个任务,如果有空闲线程可用,就使用现有空闲线程执行任务;如果没有可用的空闲线程,则创建一个新线程。
newSingleThreadExecutor()
:创建只包含单个线程的线程池。由一个线程顺序地执行所提交的任务(一个接着一个执行)。
newScheduledThreadPool(int corePoolSize)
:创建一个固定大小的线程池,该线程池可以安排任务在指定延迟后执行,或定期执行,是进阶版的 Timer。它返回一个ScheduledExecutorService 接口的实例。
不同方法创建出的线程池具有不同的特性。这些特性决定了当外部线程向线程池提交一个任务后,该线程池将如何调度其中的工作线程来处理这个任务。
值得注意的是:在创建线程池pool时,并非直接new了一个ExecutorService对象,而是通过了Executors类里面的静态方法完成对象的构造,这样的静态方法就是前面提到的静态工厂方法。new操作隐藏在了这个方法背后。这里就涉及到了一种常见的设计模式:工厂模式。
工厂模式在创建对象时不再直接new,而是使用一些其他的方法(通常是静态方法)协助我们把对象创建出来。
事实上,工厂模式更多地是用来填构造方法的“坑”的。我们知道,如果同一个类要想提供多种不同的构造对象的方式,就得基于构造方法的重载。但是构造方法是有局限性的。比如下面这种情况:将直角坐标系中的一个点封装成一个类,分别提供两个构造方法,其一是通过横坐标x和纵坐标y来表示这个点的位置,其二是用极坐标来表示这个点的位置。根据不同的构造方式写出的两个构造方法。然而,这两个方法的签名完全相同,它们并不能构成重载。
如果没有遇到这样的问题,那自然而然也就不需要工厂模式。但一旦遇到这样的问题,工厂模式就可以解决。即拿一个普通的static方法来替代。
构造一个工厂类 PointBuilder (一般工厂类的命名都是XxxxBuilder,不过上面的 Executors 是一个例外。)
上面的工厂类中,两个方法的方法名可以自定义。方法名不一样了,上述问题自然迎刃而解。
总而言之,工厂模式(Factory Pattern)是一种创建型设计模式,它提供了一种封装对象创建过程的方式。在工厂模式中,我们通过使用工厂类来创建对象,而不是直接在客户端代码中使用 new 关键字实例化对象。
工厂模式的主要目标是将对象的创建和使用分离,通过引入一个工厂类来负责创建对象。这样可以降低客户端代码和具体对象之间的耦合度,使得代码更具灵活性和可维护性。
这里Java中创建线程池也是类似,通过工厂模式来构造出了线程池。
进入 Executors.newFixedThreadPool() 的源码可以看到,确实是在工厂类中new了一个对象:
ThreadPoolExecutor类是 Java 中用于创建和管理线程池的一个强大工具类。它是 ExecutorService
接口的一个实现,提供了更灵活的线程池功能。
ThreadPoolExecutor类是原装的线程池类,上述所有的工厂方法都是对这个类的对象做了进一步封装。用 Executors类创建线程,使用起来更为简单但定制化能力更差;而用ThreadPoolExecutor类创建线程使用更复杂,但定制化能力更强。
打开Java官方文档,找到 java.util.concurrent,再找其下的 ThreadPoolExecutor 类。这里就能看到Java官方对这个类的详细说明。
ThreadPoolExecutor是ExecutorService接口的一个具体实现,它提供了更加灵活的线程池功能。它有多个构造方法:
这些构造方法中都有比较多的参数。我们以最后一个构造方法为例,解读一下该构造方法中的各个参数。
ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler)
参数说明:
在理解时,我们可以把线程池想象成一个公司,公司中有两类员工,一类是正式员工,另一类是实习生或临时工。线程池中核心线程,指的就是正式员工;非核心线程,指的则是实习生。忙的情况下,公司就多招几个实习生干活,就好比线程池中多创建几个非核心线程共同完成任务;等到闲下来了,为了节省资源,公司又将实习生辞退,相应地非核心线程也会被杀死。
corePoolSize就相当于正式员工的数量,而maximumPoolSize相当于正式员工+实习生的数量。
正式员工是签了劳动合同的,不能随意辞退,所以即使核心线程处于空闲状态也不会被销毁;而实习生并没有签劳动合同,只是签了实习合同,是可以随时辞退的。keepAliveTime就规定了实习生线程保持存活的时间。
上面的这些参数中,线程池的拒绝策略是重点。
下面是标准库提供的四种拒绝策略。
注意,这里线程池并没有依赖于阻塞队列的阻塞行为,而是通过额外实现其它逻辑来更好地处理这个场景的操作。就好比班长告诉你你要去打扫卫生,然后他就阻塞住了,他也干不了别的你也干不了别的……最好的情况应当是你立即给出答复。在线程池中,并不希望依赖“满了阻塞”,而更主要是利用“空了阻塞”。
关于各个拒绝策略的具体场景,可以参考这篇文章:
线程池的拒绝策略
下面代码实现了一个固定线程数的简单的线程池:
import java.util.*;
import java.util.concurrent.BlockingDeque;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingDeque;
import java.util.concurrent.LinkedBlockingQueue;
class MyThreadPool{
//阻塞队列用来存放任务
private BlockingQueue queue = new LinkedBlockingQueue<>();
//提交任务
public void submit(Runnable runnable) throws InterruptedException {
queue.put(runnable);
}
//实现一个固定线程数的线程池
public MyThreadPool(int n) {
for (int i = 0; i < n; i++) {
Thread t = new Thread(() -> {
try {
while (true) {
//取出线程池中的一个任务
Runnable runnable = queue.take();
runnable.run();
}
} catch (InterruptedException e) {
e.printStackTrace();
}
});
t.start();
}
}
}
public class ThreadDemo3 {
public static void main(String[] args) throws InterruptedException {
MyThreadPool myThreadPool = new MyThreadPool(10);
for (int i = 0; i < 1000; i++) {
int num = i; //lambda表达式的变量捕获规则
myThreadPool.submit(()->{
System.out.println("hello " + num);
});
}
Thread.sleep(3000);
}
}
运行结果:
核心的数据结构是BlockingQueue,它用于存放各个可执行的任务(runnable):
class MyThreadPool{
//阻塞队列用来存放任务
private BlockingQueue queue = new LinkedBlockingQueue<>();
//...
}
submit()就像生产者一样,给队列中添加任务:
class MyThreadPool{
//向线程池中提交任务
public void submit(Runnable runnable) throws InterruptedException {
queue.put(runnable);
}
//...
}
在线程池内部有一组总数为n的工作线程,它就像消费者一样,不停地从队列中取任务然后执行。
主线程中传入n为10,即线程数固定为10。在线程池构造方法中,通过for循环创建了10个工作线程。这10个工作线程是并发执行,无序调度的。每一个线程的任务都是不断从阻塞队列中获取任务并执行。因此这里为了保证工作线程的活跃,不会在执行完一个任务后立即终止线程,需要给取任务、执行任务的操作加上while(true)。如果没有while(true),线程执行完一个任务后就会终止,导致线程池中的线程数量不足,无法处理后续的任务(运行结果中只能打印10次)。
class MyThreadPool{
//实现一个固定线程数的线程池
public MyThreadPool(int n) {
for (int i = 0; i < n; i++) {
Thread t = new Thread(() -> {
try {
while (true) {
//取出线程池中的一个任务
Runnable runnable = queue.take();
runnable.run();
}
} catch (InterruptedException e) {
e.printStackTrace();
}
});
t.start();
}
}
//...
}
主线程中,创建出线程池,并通过循环向其中添加1000个任务。这1000个任务先后被添加到阻塞队列中,工作线程从阻塞队列中获取任务并执行。注意,这里num的创建是由于lambda表达式(或匿名内部类)的变量捕获规则,它要求lambda表达式中捕获到的变量必须是final或实际final的(即不能被更改),由于变量 i 被更改了,因此重新创建一个变量来保存 i,代替 i 来使用:
public class ThreadDemo3 {
public static void main(String[] args) throws InterruptedException {
MyThreadPool myThreadPool = new MyThreadPool(10);
for (int i = 0; i < 1000; i++) {
int num = i;
myThreadPool.submit(()->{
System.out.println("hello " + num);
});
}
Thread.sleep(3000);
}
}
实际不同的程序中,线程需要干的活大不相同。一个线程池的线程数量设置成几是比较合适的?这需要结合具体的任务情况,测试而定。
不过,实践中很少有这么极端的情况,具体要通过测试的方式来确定,选取一个性能上恰当且资源使用上也恰当的这样一个均衡的结果。
测试的大体思路是:运行程序,通过记录时间戳计算一下执行时间(平均值),同时监测资源使用状态。
当有任务需要执行时,将任务提交给线程池。
线程池会将提交的任务放入任务队列中。任务队列是一个缓冲区,用于存储等待执行的任务。不同类型的线程池可能使用不同的任务队列。
线程池中的线程会从任务队列中获取任务。线程池会根据配置的核心线程数、最大线程数、线程空闲时间等参数来决定是否创建新的线程,或者复用空闲的线程来执行任务。
线程池中的工作线程会执行从任务队列中获取的任务。每个线程执行一个任务后,会继续从任务队列中获取下一个任务,直到线程池关闭或者没有更多的任务可以执行。
如果线程池中的线程空闲时间超过设定的时间(线程空闲时间设置的过程中),则线程会被回收,以减少资源消耗。但是,核心线程不会被回收,线程池会保持至少核心线程数的线程处于运行状态。
当不再需要线程池时,应该显式地关闭线程池,释放相关资源。关闭线程池后,线程池将不再接受新的任务,并且会等待所有已提交的任务执行完毕。
总结来说,线程池的执行流程涉及任务提交、队列处理、线程调度、任务执行和线程回收等步骤。通过线程池的管理,我们可以更好地控制线程的创建和销毁,提高程序的性能和效率,并避免因为频繁创建和销毁线程而导致的资源浪费和性能下降。