当我们需要在Java中处理大量的并发任务时,线程池是一种非常有用的工具。线程池可以帮助我们管理和复用线程,从而提高程序的性能和可伸缩性。在本篇技术博客中,我们将深入探讨Java线程池的概念、用法和最佳实践。
什么是线程池?
在Java中,线程池是一种基于池化思想管理线程的工具。线程池是通过java.util.concurrent包中的Executor框架来实现的。Executor框架提供了一个Executor接口和一个ThreadPoolExecutor类,可以用来创建和管理线程池。通过使用线程池,我们可以将任务提交给线程池,线程池会从池中选择一个可用的线程来执行任务。当线程执行完任务后,它将返回到池中,等待下一个任务的到来。线程池可以避免我们频繁的创建线程和销毁现场,带来不必要的性能损耗。
先来看看在没有使用线程池之前,我们是如何执行任务的:
public class Task implements Runnable{
@Override
public void run() {
System.out.println(Thread.currentThread().getName());
}
}
这是一个任务,任务的内容是输出当前线程名称,接下来我们执行该任务。
public class Demo19 {
public static void main(String[] args) {
Runnable task=new Task();//创建任务
Thread thread=new Thread(task);//创建线程
thread.start();
}
}
首先,将任务创建出来,然后创建线程,接着将任务传递给线程,之后执行线程。我们发现程序输出Thread-0。我们发现一个线程只能执行一个任务,不能连续执行任务,例如,如果我们有三个任务,就只能再创建两个线程去执行另外两个任务。
public class Demo19 {
public static void main(String[] args) {
Runnable task1=new Task();//创建任务
Runnable task2=new Task();//创建任务
Runnable task3=new Task();//创建任务
Thread thread1=new Thread(task1);//创建线程
Thread thread2=new Thread(task2);
Thread thread3=new Thread(task3);
thread1.start();
thread2.start();
thread3.start();
}
}
这些线程执行完任务之后就销毁了,这也暴露了一个问题,线程不能复用,重复创建和销毁又耗时耗资源。,这就有了我们的线程池。
我们来看看线程池是怎么执行任务的:
public class Demo20 {
public static void main(String[] args) {
Runnable task1=new Task();//创建任务
Runnable task2=new Task();//创建任务
Runnable task3=new Task();//创建任务
//创建只有一个线程的线程池
ExecutorService threadPool= Executors.newSingleThreadExecutor();
//提交任务
threadPool.execute(task1);
threadPool.execute(task2);
threadPool.execute(task3);
//关闭线程池
threadPool.shutdown();
}
}
我们创建了一个只有一个线程,这不是重点,重点是这个线程是可以复用的,调用execute方法,将需要执行的任务提交给线程池。任务提交以后,线程池会自动分配线程去执行提交过来的任务,最后调用它的shutdown方法关闭线程池。当线程池中的任务全部执行完毕一行,线程池则会关闭。
程序输出三个名称一样的线程,说明一个线程执行了三个任务,足以证明线程得到复用
对比线程,线程池的好处有很多,
Java线程池的优点包括:
-
提高性能:线程池可以重复利用已经创建的线程,避免了重复创建和销毁线程的开销,从而提高了应用程序的性能和响应速度。
-
提高可管理性:线程池可以限制并发线程的数量,避免了过多的线程竞争导致的资源浪费和性能下降。此外,线程池还可以提供监控和管理功能,例如线程池大小、线程池状态、线程执行时间等,方便开发人员进行调试和优化。
-
提高稳定性:线程池可以避免由于线程过多导致的系统崩溃和资源耗尽的问题,从而提高了应用程序的稳定性和可靠性。
-
提高可扩展性:线程池可以根据应用程序的需求动态调整线程池大小,从而提高了应用程序的可扩展性和灵活性。
总之,Java线程池是一种非常有用的多线程编程工具,可以提高应用程序的性能、可管理性、稳定性和可扩展性,同时也可以避免一些常见的多线程编程问题,例如线程过多、线程竞争和资源浪费等
如何创建线程池?
创建线程池的方法一共有8种,但万变不离其宗,这个宗就是原生创建线程池的方式。这种方式也是阿里极其推荐的一种。在《阿里巴巴java开发手册》的第七章第四小节种这样写道:线程池不允许使用Executors去创建,而是通过ThreadPoolExecutor的方式去创建,其他的创建方式有资源耗尽的风险,后续章节在介绍其他创建方式时也会提到。
这是线程池核心UML类图。
从图中我们可以看到,能实例化的只有ThreadPoolExecutor 和ScheduledThreadPoolExecutor这两个,其中ThreadPoolExecutor就是线程池的核心类,它一共有四个构造方法,不过都大同小异
线程池核心参数
最后一个构造方法参数最多,我们就从它开始。该方法一共有七个参数,这七个参数的含义如图所示:
前四个参数结合在一起看比较好理解,这里我把他们都赋上值:
线程存活时间和时间单位结合在一起就是:空闲线程的存活时间是10s ,10s内没工作就会被销毁。(如果空闲时间是0的话,空闲线程不会被销毁)
这就相当于我们构造了一个这样的线程池:
核心线程数是10,表示线程池中有10给核心线程,成为核心线程的好处就是,只要线程池不关闭,它就不会被销毁。最大线程数为25,表示线程池中最多允许有25个线程,核心线程之外的线程是非核心线程,非核心线程没有执行任务的话是要被清理的,在被清理之前能存活多久,取决于 线程存活时间和时间单位。
WorkQueue
任务队列,是放任务的容器,提交给线程池的任务都存放在这里,线程池的线程们也都是在这领取的任务。
我们一般使用的是LinkedBlockingQueue:链式阻塞队列,这是一个基于链表的阻塞队列,还有一个常用的是ArrayBlockingQueue:数组阻塞队列,除此以外,大家也可以尝试其他的阻塞队列。
threadFactory
线程工厂,顾名思义,可以指定线程如何生产,它是一个接口,实现里面的newThread方法,可以自定义线程的相关设置,例如我们可以指定线程名称,还可以指定线程是否为后台线程等等。
@Override
public Thread newThread(Runnable r) {
//创建线程,并指定任务;
Thread thread=newThread(r);
//设置线程名称
thread.setName("线程1");
//返回线程
return thread;
}
如果不想自定义线程工厂,我们也可以使用Executors类中默认线程工厂,实际开发中我们用自定义的比较多,因为我们可以自定义名称,方便我们后续查看日志。
Handler
任务拒绝策略,在什么情况下,我们提交给线程池的任务会被拒绝呢?同时满足以下四种情况,我们提交给线程池的任务会被拒绝:
- 线程池中的线程已满
- 无法再继续扩容
- 没有空闲线程,所有线程都在执行任务
- 任务队列已满,无法再存入新任务
线程池拒绝我们任务的方式也有四种,如图所示:
接下来我们开始开始创建一个线程池:
先定义一个CustomThreadFactory类,实现ThreadFactory接口,重写newThread方法,定义一个AtomicInteger类型的计数器来保证线程安全,初始值为1,该计数器用在线程名称中给线程编号。创建线程实例,并指定任务。自定义线程名称,每创建一个线程计数器递增一次,最后返回线程实例。
public class CustomThreadFactory implements ThreadFactory {
private final AtomicInteger i=new AtomicInteger(1);
@Override
public Thread newThread(Runnable r) {
//创建线程并指定任务
Thread thread=newThread(r);
//设置线程名称
thread.setName("线程"+i.getAndIncrement()+"号");
//返回实例
return thread;
}
}
接下来编写一个任务,任务内容是输出当前线程名称,我们来执行该任务
public class Task implements Runnable{
@Override
public void run() {
System.out.println(Thread.currentThread().getName());
}
}
创建三个任务,然后创建一个线程池,核心线程数为10,最大线程数为25,空闲线程存活时间为10,时间单位为s,任务队列采用链式阻塞队列,线程工厂为刚刚自定义的线程工厂,任务拒绝策略采用默认。接下来向线程池中提交任务,最后关闭线程池。
Task task1=new Task();//创建任务
Task task2=new Task();//创建任务
Task task3=new Task();//创建任务
//创建线程池
ThreadPoolExecutor threadPool=new ThreadPoolExecutor(10,
25,10L, TimeUnit.SECONDS,
new LinkedBlockingDeque<>(),new CustomThreadFactory(),
new ThreadPoolExecutor.AbortPolicy());
//提交任务
threadPool.execute(task1);
threadPool.execute(task2);
threadPool.execute(task3);
//关闭线程池
threadPool.shutdown();
}
执行任务,从执行结果来看,我们自定义的线程工厂生效了,使用原生方式创建线程池成功。
创建线程池的方式多种多样,但这三种千万别用在正式环境中,它们分别是:FixedThreadPool(固定大小的线程池),SingleThreadExecutor(单个线程的线程池),CachedThreadPool(可缓存的线程池)
这三种创建方式都在Executors工具类中,所有以new开头的方法都可以创建线程池。
一共有12个这样的方法,去掉重载方法后,也就剩下6个。看似方式繁多,其实搞懂它们一点都不难,因为它们都和ThreadPoolExecutors有直接或间接关系,ThreadPoolExecutor是创建线程池的原生方式。
FixedThreadPool,SingleThreadExecutor,CachedThreadPool内部都采用ThreadPoolExecutor来创建线程池,只不过内部做了一些简化参数的工作。
接下来我们就依次来了解这三种创建线程池的方式:
FixedThreadPool内部采用ThreadPoolExecutor来创建线程池,并且最大线程数和核心线程数一样,意味着它里面全都是核心线程,空闲线程存活时间为0,这样空闲线程就不会被销毁,任务队列采用的是LinkedBlockingQueue,需要注意的是,此队列有资源耗尽的风险。因为LinkedBlockingQueue的容量为Integer的最大值(2^31),这么多任务内存随时都有可能爆掉。所以FixedThreadPool这种创建方式极力不推荐使用。
另外,在《阿里巴巴Java开发手册》的第一章第7小节第四条中这样写道:FixedThreadPool和SingleThreadPool允许的请求队列长度为Integer.MAX_VALUE,可能会堆积大量的请求,从而导致OOM。
在阿里内部,直接就明令禁止使用它们来创建线程池。
SingleThreadExecutor内部也采用ThreadPoolExecutor来创建线程池,并且最大线程数和核心线程数一样,意味着它里面全都是核心线程,空闲线程存活时间为0,这样空闲线程就不会被销毁,任务队列采用的是LinkedBlockingQueue,风险就不再赘述了
CachedThreadPool意为可缓存的线程池,它里面出来核心线程以外还有非核心线程,内部也采用ThreadPoolExecutor来创建线程池,核心线程数是0,意味着里面全部都是非核心线程,非核心线程空闲下来是要被销毁的,空闲线程存活时间为60s,最大线程数是Integer的最大值风险不言而喻。任务队列采用的是SychronousQueue,这是一个同步队列。
提交任务的方式
提交任务的方式有两组,分别是execute和submit
Execute
该方法位于executor接口中,作用是向线程池中提交Runnable任务,我们知道Runnable任务是一个无返回值的任务,所以execute知识和提交无返回值的任务,如果我们的任务是有返回值的那么我们就需要创建Callable任务,他是一个有返回值的任务,Callable任务执行完会将任务执行结果封装到Future对象中,然后返回给调用者,调用者再通过Future对象获取结果。
在execute方法中,提交的任务如果被拒绝,则抛出任务拒绝异常,提交的任务不能为null,否则会抛出空指针异常。
Submit
该方法位于ExecutorService接口中,一共有三个submit方法
它们的作用稍有不同,这里我将三个方法的作用分别列举出来,他们的返回值类型都是Future类型,而且都带泛型,任务执行结果就封装在Future对象里面。
Future是一个接口,该接口定义了方法与任务执行结果相关的功能 ,它里面有五种可用的方法,这里就不细说了。
回到submit本身,我们先来看它的第一种实现方法,它的作用是提交Runnable任务,返回一个Future对象。Runnable都没有返回值了,为什么还要返回Future对象你?这是因为Future除了可以获取任务执行结果以外还可以观察任务是否执行完毕,以及取消任务等等操作。所以Future对象可以选择接收,也可以选择不接收。